Awesome LinuxDo Reader

面向 LINUX DO 的沉浸式增强阅读器,支持父回复预览、消息/历史/收藏、原图灯箱、主题布局、请求限流、缓存与 DOM 渲染管理。

2026/07/25のページです。最新版はこちら

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

作者のサイトでサポートを受ける。または、このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Awesome LinuxDo Reader
// @name:zh-CN   更流畅的 LinuxDo 阅读器
// @namespace    https://github.com/sunbigfly/awesome-linuxdo-reader
// @version      0.1.5
// @license      MIT
// @description  面向 LINUX DO 的沉浸式增强阅读器,支持父回复预览、消息/历史/收藏、原图灯箱、主题布局、请求限流、缓存与 DOM 渲染管理。
// @description:en An immersive LINUX DO reader with threaded context, community panels, image lightbox, layouts, request control, cache, and DOM rendering management.
// @author       sunbigfly
// @homepageURL  https://github.com/sunbigfly/awesome-linuxdo-reader
// @supportURL   https://github.com/sunbigfly/awesome-linuxdo-reader/issues
// @match        https://linux.do/*
// @icon         https://cdn3.ldstatic.com/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_512x512.png
// @grant        GM_xmlhttpRequest
// @grant        GM_getResourceText
// @grant        unsafeWindow
// @connect      connect.linux.do
// @run-at       document-start
// @resource     ldpReaderStyles https://cdn.jsdelivr.net/gh/sunbigfly/awesome-linuxdo-reader@ade05cf/work/main.css#sha256=add7644aee3da62de89eb7ef7a7459849e6d100358d7d032265e20ff021af2c6
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/index.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js
// ==/UserScript==

(function () {
	'use strict';

	const BASE = location.origin;
	const PAGE_ROOT = document.documentElement;
	const makeElement = (tagName) => document.createElement(tagName);
	const READER_VERSION = '0.1.5';
	const HOST_PAGE_WINDOW = globalThis.unsafeWindow;
	const TOPIC_CACHE_TTL = 90 * 1000;
	const READ_THRESHOLD = 1500;
	const FLUSH_INTERVAL = 5000;
	const JUMP_HIGHLIGHT_DEFAULTS = Object.freeze({
		color: '#0888cc',
		radius: 10,
		borderWidth: 1,
		rate: 0.8,
		count: 2,
	});
	const JUMP_HIGHLIGHT_LIMITS = Object.freeze({
		radius: Object.freeze({ min: 0, max: 24, step: 1, integer: true }),
		borderWidth: Object.freeze({ min: 0, max: 4, step: 1, integer: true }),
		rate: Object.freeze({ min: 0.5, max: 2, step: 0.1, integer: false }),
		count: Object.freeze({ min: 1, max: 6, step: 1, integer: true }),
	});
	const JUMP_HIGHLIGHT_SETTING_FIELDS = Object.freeze([
		{ key: 'color', pref: 'jumpHighlightColor', label: '闪烁颜色', type: 'color', format: String,
			help: '选择跳转目标楼层的闪烁颜色;提示使用半透明底色避免遮住正文,并用同色细轮廓准确呈现所选颜色。选择时实时预览,确认后保存。' },
		{ key: 'radius', pref: 'jumpHighlightRadius', label: '圆角', ariaLabel: '闪烁圆角', format: (value) => `${value}px`,
			help: `控制闪烁背景的圆角,可在 ${JUMP_HIGHLIGHT_LIMITS.radius.min}–${JUMP_HIGHLIGHT_LIMITS.radius.max}px 之间调整。拖动时实时预览,确认后保存。` },
		{ key: 'borderWidth', pref: 'jumpHighlightBorderWidth', label: '边框宽度', ariaLabel: '闪烁边框宽度', format: (value) => `${value}px`,
			help: `控制闪烁轮廓的宽度,可在 ${JUMP_HIGHLIGHT_LIMITS.borderWidth.min}–${JUMP_HIGHLIGHT_LIMITS.borderWidth.max}px 之间调整;设为 0px 可关闭边框,颜色跟随闪烁颜色。拖动时实时预览,确认后保存。` },
		{ key: 'rate', pref: 'jumpHighlightRate', label: '速率', ariaLabel: '闪烁速率', format: (value) => `${value.toFixed(1)} 次/秒`,
			help: `控制每秒闪烁次数,可在 ${JUMP_HIGHLIGHT_LIMITS.rate.min}–${JUMP_HIGHLIGHT_LIMITS.rate.max} 次/秒之间调整;数值越大闪得越快。拖动时实时预览,确认后保存。` },
		{ key: 'count', pref: 'jumpHighlightCount', label: '次数', ariaLabel: '闪烁次数', format: (value) => `${value} 次`,
			help: `控制一次跳转连续闪烁多少次,可在 ${JUMP_HIGHLIGHT_LIMITS.count.min}–${JUMP_HIGHLIGHT_LIMITS.count.max} 次之间调整。拖动时实时预览,确认后保存。` },
	]);
	const LINUXDO_LOGO_URL = 'https://cdn3.ldstatic.com/optimized/4X/6/a/6/6a6affc7b1ce8140279e959d32671304db06d5ab_2_512x512.png';
	const READER_MANUAL_URL = 'https://sunbigfly.github.io/awesome-linuxdo-reader/';
	const EXTERNAL_FONT_RENDERING_MARKER = 'fr-init-once';
	const FONT_RENDERING_PROJECT_URL = 'https://github.com/F9y4ng/GreasyFork-Scripts/';
	const FONT_RENDERING_LICENSE_URL = 'https://github.com/F9y4ng/GreasyFork-Scripts/blob/master/LICENSE';
	const READER_FONT_RENDERING_DEFAULTS = (() => {
		const userAgent = navigator.userAgent || '';
		const isGecko = /Firefox\//.test(userAgent);
		const isWebKit = /AppleWebKit\//.test(userAgent)
			&& !/(?:Chrome|Chromium|Edg|OPR|CriOS|FxiOS)\//.test(userAgent);
		return Object.freeze({
			stroke: isGecko ? 0.03 : isWebKit ? 0.05 : 0.015,
			shadow: isGecko ? 0.55 : isWebKit ? 0.45 : 0.75,
			macSmoothing: /Mac/.test(navigator.platform || ''),
		});
	})();
	PAGE_ROOT.style.setProperty('--ldp-font-rendering-stroke-runtime', `${READER_FONT_RENDERING_DEFAULTS.stroke}px currentcolor`);
	PAGE_ROOT.style.setProperty('--ldp-font-rendering-shadow-runtime', `0 0 ${READER_FONT_RENDERING_DEFAULTS.shadow}px #7c7c7cdd`);
	if (READER_FONT_RENDERING_DEFAULTS.macSmoothing) {
		PAGE_ROOT.style.setProperty('--ldp-font-webkit-smoothing', 'antialiased');
		PAGE_ROOT.style.setProperty('--ldp-font-moz-smoothing', 'grayscale');
	}
	const LDP_PREF_KEY = 'linuxdo-enhanced-reader:prefs';
	const LDP_CONFIG_EXPORT_FORMAT = 'awesome-linuxdo-reader-settings';
	const LDP_CONFIG_EXPORT_VERSION = 4;
	const LDP_HISTORY_KEY = 'linuxdo-enhanced-reader:history';
	const LDP_READER_QUEUE_KEY = 'linuxdo-enhanced-reader:reader-queue:v1';
	const LDP_TOPIC_CACHE_KEY = 'linuxdo-enhanced-reader:cache:topics';
	const LDP_TOPIC_NAV_ICON_CACHE_KEY = 'linuxdo-enhanced-reader:cache:topic-nav-icons';
	const LDP_USER_CARD_CACHE_KEY = 'linuxdo-enhanced-reader:cache:users';
	const LDP_NOTIFICATION_CACHE_KEY = 'linuxdo-enhanced-reader:cache:notifications';
	const LDP_SHARED_REQUEST_STATE_KEY = 'linuxdo-enhanced-reader:shared-request-state:v1';
	const LDP_SHARED_REQUEST_CHANNEL = 'linuxdo-enhanced-reader:shared-request-flow:v1';
	const LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY = 'linuxdo-enhanced-reader:cloudflare-challenge:v1';
	const LDP_CLOUDFLARE_CHALLENGE_RESULT_KEY = 'linuxdo-enhanced-reader:cloudflare-challenge-result:v1';
	const LDP_CLOUDFLARE_CHALLENGE_SEEN_KEY = 'linuxdo-enhanced-reader:cloudflare-challenge-seen:v1';
	const LDP_CLOUDFLARE_CHALLENGE_RETURN_PARAM = 'ldp_challenge_return';
	const LDP_CLOUDFLARE_CHALLENGE_LEASE_TTL = 15 * 1000;
	const LDP_EMBEDDED_RELOAD_STATE_KEY = 'linuxdo-enhanced-reader:embedded-reload-state:v1';
	const LDP_EMBEDDED_RELOAD_STATE_TTL = 30 * 1000;
	const LDP_AVATAR_CACHE_NAME = 'linuxdo-enhanced-reader:avatars:v1';
	const LDP_EMOJI_IMAGE_CACHE_NAME = 'linuxdo-enhanced-reader:emoji-images:v1';
	const LDP_LIGHTBOX_IMAGE_CACHE_NAME = 'linuxdo-enhanced-reader:lightbox-images:v1';
	const LDP_RESPONSE_CACHE_DB_NAME = 'linuxdo-enhanced-reader:responses:v1';
	const LDP_RESPONSE_CACHE_STORE = 'responses';
	const PERSISTENT_CACHE_VERSION = 1;
	const TOPIC_SNAPSHOT_VERSION = 1;
	const TOPIC_SNAPSHOT_WRITE_DELAY = 750;
	const TOPIC_SNAPSHOT_WRITE_MAX_DELAY = 3000;
	const MINUTE = 60 * 1000;
	const HOUR = 60 * MINUTE;
	const DAY = 24 * HOUR;
	const LDP_AVATAR_CACHE_MAX_AGE = 90 * DAY;
	const LDP_EMOJI_IMAGE_CACHE_MAX_AGE = 90 * DAY;
	const LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE = 7 * DAY;
	const LDP_SCROLL_LOCK_CLASS = 'ldp-scroll-lock';
	const NATIVE_BYPASS_PARAM = 'ldp_native';
	const NATIVE_BYPASS_TAB_KEY = 'linuxdo-enhanced-reader:native-tab';

	const cloudflareChallengeReturnOwner = location.pathname === '/topics/timings'
		? new URLSearchParams(location.search).get(LDP_CLOUDFLARE_CHALLENGE_RETURN_PARAM)
		: '';
	if (cloudflareChallengeReturnOwner) {
		window.addEventListener('storage', (event) => {
			if (event.key !== LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY || !event.newValue) return;
			let lease = null;
			try { lease = JSON.parse(event.newValue); } catch (e) {}
			if (lease && lease.owner === cloudflareChallengeReturnOwner && lease.state === 'passed') {
				try { window.close(); } catch (e) {}
			}
		});
		const reportChallengePopupNavigation = () => {
			const challenge = !!(
				window._cf_chl_opt ||
				document.querySelector(
					'iframe[src*="challenges.cloudflare.com"],script[src*="/cdn-cgi/challenge-platform/"],#challenge-running'
				) ||
				/^(?:Just a moment|请稍候)/i.test(String(document.title || ''))
			);
			if (challenge) {
				try {
					sessionStorage.setItem(LDP_CLOUDFLARE_CHALLENGE_SEEN_KEY, cloudflareChallengeReturnOwner);
				} catch (e) {}
				return;
			}
			const navigation = performance?.getEntriesByType
				? performance.getEntriesByType('navigation')[0]
				: null;
			let challenged = false;
			try {
				challenged = sessionStorage.getItem(LDP_CLOUDFLARE_CHALLENGE_SEEN_KEY) === cloudflareChallengeReturnOwner;
				sessionStorage.removeItem(LDP_CLOUDFLARE_CHALLENGE_SEEN_KEY);
			} catch (e) {}
			const result = {
				owner: cloudflareChallengeReturnOwner,
				status: Math.max(0, Number(navigation?.responseStatus) || 0),
				challenged,
				at: Date.now(),
			};
			try {
				localStorage.setItem(LDP_CLOUDFLARE_CHALLENGE_RESULT_KEY, JSON.stringify(result));
			} catch (e) {}
		};
		if (document.readyState === 'loading') {
			document.addEventListener('DOMContentLoaded', reportChallengePopupNavigation, { once: true });
		} else {
			reportChallengePopupNavigation();
		}
		return;
	}

	const KATEX_VERSION = '0.16.22';
	const IMAGE_SCALE_OPTIONS = [25, 50, 100, 125, 150, 200];
	const LIGHTBOX_DESCRIPTION_HEIGHT_MIN = 56;
	const LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT = 120;
	const LIGHTBOX_COMMENTS_WIDTH_DEFAULT = 25;
	const LIGHTBOX_COMMENTS_WIDTH_MIN = 18;
	const LIGHTBOX_COMMENTS_WIDTH_MAX = 50;
	const IMAGE_SCALE_MIN = 50;
	const IMAGE_SCALE_MAX = 200;
	const FONT_SCALE_MIN = 50;
	const FONT_SCALE_MAX = 250;
	const FONT_SCALE_DEFAULT = 100;
	const COMPOSER_FONT_SCALE_DEFAULT = 80;
	const READER_THEME_MODES = Object.freeze(['light', 'dark', 'system']);
	const READER_THEME_LABELS = Object.freeze({ light: '明', dark: '暗', system: '系统' });
	const READER_THEME_VALUE_ROWS = [
		['--secondary', '#ffffff', '#171b20'],
		['--primary', '#20252b', '#e7edf3'],
		['--primary-high', '#333a42', '#cbd4dc'],
		['--primary-medium', '#68737e', '#98a4af'],
		['--primary-low-mid', '#b8c0c8', '#4a5560'],
		['--primary-low', '#d7dde3', '#39414a'],
		['--primary-very-low', '#f2f4f6', '#242a31'],
		['--secondary-low', '#4b545d', '#363c43'],
		['--secondary-medium', '#89929c', '#7f8790'],
		['--secondary-high', '#dfe3e6', '#c9d0d8'],
		['--secondary-very-high', '#f7f8f9', '#e3e8ed'],
		['--tertiary', '#47855f', '#78c295'],
		['--tertiary-low', '#dceee2', '#223a2c'],
		['--header_background', '#ffffff', '#171b20'],
		['--header_primary', '#20252b', '#e7edf3'],
		['--header_primary-low', '#e7eaed', '#2a3037'],
		['--header_primary-low-mid', '#b7bec6', '#4a5560'],
		['--header_primary-medium', '#68737e', '#98a4af'],
		['--header_primary-high', '#333a42', '#cbd4dc'],
		['--highlight', '#fff0a8', '#6b5a16'],
		['--highlight-low', '#fff8d8', '#302b18'],
		['--highlight-medium', '#ffe47a', '#8a7420'],
		['--highlight-high', '#806200', '#ffe47a'],
		['--quaternary', '#d86645', '#f08a67'],
		['--danger', '#c84545', '#ff7b72'],
		['--danger-low', '#f7dddd', '#44262a'],
		['--danger-low-mid', '#efb5b5', '#6b353b'],
		['--success', '#47855f', '#78c295'],
		['--success-low', '#dceee2', '#223a2c'],
		['--love', '#dc6542', '#ff8a65'],
		['--love-low', '#f7ddd5', '#472b25'],
		['--d-link-color', '#2870b8', '#71b7ff'],
		['--d-post-aside-background', '#f7f9fa', '#20262d'],
		['--ldp-zebra-color', '#eafbea', '#1b2b21'],
		['--ldp-reply-line-color', '#47855f', '#78c295'],
		['--ldp-quote-line-color', '#d7d7d7', '#46505a'],
		['--ldp-divider-line-color', '#e5e5e5', '#343b44'],
		['--ldp-request-metric-pine', '#315f47', '#86b49a'],
		['--ldp-request-metric-navy', '#365673', '#8eacc8'],
		['--ldp-request-metric-plum', '#5e4b72', '#b09bc4'],
		['--ldp-request-metric-bronze', '#72551f', '#c5a36d'],
		['--ldp-request-metric-wine', '#743f47', '#c7929a'],
	];
	const READER_THEME_VARIABLES = Object.freeze(Object.fromEntries(
		['light', 'dark'].map((mode, valueIndex) => [mode, Object.freeze(Object.fromEntries(
			READER_THEME_VALUE_ROWS.map(([name, ...values]) => [name, values[valueIndex]]),
		))]),
	));
	const ACCENT_COLOR_DEFAULT = READER_THEME_VARIABLES.light['--tertiary'];
	const LINK_COLOR_DEFAULT = READER_THEME_VARIABLES.light['--d-link-color'];
	const ZEBRA_COLOR_DEFAULT = '#eafbea';
	const LIST_ZEBRA_COLOR_DEFAULT = '#e5e7eb';
	const ZEBRA_RADIUS_MIN = 0;
	const ZEBRA_RADIUS_MAX = 16;
	const ZEBRA_RADIUS_DEFAULT = 10;
	const ZEBRA_RADIUS_STEP = 1;
	const REPLY_LINE_COLOR_DEFAULT = '#47855f';
	const LINE_WIDTH_MIN = 0.5;
	const LINE_WIDTH_MAX = 4;
	const LINE_WIDTH_STEP = 0.5;
	const REPLY_LINE_WIDTH_DEFAULT = 1;
	const REPLY_LINE_RADIUS_MIN = 0;
	const REPLY_LINE_RADIUS_MAX = 16;
	const REPLY_LINE_RADIUS_DEFAULT = 15;
	const REPLY_LINE_RADIUS_STEP = 1;
	const QUOTE_LINE_COLOR_DEFAULT = '#d7d7d7';
	const QUOTE_LINE_WIDTH_DEFAULT = 0.5;
	const DIVIDER_LINE_COLOR_DEFAULT = '#e5e5e5';
	const DIVIDER_LINE_WIDTH_DEFAULT = 0.5;
	const FLOOR_PREVIEW_COLOR_DEFAULT = '#dc2626';
	const FLOOR_PREVIEW_COLOR_DARK_DEFAULT = '#f87171';
	const FLOOR_PREVIEW_HEIGHT_MIN = 180;
	const FLOOR_PREVIEW_HEIGHT_MAX = 720;
	const FLOOR_PREVIEW_HEIGHT_DEFAULT = 300;
	const FLOOR_PREVIEW_HEIGHT_STEP = 10;
	const PERFORMANCE_SETTING_GROUPS = [
		{
			id: 'main-request', title: '主楼层取数',
			description: '决定单次主信息流请求的规模;只改变每批楼层数,不绕过共享调度。',
			fields: [
				{ key: 'pageSize', pref: 'performancePageSize', presets: [16, 40, 48], limits: { min: 12, max: 64, step: 1, integer: true }, title: '每批请求主楼层', unit: '条', inputMode: 'numeric',
					description: '接近已取数据边缘时,按该数量提交楼层 ID;越大越少等待,但单次下载、解析与缓存写入更重。',
					help: '主信息流接近数据边缘时,一次向服务器提交多少个楼层 ID。它决定响应规模,不是 DOM 数量,也不绕过共享请求调度。' },
			],
		},
		{
			id: 'dom', title: '主楼层 DOM 窗口',
			description: '只控制页面挂载与渲染;滚动换窗会回收远处楼层,不会自行请求楼层。',
			fields: [
				{ key: 'streamOverscanViewports', pref: 'performanceStreamOverscan', presets: [0.35, 1.25, 2], limits: { min: 0.25, max: 3, step: 0.05 }, title: '视口外挂载缓冲', unit: '屏', inputMode: 'decimal',
					description: '在可见区域前后额外挂载多少屏;快速滚动时会按方向临时前探,但仍受 DOM 上限约束。',
					help: '在当前屏幕前后额外挂载多少屏主楼层。只影响 DOM 与渲染,不发起楼层请求,并始终受同时挂载上限约束。' },
				{ key: 'streamMaxItems', pref: 'performanceStreamMaxItems', presets: [28, 72, 96], limits: { min: 24, max: 128, step: 1, integer: true }, title: '同时挂载主楼层上限', unit: '个', inputMode: 'numeric',
					description: '滚动、首次进入和跳楼期间持续生效的硬上限;窗口移走后远处楼层会卸载,重媒体正文可更早退回占位。',
					help: '滚动、首次进入和跳楼期间同时挂载的主楼层硬上限。远离窗口的楼层会从 DOM 卸载,必要时保留高度占位。' },
			],
		},
		{
			id: 'nested', title: '楼中楼自动请求',
			description: '这是独立请求来源:父楼层进入触发范围后,会请求该楼层的直属回复。',
			fields: [
				{ key: 'nestedPrefetchViewports', pref: 'performanceNestedPrefetch', presets: [0.35, 1.25, 2], limits: { min: 0, max: 3, step: 0.05 }, title: '自动请求触发距离', unit: '屏', inputMode: 'decimal',
					description: '0 表示父楼层进入视口后请求;大于 0 会提前对应屏数。每个父楼层首批最多取 20 条直属回复,后续页只在翻页时请求。',
					help: '父楼层进入该距离后自动请求 /posts/:id/replies.json。0 仍会在进入视口时请求;大于 0 才会提前预取。' },
			],
		},
		{
			id: 'request', title: '共享请求调度边界',
			description: '主题、主楼层、楼中楼、消息和用户资料 JSON 共用;实际运行值只会比配置更保守。',
			fields: [
				{ key: 'requestMaxConcurrent', pref: 'performanceRequestConcurrency', presets: [1, 3, 4], limits: { min: 1, max: 4, step: 1, integer: true }, title: '配置并发上限', unit: '路', inputMode: 'numeric',
					description: '允许值的天花板,不保证跑满;多实例取更严格值,宿主活动、限流和恢复探测会继续压低实际并发。',
					help: '阅读器 JSON 请求的配置天花板。多实例、宿主活动、限流窗口与恢复状态可让实际并发更低,不会让它更高。' },
				{ key: 'requestMinInterval', pref: 'performanceRequestInterval', presets: [220, 90, 80], limits: { min: 80, max: 500, step: 10, integer: true }, title: '配置启动间隔下限', unit: 'ms', inputMode: 'numeric',
					description: '正常状态下的最快启动间隔;共享窗口、宿主请求或 429 会自动延长,也不会在恢复后突发补发。',
					help: '正常状态下两次 JSON 请求启动的最快间隔。自适应调度可以延长它,且不会在队列恢复后突发追赶。' },
				{ key: 'requestRateTarget', pref: 'performanceRequestRateTarget', presets: [80, 80, 80], limits: { min: 50, max: 95, step: 1, integer: true }, title: '窗口预算目标占用', unit: '%', inputMode: 'numeric',
					description: '按已探测的 10 秒与 60 秒边界保留余量;只调节启动预算,不改变主楼层批量或楼中楼触发数量。',
					help: '使用已探测 10 秒与 60 秒预算的目标比例。它只调节启动节奏,不减少主楼层批次或楼中楼产生的请求。' },
			],
		},
	];
	const PERFORMANCE_SETTING_FIELDS = PERFORMANCE_SETTING_GROUPS.flatMap((group) => group.fields);
	const PERFORMANCE_PRESETS = Object.freeze(Object.fromEntries(
		['low', 'balanced', 'high'].map((name, valueIndex) => [name, Object.freeze(Object.fromEntries(
			PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, field.presets[valueIndex]]),
		))]),
	));
	const PERFORMANCE_LIMITS = Object.freeze(Object.fromEntries(
		PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, Object.freeze(field.limits)]),
	));
	const PERFORMANCE_PREF_KEYS = Object.freeze(Object.fromEntries(
		PERFORMANCE_SETTING_FIELDS.map((field) => [field.key, field.pref]),
	));
	const PERFORMANCE_SETTING_KEYS = Object.freeze(Object.keys(PERFORMANCE_PREF_KEYS));
	const LAYOUT_REGION_KEYS = Object.freeze(['left', 'main', 'gap', 'timeline', 'right']);
	const LAYOUT_REGION_LABELS = Object.freeze({
		left: '左留白',
		main: '主帖',
		gap: '中间间距',
		timeline: '时间轴',
		right: '右留白',
	});
	const COMPOSER_WINDOW_PREFIX = 'composerWindow';
	const READER_LAYOUT_DEFAULT = Object.freeze({ left: 10, main: 72, gap: 3, timeline: 10, right: 5 });
	const FONT_FAMILY_DEFAULT = 'system';
	const FONT_FAMILY_OPTIONS = Object.freeze({
		site: Object.freeze({ label: '跟随站点', stack: 'inherit' }),
		system: Object.freeze({ label: '当前系统字体', stack: 'system-ui,sans-serif' }),
		cjkSans: Object.freeze({ label: '中文无衬线', stack: '"Noto Sans CJK SC","Microsoft YaHei","PingFang SC",system-ui,sans-serif' }),
		serif: Object.freeze({ label: '衬线', stack: '"Noto Serif CJK SC","Songti SC",SimSun,serif' }),
		monospace: Object.freeze({ label: '等宽', stack: 'ui-monospace,SFMono-Regular,Consolas,monospace' }),
		custom: Object.freeze({ label: '自定义本机字体', stack: '' }),
	});
	const FONT_WEIGHT_DEFAULT = 400;
	const FONT_WEIGHT_OPTIONS = Object.freeze([300, 400, 500, 600]);
	const FONT_WEIGHT_LABELS = Object.freeze({ 300: '细', 400: '常规', 500: '中等', 600: '半粗' });
	const HOST_EMBED_SIZE_MIN = 50;
	const HOST_EMBED_SIZE_MAX = 200;
	const HOST_EMBED_SIZE_DEFAULT = 100;
	const HOST_EMBED_SIZE_SETTINGS = Object.freeze({
		title: Object.freeze({ pref: 'hostEmbeddedTitleScale', label: '标题', defaultScale: 105, properties: [['--ldp-host-topic-title-size', 15]] }),
		avatar: Object.freeze({ pref: 'hostEmbeddedAvatarScale', label: '头像', defaultScale: 70, properties: [['--ldp-host-topic-avatar-size', 32], ['--ldp-host-topic-avatar-size-medium', 24], ['--ldp-host-topic-avatar-size-small', 20]] }),
		stats: Object.freeze({ pref: 'hostEmbeddedStatsScale', label: '右下角信息', defaultScale: 105, properties: [['--ldp-host-topic-stats-size', 10], ['--ldp-host-topic-stats-label-size', 9], ['--ldp-host-topic-stats-row-offset', -4]] }),
		labelCard: Object.freeze({ pref: 'hostEmbeddedLabelCardScale', label: 'Label Card', defaultScale: 105, properties: [['--ldp-host-label-card-height', 22], ['--ldp-host-label-card-font-size', 11], ['--ldp-host-label-card-icon-size', 14], ['--ldp-host-label-card-gap', 3], ['--ldp-host-label-card-padding', 7]] }),
	});
	const FONT_SCOPE_KEYS = Object.freeze(['interface', 'post', 'composer']);
	const FONT_SCOPE_CONFIG = Object.freeze({
		interface: Object.freeze({ label: '界面', family: 'family', customFamily: 'customFamily', weight: 'weight', color: 'interfaceColor', scale: 'interface' }),
		post: Object.freeze({ label: '正文', family: 'postFamily', customFamily: 'postCustomFamily', weight: 'postWeight', color: 'postColor', scale: 'post' }),
		composer: Object.freeze({ label: '回复', family: 'composerFamily', customFamily: 'composerCustomFamily', weight: 'composerWeight', color: 'composerColor', scale: 'composer' }),
	});
	const HOST_FONT_CONFIG = Object.freeze({
		label: '宿主主题列表', family: 'hostFontFamily', customFamily: 'hostFontCustomFamily', weight: 'hostFontWeight', color: 'hostFontColor',
	});
	const HOST_FONT_DEFAULTS = Object.freeze({
		hostFontFamily: FONT_FAMILY_DEFAULT,
		hostFontCustomFamily: '',
		hostFontWeight: FONT_WEIGHT_DEFAULT,
		hostFontColor: '',
	});
	const FONT_PICKER_SCOPE_KEYS = Object.freeze([...FONT_SCOPE_KEYS, 'host']);
	const FONT_PROFILE_VALUE_KEYS = Object.freeze([
		'family', 'customFamily', 'weight', 'interfaceColor', 'interface',
		'postFamily', 'postCustomFamily', 'postWeight', 'postColor', 'post',
		'composerFamily', 'composerCustomFamily', 'composerWeight', 'composerColor', 'composer',
	]);
	const READER_FONT_DEFAULT = Object.freeze({
		family: FONT_FAMILY_DEFAULT, customFamily: '', weight: FONT_WEIGHT_DEFAULT, interfaceColor: '', interface: 92,
		postFamily: FONT_FAMILY_DEFAULT, postCustomFamily: '', postWeight: FONT_WEIGHT_DEFAULT, postColor: '', post: 95,
		composerFamily: FONT_FAMILY_DEFAULT, composerCustomFamily: '', composerWeight: FONT_WEIGHT_DEFAULT,
		composerColor: '', composer: COMPOSER_FONT_SCALE_DEFAULT,
	});
	const IMAGE_PROFILE_DEFAULT = Object.freeze({ preset: '100', custom: 100 });
	const APPEARANCE_COLOR_KEYS = Object.freeze([
		'accentColor', 'linkColor', 'zebraColor', 'listZebraColor',
		'replyLineColor', 'quoteLineColor', 'dividerLineColor', 'floorPreviewColor',
	]);
	const APPEARANCE_COLOR_THEME_LIMITS = Object.freeze({
		accentColor: Object.freeze({ saturationMax: 82, light: [30, 55], dark: [58, 78] }),
		linkColor: Object.freeze({ saturationMax: 90, light: [30, 55], dark: [60, 80] }),
		zebraColor: Object.freeze({ saturationMax: 70, light: [92, 100], dark: [10, 20] }),
		listZebraColor: Object.freeze({ saturationMax: 38, light: [88, 98], dark: [10, 20] }),
		replyLineColor: Object.freeze({ saturationMax: 76, light: [30, 58], dark: [55, 78] }),
		quoteLineColor: Object.freeze({ saturationMax: 50, light: [72, 92], dark: [22, 42] }),
		dividerLineColor: Object.freeze({ saturationMax: 40, light: [80, 94], dark: [18, 34] }),
		floorPreviewColor: Object.freeze({ saturationMax: 88, light: [35, 58], dark: [58, 78] }),
	});
	const APPEARANCE_PROFILE_SETTING_KEYS = Object.freeze([
		'accentColor', 'linkColor', 'zebraColor', 'zebraRadius', 'listZebraColor',
		'structureColorsEnabled', 'replyLineColor', 'replyLineWidth', 'replyLineRadius',
		'quoteLineColor', 'quoteLineWidth', 'dividerLineColor', 'dividerLineWidth',
		'floorPreviewColor', 'floorPreviewHeight',
	]);
	const APPEARANCE_PROFILE_DEFAULT = Object.freeze({
		accentColor: ACCENT_COLOR_DEFAULT,
		accentColorDark: READER_THEME_VARIABLES.dark['--tertiary'],
		linkColor: LINK_COLOR_DEFAULT,
		linkColorDark: READER_THEME_VARIABLES.dark['--d-link-color'],
		zebraColor: ZEBRA_COLOR_DEFAULT,
		zebraColorDark: READER_THEME_VARIABLES.dark['--ldp-zebra-color'],
		zebraRadius: ZEBRA_RADIUS_DEFAULT,
		listZebraColor: LIST_ZEBRA_COLOR_DEFAULT,
		listZebraColorDark: READER_THEME_VARIABLES.dark['--primary-very-low'],
		structureColorsEnabled: true,
		replyLineColor: REPLY_LINE_COLOR_DEFAULT,
		replyLineColorDark: READER_THEME_VARIABLES.dark['--ldp-reply-line-color'],
		replyLineWidth: REPLY_LINE_WIDTH_DEFAULT,
		replyLineRadius: REPLY_LINE_RADIUS_DEFAULT,
		quoteLineColor: QUOTE_LINE_COLOR_DEFAULT,
		quoteLineColorDark: READER_THEME_VARIABLES.dark['--ldp-quote-line-color'],
		quoteLineWidth: QUOTE_LINE_WIDTH_DEFAULT,
		dividerLineColor: DIVIDER_LINE_COLOR_DEFAULT,
		dividerLineColorDark: READER_THEME_VARIABLES.dark['--ldp-divider-line-color'],
		dividerLineWidth: DIVIDER_LINE_WIDTH_DEFAULT,
		floorPreviewColor: FLOOR_PREVIEW_COLOR_DEFAULT,
		floorPreviewColorDark: FLOOR_PREVIEW_COLOR_DARK_DEFAULT,
		floorPreviewHeight: FLOOR_PREVIEW_HEIGHT_DEFAULT,
	});
	const APPEARANCE_PROFILE_VALUE_KEYS = Object.freeze(APPEARANCE_PROFILE_SETTING_KEYS.flatMap((key) =>
		APPEARANCE_COLOR_KEYS.includes(key) ? [key, `${key}Dark`] : [key]
	));
	const INTERFACE_FONT_TOKEN_BASES = Object.freeze({
		'--ldp-font-micro': 9,
		'--ldp-font-xs': 10,
		'--ldp-font-sm': 11,
		'--ldp-font-ui': 12,
		'--ldp-font-base': 13,
		'--ldp-font-md': 14,
		'--ldp-font-lg': 15,
		'--ldp-font-xl': 16,
		'--ldp-font-2xl': 17,
		'--ldp-font-3xl': 18,
	});
	const POST_FONT_BASE_PX = 14;
	const LAYOUT_MIN_RATIOS = Object.freeze({ left: 0, main: 40, gap: 0, timeline: 6, right: 0 });
	const READER_WINDOW_DEFAULT_WIDTH = 1080;
	const READER_WINDOW_DEFAULT_VIEWPORT_WIDTH = 0.94;
	const READER_WINDOW_DEFAULT_VIEWPORT_HEIGHT = 0.86;
	const READER_WINDOW_MIN_WIDTH = 360;
	const READER_WINDOW_MIN_HEIGHT = 320;
	const READER_WINDOW_COMPACT_WIDTH = 700;
	const READER_WINDOW_MARGIN = 8;
	const READER_SETTINGS_SURFACE_MARGIN = 8;
	const READER_SETTINGS_SHORT_HEIGHT = 560;
	const READER_WINDOW_HANDLE_DOCK_THRESHOLD = 38;
	const READER_EMBED_MIN_WIDTH = 360;
	const READER_EMBED_DEFAULT_WIDTH = 520;
	const READER_HOST_MIN_WIDTH = 680;
	const LIST_READER_MODES = Object.freeze(['floating', 'fullpage', 'embed-left', 'embed-right']);
	const TOPIC_READER_MODES = Object.freeze(['floating', 'fullpage']);
	const READER_PRESENTATIONS = Object.freeze({
		floating: Object.freeze({ mode: 'floating', floating: true, fullPage: false, embedded: false, side: '' }),
		fullpage: Object.freeze({ mode: 'fullpage', floating: false, fullPage: true, embedded: false, side: '' }),
		'embed-left': Object.freeze({ mode: 'embed-left', floating: false, fullPage: false, embedded: true, side: 'left' }),
		'embed-right': Object.freeze({ mode: 'embed-right', floating: false, fullPage: false, embedded: true, side: 'right' }),
	});
	const BOOST_COPY_MAX_LENGTH = 16;
	const BOOST_COPY_MODES = Object.freeze(['counter', 'text']);
	const BOOST_COPY_DEFAULTS = Object.freeze({
		mode: 'counter',
		prefix: '',
		counterMarker: '+',
		counterStep: 1,
		fixedSuffix: '',
	});
	const BALANCED_PERFORMANCE = PERFORMANCE_PRESETS.balanced;
	const NOTIFICATION_PAGE_SIZE = 24;
	const NOTIFICATION_CACHE_FRESH_FOR = MINUTE;
	const NOTIFICATION_LIVE_REFRESH_DEBOUNCE = 240;
	const LIGHTBOX_COMMENT_RELATION_CACHE_FRESH_FOR = 10 * MINUTE;
	const HISTORY_PAGE_SIZE = 20;
	const BOOKMARKS_PAGE_SIZE = 20;
	const GIVEN_REACTIONS_PAGE_SIZE = 20;
	const GIVEN_LIKES_PAGE_SIZE = 60;
	const USER_CARD_CACHE_FRESH_FOR = 24 * HOUR;
	const USER_FOLLOW_LIST_CACHE_FRESH_FOR = 5 * MINUTE;
	const USER_FOLLOW_LIST_CACHE_MAX_ENTRIES = 32;
	const NOTIFICATION_AVATAR_CACHE_MAX_ENTRIES = 512;
	const USER_FOLLOW_LIST_PAGE_SIZE = 10;
	const NOTIFICATION_GROUPS = {
		all: { label: '全部', types: [] },
		replies: { label: '回复', types: ['mentioned', 'replied', 'quoted', 'posted', 'group_mentioned'] },
		likes: { label: '点赞', types: ['liked', 'liked_consolidated', 'reaction'] },
		boosts: { label: 'Boost', types: ['boost'] },
		messages: { label: '私信', types: ['private_message', 'invited_to_private_message', 'group_message_summary'] },
	};
	const PERSISTENT_CACHE_CONFIG = {
		history: { storageKey: LDP_HISTORY_KEY, maxAge: 365 * DAY },
		topics: { maxAge: 180 * DAY, maxEntries: 8, storedMaxEntries: 64, storedMaxBytes: 64 * 1024 * 1024 },
		topicNavIcons: { storageKey: LDP_TOPIC_NAV_ICON_CACHE_KEY, maxAge: 180 * DAY, maxEntries: 512 },
		users: {
			storageKey: LDP_USER_CARD_CACHE_KEY,
			maxAge: 180 * DAY,
			maxEntries: 128,
			storedMaxEntries: 256,
			storedMaxBytes: 16 * 1024 * 1024,
		},
		notifications: {
			storageKey: LDP_NOTIFICATION_CACHE_KEY,
			maxAge: 180 * DAY,
			maxEntries: 32,
			storedMaxEntries: 64,
			storedMaxBytes: 8 * 1024 * 1024,
		},
		responses: { maxAge: 365 * DAY, storedMaxEntries: 256, storedMaxBytes: 32 * 1024 * 1024 },
		assets: { maxAge: 90 * DAY },
	};
	const CACHE_TYPES = [
		{ key: 'history', label: '浏览历史', help: '勾选“浏览历史”后再点下方清理,会删除阅读器保存的主题、最近阅读楼层和查看时间;不会删除浏览器本身的访问历史。' },
		{ key: 'topics', label: '主题与楼层', help: '勾选“主题与楼层”后清理,会删除本机保存的主题元数据、楼层正文、楼层关系、分类标签图标和相关 API 响应;下次打开相关主题时会重新联网获取。' },
		{ key: 'users', label: '用户卡', help: '勾选“用户卡”后清理,会删除用户名、简介、徽章、用户组等资料快照和相关接口响应;头像图片仍归“头像与图片”单独管理。' },
		{ key: 'notifications', label: '消息', help: '勾选“消息”后清理,会删除通知分页、已读状态、跳转位置和发送者摘要;头像资源保留,也不会删除站点账号里的真实消息。' },
		{ key: 'responses', label: 'API 响应(含收藏与回应)', help: '勾选“API 响应(含收藏与回应)”后清理,会删除收藏列表、给出的回应与点赞、表情注册表和其他通用接口结果;不会撤销站点上的真实收藏或回应。' },
		{ key: 'assets', label: '头像与图片', help: '勾选“头像与图片”后清理,会分别删除头像、Boost 表情和实际查看过的原图资源;不会删除帖子中的线上图片。' },
	];
	const BOOKMARK_TAB_TYPES = Object.freeze(['Reaction', 'Topic', 'Post']);
	const BOOKMARK_TAB_LABELS = Object.freeze({ Reaction: '回应', Topic: '帖子', Post: '楼层' });
	const HISTORY_EDGE_TRIGGER_MIN = 0;
	const HISTORY_EDGE_TRIGGER_MAX = 15;
	const HISTORY_EDGE_TRIGGER_DEFAULT = 15;
	const READER_QUEUE_PREFETCH_BATCH_SIZE = 20;
	const READER_QUEUE_PREFETCH_MAX_POSTS = 200;
	const READER_QUEUE_PREFETCH_URL_BEFORE_POSTS = 100;
	const READER_QUEUE_PREFETCH_HEAD_POSTS = 40;
	const READER_QUEUE_PREFETCH_BEFORE_POSTS = 80;
	const READER_QUEUE_PREFETCH_MEDIA_CONCURRENCY = 2;
	const READER_QUEUE_DOCK_THRESHOLD_RATIO = .01;
	const READER_LOADING_ANIMATION_KEYS = Object.freeze([
		'portal', 'constellation', 'corridor', 'typewave', 'crystal',
		'marginalia', 'chapters', 'quoteecho', 'footnotes', 'inkverse',
	]);
	const DEFAULT_PREFS = {
		topicReaderMode: 'fullpage',
		imageProfile: IMAGE_PROFILE_DEFAULT,
		lightboxOriginalByDefault: false,
		lightboxCommentsExpandedByDefault: true,
		lightboxDescriptionExpanded: true,
		lightboxDescriptionHeight: LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT,
		lightboxCommentsWidthPercent: LIGHTBOX_COMMENTS_WIDTH_DEFAULT,
		themeMode: 'system',
		fontRenderingEnabled: true,
		fontRenderingOnHost: true,
		...HOST_FONT_DEFAULTS,
		hostEmbeddedTitleScale: HOST_EMBED_SIZE_SETTINGS.title.defaultScale,
		hostEmbeddedAvatarScale: HOST_EMBED_SIZE_SETTINGS.avatar.defaultScale,
		hostEmbeddedStatsScale: HOST_EMBED_SIZE_SETTINGS.stats.defaultScale,
		hostEmbeddedLabelCardScale: HOST_EMBED_SIZE_SETTINGS.labelCard.defaultScale,
		fontProfile: READER_FONT_DEFAULT,
		appearanceProfile: APPEARANCE_PROFILE_DEFAULT,
		...performancePrefsPatch(BALANCED_PERFORMANCE, 'balanced'),
		layoutProfile: READER_LAYOUT_DEFAULT,
		...normalizeReaderWindowPreferenceGroup({}, 'readerWindow'),
		listReaderMode: 'floating',
		listReaderEmbedWidth: READER_EMBED_DEFAULT_WIDTH,
		...normalizeWindowGeometryPreferenceGroup({}, COMPOSER_WINDOW_PREFIX),
		historySortMode: 'recent-viewed',
		bookmarkTabOrder: BOOKMARK_TAB_TYPES,
		historyButtonsAlwaysVisible: false,
		historyEdgeTriggerPercent: HISTORY_EDGE_TRIGGER_DEFAULT,
		loadingAnimation: 'random',
		openTopicsAtFirstPost: false,
		expandNestedRepliesByDefault: true,
		expandLeafNestedReplies: false,
		...jumpHighlightPrefsPatch(JUMP_HIGHLIGHT_DEFAULTS),
		boostCopyMode: BOOST_COPY_DEFAULTS.mode,
		boostCopyPrefix: BOOST_COPY_DEFAULTS.prefix,
		boostCopyCounterMarker: BOOST_COPY_DEFAULTS.counterMarker,
		boostCopyCounterStep: BOOST_COPY_DEFAULTS.counterStep,
		boostCopyFixedSuffix: BOOST_COPY_DEFAULTS.fixedSuffix,
	};
	const bypassReaderForThisTab = consumeNativeBypassTabSignal() || shouldBypassReaderUrl(location.href);
	if (bypassReaderForThisTab) stripBypassParamFromUrl();
	const INITIAL_TOPIC_ID = (extractTopicRouteFromUrl(location.href) || {}).topicId || null;
	let ME_USERNAME = null;
	let ME_CURRENT_USER = null;
	const DISCOURSE_MODULE_CACHE = new Map();
	const DISCOURSE_LOOKUP_CACHE = new Map();
	let CONNECT_TRUST_CACHE = null;
	let CONNECT_TRUST_REQUEST = null;
	const SHARED_ISSUE_FORBIDDEN_TOPIC_IDS = new Set();
	let NOTIFICATION_UNREAD_COUNT = 0;
	let ACTIVE_READER_REQUEST_SCHEDULER = null;
	const READER_QUEUE_ENTRIES = [];
	const READER_QUEUE_BY_TOPIC = new Map();
	let READER_QUEUE_ACTIVE_TOPIC_ID = '';
	let READER_QUEUE_PREFETCH_RUNNING = false;
	let READER_QUEUE_PERSIST_TIMER = 0;
	let READER_QUEUE_RESTORING = false;
	let READER_QUEUE_SURFACE_STATE = { x: null, y: null, dock: '' };
	let SHARED_REQUEST_COORDINATOR = null;
	let READER_PORTAL_HOST = null;
	let READER_ICON_TOOLTIP_CLEANUP = null;
	if (INITIAL_TOPIC_ID) PAGE_ROOT.classList.add('ldp-route-takeover');

	// --- 楼中楼分批加载 & 请求节流 相关配置 ---
	const SUB_REPLY_PAGE_SIZE = 10;     // 楼中楼分页每页条数
	const SUB_REPLY_FETCH_SIZE = 20;    // Discourse replies 接口单次返回上限
	const REPLIES_HOVER_DELAY = 40;     // 楼层真正进入视口并短暂停留后触发抓取(ms)
	const POST_REQUEST_MAX_429_RETRIES = 1;
	const POST_REQUEST_429_BACKOFF = 1500;
	const POST_REQUEST_TIMEOUT = 15000;
	const READER_REQUEST_QUEUE_LIMIT = 24;
	const READER_REQUEST_CONCURRENCY_RECOVERY_SUCCESSES = 6;
	const READER_REQUEST_RATE_PROBE_SUCCESSES = 6;
	const READER_REQUEST_PROBE_RATE_STEP = 1.12;
	const READER_REQUEST_RECOVERY_PROBE_INTERVAL = 1200;
	const READER_REQUEST_RATE_MEMORY_TTL = 10 * 60 * 1000;
	const READER_ENDPOINT_429_EVIDENCE_WINDOW_MS = 4000;
	const READER_ENDPOINT_429_BASE_BLOCK_MS = 5000;
	const READER_ENDPOINT_429_MAX_BLOCK_MS = 5 * 60 * 1000;
	const READER_ENDPOINT_429_STATE_TTL = 10 * 60 * 1000;
	const READER_ENDPOINT_429_MAX_ENTRIES = 128;
	const READER_REQUEST_SHORT_WINDOW_MS = 10 * 1000;
	const READER_REQUEST_LONG_WINDOW_MS = 60 * 1000;
	const SHARED_REQUEST_POLICY_TTL = 30 * 1000;
	const SHARED_CACHE_FLIGHT_TTL = READER_REQUEST_LONG_WINDOW_MS + POST_REQUEST_TIMEOUT + 15000;
	const SHARED_CACHE_FLIGHT_STALE_MS = 35 * 1000;
	// Discourse defaults; each site may override them, so 429 feedback remains authoritative.
	const READER_REQUEST_DEFAULT_SHORT_CEILING = 50;
	const READER_REQUEST_DEFAULT_LONG_CEILING = 200;
	const REQUEST_FLOW_MAX_ENTRIES = 1200;
	const REQUEST_FLOW_RETENTION_MS = 15 * 60 * 1000;
	const REQUEST_FLOW_TRACE_WINDOW_MS = 10 * 1000;
	const REQUEST_FLOW_SLOW_MS = 1800;
	const REQUEST_FLOW_EVENTS = [];
	const REQUEST_FLOW_RESOURCE_DROPS = [];
	const REQUEST_FLOW_FETCH_META = Symbol('ldpRequestFlowFetchMeta');
	const REQUEST_FLOW_AJAX_STATE = new WeakMap();
	let REQUEST_FLOW_RESOURCE_OBSERVER = null;
	let REQUEST_FLOW_AJAX_BOUND = false;
	const REQUEST_FLOW_TYPE_META = Object.freeze({
		topic: Object.freeze({ label: '正文' }),
		nested: Object.freeze({ label: '楼中楼' }),
		avatar: Object.freeze({ label: '头像' }),
		media: Object.freeze({ label: '图片媒体' }),
		user: Object.freeze({ label: '用户资料' }),
		bookmark: Object.freeze({ label: '收藏' }),
		notification: Object.freeze({ label: '消息' }),
		realtime: Object.freeze({ label: '实时通道' }),
		presence: Object.freeze({ label: '在线状态' }),
		reaction: Object.freeze({ label: '回应操作' }),
		search: Object.freeze({ label: '搜索' }),
		read: Object.freeze({ label: '已读上报' }),
		asset: Object.freeze({ label: '静态资源' }),
		other: Object.freeze({ label: '其他' }),
	});
	const REQUEST_FLOW_SOURCE_LABELS = Object.freeze({ reader: '阅读器', host: '宿主', browser: '资源' });
	const REQUEST_FLOW_WAIT_REASON_LABELS = Object.freeze({
		priority: '优先级', concurrency: '并发槽', cooldown: '429 退避', 'endpoint-cooldown': '端点熔断',
		interval: '启动间隔', '10s': '10 秒窗口', '60s': '60 秒窗口',
	});
	const RESOURCE_MONITOR_SCOPE_LABELS = Object.freeze({ reader: '阅读器', host: '宿主/未标', shared: '页面共享' });
	let REQUEST_FLOW_SEQUENCE = 0;

	function requestFlowNow() {
		const clock = globalThis.performance;
		if (clock && Number.isFinite(clock.timeOrigin) && isFunction(clock.now)) {
			return clock.timeOrigin + clock.now();
		}
		return Date.now();
	}

	function requestFlowCallSite() {
		let stack = '';
		try { stack = String(new Error().stack || ''); } catch (error) {}
		const internalFrame = /\b(?:requestFlowCallSite|beginRequestFlow|trackedFetch|trackedSend|waitForReaderRequestPermit|requestCachedJSON|requestJSON|fetchJSON|runTask|apiSend)\b/;
		const frame = stack.split('\n').map((line) => line.trim()).find((line, index) =>
			index > 0 && line && !internalFrame.test(line) && !/\b(?:native code|Promise\.then)\b/.test(line));
		if (!frame) return '';
		return frame.replace(/(?:https?|chrome-extension):\/\/[^\s)]+/g, (raw) => {
			const locationSuffix = raw.match(/(:\d+:\d+)$/)?.[1] || '';
			const source = locationSuffix ? raw.slice(0, -locationSuffix.length) : raw;
			try {
				const url = new URL(source);
				return `${url.origin}${url.pathname}${locationSuffix}`;
			} catch (error) {
				return source.split(/[?#]/, 1)[0] + locationSuffix;
			}
		}).replace(/^at\s+/, '').slice(0, 220);
	}

	function requestFlowEventIndex(startedAt, id = -Infinity) {
		let low = 0;
		let high = REQUEST_FLOW_EVENTS.length;
		while (low < high) {
			const middle = Math.floor((low + high) / 2);
			const current = REQUEST_FLOW_EVENTS[middle];
			if (current.startedAt < startedAt ||
					(current.startedAt === startedAt && current.id <= id)) low = middle + 1;
			else high = middle;
		}
		return low;
	}

	function insertRequestFlowEvent(event) {
		const last = REQUEST_FLOW_EVENTS[REQUEST_FLOW_EVENTS.length - 1];
		if (!last || last.startedAt < event.startedAt ||
				(last.startedAt === event.startedAt && last.id <= event.id)) {
			REQUEST_FLOW_EVENTS.push(event);
			return;
		}
		REQUEST_FLOW_EVENTS.splice(requestFlowEventIndex(event.startedAt, event.id), 0, event);
	}

	function requestFlowUrl(input) {
		try {
			const value = input && typeof input === 'object' && input.url ? input.url : input;
			if (value == null || String(value) === '') return null;
			return new URL(String(value), location.href);
		} catch (error) {
			return null;
		}
	}

	function requestFlowPath(url) {
		if (!url) return '未知请求';
		if (!['http:', 'https:'].includes(url.protocol)) return `${url.protocol.replace(':', '') || '本地'}资源`;
		const prefix = url.origin === location.origin ? '' : url.host;
		return `${prefix}${url.pathname || '/'}`.slice(0, 180);
	}

	function requestFlowType(url, initiatorType = '', method = 'GET') {
		const path = String(url?.pathname || '').toLowerCase();
		const host = String(url?.hostname || '').toLowerCase();
		const initiator = String(initiatorType || '').toLowerCase();
		if (/\/posts\/\d+\/replies(?:\.json)?$/.test(path)) return 'nested';
		if (/\/user_avatar\/|\/letter_avatar\//.test(path) || /avatar/.test(host)) return 'avatar';
		if (/\/(?:uploads|optimized)\//.test(path) || /\.(?:avif|gif|jpe?g|png|webp|svg|mp4|webm|mp3|ogg)(?:$|\/)/.test(path)) return 'media';
		if (/\/bookmarks?(?:\/|\.|$)|remove_bookmarks/.test(path)) return 'bookmark';
		if (/\/notifications?(?:\/|\.|$)/.test(path)) return 'notification';
		if (/\/message-bus(?:\/|$)/.test(path)) return 'realtime';
		if (/\/presence\//.test(path)) return 'presence';
		if (/\/search(?:\/|\.|$)|\/filter(?:\/|\.|$)/.test(path)) return 'search';
		if (/topic-timings|\/timings(?:\/|\.|$)/.test(path)) return 'read';
		if (/\/u\/|\/directory_items|\/session\/current|\/category-experts\//.test(path)) return 'user';
		if (/post_actions|user_actions|discourse-reactions|\/boosts?(?:\/|\.|$)|\/emojis\.json$/.test(path)) return 'reaction';
		if (/\/t\/|\/posts(?:\/|\.|$)|\/posts\/by_number\//.test(path)) return 'topic';
		if (!['GET', 'HEAD'].includes(method)) return 'reaction';
		if (['img', 'image', 'video', 'audio'].includes(initiator)) return 'media';
		if (['css', 'link', 'script', 'font'].includes(initiator)) return 'asset';
		return 'other';
	}

	function pruneRequestFlowEvents(at = Date.now()) {
		const cutoff = at - REQUEST_FLOW_RETENTION_MS;
		let expired = 0;
		while (expired < REQUEST_FLOW_EVENTS.length && REQUEST_FLOW_EVENTS[expired].startedAt < cutoff) expired++;
		if (expired) REQUEST_FLOW_EVENTS.splice(0, expired);
		if (REQUEST_FLOW_EVENTS.length > REQUEST_FLOW_MAX_ENTRIES) {
			REQUEST_FLOW_EVENTS.splice(0, REQUEST_FLOW_EVENTS.length - REQUEST_FLOW_MAX_ENTRIES);
		}
		while (REQUEST_FLOW_RESOURCE_DROPS.length && REQUEST_FLOW_RESOURCE_DROPS[0].at < cutoff) {
			REQUEST_FLOW_RESOURCE_DROPS.shift();
		}
	}

	function createRequestFlowEvent(url, details) {
		const { queuedAt, permittedAt, startedAt } = details;
		return {
			id: ++REQUEST_FLOW_SEQUENCE,
			...details,
			endedAt: Number(details.endedAt) || 0,
			duration: 0,
			permitWait: Math.max(0, permittedAt - queuedAt),
			dispatchDuration: Math.max(0, startedAt - permittedAt),
			priority: Number.isFinite(details.priority) ? details.priority : null,
			recoveryProbe: details.recoveryProbe === true,
			attempt: Math.max(1, Math.floor(Number(details.attempt) || 1)),
			waitReason: String(details.waitReason || ''),
			callSite: details.callSite == null ? requestFlowCallSite() : String(details.callSite),
			type: details.type || requestFlowType(url, details.transport, details.method),
			path: String(details.path || requestFlowPath(url)),
			sameOrigin: !!url && url.origin === BASE,
			status: details.controlOnly ? 0 : null,
			size: 0,
			pending: details.controlOnly !== true,
			controlOnly: details.controlOnly === true,
			controlReason: String(details.controlReason || ''),
			rateLimitCode: '',
			retryAfter: '',
			serverLimit: '',
			serverRemaining: '',
			serverReset: '',
			error: '',
			resourceTimed: false,
		};
	}

	function beginRequestFlow(input, init = {}, transport = 'fetch') {
		const url = requestFlowUrl(input);
		const method = String(init.method || (input?.method) || 'GET').toUpperCase();
		const meta = init?.[REQUEST_FLOW_FETCH_META] || {};
		const startedAt = Number.isFinite(Number(meta.startedAt)) ? Number(meta.startedAt) : requestFlowNow();
		const queuedAt = Number.isFinite(Number(meta.queuedAt))
			? Math.min(startedAt, Math.max(startedAt - REQUEST_FLOW_RETENTION_MS, Number(meta.queuedAt)))
			: startedAt;
		const permittedAt = Number.isFinite(Number(meta.permittedAt))
			? Math.min(startedAt, Math.max(queuedAt, Number(meta.permittedAt)))
			: startedAt;
		const event = createRequestFlowEvent(url, {
			queuedAt,
			permittedAt,
			startedAt,
			endedAt: meta.endedAt,
			method,
			transport,
			source: meta.source === 'reader' ? 'reader' : 'host',
			priority: meta.priority,
			recoveryProbe: meta.recoveryProbe,
			attempt: meta.attempt,
			waitReason: meta.waitReason,
			callSite: meta.callSite,
			type: String(meta.type || requestFlowType(url, meta.classificationTransport || transport, method)),
			controlOnly: meta.controlOnly,
			controlReason: meta.controlReason,
			path: meta.path,
		});
		insertRequestFlowEvent(event);
		pruneRequestFlowEvents(event.startedAt);
		if (SHARED_REQUEST_COORDINATOR && !event.controlOnly) SHARED_REQUEST_COORDINATOR.recordHostStart(event);
		return event;
	}

	function recordRequestFlowControl(input, options = {}) {
		const endedAt = requestFlowNow();
		return beginRequestFlow(input, {
			method: options.method,
			[REQUEST_FLOW_FETCH_META]: {
				...options,
				source: 'reader',
				startedAt: endedAt,
				permittedAt: endedAt,
				endedAt,
				controlOnly: true,
				controlReason: options.controlReason || 'cancelled',
				classificationTransport: 'fetch',
			},
		}, 'scheduler');
	}

	const REQUEST_FLOW_RESPONSE_HEADERS = {
		rateLimitCode: ['Discourse-Rate-Limit-Error-Code', 'X-Discourse-Rate-Limit-Error-Code'],
		retryAfter: ['Retry-After'],
		serverLimit: ['RateLimit-Limit', 'X-RateLimit-Limit'],
		serverRemaining: ['RateLimit-Remaining', 'X-RateLimit-Remaining'],
		serverReset: ['RateLimit-Reset', 'X-RateLimit-Reset'],
	};

	function requestFlowHeader(headers, names, splitHeaders = null) {
		if (typeof headers === 'string') {
			const lines = splitHeaders || headers.split(/\r?\n/);
			for (const name of names) {
				const prefix = `${name.toLowerCase()}:`;
				const line = lines.find((candidate) => candidate.toLowerCase().startsWith(prefix));
				if (line) return line.slice(line.indexOf(':') + 1).trim();
			}
			return '';
		}
		if (!headers || !isFunction(headers.get)) return '';
		for (const name of names) {
			const value = headers.get(name);
			if (value != null && value !== '') return String(value);
		}
		return '';
	}

	function requestFlowRateLimitNumber(value) {
		const match = String(value == null ? '' : value).match(/-?\d+(?:\.\d+)?/);
		if (!match) return NaN;
		const number = Number(match[0]);
		return Number.isFinite(number) ? number : NaN;
	}

	function requestFlowRateLimitResetDelay(value, at = Date.now()) {
		const raw = String(value == null ? '' : value).trim();
		const number = Number(raw);
		if (!Number.isFinite(number)) {
			const resetAt = Date.parse(raw);
			return Number.isFinite(resetAt) ? Math.max(0, resetAt - at) : 0;
		}
		if (!(number > 0)) return 0;
		if (number > 1e12) return Math.max(0, number - at);
		if (number > at / 1000 - DAY / 1000) return Math.max(0, number * 1000 - at);
		return Math.max(0, number * 1000);
	}

	function rateLimitWindowFromCode(code) {
		const short = /10[_-]?(?:secs?|seconds?)/i.test(String(code || ''));
		const long = /60[_-]?(?:secs?|seconds?)|minute/i.test(String(code || ''));
		return {
			short, long, known: short || long,
			label: short && long ? '10s+60s' : short ? '10s' : long ? '60s' : 'unknown',
		};
	}

	function requestFlowRateLimitPolicies(event) {
		const rawLimit = String(event?.serverLimit || '');
		if (!rawLimit) return [];
		const remainingParts = String(event?.serverRemaining || '').split(',');
		const resetParts = String(event?.serverReset || '').split(',');
		const rateWindow = rateLimitWindowFromCode(event?.rateLimitCode);
		const byWindow = new Map();
		rawLimit.split(',').forEach((part, index) => {
			const limit = requestFlowRateLimitNumber(part);
			if (!(limit > 0)) return;
			const explicitWindow = String(part).match(/(?:^|;)\s*w\s*=\s*"?(\d+)/i);
			const windowSeconds = explicitWindow ? Number(explicitWindow[1]) : 0;
			const resetDelay = requestFlowRateLimitResetDelay(resetParts[index] || resetParts[0]);
			let window = windowSeconds > 0 && windowSeconds <= 15
				? '10s'
				: windowSeconds > 15 && windowSeconds <= 90
					? '60s'
					: '';
			if (explicitWindow && !window) return;
			if (!window) {
				const { short: hitShort, long: hitLong } = rateWindow;
				if (resetDelay > 90000 && !hitShort && !hitLong) return;
				window = hitShort && !hitLong
					? '10s'
					: hitLong && !hitShort
						? '60s'
						: limit <= READER_REQUEST_DEFAULT_SHORT_CEILING * 2
							? '10s'
							: '60s';
			}
			const policy = {
				window,
				limit,
				remaining: requestFlowRateLimitNumber(remainingParts[index] || (index === 0 ? remainingParts[0] : '')),
				resetDelay,
			};
			const previous = byWindow.get(window);
			if (!previous || policy.limit < previous.limit) byWindow.set(window, policy);
		});
		return [...byWindow.values()];
	}

	function finishRequestFlow(event, details = {}) {
		if (!event || !event.pending) return;
		event.endedAt = Number(details.endedAt) || requestFlowNow();
		if (!event.resourceTimed) {
			event.duration = Number.isFinite(Number(details.duration))
				? Math.max(0, Number(details.duration))
				: Math.max(0, event.endedAt - event.startedAt);
			event.size = Math.max(0, Number(details.size) || 0);
		}
		event.status = Number.isFinite(Number(details.status)) ? Number(details.status) : 0;
		event.pending = false;
		event.rateLimitCode = String(details.rateLimitCode || '');
		event.retryAfter = String(details.retryAfter || '');
		event.serverLimit = String(details.serverLimit || '');
		event.serverRemaining = String(details.serverRemaining || '');
		event.serverReset = String(details.serverReset || '');
		event.error = String(details.error || '');
		if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.recordHostFinish(event);
		const hostApiEvent = sharedRequestApiEvent(event);
		const readerApiEvent = sharedRequestApiEvent(event, 'reader');
		const successfulApiEvent = (hostApiEvent || readerApiEvent) && event.status >= 200 && event.status < 400;
		let schedulerState = null;
		if (SHARED_REQUEST_COORDINATOR && (successfulApiEvent || (hostApiEvent && event.status === 429))) {
			try { schedulerState = ACTIVE_READER_REQUEST_SCHEDULER?.snapshot(); } catch (error) {}
		}
		if (successfulApiEvent && SHARED_REQUEST_COORDINATOR && event.serverLimit) {
			SHARED_REQUEST_COORDINATOR.noteServerLimits({
				policies: requestFlowRateLimitPolicies(event),
				rateTarget: schedulerState ? schedulerState.requestRateTarget / 100 : 0.8,
				shortBudgetCap: schedulerState ? schedulerState.configuredShortWindowBudget : 40,
				longBudgetCap: schedulerState ? schedulerState.configuredLongWindowBudget : 160,
			});
		}
		if (successfulApiEvent && SHARED_REQUEST_COORDINATOR) {
			SHARED_REQUEST_COORDINATOR.noteSuccess({
				host: hostApiEvent,
				recoveryProbe: readerApiEvent && event.recoveryProbe,
				shortBudgetCap: schedulerState ? schedulerState.configuredShortWindowBudget : 40,
				longBudgetCap: schedulerState ? schedulerState.configuredLongWindowBudget : 160,
			});
		}
		if (hostApiEvent && event.status === 429 && SHARED_REQUEST_COORDINATOR) {
			const retrySeconds = event.retryAfter.trim() ? Number(event.retryAfter) : NaN;
			const retryDate = !Number.isFinite(retrySeconds) && event.retryAfter ? Date.parse(event.retryAfter) : 0;
			const wait = Number.isFinite(retrySeconds)
				? retrySeconds * 1000
				: retryDate
					? Math.max(0, retryDate - Date.now())
					: POST_REQUEST_429_BACKOFF;
			SHARED_REQUEST_COORDINATOR.noteRateLimit({
				wait: Math.max(1000, wait),
				window: rateLimitWindowFromCode(event.rateLimitCode).label,
				shortBudget: schedulerState ? schedulerState.shortWindowBudget : 40,
				longBudget: schedulerState ? schedulerState.longWindowBudget : 160,
			});
		}
	}

	function finishResponseRequestFlow(event, responseUrl, status, headers) {
		const finalUrl = requestFlowUrl(responseUrl);
		if (finalUrl) {
			event.path = requestFlowPath(finalUrl);
			event.sameOrigin = finalUrl.origin === BASE;
			if (event.type === 'other') {
				event.type = requestFlowType(finalUrl, event.transport, event.method);
			}
		}
		const splitHeaders = typeof headers === 'string' ? headers.split(/\r?\n/) : null;
		const contentLength = Number(requestFlowHeader(headers, ['Content-Length'], splitHeaders));
		finishRequestFlow(event, {
			status,
			size: Number.isFinite(contentLength) ? contentLength : 0,
			...Object.fromEntries(Object.entries(REQUEST_FLOW_RESPONSE_HEADERS).map(
				([key, names]) => [key, requestFlowHeader(headers, names, splitHeaders)]
			)),
		});
		return finalUrl;
	}

	function hostTopicResponseId(url) {
		if (!url || url.origin !== BASE) return 0;
		const match = url.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/|\.json|$)/);
		return match ? Number(match[1]) : 0;
	}

	function storeHostTopicResponse(event, url, payload) {
		if (!event || event.source !== 'host' || event.method !== 'GET') return;
		const topicId = hostTopicResponseId(url);
		const postStream = payload?.post_stream;
		if (!topicId || Number(payload?.id) !== topicId || !postStream ||
			!Array.isArray(postStream.stream) || !Array.isArray(postStream.posts)) return;
		const key = String(topicId);
		const readerEntry = TOPIC_DATA_CACHE.get(key);
		if (readerEntry && readerEntry.scope === responseCacheAuthScope() &&
				topicCacheEntryTimestamp(readerEntry) >= Number(event.startedAt || 0)) return;
		const hostEntry = HOST_TOPIC_DATA_CACHE.get(key);
		if (hostEntry && Number(hostEntry.startedAt || 0) > event.startedAt) return;
		HOST_TOPIC_DATA_CACHE.delete(key);
		HOST_TOPIC_DATA_CACHE.set(key, { topic: payload, at: Date.now(), startedAt: event.startedAt });
		setCachedTopicData(topicId, payload, { fetchedAt: Date.now(), source: 'host' });
		while (HOST_TOPIC_DATA_CACHE.size > HOST_TOPIC_CACHE_MAX_ENTRIES) {
			HOST_TOPIC_DATA_CACHE.delete(HOST_TOPIC_DATA_CACHE.keys().next().value);
		}
	}

	function captureHostTopicXhrResponse(event, xhr, url) {
		if (!xhr || event.source !== 'host' || event.method !== 'GET' || xhr.status < 200 || xhr.status >= 300 ||
			!hostTopicResponseId(url)) return;
		try {
			const contentType = String(xhr.getResponseHeader('Content-Type') || '').toLowerCase();
			if (!contentType.includes('json')) return;
			const payload = xhr.responseType === 'json' ? xhr.response : JSON.parse(xhr.responseText);
			storeHostTopicResponse(event, url, payload);
		} catch (error) {}
	}

	function recordResourceRequestFlow(entry) {
		const initiator = String(entry?.initiatorType || '').toLowerCase();
		if (!entry) return;
		const url = requestFlowUrl(entry.name);
		if (!url || !['http:', 'https:'].includes(url.protocol)) return;
		const startedAt = performance.timeOrigin + Number(entry.startTime || 0);
		const duration = Math.max(0, Number(entry.duration) || 0);
		const size = Math.max(0, Number(entry.transferSize || entry.encodedBodySize) || 0);
		const path = requestFlowPath(url);
		if (['fetch', 'xmlhttprequest'].includes(initiator)) {
			let matchingRequest = null;
			for (let index = requestFlowEventIndex(startedAt - 100); index < REQUEST_FLOW_EVENTS.length; index++) {
				const event = REQUEST_FLOW_EVENTS[index];
				const eventStartedAt = Number(event.startedAt);
				if (eventStartedAt > startedAt + 100) break;
				if (!Number.isFinite(eventStartedAt) || event.controlOnly || event.resourceTimed ||
						event.transport !== initiator || event.path !== path) continue;
				if (!matchingRequest || Math.abs(eventStartedAt - startedAt) < Math.abs(matchingRequest.startedAt - startedAt)) {
					matchingRequest = event;
				}
			}
			if (matchingRequest) {
				Object.assign(matchingRequest, { resourceTimed: true, duration, size });
				return;
			}
		}
		const event = createRequestFlowEvent(url, {
			queuedAt: startedAt,
			permittedAt: startedAt,
			startedAt,
			method: 'GET',
			transport: initiator || 'resource',
			source: 'browser',
			priority: null,
			callSite: initiator ? `${initiator} 资源加载` : '浏览器资源加载',
			type: requestFlowType(url, initiator, 'GET'),
			path,
		});
		finishRequestFlow(event, {
			endedAt: performance.timeOrigin + Number(entry.responseEnd || entry.startTime || 0),
			duration,
			status: Number(entry.responseStatus) || 0,
			size,
		});
		event.resourceTimed = true;
		insertRequestFlowEvent(event);
	}

	const SHARED_REQUEST_API_TRANSPORTS = Object.freeze({ host: ['fetch', 'xmlhttprequest'], reader: ['fetch'] });
	function sharedRequestApiEvent(event, source = 'host') {
		return !!event && event.source === source && event.sameOrigin === true &&
			SHARED_REQUEST_API_TRANSPORTS[source]?.includes(event.transport) === true &&
			!['avatar', 'media', 'asset', 'realtime'].includes(event.type);
	}

	function createSharedRequestCoordinator() {
		const contextId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
		const lockName = `${LDP_SHARED_REQUEST_CHANNEL}:lock`;
		const cacheLockName = `${LDP_SHARED_REQUEST_CHANNEL}:cache-lock`;
		const waiters = new Set();
		const cacheListeners = new Set();
		let sequence = 0;
		let destroyed = false;
		let channel = null;

		const emptyState = () => ({
			version: 2,
			updatedAt: 0,
			events: [],
			intents: [],
			active: [],
			policies: [],
			flights: [],
			cacheEpoch: 0,
			shortBudget: 0,
			longBudget: 0,
			shortServerBudget: 0,
			longServerBudget: 0,
			cooldownUntil: 0,
			nextProbeAt: 0,
			shortServerBlockUntil: 0,
			longServerBlockUntil: 0,
			rateLimited: false,
			successfulSinceLimit: 0,
			lastRateLimitWindow: '',
			learnedUntil: 0,
		});

		const requestPolicy = (source = {}, policyContextId = contextId) => ({
			contextId: String(policyContextId),
			shortBudget: Math.max(1, Math.floor(Number(source.shortBudget) || READER_REQUEST_DEFAULT_SHORT_CEILING)),
			longBudget: Math.max(1, Math.floor(Number(source.longBudget) || READER_REQUEST_DEFAULT_LONG_CEILING)),
			minInterval: Math.max(0, Math.floor(Number(source.minInterval) || 0)),
			maxConcurrent: Math.max(1, Math.floor(Number(source.maxConcurrent) || 1)),
		});

		const normalizeState = (raw, at = Date.now()) => {
			const state = raw && typeof raw === 'object' ? raw : emptyState();
			state.version = 2;
			state.events = (Array.isArray(state.events) ? state.events : [])
				.map((event) => ({ at: Number(event?.at) || 0 }))
				.filter((event) => event.at > at - READER_REQUEST_LONG_WINDOW_MS && event.at <= at)
				.sort((a, b) => a.at - b.at)
				.slice(-600);
			state.intents = (Array.isArray(state.intents) ? state.intents : [])
				.filter((intent) => intent && Number(intent.expiresAt) > at)
				.slice(-120);
			state.active = (Array.isArray(state.active) ? state.active : [])
				.filter((lease) => lease && Number(lease.expiresAt) > at)
				.slice(-32);
			state.policies = (Array.isArray(state.policies) ? state.policies : [])
				.filter((policy) => policy?.contextId && Number(policy.expiresAt) > at)
				.map((policy) => ({
					...requestPolicy(policy, policy.contextId),
					expiresAt: Number(policy.expiresAt),
				}))
				.slice(-32);
			state.flights = (Array.isArray(state.flights) ? state.flights : [])
				.map((flight) => ({
					...flight,
					heartbeatAt: Number(flight?.heartbeatAt) ||
						Math.max(0, Number(flight?.expiresAt) - SHARED_CACHE_FLIGHT_TTL),
				}))
				.filter((flight) => flight?.token && Number(flight.expiresAt) > at &&
					Number(flight.heartbeatAt) > at - SHARED_CACHE_FLIGHT_STALE_MS)
				.sort((a, b) => Number(a.expiresAt) - Number(b.expiresAt))
				.slice(-120);
			state.cacheEpoch = Math.max(0, Math.floor(Number(state.cacheEpoch) || 0));
			state.shortBudget = Math.max(0, Number(state.shortBudget) || 0);
			state.longBudget = Math.max(0, Number(state.longBudget) || 0);
			state.shortServerBudget = Math.max(0, Number(state.shortServerBudget) || 0);
			state.longServerBudget = Math.max(0, Number(state.longServerBudget) || 0);
			state.cooldownUntil = Math.max(0, Number(state.cooldownUntil) || 0);
			state.nextProbeAt = Math.max(0, Number(state.nextProbeAt) || 0);
			state.shortServerBlockUntil = Math.max(0, Number(state.shortServerBlockUntil) || 0);
			state.longServerBlockUntil = Math.max(0, Number(state.longServerBlockUntil) || 0);
			state.successfulSinceLimit = Math.max(0, Number(state.successfulSinceLimit) || 0);
			state.lastRateLimitWindow = String(state.lastRateLimitWindow || '');
			state.learnedUntil = Math.max(0, Number(state.learnedUntil) || 0);
			if (state.learnedUntil <= at) {
				state.shortBudget = 0;
				state.longBudget = 0;
				state.shortServerBudget = 0;
				state.longServerBudget = 0;
				state.rateLimited = false;
				state.successfulSinceLimit = 0;
				state.lastRateLimitWindow = '';
			} else state.rateLimited = state.rateLimited === true;
			return state;
		};

		const readState = (at = Date.now()) => {
			try {
				return normalizeState(JSON.parse(localStorage.getItem(LDP_SHARED_REQUEST_STATE_KEY) || 'null'), at);
			} catch (error) {
				return emptyState();
			}
		};

		const notifyWaiters = () => {
			waiters.forEach((resolve) => resolve());
			waiters.clear();
		};

		const transact = async (operation) => {
			const execute = () => {
				const at = Date.now();
				const state = readState(at);
				const result = operation(state, at);
				state.updatedAt = at;
				try { localStorage.setItem(LDP_SHARED_REQUEST_STATE_KEY, JSON.stringify(state)); } catch (error) {}
				try { if (channel) channel.postMessage({ type: 'updated', contextId }); } catch (error) {}
				notifyWaiters();
				return result;
			};
			const locks = navigator.locks;
			if (isFunction(locks?.request)) {
				return locks.request(lockName, { mode: 'exclusive' }, execute);
			}
			return execute();
		};

		const withCacheLock = (operation) => {
			const locks = navigator.locks;
			if (isFunction(locks?.request)) {
				return locks.request(cacheLockName, { mode: 'exclusive' }, operation);
			}
			return Promise.resolve().then(operation);
		};

		const windowState = (state, at, requestedShortBudget, requestedLongBudget) => {
			const shortBudget = Math.max(1, Math.floor(Math.min(
				requestedShortBudget,
				state.shortBudget || requestedShortBudget,
				state.shortServerBudget || requestedShortBudget
			)));
			const longBudget = Math.max(1, Math.floor(Math.min(
				requestedLongBudget,
				state.longBudget || requestedLongBudget,
				state.longServerBudget || requestedLongBudget
			)));
			const shortEvents = state.events.filter((event) => event.at > at - READER_REQUEST_SHORT_WINDOW_MS);
			const longEvents = state.events;
			const delayFor = (events, budget, windowMs) => events.length < budget
				? 0
				: Math.max(0, events[events.length - budget].at + windowMs - at + 1);
			const shortDelay = delayFor(shortEvents, shortBudget, READER_REQUEST_SHORT_WINDOW_MS);
			const longDelay = delayFor(longEvents, longBudget, READER_REQUEST_LONG_WINDOW_MS);
			const shortServerDelay = Math.max(0, state.shortServerBlockUntil - at);
			const longServerDelay = Math.max(0, state.longServerBlockUntil - at);
			const effectiveShortDelay = Math.max(shortDelay, shortServerDelay);
			const effectiveLongDelay = Math.max(longDelay, longServerDelay);
			return {
				shortCount: shortEvents.length,
				longCount: longEvents.length,
				shortBudget,
				longBudget,
				serverDelay: Math.max(shortServerDelay, longServerDelay),
				delay: Math.max(effectiveShortDelay, effectiveLongDelay),
				blockingWindow: effectiveShortDelay >= effectiveLongDelay && effectiveShortDelay
					? '10s'
					: effectiveLongDelay
						? '60s'
						: '',
			};
		};

		const permitTiming = (state, at, policy) => {
			const windows = windowState(state, at, policy.shortBudget, policy.longBudget);
			const cooldownDelay = Math.max(0, state.cooldownUntil - at);
			const latestStart = state.events.length ? Number(state.events[state.events.length - 1].at) || 0 : 0;
			return { windows, cooldownDelay, intervalDelay: Math.max(0, latestStart + policy.minInterval - at) };
		};

		const rememberPolicy = (state, at, options = {}) => {
			const policy = { ...requestPolicy(options), expiresAt: at + SHARED_REQUEST_POLICY_TTL };
			const index = state.policies.findIndex((candidate) => candidate.contextId === contextId);
			if (index >= 0) state.policies[index] = policy;
			else state.policies.push(policy);
			return policy;
		};

		const effectivePolicy = (state, ownPolicy) => {
			const effective = {
				shortBudget: ownPolicy.shortBudget,
				longBudget: ownPolicy.longBudget,
				minInterval: ownPolicy.minInterval,
				maxConcurrent: Math.min(state.rateLimited ? 2 : Infinity, ownPolicy.maxConcurrent),
			};
			state.policies.forEach((policy) => {
				if (policy.contextId === contextId) return;
				effective.shortBudget = Math.min(effective.shortBudget, policy.shortBudget);
				effective.longBudget = Math.min(effective.longBudget, policy.longBudget);
				effective.minInterval = Math.max(effective.minInterval, policy.minInterval);
				effective.maxConcurrent = Math.min(effective.maxConcurrent, policy.maxConcurrent);
			});
			return effective;
		};

		const intentPriority = (intent, at) => {
			const ageBoost = Math.min(2, Math.floor(Math.max(0, at - intent.queuedAt) / 5000));
			return Number(intent.priority) - ageBoost;
		};

		const waitForUpdate = (delay, signal) => new Promise((resolve, reject) => {
			let settled = false;
			const settle = (callback) => {
				if (settled) return;
				settled = true;
				clearTimeout(timer);
				waiters.delete(finish);
				if (signal) signal.removeEventListener('abort', abort);
				callback();
			};
			const finish = () => settle(resolve);
			const abort = () => settle(() => reject(new DOMException('Aborted', 'AbortError')));
			const timer = setTimeout(finish, Math.max(25, Math.min(1000, delay || 80)));
			waiters.add(finish);
			if (signal) signal.addEventListener('abort', abort, { once: true });
		});

		const cancelIntent = (intentId) => {
			void transact((state) => {
				state.intents = state.intents.filter((intent) => intent.id !== intentId);
			});
		};

		const acquirePermit = async (options = {}) => {
			const intentId = `${contextId}:intent:${++sequence}`;
			const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible;
			const signal = options.signal;
			let granted = false;
			let waitReason = '';
			try {
				while (!destroyed && !(signal?.aborted)) {
					const result = await transact((state, at) => {
						const ownPolicy = rememberPolicy(state, at, options);
						const policy = effectivePolicy(state, ownPolicy);
						const existing = state.intents.find((intent) => intent.id === intentId);
						if (existing) existing.expiresAt = at + 15000;
						else state.intents.push({
							id: intentId,
							contextId,
							priority,
							queuedAt: at,
							expiresAt: at + 15000,
						});
						state.intents.sort((a, b) => intentPriority(a, at) - intentPriority(b, at) || a.queuedAt - b.queuedAt);
						if (!state.intents.length || state.intents[0].id !== intentId) {
							return { granted: false, wait: 80, reason: 'priority' };
						}
						if (state.active.length >= policy.maxConcurrent) {
							return { granted: false, wait: 80, reason: 'concurrency' };
						}
						const { windows, cooldownDelay, intervalDelay } = permitTiming(state, at, policy);
						const canProbe = state.rateLimited && !cooldownDelay && !windows.serverDelay && windows.delay > 0 &&
							!intervalDelay && priority <= POST_REQUEST_PRIORITY.visible && at >= state.nextProbeAt;
						const wait = Math.max(cooldownDelay, intervalDelay, canProbe ? 0 : windows.delay);
						if (wait > 0) {
							return {
								granted: false,
								wait,
								reason: cooldownDelay ? 'cooldown' : intervalDelay ? 'interval' : windows.blockingWindow,
							};
						}
						state.intents.shift();
						if (canProbe) state.nextProbeAt = at + READER_REQUEST_RECOVERY_PROBE_INTERVAL;
						state.events.push({ at });
						const permitId = `${contextId}:permit:${++sequence}`;
						state.active.push({ id: permitId, contextId, expiresAt: at + POST_REQUEST_TIMEOUT + 5000 });
						return { granted: true, permitId, startedAt: at, recoveryProbe: canProbe };
					});
					if (result.granted) {
						granted = true;
						return { ...result, waitReason };
					}
					if (result.cancelled) {
						waitReason = String(result.reason || waitReason || 'cancelled');
						throw new DOMException(waitReason, 'AbortError');
					}
					waitReason = String(result.reason || waitReason);
					await waitForUpdate(result.wait, signal);
				}
				throw new DOMException('Aborted', 'AbortError');
			} finally {
				if (!granted) cancelIntent(intentId);
			}
		};

		const recordHostStart = (event) => {
			if (!sharedRequestApiEvent(event) || destroyed) return;
			const activeId = `${contextId}:host-active:${event.id}`;
			event.sharedRequestActiveId = activeId;
			event.sharedRequestStart = transact((state, at) => {
				const startedAt = Math.max(at - 5000, Math.min(at, Number(event.startedAt) || at));
				state.events.push({ at: startedAt });
				state.active.push({ id: activeId, contextId, expiresAt: at + POST_REQUEST_TIMEOUT + 5000 });
			});
		};

		const finishPermit = (permitId) => {
			if (!permitId) return;
			void transact((state) => {
				state.active = state.active.filter((lease) => lease.id !== permitId);
			});
		};

		const recordHostFinish = (event) => {
			if (!event || !event.sharedRequestActiveId) return;
			Promise.resolve(event.sharedRequestStart).catch(() => {}).then(() => {
				finishPermit(event.sharedRequestActiveId);
			});
		};

		const acquireCacheFlight = (token) => transact((state, at) => {
			const normalizedToken = String(token || '');
			if (!normalizedToken) return { producer: true, flightId: '' };
			const existing = state.flights.find((flight) => flight.token === normalizedToken);
			if (existing) {
				return {
					producer: false,
					flightId: existing.id,
					expiresAt: existing.expiresAt,
					cacheEpoch: Number(existing.cacheEpoch) || 0,
				};
			}
			const flightId = `${contextId}:flight:${++sequence}`;
			state.flights.push({
				id: flightId,
				token: normalizedToken,
				contextId,
				cacheEpoch: state.cacheEpoch,
				heartbeatAt: at,
				expiresAt: at + SHARED_CACHE_FLIGHT_TTL,
			});
			return {
				producer: true,
				flightId,
				expiresAt: at + SHARED_CACHE_FLIGHT_TTL,
				cacheEpoch: state.cacheEpoch,
			};
		});

		const releaseCacheFlight = (token, flightId) => {
			if (!token || !flightId) return Promise.resolve();
			return transact((state) => {
				state.flights = state.flights.filter((flight) =>
					flight.token !== String(token) || flight.id !== String(flightId));
			});
		};

		const renewCacheFlight = (token, flightId) => {
			if (!token || !flightId) return Promise.resolve(false);
			return transact((state, at) => {
				const flight = state.flights.find((candidate) =>
					candidate.token === String(token) && candidate.id === String(flightId));
				if (!flight) return false;
				flight.heartbeatAt = at;
				flight.expiresAt = at + SHARED_CACHE_FLIGHT_TTL;
				return true;
			});
		};

		const writeCacheFlight = (token, flightId, cacheEpoch, operation) => withCacheLock(async () => {
			const state = readState();
			const valid = state.cacheEpoch === Number(cacheEpoch || 0) && state.flights.some((flight) =>
				flight.token === String(token) && flight.id === String(flightId));
			if (!valid) return false;
			await operation();
			return true;
		});

		const invalidateCacheWrites = () => withCacheLock(() => transact((state) => {
			state.cacheEpoch++;
			return state.cacheEpoch;
		}));

		const waitForCacheFlight = async (token, signal, deadline = Date.now() + SHARED_CACHE_FLIGHT_TTL) => {
			const normalizedToken = String(token || '');
			while (!destroyed && Date.now() < deadline && !(signal?.aborted)) {
				const state = readState();
				const flight = state.flights.find((candidate) => candidate.token === normalizedToken);
				if (!flight) return true;
				await waitForUpdate(Math.min(1000, Math.max(25, flight.expiresAt - Date.now())), signal);
			}
			if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
			return false;
		};

		const noteRateLimit = (options = {}) => {
			void transact((state, at) => {
				const windows = windowState(state, at, options.shortBudget, options.longBudget);
				const limitWindow = String(options.window || 'unknown');
				const hitShort = limitWindow === 'unknown' || limitWindow.includes('10s');
				const hitLong = limitWindow === 'unknown' || limitWindow.includes('60s');
				if (hitShort) {
					const safe = Math.max(1, Math.ceil(windows.shortCount * 0.9));
					state.shortBudget = state.shortBudget ? Math.min(state.shortBudget, safe) : safe;
				}
				if (hitLong) {
					const safe = Math.max(1, Math.ceil(windows.longCount * 0.9));
					state.longBudget = state.longBudget ? Math.min(state.longBudget, safe) : safe;
				}
				state.cooldownUntil = Math.max(state.cooldownUntil, at + Math.max(1000, Number(options.wait) || 0));
				state.nextProbeAt = state.cooldownUntil;
				state.rateLimited = true;
				state.successfulSinceLimit = 0;
				state.lastRateLimitWindow = limitWindow;
				state.learnedUntil = at + READER_REQUEST_RATE_MEMORY_TTL;
			});
		};

		const resetRateLimits = () => {
			return transact((state) => {
				[
					'shortBudget',
					'longBudget',
					'shortServerBudget',
					'longServerBudget',
					'cooldownUntil',
					'nextProbeAt',
					'shortServerBlockUntil',
					'longServerBlockUntil',
					'successfulSinceLimit',
					'learnedUntil',
				].forEach((key) => { state[key] = 0; });
				state.rateLimited = false;
				state.lastRateLimitWindow = '';
				return true;
			});
		};

		const noteServerLimits = (options = {}) => {
			const rateTarget = Math.min(0.95, Math.max(0.5, Number(options.rateTarget) || 0.8));
			const shortCap = Math.max(1, Math.floor(Number(options.shortBudgetCap) || 40));
			const longCap = Math.max(1, Math.floor(Number(options.longBudgetCap) || 160));
			const policies = (Array.isArray(options.policies) ? options.policies : []).map((policy) => {
				const window = policy && policy.window === '10s' ? '10s' : policy && policy.window === '60s' ? '60s' : '';
				const limit = Math.max(0, Number(policy?.limit) || 0);
				const cap = window === '10s' ? shortCap : longCap;
				return {
					window,
					limit,
					remaining: Number(policy?.remaining),
					resetDelay: Math.max(0, Number(policy?.resetDelay) || 0),
					cap,
					desired: Math.max(1, Math.min(cap, Math.floor(limit * rateTarget))),
				};
			}).filter((policy) => policy.window && policy.limit > 0);
			if (!policies.length) return;
			const previewAt = Date.now();
			const preview = readState(previewAt);
			const needsUpdate = policies.some((policy) => {
				const budgetKey = policy.window === '10s' ? 'shortServerBudget' : 'longServerBudget';
				const blockKey = policy.window === '10s' ? 'shortServerBlockUntil' : 'longServerBlockUntil';
				const current = Number(preview[budgetKey]) || 0;
				const effective = current || policy.cap;
				const budgetChanges = policy.desired < effective ||
					(!preview.rateLimited && current > 0 && policy.desired > current);
				const reserve = Math.max(1, Math.ceil(policy.limit * (1 - rateTarget)));
				const shouldBlock = Number.isFinite(policy.remaining) && policy.remaining <= reserve && policy.resetDelay > 0;
				const blockUntil = shouldBlock ? previewAt + policy.resetDelay : 0;
				const blockChanges = shouldBlock
					? blockUntil > Number(preview[blockKey] || 0) + 500
					: Number(preview[blockKey] || 0) > previewAt;
				return budgetChanges || blockChanges;
			});
			if (!needsUpdate) return;
			void transact((state, at) => {
				let changed = false;
				policies.forEach((policy) => {
					const budgetKey = policy.window === '10s' ? 'shortServerBudget' : 'longServerBudget';
					const blockKey = policy.window === '10s' ? 'shortServerBlockUntil' : 'longServerBlockUntil';
					const windowMs = policy.window === '10s' ? READER_REQUEST_SHORT_WINDOW_MS : READER_REQUEST_LONG_WINDOW_MS;
					const current = Number(state[budgetKey]) || 0;
					if (!current && policy.desired < policy.cap) {
						state[budgetKey] = policy.desired;
						changed = true;
					} else if (current && policy.desired < current) {
						state[budgetKey] = policy.desired;
						changed = true;
					} else if (!state.rateLimited && current && policy.desired > current) {
						const step = Math.max(1, Math.ceil((policy.desired - current) * 0.35));
						const next = Math.min(policy.desired, current + step);
						state[budgetKey] = next >= policy.cap && policy.desired >= policy.cap ? 0 : next;
						changed = true;
					}
					const reserve = Math.max(1, Math.ceil(policy.limit * (1 - rateTarget)));
					const shouldBlock = Number.isFinite(policy.remaining) && policy.remaining <= reserve && policy.resetDelay > 0;
					if (shouldBlock) {
						const blockUntil = at + Math.min(windowMs + 1000, policy.resetDelay);
						if (blockUntil > Number(state[blockKey] || 0) + 500) {
							state[blockKey] = blockUntil;
							changed = true;
						}
					} else if (Number(state[blockKey] || 0) > at) {
						state[blockKey] = 0;
						changed = true;
					}
				});
				if (changed) state.learnedUntil = Math.max(state.learnedUntil, at + READER_REQUEST_RATE_MEMORY_TTL);
			});
		};

		const noteSuccess = (options = {}) => {
			const preview = readState();
			if (!preview.rateLimited && !preview.shortBudget && !preview.longBudget) return;
			void transact((state, at) => {
				if (at < state.cooldownUntil || (!state.shortBudget && !state.longBudget)) return;
				const credit = options.recoveryProbe ? 2 : options.host ? 0.5 : 1;
				state.successfulSinceLimit += credit;
				const shouldGrow = state.rateLimited ||
					state.successfulSinceLimit >= READER_REQUEST_RATE_PROBE_SUCCESSES;
				if (!shouldGrow) return;
				const shortCap = Math.max(1, Number(options.shortBudgetCap) || READER_REQUEST_DEFAULT_SHORT_CEILING);
				const longCap = Math.max(1, Number(options.longBudgetCap) || READER_REQUEST_DEFAULT_LONG_CEILING);
				const rateStep = options.recoveryProbe ? READER_REQUEST_PROBE_RATE_STEP : 1.04;
				if (state.shortBudget) {
					state.shortBudget = Math.min(
						shortCap,
						Math.max(state.shortBudget * rateStep, state.shortBudget + (options.recoveryProbe ? 2 : 1))
					);
				}
				if (state.longBudget) {
					state.longBudget = Math.min(
						longCap,
						Math.max(state.longBudget * rateStep, state.longBudget + (options.recoveryProbe ? 6 : 3))
					);
				}
				if (state.successfulSinceLimit >= READER_REQUEST_CONCURRENCY_RECOVERY_SUCCESSES) {
					state.rateLimited = false;
					state.successfulSinceLimit = 0;
				} else if (!state.rateLimited) {
					state.successfulSinceLimit = 0;
				}
				const shortRecovered = !state.shortBudget || state.shortBudget >= shortCap;
				const longRecovered = !state.longBudget || state.longBudget >= longCap;
				if (!state.rateLimited && shortRecovered && longRecovered) {
					state.shortBudget = 0;
					state.longBudget = 0;
				}
				state.learnedUntil = at + READER_REQUEST_RATE_MEMORY_TTL;
			});
		};

		const snapshot = (
			requestedShortBudget,
			requestedLongBudget,
			requestedMinInterval = 0,
			requestedMaxConcurrent = 1,
			at = Date.now()
		) => {
			const state = readState(at);
			const policy = effectivePolicy(state, requestPolicy({
				shortBudget: requestedShortBudget,
				longBudget: requestedLongBudget,
				minInterval: requestedMinInterval,
				maxConcurrent: requestedMaxConcurrent,
			}));
			const { windows, cooldownDelay: cooldownRemaining, intervalDelay } = permitTiming(state, at, policy);
			const nextPermitDelay = Math.max(cooldownRemaining, intervalDelay, windows.delay);
			const liveContexts = new Set([
				...state.policies.map((entry) => entry.contextId),
				...state.intents.map((entry) => entry.contextId),
				...state.active.map((entry) => entry.contextId),
			].filter(Boolean));
			return {
				...windows,
				queued: state.intents.length,
				active: state.active.length,
				instances: Math.max(1, liveContexts.size),
				maxConcurrent: policy.maxConcurrent,
				minInterval: policy.minInterval,
				coordinationMode: isFunction(navigator.locks?.request)
					? 'atomic'
					: 'best-effort',
				coolingDown: cooldownRemaining > 0,
				cooldownRemaining,
				nextPermitDelay,
				blockingReason: cooldownRemaining ? 'cooldown' : intervalDelay ? 'interval' : windows.blockingWindow,
				rateLimited: state.rateLimited,
				lastRateLimitWindow: state.lastRateLimitWindow,
			};
		};

		const onStorage = (event) => {
			if (event.key === LDP_SHARED_REQUEST_STATE_KEY) notifyWaiters();
		};
		const publishCache = (message) => {
			try {
				if (channel) channel.postMessage({ type: 'cache', contextId, message });
			} catch (error) {}
		};
		const onChannelMessage = (event) => {
			const data = event?.data;
			if (data && data.type === 'cache' && data.contextId !== contextId) {
				cacheListeners.forEach((listener) => {
					try { listener(data.message || {}); } catch (error) {}
				});
			}
			notifyWaiters();
		};
		try {
			channel = new globalThis.BroadcastChannel(LDP_SHARED_REQUEST_CHANNEL);
			channel.addEventListener('message', onChannelMessage);
		} catch (error) {}
		window.addEventListener('storage', onStorage);

		return {
			contextId,
			acquirePermit,
			recordHostStart,
			recordHostFinish,
			finishPermit,
			acquireCacheFlight,
			releaseCacheFlight,
			renewCacheFlight,
			writeCacheFlight,
			invalidateCacheWrites,
			waitForCacheFlight,
			noteRateLimit,
			resetRateLimits,
			noteServerLimits,
			noteSuccess,
			snapshot,
			publishCache,
			subscribeCache(listener) {
				if (!isFunction(listener)) return () => {};
				cacheListeners.add(listener);
				return () => cacheListeners.delete(listener);
			},
			destroy() {
				if (destroyed) return;
				destroyed = true;
				void transact((state) => {
					state.intents = state.intents.filter((intent) => intent.contextId !== contextId);
					state.active = state.active.filter((lease) => lease.contextId !== contextId);
					state.policies = state.policies.filter((policy) => policy.contextId !== contextId);
					state.flights = state.flights.filter((flight) => flight.contextId !== contextId);
				});
				window.removeEventListener('storage', onStorage);
				if (channel) channel.close();
				channel = null;
				cacheListeners.clear();
				notifyWaiters();
			},
		};
	}

	function installRequestFlowInstrumentation() {
		if (!REQUEST_FLOW_AJAX_BOUND) {
			const jqueryModule = lookupDiscourseModule('jquery');
			const jquery = jqueryModule && (jqueryModule.default || jqueryModule);
			if (isFunction(jquery)) {
				const target = jquery(document);
				target.on('ajaxSend.ldpRequestFlow', (_event, xhr, settings = {}) => {
					const method = String(settings.type || settings.method || 'GET').toUpperCase();
					REQUEST_FLOW_AJAX_STATE.set(xhr, {
						event: beginRequestFlow(settings.url, {
							method,
							[REQUEST_FLOW_FETCH_META]: { callSite: '' },
						}, 'xmlhttprequest'),
						url: settings.url,
					});
				});
				target.on('ajaxComplete.ldpRequestFlow', (_event, xhr) => {
					const state = REQUEST_FLOW_AJAX_STATE.get(xhr);
					if (!state) return;
					REQUEST_FLOW_AJAX_STATE.delete(xhr);
					let headers = '';
					try { headers = xhr.getAllResponseHeaders(); } catch (error) {}
					const finalUrl = finishResponseRequestFlow(
						state.event,
						xhr.responseURL || state.url,
						xhr.status,
						headers
					);
					captureHostTopicXhrResponse(state.event, xhr, finalUrl);
				});
				REQUEST_FLOW_AJAX_BOUND = true;
			}
		}

		if (!REQUEST_FLOW_RESOURCE_OBSERVER && isFunction(window.PerformanceObserver)) {
			try {
				REQUEST_FLOW_RESOURCE_OBSERVER = new window.PerformanceObserver((list, _observer, options) => {
					list.getEntries().forEach(recordResourceRequestFlow);
					const dropped = Math.max(0, Number(options?.droppedEntriesCount) || 0);
					if (dropped) REQUEST_FLOW_RESOURCE_DROPS.push({ at: requestFlowNow(), count: dropped });
					pruneRequestFlowEvents();
				});
				REQUEST_FLOW_RESOURCE_OBSERVER.observe({ type: 'resource', buffered: true });
			} catch (error) {}
		}
		return REQUEST_FLOW_AJAX_BOUND;
	}

	function trackedReaderFetch(input, init = {}) {
		const meta = init[REQUEST_FLOW_FETCH_META] || {};
		const trackedInit = {
			...init,
			[REQUEST_FLOW_FETCH_META]: { ...meta, source: 'reader' },
		};
		const event = beginRequestFlow(input, trackedInit, 'fetch');
		let request;
		try {
			request = window.fetch(input, init);
		} catch (error) {
			finishRequestFlow(event, { error: error?.name || 'fetch_error' });
			throw error;
		}
		return Promise.resolve(request).then((response) => {
			finishResponseRequestFlow(
				event,
				response?.url,
				response?.status,
				response?.headers
			);
			return response;
		}, (error) => {
			finishRequestFlow(event, { error: error?.name || 'fetch_error' });
			throw error;
		});
	}

	SHARED_REQUEST_COORDINATOR = createSharedRequestCoordinator();
	installRequestFlowInstrumentation();

	function resetReaderRateAfterChallenge() {
		if (ACTIVE_READER_REQUEST_SCHEDULER?.resetRateLimits) {
			void ACTIVE_READER_REQUEST_SCHEDULER.resetRateLimits();
			return true;
		}
		if (!SHARED_REQUEST_COORDINATOR || !SHARED_REQUEST_COORDINATOR.resetRateLimits) return false;
		void SHARED_REQUEST_COORDINATOR.resetRateLimits();
		return true;
	}
	const POST_REQUEST_PRIORITY = Object.freeze({
		target: -2,
		topic: -1,
		scroll: 0,
		visible: 1,
		nested: 2,
		userCard: 1,
		auxiliary: 3,
		background: 4,
	});
	const STREAM_PLACEHOLDER_MIN_HEIGHT = 64;
	const STREAM_ESTIMATED_HEIGHT = 180;

	const USER_CARD_LINK_SEL = [
		'.ldp-user-link[data-user-card]',
		'.ldp-user-avatar-card[data-user-card]',
		'.ldp-topic-owner-link[data-user-card]',
		'html.ldp-reader-workspace :is(.topic-list-item,.latest-topic-list-item) :is(.posters,.topic-poster) [data-user-card]',
	].join(',');
	const USER_CARD_PREFETCH_DELAY = 250;
	const USER_CARD_SHOW_DELAY = 500;
	const HOST_TOPIC_CACHE_MAX_ENTRIES = 8;
	const HOST_TOPIC_DATA_CACHE = new Map();
	const TOPIC_DATA_CACHE = new Map();
	const TOPIC_CACHE_SUBSCRIBERS = new Map();
	const TOPIC_SNAPSHOT_WRITE_QUEUE = new Map();
	const TOPIC_NAV_ICON_CACHE = new Map();
	const USER_CARD_CACHE = new Map();
	const USER_CARD_REQUESTS = new Map();
	const USER_FOLLOW_LIST_CACHE = new Map();
	const USER_FOLLOW_LIST_REQUESTS = new Map();
	const NOTIFICATION_CACHE = new Map();
	const NOTIFICATION_REQUESTS = new Map();
	const NOTIFICATION_AVATAR_CACHE = new Map();
	const AVATAR_OBJECT_URL_CACHE = new Map();
	const AVATAR_RESOURCE_REQUESTS = new Map();
	const AVATAR_HYDRATION_PENDING = new WeakSet();
	const EMOJI_IMAGE_OBJECT_URL_CACHE = new Map();
	const EMOJI_IMAGE_RESOURCE_REQUESTS = new Map();
	const LIGHTBOX_IMAGE_OBJECT_URL_CACHE = new Map();
	const LIGHTBOX_IMAGE_RESOURCE_REQUESTS = new Map();
	const RESOURCE_CACHE_GROUPS = [
		{
			label: '头像', cacheName: LDP_AVATAR_CACHE_NAME, objectUrls: AVATAR_OBJECT_URL_CACHE,
			requests: AVATAR_RESOURCE_REQUESTS, maxEntries: 512, maxBytes: 32 * 1024 * 1024,
		},
		{
			label: '表情', cacheName: LDP_EMOJI_IMAGE_CACHE_NAME, objectUrls: EMOJI_IMAGE_OBJECT_URL_CACHE,
			requests: EMOJI_IMAGE_RESOURCE_REQUESTS, maxEntries: 512, maxBytes: 16 * 1024 * 1024,
		},
		{
			label: '原图', cacheName: LDP_LIGHTBOX_IMAGE_CACHE_NAME, objectUrls: LIGHTBOX_IMAGE_OBJECT_URL_CACHE,
			requests: LIGHTBOX_IMAGE_RESOURCE_REQUESTS, maxEntries: 24, maxBytes: 96 * 1024 * 1024,
		},
	];
	const RESOURCE_CACHE_WRITES = new Set();
	const RESOURCE_CACHE_PRUNE_REQUESTS = new Map();
	let RESOURCE_CACHE_ABORT_CONTROLLER = new AbortController();
	let RESOURCE_CACHE_CLEARING = false;
	const RESPONSE_CACHE_MEMORY = new Map();
	const RESPONSE_CACHE_REQUESTS = new Map();
	const RESPONSE_CACHE_WRITES = new Map();
	const RESPONSE_CACHE_VALUE_META = new WeakMap();
	const RESPONSE_CACHE_MEMORY_MAX_ENTRIES = 96;
	const RESPONSE_CACHE_MEMORY_MAX_BYTES = 24 * 1024 * 1024;
	const RESOURCE_CACHE_PRUNE_BATCH_SIZE = 32;
	const SEARCH_PINYIN_CACHE_MAX_ENTRIES = 512;
	let responseCacheDbRequest = null;
	let responseCachePersistenceRequested = false;
	let responseCachePruneRequest = null;
	let responseCachePrunedAt = 0;
	const SEARCH_PINYIN_CACHE = new Map();
	const PERSISTENT_CACHE_WRITE_KEYS = new Set();
	let cancelPersistentCacheWrite = null;
	let persistentCacheWriteDueAt = 0;

	function trimResponseCacheMemory() {
		let bytes = 0;
		RESPONSE_CACHE_MEMORY.forEach((entry) => { bytes += Math.max(0, Number(entry?.bytes) || 0); });
		while (RESPONSE_CACHE_MEMORY.size > RESPONSE_CACHE_MEMORY_MAX_ENTRIES ||
				(bytes > RESPONSE_CACHE_MEMORY_MAX_BYTES && RESPONSE_CACHE_MEMORY.size > 1)) {
			const oldestId = RESPONSE_CACHE_MEMORY.keys().next().value;
			const oldest = RESPONSE_CACHE_MEMORY.get(oldestId);
			bytes -= Math.max(0, Number(oldest?.bytes) || 0);
			RESPONSE_CACHE_MEMORY.delete(oldestId);
		}
	}

	function rememberResponseCacheEntry(id, entry) {
		RESPONSE_CACHE_MEMORY.delete(id);
		RESPONSE_CACHE_MEMORY.set(id, entry);
		trimResponseCacheMemory();
	}

	function markResponseCacheStoredAt(value, storedAt) {
		if (value && typeof value === 'object') {
			RESPONSE_CACHE_VALUE_META.set(value, Number(storedAt) || Date.now());
		}
		return value;
	}

	function responseCacheValueStoredAt(value) {
		return Number(value && typeof value === 'object' && RESPONSE_CACHE_VALUE_META.get(value)) || 0;
	}

	function sharedCacheIdToken(value) {
		const source = String(value || '');
		let hash = 2166136261;
		for (let index = 0; index < source.length; index++) {
			hash ^= source.charCodeAt(index);
			hash = Math.imul(hash, 16777619);
		}
		return (hash >>> 0).toString(36);
	}

	function openResponseCacheDatabase() {
		if (responseCacheDbRequest) return responseCacheDbRequest;
		if (typeof indexedDB === 'undefined') return Promise.resolve(null);
		let pendingRequest;
		pendingRequest = new Promise((resolve) => {
			let settled = false;
			const finish = (db) => {
				if (settled) {
					if (db) db.close();
					return;
				}
				settled = true;
				resolve(db);
			};
			let request;
			try {
				request = indexedDB.open(LDP_RESPONSE_CACHE_DB_NAME, 1);
			} catch (error) {
				finish(null);
				return;
			}
			request.onupgradeneeded = () => {
				const db = request.result;
				const store = db.objectStoreNames.contains(LDP_RESPONSE_CACHE_STORE)
					? request.transaction.objectStore(LDP_RESPONSE_CACHE_STORE)
					: db.createObjectStore(LDP_RESPONSE_CACHE_STORE, { keyPath: 'id' });
				if (!store.indexNames.contains('kind')) store.createIndex('kind', 'kind', { unique: false });
				if (!store.indexNames.contains('tags')) store.createIndex('tags', 'tags', { unique: false, multiEntry: true });
			};
			request.onsuccess = () => {
				const db = request.result;
				db.onversionchange = () => {
					db.close();
					if (responseCacheDbRequest === pendingRequest) responseCacheDbRequest = null;
				};
				finish(db);
			};
			request.onerror = () => finish(null);
			request.onblocked = () => finish(null);
		});
		responseCacheDbRequest = pendingRequest;
		pendingRequest.then((db) => {
			if (!db && responseCacheDbRequest === pendingRequest) responseCacheDbRequest = null;
		});
		return pendingRequest;
	}

	function runResponseCacheTransaction(db, mode, operation) {
		return new Promise((resolve) => {
			try {
				const transaction = db.transaction(LDP_RESPONSE_CACHE_STORE, mode);
				transaction.oncomplete = () => resolve(true);
				transaction.onerror = transaction.onabort = () => resolve(false);
				operation(transaction.objectStore(LDP_RESPONSE_CACHE_STORE));
			} catch (error) {
				resolve(false);
			}
		});
	}

	function publishResponseCacheMutation(action, details) {
		if (SHARED_REQUEST_COORDINATOR) {
			SHARED_REQUEST_COORDINATOR.publishCache({ action, ...details });
		}
	}

	function responseCacheUrl(url) {
		try {
			const parsed = new URL(String(url || ''), location.href);
			['track_visit', 'forceLoad', '_'].forEach((key) => parsed.searchParams.delete(key));
			const postIds = parsed.searchParams.getAll('post_ids[]').sort((a, b) => String(a).localeCompare(String(b)));
			if (postIds.length) {
				parsed.searchParams.delete('post_ids[]');
				postIds.forEach((id) => parsed.searchParams.append('post_ids[]', id));
			}
			parsed.searchParams.sort();
			parsed.hash = '';
			return parsed;
		} catch (error) {
			return null;
		}
	}

	function responseCacheAuthScope() {
		const currentUserId = Number(ME_CURRENT_USER?.id) || 0;
		const currentUsername = String(ME_USERNAME || '').trim().toLocaleLowerCase();
		const accountIdentity = currentUserId
			? `id:${currentUserId}`
			: currentUsername
				? `username:${currentUsername}`
				: ME_USERNAME === ''
					? 'anonymous'
					: '';
		if (accountIdentity) return `account:${sharedCacheIdToken(accountIdentity)}`;
		const token = String((document.querySelector('meta[name="csrf-token"]') || {}).content || '');
		if (!token) return 'session:anonymous';
		return `session:${sharedCacheIdToken(token)}`;
	}

	function responseCachePolicy(url, fetchOptions = {}, options = {}) {
		const method = String(fetchOptions.method || 'GET').toUpperCase();
		const parsed = responseCacheUrl(url);
		if (method !== 'GET' || !parsed || parsed.origin !== BASE) return null;
		const path = parsed.pathname;
		const tags = new Set();
		let kind = 'responses';
		let freshFor = HOUR;
		let retainFor = 30 * DAY;
		let persist = true;

		const topicMatch = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/|\.json|$)/);
		const byNumberMatch = path.match(/^\/posts\/by_number\/(\d+)\/(\d+)\.json$/);
		const postMatch = path.match(/^\/posts\/(\d+)(?:\/replies)?\.json$/);
		const userMatch = path.match(/^\/u\/([^/]+)(?:\/[^/]+)?\.json$/);
		const bookmarkListMatch = path.match(/^\/u\/[^/]+\/bookmarks\.json$/);
		const givenReactionsMatch = /^\/discourse-reactions\/posts\/reactions(?:\.json)?$/.test(path);
		const givenLikesMatch = path === '/user_actions.json' && String(parsed.searchParams.get('filter') || '')
			.split(',').includes('1');
		if (topicMatch) tags.add(`topic:${topicMatch[1]}`);
		if (byNumberMatch) {
			tags.add(`topic:${byNumberMatch[1]}`);
			tags.add(`post-number:${byNumberMatch[1]}:${byNumberMatch[2]}`);
		}
		if (postMatch) tags.add(`post:${postMatch[1]}`);
		parsed.searchParams.getAll('post_ids[]').forEach((id) => tags.add(`post:${id}`));
		const queryPostId = Number(parsed.searchParams.get('post_id'));
		if (queryPostId) tags.add(`post:${queryPostId}`);

		if (path === '/notifications.json') {
			kind = 'notifications';
			freshFor = MINUTE;
			retainFor = 5 * MINUTE;
			persist = false;
			tags.add('notifications');
		} else if (bookmarkListMatch) {
			freshFor = MINUTE;
			tags.add('bookmarks');
		} else if (givenReactionsMatch || givenLikesMatch) {
			freshFor = MINUTE;
			tags.add('reactions-given');
		} else if (path === '/search.json') {
			freshFor = MINUTE;
			retainFor = 5 * MINUTE;
			persist = false;
		} else if (topicMatch || byNumberMatch || postMatch || path === '/post_voting/comments') {
			kind = 'topics';
			freshFor = path.includes('/replies.json') || path === '/post_voting/comments' ? 5 * MINUTE : 30 * MINUTE;
			retainFor = 180 * DAY;
		} else if (path.startsWith('/category-experts/endorsable-categories/')) {
			kind = 'users';
			freshFor = MINUTE;
			retainFor = 5 * MINUTE;
			persist = false;
			const endorsedUser = path.match(/^\/category-experts\/endorsable-categories\/([^/.]+)\.json$/);
			if (endorsedUser) tags.add(`user:${decodeURIComponent(endorsedUser[1]).toLocaleLowerCase()}`);
		} else if (userMatch || path.startsWith('/directory_items.json')) {
			kind = 'users';
			freshFor = 24 * HOUR;
			retainFor = 180 * DAY;
			if (userMatch) tags.add(`user:${decodeURIComponent(userMatch[1]).toLocaleLowerCase()}`);
			const directoryUsername = parsed.searchParams.get('username');
			if (directoryUsername) tags.add(`user:${directoryUsername.toLocaleLowerCase()}`);
		}

		const scope = responseCacheAuthScope();
		return {
			id: `${scope}|${parsed.href}`,
			kind,
			tags: [...tags],
			freshFor,
			retainFor,
			persist,
			cacheMode: options.cacheMode || 'default',
		};
	}

	async function getStoredResponseCacheEntry(id) {
		const cachedEntry = RESPONSE_CACHE_MEMORY.get(id);
		if (cachedEntry) {
			RESPONSE_CACHE_MEMORY.delete(id);
			RESPONSE_CACHE_MEMORY.set(id, cachedEntry);
			return cachedEntry;
		}
		const db = await openResponseCacheDatabase();
		if (!db) return null;
		let entry = null;
		await runResponseCacheTransaction(db, 'readonly', (store) => {
			const request = store.get(id);
			request.onsuccess = () => {
				entry = request.result || null;
			};
		});
		if (entry) rememberResponseCacheEntry(id, entry);
		return entry;
	}

	async function deleteStoredResponseCacheEntry(id) {
		RESPONSE_CACHE_MEMORY.delete(id);
		await queueStoredResponseCacheWrite(id, (store) => store.delete(id));
		publishResponseCacheMutation('response-delete', { token: sharedCacheIdToken(id) });
	}

	function storedResponseCacheEntry(policy, value, bytes = policy.bytes) {
		const storedAt = Date.now();
		return {
			id: policy.id, kind: policy.kind, tags: policy.tags, storedAt,
			expiresAt: storedAt + policy.retainFor,
			bytes: Number.isFinite(bytes) ? Math.max(0, Number(bytes)) : estimateCacheBytes(value),
			value,
		};
	}

	function queueStoredResponseCacheWrite(id, updateStore, afterWrite) {
		const previousWrite = RESPONSE_CACHE_WRITES.get(id) || Promise.resolve();
		return trackSingleFlightRequest(RESPONSE_CACHE_WRITES, id,
			previousWrite.catch(() => {}).then(() => openResponseCacheDatabase())
				.then((db) => db && runResponseCacheTransaction(db, 'readwrite', updateStore))
				.then((stored) => afterWrite ? afterWrite(stored) : stored).catch(() => null));
	}

	function setStoredResponseCacheEntry(policy, value) {
		const entry = storedResponseCacheEntry(policy, value);
		rememberResponseCacheEntry(policy.id, entry);
		if (!policy.persist) return Promise.resolve();
		if (!responseCachePersistenceRequested) {
			responseCachePersistenceRequested = true;
			if (isFunction(navigator.storage?.persist)) {
				navigator.storage.persist().catch(() => false);
			}
		}
		return queueStoredResponseCacheWrite(policy.id, (store) => store.put(entry), (stored) => {
			if (stored) publishResponseCacheMutation('response-write', { token: sharedCacheIdToken(policy.id) });
		});
	}

	function topicSnapshotPolicy(topicId, bytes) {
		const safeTopicId = Number(topicId);
		if (!(safeTopicId > 0)) return null;
		return {
			id: `${responseCacheAuthScope()}|snapshot:topic:${safeTopicId}`,
			kind: 'topics',
			tags: [`topic:${safeTopicId}`],
			freshFor: TOPIC_CACHE_TTL,
			retainFor: PERSISTENT_CACHE_CONFIG.topics.maxAge,
			persist: true,
			bytes,
		};
	}

	function topicPostStream(topic) {
		return topic?.post_stream || {};
	}

	function estimateTopicSnapshotBytes(topic) {
		const postStream = topicPostStream(topic);
		const posts = Array.isArray(postStream.posts) ? postStream.posts : [];
		const stream = Array.isArray(postStream.stream) ? postStream.stream : [];
		let bytes = 4096 + stream.length * 16;
		posts.forEach((post) => {
			if (!post || typeof post !== 'object') return;
			bytes += 1024;
			['cooked', 'raw', 'name', 'username', 'display_username', 'avatar_template'].forEach((key) => {
				bytes += String(post[key] || '').length * 2;
			});
		});
		return bytes;
	}

	function subscribeTopicCache(topicId, listener) {
		const key = String(Number(topicId) || '');
		if (!key || !isFunction(listener)) return () => {};
		if (!TOPIC_CACHE_SUBSCRIBERS.has(key)) TOPIC_CACHE_SUBSCRIBERS.set(key, new Set());
		const listeners = TOPIC_CACHE_SUBSCRIBERS.get(key);
		listeners.add(listener);
		return () => {
			listeners.delete(listener);
			if (!listeners.size) TOPIC_CACHE_SUBSCRIBERS.delete(key);
		};
	}

	function notifyTopicCacheSubscribers(topicId, topic) {
		const listeners = TOPIC_CACHE_SUBSCRIBERS.get(String(Number(topicId) || ''));
		if (!listeners || !listeners.size || !topic) return;
		listeners.forEach((listener) => {
			try { listener(topic); } catch (error) {}
		});
	}

	function mergeStoredTopicSnapshot(policy, payload) {
		let finalEntry = null;
		let finalPayload = null;
		let mergedPosts = false;
		return queueStoredResponseCacheWrite(policy.id, (store) => {
			const request = store.get(policy.id);
			request.onsuccess = () => {
				const existing = request.result;
				const existingValue = existing?.value;
				const existingValid = existingValue && existingValue.version === TOPIC_SNAPSHOT_VERSION &&
					Number(existingValue.topicId) === Number(payload.topicId) && existingValue.topic;
				if (existingValid && Number(existingValue.updatedAt) > Number(payload.updatedAt)) {
					mergedPosts = mergeMissingSnapshotPosts(existingValue.topic, payload.topic);
					finalPayload = {
						...existingValue,
						lastAccessAt: Math.max(Number(existingValue.lastAccessAt) || 0, Number(payload.lastAccessAt) || 0),
						topic: existingValue.topic,
					};
				} else {
					if (existingValid) mergedPosts = mergeMissingSnapshotPosts(payload.topic, existingValue.topic);
					finalPayload = payload;
				}
				finalEntry = storedResponseCacheEntry(
					policy, finalPayload, estimateTopicSnapshotBytes(finalPayload.topic)
				);
				try {
					store.put(finalEntry);
				} catch (error) {
					finalEntry = null;
					finalPayload = null;
				}
			};
			request.onerror = () => {
				finalEntry = null;
				finalPayload = null;
			};
		}, (stored) => {
			if (stored && finalEntry) {
				rememberResponseCacheEntry(policy.id, finalEntry);
				publishResponseCacheMutation('response-write', { token: sharedCacheIdToken(policy.id) });
			}
			return stored && finalPayload ? { payload: finalPayload, mergedPosts } : null;
		});
	}

	async function flushTopicSnapshotWrite(topicId) {
		const key = String(Number(topicId) || '');
		const queued = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key);
		if (queued?.timer) clearTimeout(queued.timer);
		TOPIC_SNAPSHOT_WRITE_QUEUE.delete(key);
		const entry = TOPIC_DATA_CACHE.get(key);
		const scope = responseCacheAuthScope();
		if (!entry || entry.scope !== scope || !entry.topic) return;
		const policy = topicSnapshotPolicy(key, estimateTopicSnapshotBytes(entry.topic));
		if (!policy) return;
		const storedResult = await mergeStoredTopicSnapshot(policy, {
			version: TOPIC_SNAPSHOT_VERSION,
			topicId: Number(key),
			fetchedAt: Number(entry.fetchedAt) || Date.now(),
			lastAccessAt: Number(entry.lastAccessAt) || Date.now(),
			updatedAt: Number(entry.updatedAt) || Date.now(),
			topic: entry.topic,
		});
		if (!storedResult) return;
		const stored = storedResult.payload;
		if (stored.topic !== entry.topic || Number(stored.updatedAt) !== Number(entry.updatedAt)) {
			rememberCachedTopicData(key, stored.topic, {
				fetchedAt: stored.fetchedAt,
				lastAccessAt: stored.lastAccessAt,
				updatedAt: stored.updatedAt,
				full: true,
				notify: true,
				source: 'snapshot-merge',
			});
		} else if (storedResult.mergedPosts) {
			notifyTopicCacheSubscribers(key, stored.topic);
		}
		if (SHARED_REQUEST_COORDINATOR) {
			SHARED_REQUEST_COORDINATOR.publishCache({
				action: 'topic-snapshot-write',
				topicId: Number(key),
				scopeToken: sharedCacheIdToken(scope),
				updatedAt: Number(stored.updatedAt) || Date.now(),
			});
		}
	}

	function scheduleTopicSnapshotWrite(topicId) {
		const key = String(Number(topicId) || '');
		const entry = TOPIC_DATA_CACHE.get(key);
		if (!key || !entry || entry.scope !== responseCacheAuthScope()) return;
		const now = Date.now();
		const previous = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key);
		if (previous?.timer) clearTimeout(previous.timer);
		const firstQueuedAt = Number(previous?.firstQueuedAt) || now;
		const dueAt = Math.min(now + TOPIC_SNAPSHOT_WRITE_DELAY, firstQueuedAt + TOPIC_SNAPSHOT_WRITE_MAX_DELAY);
		const timer = setTimeout(() => { void flushTopicSnapshotWrite(key); }, Math.max(0, dueAt - now));
		TOPIC_SNAPSHOT_WRITE_QUEUE.set(key, { firstQueuedAt, timer });
	}

	function cancelTopicSnapshotWrites(topicId) {
		const keys = topicId == null
			? [...TOPIC_SNAPSHOT_WRITE_QUEUE.keys()]
			: [String(Number(topicId) || '')];
		keys.forEach((key) => {
			const queued = TOPIC_SNAPSHOT_WRITE_QUEUE.get(key);
			if (queued?.timer) clearTimeout(queued.timer);
			TOPIC_SNAPSHOT_WRITE_QUEUE.delete(key);
		});
	}

	function flushTopicSnapshotWrites() {
		return Promise.all(Array.from(TOPIC_SNAPSHOT_WRITE_QUEUE.keys(), (topicId) => (
			flushTopicSnapshotWrite(topicId)
		)));
	}

	function mergeMissingSnapshotPosts(targetTopic, sourceTopic) {
		const targetStream = targetTopic?.post_stream;
		const sourceStream = sourceTopic?.post_stream;
		if (!targetStream || !sourceStream) return false;
		const current = Array.isArray(targetStream.posts) ? targetStream.posts : [];
		const indexes = new Map(current.map((post, index) => [Number(post?.id), index]));
		let changed = false;
		(Array.isArray(sourceStream.posts) ? sourceStream.posts : []).forEach((post) => {
			const postId = Number(post?.id);
			if (!postId || indexes.has(postId)) return;
			indexes.set(postId, current.length);
			current.push(post);
			changed = true;
		});
		targetStream.posts = current;
		if ((!Array.isArray(targetStream.stream) || !targetStream.stream.length) && Array.isArray(sourceStream.stream)) {
			targetStream.stream = sourceStream.stream.slice();
			changed = true;
		}
		return changed;
	}

	async function getStoredTopicSnapshot(topicId, options = {}) {
		const policy = topicSnapshotPolicy(topicId);
		if (!policy) return null;
		if (options.preferStored === true) RESPONSE_CACHE_MEMORY.delete(policy.id);
		const stored = await getStoredResponseCacheEntry(policy.id);
		const value = stored?.value;
		const now = Date.now();
		const fetchedAt = Number(value?.fetchedAt) || Number(stored?.storedAt) || 0;
		if (!value || value.version !== TOPIC_SNAPSHOT_VERSION || Number(value.topicId) !== Number(topicId) ||
				!value.topic || !(fetchedAt > 0) || now - fetchedAt > PERSISTENT_CACHE_CONFIG.topics.maxAge) {
			if (stored) await deleteStoredResponseCacheEntry(policy.id);
			return null;
		}
		const key = String(Number(topicId));
		const current = TOPIC_DATA_CACHE.get(key);
		const storedUpdatedAt = Number(value.updatedAt) || Number(stored.storedAt) || fetchedAt;
		if (current && current.scope === responseCacheAuthScope() && current.topic) {
			const mergedIntoCurrent = mergeMissingSnapshotPosts(current.topic, value.topic);
			const currentUpdatedAt = Number(current.updatedAt) || 0;
			const currentWins = currentUpdatedAt > storedUpdatedAt ||
				(currentUpdatedAt === storedUpdatedAt && options.preferStored !== true);
			if (currentWins) {
				if (mergedIntoCurrent) notifyTopicCacheSubscribers(topicId, current.topic);
				return current.topic;
			}
			mergeMissingSnapshotPosts(value.topic, current.topic);
		}
		rememberCachedTopicData(topicId, value.topic, {
			fetchedAt,
			lastAccessAt: now,
			updatedAt: storedUpdatedAt,
			full: true,
			notify: true,
			source: options.preferStored === true ? 'shared-snapshot' : 'snapshot',
		});
		return value.topic;
	}

	function givenReactionSnapshotPolicy(username) {
		const normalizedUsername = String(username || '').trim().toLocaleLowerCase();
		if (!normalizedUsername) return null;
		return {
			id: `${responseCacheAuthScope()}|snapshot:reactions-given:${encodeURIComponent(normalizedUsername)}`,
			kind: 'responses',
			tags: ['reactions-given'],
			freshFor: MINUTE,
			retainFor: 30 * DAY,
			persist: true,
		};
	}

	async function getGivenReactionSnapshot(username) {
		const policy = givenReactionSnapshotPolicy(username);
		if (!policy) return null;
		const cached = await getStoredResponseCacheEntry(policy.id);
		if (!cached) return null;
		const storedAt = Number(cached.storedAt || 0);
		const age = Date.now() - storedAt;
		const value = cached.value || {};
		if (!(storedAt > 0) || age > policy.retainFor || value.version !== 1 || !Array.isArray(value.entries)) {
			await deleteStoredResponseCacheEntry(policy.id);
			return null;
		}
		return {
			entries: value.entries,
			storedAt,
			fresh: age <= policy.freshFor,
		};
	}

	function setGivenReactionSnapshot(username, entries) {
		const policy = givenReactionSnapshotPolicy(username);
		if (!policy) return;
		setStoredResponseCacheEntry(policy, {
			version: 1,
			entries: Array.isArray(entries) ? entries : [],
		});
	}

	function responseCacheCanFallback(error) {
		if (!error || error.name === 'AbortError') return false;
		const status = Number(error.status || 0);
		return !status || status === 408 || status === 429 || status >= 500;
	}

	async function requestCachedJSON(url, fetchOptions, options, networkRequest) {
		if (options.cacheMode === 'bypass') return networkRequest();
		const policy = responseCachePolicy(url, fetchOptions, options);
		if (!policy) return networkRequest();
		let cached = await getStoredResponseCacheEntry(policy.id);
		const cachedBeforeRequest = Number(cached?.storedAt || 0);
		let age = cached ? Date.now() - Number(cached.storedAt || 0) : Infinity;
		if (cached && age > policy.retainFor) {
			await deleteStoredResponseCacheEntry(policy.id);
			cached = null;
			age = Infinity;
		} else if (cached && policy.cacheMode !== 'refresh' && age <= policy.freshFor) {
			return markResponseCacheStoredAt(cached.value, cached.storedAt);
		}

		const requestKey = `${policy.cacheMode}:${policy.id}`;
		const pendingRequest = RESPONSE_CACHE_REQUESTS.get(requestKey);
		if (pendingRequest) return pendingRequest;
		const request = (async () => {
			const flightToken = policy.persist && SHARED_REQUEST_COORDINATOR
				? sharedCacheIdToken(policy.id)
				: '';
			const readLatestSharedCache = async () => {
				RESPONSE_CACHE_MEMORY.delete(policy.id);
				const latest = await getStoredResponseCacheEntry(policy.id);
				const storedAt = Number(latest?.storedAt || 0);
				const latestAge = latest ? Date.now() - storedAt : Infinity;
				if (!latest || latestAge > policy.retainFor) return undefined;
				cached = latest;
				age = latestAge;
				const usable = policy.cacheMode === 'refresh'
					? storedAt > cachedBeforeRequest
					: latestAge <= policy.freshFor;
				return usable ? markResponseCacheStoredAt(latest.value, storedAt) : undefined;
			};
			let ownedFlight = null;
			let flightHeartbeat = 0;
			try {
				if (flightToken) {
					const flightDeadline = Date.now() + SHARED_CACHE_FLIGHT_TTL * 2 + 5000;
					while (!ownedFlight) {
						if (Date.now() >= flightDeadline) {
							throw requestTimeoutError('等待共享缓存请求超时');
						}
						const flight = await SHARED_REQUEST_COORDINATOR.acquireCacheFlight(flightToken);
						if (!flight.producer) {
							const released = await SHARED_REQUEST_COORDINATOR.waitForCacheFlight(
								flightToken,
								fetchOptions.signal,
								flightDeadline
							);
							if (!released) {
								throw requestTimeoutError('等待共享缓存请求超时');
							}
							const shared = await readLatestSharedCache();
							if (shared !== undefined) return shared;
							continue;
						}
						ownedFlight = flight;
						flightHeartbeat = setInterval(() => {
							SHARED_REQUEST_COORDINATOR.renewCacheFlight(flightToken, flight.flightId).then((renewed) => {
								if (renewed || !flightHeartbeat) return;
								clearInterval(flightHeartbeat);
								flightHeartbeat = 0;
							}).catch(() => {});
						}, 15000);
						const lateCached = await readLatestSharedCache();
						if (lateCached !== undefined) return lateCached;
					}
				}
				const value = markResponseCacheStoredAt(await networkRequest(), Date.now());
				if (ownedFlight && SHARED_REQUEST_COORDINATOR) {
					await SHARED_REQUEST_COORDINATOR.writeCacheFlight(
						flightToken,
						ownedFlight.flightId,
						ownedFlight.cacheEpoch,
						() => setStoredResponseCacheEntry(policy, value)
					);
				} else {
					await setStoredResponseCacheEntry(policy, value);
				}
				return value;
			} catch (error) {
				if (options.allowStaleOnError !== false && cached && age <= policy.retainFor && responseCacheCanFallback(error)) {
					return markResponseCacheStoredAt(cached.value, cached.storedAt);
				}
				throw error;
			} finally {
				if (flightHeartbeat) clearInterval(flightHeartbeat);
				flightHeartbeat = 0;
				if (ownedFlight && SHARED_REQUEST_COORDINATOR) {
					await SHARED_REQUEST_COORDINATOR.releaseCacheFlight(flightToken, ownedFlight.flightId);
				}
			}
		})();
		return trackSingleFlightRequest(RESPONSE_CACHE_REQUESTS, requestKey, request);
	}

	async function scanStoredResponseCache(mode, visit) {
		const db = await openResponseCacheDatabase();
		if (!db) return false;
		return runResponseCacheTransaction(db, mode, (store) => {
			const request = store.openCursor();
			request.onsuccess = () => {
				const cursor = request.result;
				if (!cursor) return;
				visit(cursor.value || {}, cursor);
				cursor.continue();
			};
		});
	}

	function storedResponseCachePruneIds(entries, entryExpiresAt, now) {
		const removeIds = new Set();
		const entriesByKind = new Map();
		entries.forEach((entry) => {
			const id = String(entry?.id || '');
			if (!id) return;
			if (entryExpiresAt(entry) <= now) {
				removeIds.add(id);
				return;
			}
			const kind = PERSISTENT_CACHE_CONFIG[entry.kind] ? entry.kind : 'responses';
			let kindEntries = entriesByKind.get(kind);
			if (!kindEntries) {
				kindEntries = [];
				entriesByKind.set(kind, kindEntries);
			}
			kindEntries.push(entry);
		});
		entriesByKind.forEach((kindEntries, kind) => {
			const config = PERSISTENT_CACHE_CONFIG[kind] || PERSISTENT_CACHE_CONFIG.responses;
			const maxEntries = Math.max(0, Number(config.storedMaxEntries) || 0);
			const maxBytes = Math.max(0, Number(config.storedMaxBytes) || 0);
			if (!maxEntries && !maxBytes) return;
			let keptEntries = 0;
			let keptBytes = 0;
			kindEntries.sort((left, right) => Number(right?.storedAt || 0) - Number(left?.storedAt || 0));
			kindEntries.forEach((entry) => {
				const bytes = Math.max(0, Number(entry?.bytes) || 0);
				const exceedsEntries = maxEntries > 0 && keptEntries >= maxEntries;
				const exceedsBytes = maxBytes > 0 && keptBytes + bytes > maxBytes;
				if (exceedsEntries || exceedsBytes) {
					removeIds.add(String(entry.id));
					return;
				}
				keptEntries++;
				keptBytes += bytes;
			});
		});
		return removeIds;
	}

	async function pruneStoredResponseCache(force = false) {
		const now = Date.now();
		if (!force && now - responseCachePrunedAt < HOUR) return;
		if (responseCachePruneRequest) return responseCachePruneRequest;
		const entryExpiresAt = (entry) => {
			const explicit = Number(entry?.expiresAt);
			if (explicit > 0) return explicit;
			const id = String(entry?.id || '');
			const separator = id.indexOf('|');
			const policy = separator >= 0 ? responseCachePolicy(id.slice(separator + 1)) : null;
			return Number(entry?.storedAt || 0) +
				(policy ? policy.retainFor : PERSISTENT_CACHE_CONFIG.responses.maxAge);
		};
		responseCachePruneRequest = (async () => {
			const db = await openResponseCacheDatabase();
			if (!db) return;
			let scanned = false;
			const pruned = await runResponseCacheTransaction(db, 'readwrite', (store) => {
				const entries = [];
				const request = store.openCursor();
				request.onsuccess = () => {
					const cursor = request.result;
					if (cursor) {
						const entry = cursor.value || {};
						entries.push({
							id: entry.id,
							kind: entry.kind,
							storedAt: entry.storedAt,
							expiresAt: entry.expiresAt,
							bytes: entry.bytes,
						});
						cursor.continue();
						return;
					}
					storedResponseCachePruneIds(entries, entryExpiresAt, now).forEach((id) => {
						RESPONSE_CACHE_MEMORY.delete(id);
						store.delete(id);
					});
					scanned = true;
				};
			});
			if (!pruned || !scanned) return;
			responseCachePrunedAt = now;
		})().finally(() => {
			responseCachePruneRequest = null;
		});
		return responseCachePruneRequest;
	}

	async function clearStoredResponseCache({ kinds = [], tags = [], all = false } = {}) {
		if (SHARED_REQUEST_COORDINATOR) {
			await SHARED_REQUEST_COORDINATOR.invalidateCacheWrites();
		}
		if (RESPONSE_CACHE_WRITES.size) await Promise.all([...RESPONSE_CACHE_WRITES.values()]);
		const kindSet = new Set(kinds);
		const tagSet = new Set(tags);
		RESPONSE_CACHE_MEMORY.forEach((entry, id) => {
			if (all || kindSet.has(entry.kind) || (entry.tags || []).some((tag) => tagSet.has(tag))) {
				RESPONSE_CACHE_MEMORY.delete(id);
			}
		});
		const db = await openResponseCacheDatabase();
		if (db) {
			await runResponseCacheTransaction(db, 'readwrite', (store) => {
				if (all) {
					store.clear();
				} else {
					const lookups = [
						...Array.from(kindSet, (kind) => [store.index('kind'), kind]),
						...Array.from(tagSet, (tag) => [store.index('tags'), tag]),
					];
					const ids = new Set();
					let pending = lookups.length;
					lookups.forEach(([index, value]) => {
						const request = index.getAllKeys(IDBKeyRange.only(value));
						request.onsuccess = () => {
							(request.result || []).forEach((id) => ids.add(id));
							pending--;
							if (!pending) ids.forEach((id) => store.delete(id));
						};
						request.onerror = () => {
							pending--;
							if (!pending) ids.forEach((id) => store.delete(id));
						};
					});
				}
			});
		}
		publishResponseCacheMutation('response-clear', { kinds, tags, all });
	}

	async function storedResponseCacheStats() {
		await pruneStoredResponseCache(true);
		const counts = {
			topics: { count: 0, bytes: 0 },
			users: { count: 0, bytes: 0 },
			notifications: { count: 0, bytes: 0 },
			responses: { count: 0, bytes: 0 },
		};
		let topicSnapshotCount = 0;
		let topicRequestCount = 0;
		const topicIds = new Set();
		const postIds = new Set();
		const userNames = new Set();
		const notificationIds = new Set();
		const responseRecords = { bookmarks: 0, reactions: 0, other: 0 };
		const result = () => ({
			topics: { ...counts.topics, topicIds: [...topicIds], postCount: postIds.size,
				topicSnapshotCount, topicRequestCount },
			users: { ...counts.users, userNames: [...userNames] },
			notifications: { ...counts.notifications, notificationIds: [...notificationIds] },
			responses: { ...counts.responses, responseRecords },
		});
		await scanStoredResponseCache('readonly', (entry) => {
				const stat = counts[entry.kind];
				if (stat) {
					stat.count++;
					stat.bytes += Math.max(0, Number(entry.bytes) || 0);
					const tags = new Set(Array.isArray(entry.tags) ? entry.tags.map(String) : []);
					if (entry.kind === 'topics') {
						tags.forEach((tag) => {
							const match = String(tag).match(/^topic:(\d+)$/);
							if (match) topicIds.add(Number(match[1]));
						});
						const value = entry.value;
						const isTopicSnapshot = String(entry.id || '').includes('|snapshot:topic:') ||
							!!(value && value.version === TOPIC_SNAPSHOT_VERSION && value.topicId && value.topic);
						if (isTopicSnapshot) topicSnapshotCount++;
						else topicRequestCount++;
						const snapshotTopic = isTopicSnapshot && value
							? value.topic
							: value;
						const snapshotTopicId = Number(value?.topicId || snapshotTopic?.id || 0);
						if (snapshotTopicId > 0) topicIds.add(snapshotTopicId);
						postsFromPayload(snapshotTopic).forEach((post) => {
							const topicId = Number(post?.topic_id || snapshotTopicId || 0);
							if (topicId > 0) topicIds.add(topicId);
							const postId = Number(post?.id || 0);
							const postNumber = Number(post?.post_number || 0);
							if (postId > 0) postIds.add(`id:${postId}`);
							else if (topicId > 0 && postNumber > 0) postIds.add(`number:${topicId}:${postNumber}`);
						});
					} else if (entry.kind === 'users') {
						tags.forEach((tag) => {
							const match = tag.match(/^user:(.+)$/);
							if (match?.[1]) userNames.add(match[1].toLocaleLowerCase());
						});
						const value = entry.value || {};
						const candidates = [value.user, value];
						(Array.isArray(value.directory_items) ? value.directory_items : []).forEach((item) => {
							candidates.push(item && (item.user || item));
						});
						candidates.forEach((user) => {
							const username = cleanUsernameValue(user?.username).toLocaleLowerCase();
							if (username) userNames.add(username);
						});
					} else if (entry.kind === 'notifications') {
						const notifications = entry.value && Array.isArray(entry.value.notifications)
							? entry.value.notifications
							: [];
						addNotificationStatsIdentities(notificationIds, notifications);
					} else if (entry.kind === 'responses') {
						if (tags.has('bookmarks')) responseRecords.bookmarks++;
						else if (tags.has('reactions-given')) responseRecords.reactions++;
						else responseRecords.other++;
					}
				}
		});
		return result();
	}

	function responseCacheMutationTags(url, params, data) {
		const parsed = responseCacheUrl(url);
		if (parsed && parsed.pathname === '/topics/timings') return [];
		const tags = new Set();
		const source = Object.assign({}, params || {}, data || {});
		const topicId = Number(source.topic_id || source.topicId ||
			(source.bookmarkable_type === 'Topic' && source.bookmarkable_id) || 0);
		const postId = Number(source.post_id || source.postId ||
			(source.bookmarkable_type === 'Post' && source.bookmarkable_id) ||
			(data?.post && data.post.id) || 0);
		if (topicId) tags.add(`topic:${topicId}`);
		if (postId) tags.add(`post:${postId}`);
		if (parsed) {
			const topicMatch = parsed.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)/);
			const postMatch = parsed.pathname.match(/^\/(?:posts|post_actions)\/(\d+)/);
			const reactionPostMatch = parsed.pathname.match(/^\/discourse-reactions\/posts\/(\d+)/);
			const userMatch = parsed.pathname.match(/^\/(?:u|follow|category-experts\/endorse)\/([^/.]+)/);
			if (topicMatch) tags.add(`topic:${topicMatch[1]}`);
			if (postMatch) tags.add(`post:${postMatch[1]}`);
			if (reactionPostMatch) {
				tags.add(`post:${reactionPostMatch[1]}`);
				tags.add('reactions-given');
			}
			if (parsed.pathname === '/post_actions' && Number(params?.id)) {
				tags.add(`post:${Number(params.id)}`);
			}
			if (parsed.pathname === '/post_actions' || /^\/post_actions\/\d+/.test(parsed.pathname)) {
				tags.add('reactions-given');
			}
			if (parsed.pathname === '/bookmarks' || parsed.pathname.startsWith('/bookmarks/') ||
				/\/remove_bookmarks$/.test(parsed.pathname)) tags.add('bookmarks');
			if (userMatch) tags.add(`user:${decodeURIComponent(userMatch[1]).toLocaleLowerCase()}`);
			if (parsed.pathname.startsWith('/notifications/')) tags.add('notifications');
		}
		return [...tags];
	}

	function scheduleIdleWork(callback, timeout = 1200) {
		let cancelled = false;
		const run = () => {
			if (!cancelled) callback();
		};
		if (isFunction(requestIdleCallback)) {
			const id = requestIdleCallback(run, { timeout });
			return () => {
				cancelled = true;
				if (isFunction(cancelIdleCallback)) cancelIdleCallback(id);
			};
		}
		const id = setTimeout(run, timeout);
		return () => {
			cancelled = true;
			clearTimeout(id);
		};
	}

	function trimPersistentCacheMap(key) {
		const cache = cacheMapForKey(key);
		const maxEntries = Number(PERSISTENT_CACHE_CONFIG[key]?.maxEntries) || 0;
		if (!cache || !maxEntries) return;
		while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
	}

	function topicCacheEntryTimestamp(entry) {
		return Number(entry?.fetchedAt) || Number(entry?.at) || 0;
	}

	function rememberCachedTopicData(topicId, topic, options = {}) {
		const key = String(Number(topicId) || '');
		if (!key || !topic) return null;
		const now = Date.now();
		const scope = String(options.scope || responseCacheAuthScope());
		const previous = TOPIC_DATA_CACHE.get(key);
		const sameScope = previous && previous.scope === scope ? previous : null;
		const entry = {
			topic,
			scope,
			fetchedAt: Number(options.fetchedAt) || topicCacheEntryTimestamp(sameScope) || now,
			lastAccessAt: Number(options.lastAccessAt) || now,
			updatedAt: Number(options.updatedAt) || now,
			source: String(options.source || sameScope?.source || 'memory'),
		};
		TOPIC_DATA_CACHE.delete(key);
		TOPIC_DATA_CACHE.set(key, entry);
		trimPersistentCacheMap('topics');
		if (options.notify !== false) notifyTopicCacheSubscribers(key, topic);
		return entry;
	}

	function getCachedTopicData(topicId) {
		const key = String(Number(topicId) || '');
		if (!key) return null;
		const now = Date.now();
		const scope = responseCacheAuthScope();
		let entry = TOPIC_DATA_CACHE.get(key);
		if (entry && entry.scope !== scope) {
			if (ME_USERNAME === null && String(entry.scope || '').startsWith('account:')) return null;
			const canRescopeHost = entry.source === 'host' &&
				now - topicCacheEntryTimestamp(entry) <= TOPIC_CACHE_TTL;
			if (canRescopeHost) {
				entry.scope = scope;
				entry.updatedAt = now;
				scheduleTopicSnapshotWrite(key);
			} else {
				TOPIC_DATA_CACHE.delete(key);
				entry = null;
			}
		}
		if (entry && now - topicCacheEntryTimestamp(entry) > PERSISTENT_CACHE_CONFIG.topics.maxAge) {
			TOPIC_DATA_CACHE.delete(key);
			entry = null;
		}
		if (!entry) {
			const hostEntry = HOST_TOPIC_DATA_CACHE.get(key);
			HOST_TOPIC_DATA_CACHE.delete(key);
			if (hostEntry && now - Number(hostEntry.at || 0) <= TOPIC_CACHE_TTL) {
				entry = rememberCachedTopicData(key, hostEntry.topic, {
					fetchedAt: Number(hostEntry.at) || now,
					source: 'host',
				});
				scheduleTopicSnapshotWrite(key);
			}
		}
		if (!entry) return null;
		TOPIC_DATA_CACHE.delete(key);
		entry.lastAccessAt = now;
		TOPIC_DATA_CACHE.set(key, entry);
		return entry.topic;
	}

	function setCachedTopicData(topicId, topic, options = {}) {
		const entry = rememberCachedTopicData(topicId, topic, options);
		if (entry) scheduleTopicSnapshotWrite(topicId);
	}

	function cleanUsernameValue(value) {
		return String(value || '').replace(/^@/, '').trim();
	}

	function authScopedCacheKey(key) {
		return `${responseCacheAuthScope()}|${key}`;
	}

	function getCachedUserCard(username) {
		const key = authScopedCacheKey(cleanUsernameValue(username));
		const entry = USER_CARD_CACHE.get(key);
		if (!entry) return null;
		if (Date.now() - Number(entry.at || 0) > PERSISTENT_CACHE_CONFIG.users.maxAge) {
			USER_CARD_CACHE.delete(key);
			schedulePersistentCacheWrite('users');
			return null;
		}
		USER_CARD_CACHE.delete(key);
		entry.at = Date.now();
		USER_CARD_CACHE.set(key, entry);
		return entry.user || null;
	}

	function setCachedUserCard(username, user, persistDelay = 250) {
		const cleanUsername = cleanUsernameValue(username);
		if (!cleanUsername || !user) return;
		const key = authScopedCacheKey(cleanUsername);
		USER_CARD_CACHE.delete(key);
		USER_CARD_CACHE.set(key, { user, at: Date.now() });
		trimPersistentCacheMap('users');
		schedulePersistentCacheWrite('users', persistDelay);
	}

	function cacheMapForKey(key) {
		if (key === 'topics') return TOPIC_DATA_CACHE;
		if (key === 'topicNavIcons') return TOPIC_NAV_ICON_CACHE;
		if (key === 'users') return USER_CARD_CACHE;
		if (key === 'notifications') return NOTIFICATION_CACHE;
		return null;
	}

	function readPersistentCacheEntries(key) {
		const config = PERSISTENT_CACHE_CONFIG[key];
		if (!config || !config.storageKey || key === 'history') return [];
		try {
			const data = JSON.parse(localStorage.getItem(config.storageKey) || 'null');
			if (!data || data.version !== PERSISTENT_CACHE_VERSION || !Array.isArray(data.entries)) return [];
			return data.entries;
		} catch (e) {
			return [];
		}
	}

	function boundedPersistentEntries(key, entries) {
		const config = PERSISTENT_CACHE_CONFIG[key];
		if (!config) return [];
		const now = Date.now();
		const authPrefix = ME_USERNAME === null
			? ''
			: key === 'notifications'
				? authScopedCacheKey('tabs-v5:')
				: key === 'users'
					? authScopedCacheKey('')
					: '';
		const bounded = (Array.isArray(entries) ? entries : []).filter((entry) => {
			if (!Array.isArray(entry) || entry.length !== 2 || entry[0] == null || !entry[1]) return false;
			if (authPrefix && !String(entry[0]).startsWith(authPrefix)) return false;
			return Number.isFinite(Number(entry[1].at)) && now - Number(entry[1].at) <= config.maxAge;
		});
		return config.maxEntries ? bounded.slice(-config.maxEntries) : bounded;
	}

	function isStorageQuotaError(error) {
		return !!error && (error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
			error.code === 22 || error.code === 1014);
	}

	function persistCacheType(key) {
		const config = PERSISTENT_CACHE_CONFIG[key];
		const cache = cacheMapForKey(key);
		if (!config || !cache) return;
		const merged = new Map(readPersistentCacheEntries(key).map(([entryKey, value]) => [String(entryKey), value]));
		cache.forEach((value, entryKey) => {
			const normalizedKey = String(entryKey);
			const previous = merged.get(normalizedKey);
			if (!previous || Number(value?.at || 0) >= Number(previous?.at || 0)) {
				merged.delete(normalizedKey);
				merged.set(normalizedKey, value);
			}
		});
		const entries = boundedPersistentEntries(key, [...merged.entries()]);
		while (true) {
			try {
				if (entries.length) localStorage.setItem(config.storageKey, JSON.stringify({ version: PERSISTENT_CACHE_VERSION, entries }));
				else localStorage.removeItem(config.storageKey);
				break;
			} catch (error) {
				if (!isStorageQuotaError(error) || !entries.length) return;
				entries.shift();
			}
		}
		cache.clear();
		entries.forEach(([entryKey, value]) => cache.set(String(entryKey), value));
		if (SHARED_REQUEST_COORDINATOR) {
			SHARED_REQUEST_COORDINATOR.publishCache({ action: 'map-update', key });
		}
	}

	function flushPersistentCacheWrites() {
		if (cancelPersistentCacheWrite) {
			const cancel = cancelPersistentCacheWrite;
			cancelPersistentCacheWrite = null;
			cancel();
		}
		persistentCacheWriteDueAt = 0;
		const keys = [...PERSISTENT_CACHE_WRITE_KEYS];
		PERSISTENT_CACHE_WRITE_KEYS.clear();
		keys.forEach(persistCacheType);
	}

	function schedulePersistentCacheWrite(key, delay = 2000) {
		if (!cacheMapForKey(key) || !PERSISTENT_CACHE_CONFIG[key]?.storageKey) return;
		PERSISTENT_CACHE_WRITE_KEYS.add(key);
		const safeDelay = Math.max(0, Number(delay) || 0);
		const dueAt = Date.now() + safeDelay;
		if (cancelPersistentCacheWrite && persistentCacheWriteDueAt <= dueAt) return;
		if (cancelPersistentCacheWrite) cancelPersistentCacheWrite();
		persistentCacheWriteDueAt = dueAt;
		cancelPersistentCacheWrite = scheduleIdleWork(() => {
			cancelPersistentCacheWrite = null;
			persistentCacheWriteDueAt = 0;
			flushPersistentCacheWrites();
		}, safeDelay);
	}

	function restorePersistentCaches() {
		try { localStorage.removeItem(LDP_TOPIC_CACHE_KEY); } catch (error) {}
		['topicNavIcons', 'users', 'notifications'].forEach((key) => {
			const cache = cacheMapForKey(key);
			if (!cache) return;
			const rawEntries = readPersistentCacheEntries(key);
			const entries = boundedPersistentEntries(key, rawEntries);
			entries.forEach(([entryKey, value]) => cache.set(String(entryKey), value));
			if (key === 'notifications') {
				entries.forEach(([, entry]) => {
					const notifications = entry?.value && entry.value.notifications;
					if (!Array.isArray(notifications)) return;
					notifications.forEach((notification) => {
						const data = notificationData(notification);
						const template = notification.acting_user_avatar_template || notification.avatar_template ||
							data.acting_user_avatar_template || data.avatar_template || '';
						const actor = notificationActor(notification);
						if (actor && template) rememberNotificationAvatar(actor, template);
					});
				});
			}
			if (entries.length !== rawEntries.length) schedulePersistentCacheWrite(key);
		});
	}

	function mergeSharedPersistentCache(key) {
		const cache = cacheMapForKey(key);
		if (!cache) return;
		let changed = false;
		boundedPersistentEntries(key, readPersistentCacheEntries(key)).forEach(([entryKey, value]) => {
			const normalizedKey = String(entryKey);
			const current = cache.get(normalizedKey);
			if (!current || Number(value?.at || 0) > Number(current?.at || 0)) {
				cache.delete(normalizedKey);
				cache.set(normalizedKey, value);
				changed = true;
			}
		});
		trimPersistentCacheMap(key);
		if (changed) schedulePersistentCacheWrite(key, 0);
	}

	function clearPersistentCacheMaps(keys) {
		(Array.isArray(keys) ? keys : []).forEach((key) => {
			if (key === 'topics') {
				cancelTopicSnapshotWrites();
				HOST_TOPIC_DATA_CACHE.clear();
			}
			const cache = cacheMapForKey(String(key));
			if (cache) cache.clear();
			if (key === 'users') {
				USER_CARD_REQUESTS.clear();
				USER_FOLLOW_LIST_CACHE.clear();
				USER_FOLLOW_LIST_REQUESTS.clear();
			}
			if (key === 'notifications') NOTIFICATION_REQUESTS.clear();
		});
	}

	function applySharedCacheMessage(message) {
		const action = String(message?.action || '');
		if (action === 'topic-snapshot-write') {
			const topicId = Number(message.topicId);
			if (!(topicId > 0) || String(message.scopeToken || '') !== sharedCacheIdToken(responseCacheAuthScope())) return;
			const policy = topicSnapshotPolicy(topicId);
			if (policy) RESPONSE_CACHE_MEMORY.delete(policy.id);
			void getStoredTopicSnapshot(topicId, { preferStored: true });
			return;
		}
		if (action === 'map-update') {
			mergeSharedPersistentCache(String(message.key || ''));
			return;
		}
		if (action === 'map-delete' && message.key === 'topics') {
			const key = String(message.entryKey || '');
			TOPIC_DATA_CACHE.delete(key);
			HOST_TOPIC_DATA_CACHE.delete(key);
			cancelTopicSnapshotWrites(key);
			return;
		}
		if (action === 'map-clear') {
			clearPersistentCacheMaps(message.keys);
			return;
		}
		if (action === 'response-write' || action === 'response-delete') {
			const token = String(message.token || '');
			for (const id of RESPONSE_CACHE_MEMORY.keys()) {
				if (token && sharedCacheIdToken(id) === token) RESPONSE_CACHE_MEMORY.delete(id);
			}
			return;
		}
		if (action === 'response-clear') {
			const kindSet = new Set(Array.isArray(message.kinds) ? message.kinds : []);
			const tagSet = new Set(Array.isArray(message.tags) ? message.tags : []);
			RESPONSE_CACHE_MEMORY.forEach((entry, id) => {
				if (message.all || kindSet.has(entry.kind) || (entry.tags || []).some((tag) => tagSet.has(tag))) {
					RESPONSE_CACHE_MEMORY.delete(id);
				}
			});
		}
	}

	function readTopicHistory() {
		try {
			const data = JSON.parse(localStorage.getItem(LDP_HISTORY_KEY) || '[]');
			if (!Array.isArray(data)) return [];
			const config = PERSISTENT_CACHE_CONFIG.history;
			const safe = data.filter((entry) => entry && Number(entry.topicId) > 0 &&
				Number(entry.viewedAt || 0) >= Date.now() - config.maxAge);
			if (safe.length !== data.length) return writeTopicHistory(safe);
			return safe;
		} catch (e) {
			return [];
		}
	}

	function historyEntryFirstViewedAt(entry) {
		return Number(entry && (entry.firstViewedAt || entry.viewedAt)) || 0;
	}

	function historyEntrySortTime(entry) {
		return PREFS.historySortMode === 'recent-viewed'
			? Number(entry?.viewedAt) || 0
			: historyEntryFirstViewedAt(entry);
	}

	function orderedTopicHistory(entries = readTopicHistory()) {
		return entries.slice().sort((left, right) =>
			historyEntrySortTime(right) - historyEntrySortTime(left) ||
			Number(right.topicId || 0) - Number(left.topicId || 0)
		);
	}

	function writeTopicHistory(entries) {
		const safeEntries = Array.isArray(entries) ? entries.slice() : [];
		while (true) {
			try {
				if (safeEntries.length) localStorage.setItem(LDP_HISTORY_KEY, JSON.stringify(safeEntries));
				else localStorage.removeItem(LDP_HISTORY_KEY);
				syncReaderQueueReadHistory(safeEntries);
				return safeEntries;
			} catch (error) {
				if (!isStorageQuotaError(error) || !safeEntries.length) return safeEntries;
				safeEntries.pop();
			}
		}
	}

	function normalizeReadPostNumbers(values) {
		const source = values instanceof Set ? [...values] : (Array.isArray(values) ? values : []);
		return [...new Set(source
			.map((value) => Math.floor(Number(value) || 0))
			.filter((value) => value > 0))]
			.sort((left, right) => left - right);
	}

	function historyEntryReadPostNumbers(entry) {
		return normalizeReadPostNumbers(entry?.readPostNumbers);
	}

	function rememberTopic(topic, postNumber = 0, readPostNumbers = []) {
		const topicId = Number(topic?.id);
		if (!topicId) return;
		const history = readTopicHistory();
		const previousEntry = history.find((item) => Number(item.topicId) === topicId);
		const viewedPostNumber = Math.max(
			1,
			Math.floor(Number(postNumber) || Number(previousEntry?.postNumber) || 1),
		);
		const cumulativeReadPostNumbers = normalizeReadPostNumbers([
			...historyEntryReadPostNumbers(previousEntry),
			...normalizeReadPostNumbers(readPostNumbers),
			viewedPostNumber,
		]);
		const posts = topic?.post_stream && Array.isArray(topic.post_stream.posts)
			? topic.post_stream.posts : [];
		const firstPost = posts.find((post) => Number(post?.post_number) === 1);
		const viewedAt = Date.now();
		const entry = {
			topicId,
			title: String(topic.title || `帖子 #${topicId}`),
			postsCount: Math.max(0, Number(topic.posts_count) || 0),
			avatarTemplate: String(
				(topic.details?.created_by && topic.details.created_by.avatar_template) ||
				(firstPost?.avatar_template) || ''
			),
			ownerUsername: String(
				(topic.details?.created_by && topic.details.created_by.username) ||
				(firstPost?.username) || topic._opUsername || ''
			),
			postNumber: viewedPostNumber,
			readPostNumbers: cumulativeReadPostNumbers,
			firstViewedAt: historyEntryFirstViewedAt(previousEntry) || viewedAt,
			viewedAt,
		};
		writeTopicHistory([entry, ...history.filter((item) => Number(item.topicId) !== topicId)]);
		const readerShell = CURRENT_OVERLAY?._ldpReaderShell;
		if (isFunction(readerShell?.refreshHistoryPanel)) {
			readerShell.refreshHistoryPanel();
		}
	}

	function normalizeReaderHistoryViewport(value) {
		const source = value && typeof value === 'object' ? value : { postNumber: value };
		const postNumber = Math.floor(Number(source.postNumber));
		if (!(postNumber > 0)) return null;
		const postOffset = Number(source.postOffset);
		const scrollTop = Number(source.scrollTop);
		return {
			postNumber,
			postOffset: Number.isFinite(postOffset) ? postOffset : 0,
			scrollTop: Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : 0,
		};
	}

	function normalizeReaderHistoryPositions(values) {
		const positions = {};
		if (!values || typeof values !== 'object') return positions;
		Object.entries(values).forEach(([entryTopicId, value]) => {
			const safeTopicId = Number(entryTopicId);
			const viewport = normalizeReaderHistoryViewport(value);
			if (safeTopicId > 0 && viewport) positions[safeTopicId] = viewport;
		});
		return positions;
	}

	function normalizeHistoryEdgeTriggerPercent(value) {
		const numeric = Number(value);
		if (!Number.isFinite(numeric)) return HISTORY_EDGE_TRIGGER_DEFAULT;
		return Math.min(HISTORY_EDGE_TRIGGER_MAX, Math.max(HISTORY_EDGE_TRIGGER_MIN, Math.round(numeric)));
	}

	function createReaderHistoryNavigation(topicId, source) {
		const currentTopicId = Number(topicId);
		const normalize = (values) => [...new Set((Array.isArray(values) ? values : [])
			.map(Number).filter((id) => id > 0 && id !== currentTopicId))];
		if (source && (Array.isArray(source.back) || Array.isArray(source.forward))) {
			return {
				back: normalize(source.back),
				forward: normalize(source.forward),
				positions: normalizeReaderHistoryPositions(source.positions),
			};
		}
		const orderedTopicIds = orderedTopicHistory().map((entry) => Number(entry.topicId)).filter((id) => id > 0);
		const currentIndex = orderedTopicIds.indexOf(currentTopicId);
		const useFixedPosition = PREFS.historySortMode === 'first-viewed' && currentIndex >= 0;
		return {
			back: normalize(useFixedPosition ? orderedTopicIds.slice(currentIndex + 1) : orderedTopicIds),
			forward: normalize(useFixedPosition ? orderedTopicIds.slice(0, currentIndex).reverse() : []),
			positions: {},
		};
	}

	function bindReaderHistoryNavigation(overlay, topicId, navigation) {
		const backButton = overlay.querySelector('.ldp-reader-history-back');
		const forwardButton = overlay.querySelector('.ldp-reader-history-forward');
		if (!backButton || !forwardButton) return () => {};
		const backEdge = backButton.closest('.ldp-reader-history-edge');
		const forwardEdge = forwardButton.closest('.ldp-reader-history-edge');
		const modal = overlay.querySelector('.ldp-modal');
		const historyScope = createLifecycleScope();
		const clearActiveEdge = () => {
			backEdge?.classList.remove('is-active');
			forwardEdge?.classList.remove('is-active');
		};
		const syncVisibilityPreference = () => {
			const alwaysVisible = PREFS.historyButtonsAlwaysVisible === true;
			overlay.classList.toggle('ldp-history-buttons-always-visible', alwaysVisible);
			if (alwaysVisible) clearActiveEdge();
		};
		const onModalPointerMove = (event) => {
			if (!modal || PREFS.historyButtonsAlwaysVisible === true || (event.pointerType && event.pointerType !== 'mouse')) {
				clearActiveEdge();
				return;
			}
			const target = event.target instanceof Element ? event.target : null;
			if (target?.closest('.ldp-settings-popover,.ldp-notifications-popover,.ldp-history-popover,.ldp-bookmarks-popover')) {
				clearActiveEdge();
				return;
			}
			const rect = modal.getBoundingClientRect();
			const triggerWidth = rect.width * normalizeHistoryEdgeTriggerPercent(PREFS.historyEdgeTriggerPercent) / 100;
			const overBackButton = target?.closest('.ldp-reader-history-back') === backButton;
			const overForwardButton = target?.closest('.ldp-reader-history-forward') === forwardButton;
			backEdge?.classList.toggle('is-active', triggerWidth > 0 && (event.clientX <= rect.left + triggerWidth || overBackButton));
			forwardEdge?.classList.toggle('is-active', triggerWidth > 0 && (event.clientX >= rect.right - triggerWidth || overForwardButton));
		};
		if (modal) historyScope.listen(modal, 'pointermove', onModalPointerMove);
		if (modal) historyScope.listen(modal, 'pointerleave', clearActiveEdge);
		overlay._ldpSyncHistoryButtonVisibilityPreference = syncVisibilityPreference;
		syncVisibilityPreference();
		backButton.setAttribute('aria-keyshortcuts', 'ArrowLeft');
		forwardButton.setAttribute('aria-keyshortcuts', 'ArrowRight');
		const topicTitle = (targetTopicId) => {
			const entry = readTopicHistory().find((item) => Number(item.topicId) === Number(targetTopicId));
			return entry?.title ? entry.title : `帖子 #${targetTopicId}`;
		};
		const refreshVisibleTooltip = (button) => {
			refreshTooltips(button);
		};
		const sync = () => {
			// The reader shell can outlive a topic session; its prior navigation may still be busy.
			backButton.disabled = false;
			forwardButton.disabled = false;
			const backTarget = navigation.back[0];
			const forwardTarget = navigation.forward[0];
			backButton.hidden = !backTarget;
			forwardButton.hidden = !forwardTarget;
			if (backEdge) backEdge.hidden = !backTarget;
			if (forwardEdge) forwardEdge.hidden = !forwardTarget;
			if (backTarget) {
				const label = `上一条历史:${topicTitle(backTarget)}`;
				setLabel(backButton, label);
			}
			if (forwardTarget) {
				const label = `下一条历史:${topicTitle(forwardTarget)}`;
				setLabel(forwardButton, label);
			}
			refreshVisibleTooltip(backButton);
			refreshVisibleTooltip(forwardButton);
		};
		const navigate = (direction) => {
			const backward = direction === 'back';
			const source = backward ? navigation.back : navigation.forward;
			const targetTopicId = source[0];
			if (!targetTopicId) return;
			backButton.disabled = true;
			forwardButton.disabled = true;
			const currentPostNumber = Number(overlay.querySelector('.ldp-topic-timeline-track')?.getAttribute('aria-valuenow')) || 1;
			const currentViewport = normalizeReaderHistoryViewport(
				isFunction(overlay._ldpCaptureHistoryViewport)
					? overlay._ldpCaptureHistoryViewport()
					: { postNumber: currentPostNumber, scrollTop: overlay.querySelector('.ldp-body')?.scrollTop || 0 }
			);
			const positions = {
				...navigation.positions,
				[Number(topicId)]: currentViewport || normalizeReaderHistoryViewport(currentPostNumber),
			};
			const targetViewport = normalizeReaderHistoryViewport(positions[targetTopicId]);
			const nextNavigation = backward
				? { back: navigation.back.slice(1), forward: [Number(topicId), ...navigation.forward], positions }
				: { back: [Number(topicId), ...navigation.back], forward: navigation.forward.slice(1), positions };
			openModal(targetTopicId, {
				targetPostNumber: targetViewport ? targetViewport.postNumber : 0,
				restoreViewport: targetViewport || undefined,
				historyNavigation: nextNavigation,
				settleTargetJump: true,
			});
		};
		const onBackHover = () => refreshVisibleTooltip(backButton);
		const onForwardHover = () => refreshVisibleTooltip(forwardButton);
		historyScope.listen(backButton, 'click', () => navigate('back'));
		historyScope.listen(forwardButton, 'click', () => navigate('forward'));
		historyScope.listen(backButton, 'pointerenter', onBackHover);
		historyScope.listen(forwardButton, 'pointerenter', onForwardHover);
		const onHistoryKeyDown = (event) => {
			if (CURRENT_OVERLAY !== overlay || !overlay.isConnected || event.defaultPrevented || event.isComposing ||
				event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || readerPortalQuery('.ldp-lightbox')) return;
			const button = event.key === 'ArrowLeft' ? backButton : event.key === 'ArrowRight' ? forwardButton : null;
			if (!button || button.hidden || button.disabled) return;
			const target = event.target instanceof Element ? event.target : null;
			if (target && target !== document.body && target !== PAGE_ROOT && !overlay.contains(target)) return;
			if (target && (target.isContentEditable || target.closest('input,textarea,select,.ldp-topic-timeline-track'))) return;
			consumeEvent(event);
			button.click();
		};
		historyScope.listen(document, 'keydown', onHistoryKeyDown);
		const refreshNavigationOrder = () => {
			const nextNavigation = createReaderHistoryNavigation(topicId);
			navigation.back = nextNavigation.back;
			navigation.forward = nextNavigation.forward;
			sync();
		};
		overlay._ldpRefreshReaderHistoryNavigation = refreshNavigationOrder;
		sync();
		return () => {
			clearActiveEdge();
			historyScope.destroy();
			if (overlay._ldpRefreshReaderHistoryNavigation === refreshNavigationOrder) {
				delete overlay._ldpRefreshReaderHistoryNavigation;
			}
			if (overlay._ldpSyncHistoryButtonVisibilityPreference === syncVisibilityPreference) {
				delete overlay._ldpSyncHistoryButtonVisibilityPreference;
			}
		};
	}

	function readerQueueEntry(topicId) {
		const safeTopicId = Number(topicId);
		return safeTopicId > 0 ? READER_QUEUE_BY_TOPIC.get(String(safeTopicId)) || null : null;
	}

	function normalizeReaderQueueSurfaceState(state) {
		const rawX = state?.x;
		const rawY = state?.y;
		const x = Number(rawX);
		const y = Number(rawY);
		const hasPosition = rawX != null && rawX !== '' && rawY != null && rawY !== '' &&
			Number.isFinite(x) && Number.isFinite(y);
		return {
			x: hasPosition ? Math.max(0, Math.min(1, x)) : null,
			y: hasPosition ? Math.max(0, Math.min(1, y)) : null,
			dock: hasPosition && ['left', 'right'].includes(state?.dock)
				? state.dock
				: '',
		};
	}

	function readerQueueSurfaceStateCustomized(state = READER_QUEUE_SURFACE_STATE) {
		return Number.isFinite(state?.x) && Number.isFinite(state?.y);
	}

	function flushReaderQueueState() {
		if (READER_QUEUE_PERSIST_TIMER) clearTimeout(READER_QUEUE_PERSIST_TIMER);
		READER_QUEUE_PERSIST_TIMER = 0;
		if (READER_QUEUE_RESTORING) return;
		const entries = READER_QUEUE_ENTRIES.map((entry) => {
			const avatarSource = String(entry?.avatarSource || '');
			return {
				topicId: Number(entry?.topicId) || 0,
				title: String(entry?.title || ''),
				avatarTemplate: String(entry?.avatarTemplate || ''),
				avatarSource: /^https?:\/\//i.test(avatarSource) ? avatarSource : '',
				ownerUsername: String(entry?.ownerUsername || ''),
				addedAt: Math.max(0, Number(entry?.addedAt) || 0),
				pinned: entry && entry.pinned === true,
				totalCount: Math.max(0, Number(entry?.totalCount) || 0),
				urlPostNumber: Math.max(0, Number(entry?.urlPostNumber) || 0),
				viewport: normalizeReaderHistoryViewport(entry?.viewport),
			};
		})
			.filter((entry) => entry.topicId > 0);
		const surface = normalizeReaderQueueSurfaceState(READER_QUEUE_SURFACE_STATE);
		try {
			if (entries.length || readerQueueSurfaceStateCustomized(surface)) {
				localStorage.setItem(LDP_READER_QUEUE_KEY, JSON.stringify({ version: 1, entries, surface }));
			} else {
				localStorage.removeItem(LDP_READER_QUEUE_KEY);
			}
		} catch (error) {
			console.warn('[LDP] 阅读队列保存失败', error?.name || 'storage-error');
		}
	}

	function scheduleReaderQueueStatePersist() {
		if (READER_QUEUE_RESTORING || READER_QUEUE_PERSIST_TIMER) return;
		READER_QUEUE_PERSIST_TIMER = setTimeout(flushReaderQueueState, 250);
	}

	function restoreReaderQueueState() {
		let storedEntries = [];
		let storedSurface = null;
		try {
			const payload = JSON.parse(localStorage.getItem(LDP_READER_QUEUE_KEY) || 'null');
			storedEntries = Array.isArray(payload) ? payload : (Array.isArray(payload?.entries) ? payload.entries : []);
			storedSurface = !Array.isArray(payload) && payload && payload.surface;
		} catch (error) {
			return;
		}
		READER_QUEUE_SURFACE_STATE = normalizeReaderQueueSurfaceState(storedSurface);
		if (!storedEntries.length) return;
		const restoredTopicIds = new Set();
		READER_QUEUE_RESTORING = true;
		try {
			storedEntries.forEach((storedEntry) => {
				const topicId = Number(storedEntry?.topicId);
				if (!(topicId > 0) || restoredTopicIds.has(topicId)) return;
				restoredTopicIds.add(topicId);
				const entry = ensureReaderQueueEntry(topicId, {
					title: storedEntry.title,
					avatarTemplate: storedEntry.avatarTemplate,
					avatarSource: storedEntry.avatarSource,
					ownerUsername: storedEntry.ownerUsername,
				}, { prefetch: false, silent: true });
				if (!entry) return;
				entry.addedAt = Math.max(0, Number(storedEntry.addedAt) || 0) || entry.addedAt;
				entry.pinned = storedEntry.pinned === true;
				entry.totalCount = Math.max(entry.totalCount, Number(storedEntry.totalCount) || 0);
				entry.urlPostNumber = Math.max(0, Number(storedEntry.urlPostNumber) || 0);
				entry.viewport = normalizeReaderHistoryViewport(storedEntry.viewport) || entry.viewport;
			});
		} finally {
			READER_QUEUE_RESTORING = false;
		}
	}

	function readerQueueTopicSummary(topicId, metadata = {}, topic = null) {
		const safeTopicId = Number(topicId);
		const cachedTopic = topic || getCachedTopicData(safeTopicId);
		const historyEntry = readTopicHistory().find((item) => Number(item.topicId) === safeTopicId);
		const posts = cachedTopic?.post_stream && Array.isArray(cachedTopic.post_stream.posts)
			? cachedTopic.post_stream.posts : [];
		const firstPost = posts.find((post) => Number(post?.post_number) === 1);
		return {
			title: String(
				metadata.title || cachedTopic?.title || historyEntry?.title ||
				`帖子 #${safeTopicId}`
			).replace(/\s+/g, ' ').trim(),
			avatarTemplate: String(
				metadata.avatarTemplate ||
				cachedTopic?.details && cachedTopic.details.created_by &&
					cachedTopic.details.created_by.avatar_template ||
				firstPost?.avatar_template ||
				historyEntry?.avatarTemplate || ''
			),
			avatarSource: String(metadata.avatarSource || ''),
			ownerUsername: String(
				metadata.ownerUsername ||
				cachedTopic?.details && cachedTopic.details.created_by &&
					cachedTopic.details.created_by.username ||
				firstPost?.username ||
				historyEntry?.ownerUsername || ''
			),
			navMetadata: mergeTopicNavMetadata(
				metadata.navMetadata || {},
				cachedTopicNavMetadata(cachedTopic),
			),
			totalCount: Math.max(
				0,
				Number(cachedTopic && (cachedTopic.highest_post_number || cachedTopic.posts_count)) || 0,
				Number(historyEntry?.postsCount) || 0,
			),
			historyPostNumber: Math.max(0, Number(historyEntry?.postNumber) || 0),
			urlPostNumber: Math.max(0, Number(metadata.urlPostNumber) || 0),
			readPostNumbers: historyEntryReadPostNumbers(historyEntry),
		};
	}

	function readerQueueMetadataFromLink(link, topicId) {
		const source = link && link.nodeType === Node.ELEMENT_NODE ? link : null;
		const card = source?.closest(
			'tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result,.category-topic-link'
		);
		const titleLink = card?.querySelector(
			'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]'
		);
		const avatar = card?.querySelector(':is(.posters,.topic-poster) img.avatar,img.avatar');
		const ownerNode = card?.querySelector('[data-user-card]');
		const route = extractTopicRouteFromUrl(titleLink && (titleLink.getAttribute('href') || titleLink.href));
		return {
			title: String(
				source && source.dataset.topicTitle ||
				titleLink && (titleLink.dataset.topicTitle || titleLink.textContent) ||
				source?.textContent || `帖子 #${topicId}`
			).replace(/\s+/g, ' ').trim(),
			avatarSource: String(avatar && (avatar.currentSrc || avatar.src) || ''),
			ownerUsername: String(ownerNode && ownerNode.dataset.userCard || ''),
			navMetadata: readTopicNavMetadata(source, topicId),
			urlPostNumber: Math.max(0, Number(route?.postNumber) || 0),
		};
	}

	function readerQueueLoadedCounts(topic) {
		const postStream = topicPostStream(topic);
		const stream = Array.isArray(postStream.stream) ? postStream.stream.filter(Boolean) : [];
		const streamIds = new Set(stream.map(Number).filter(Boolean));
		const loadedIds = new Set((Array.isArray(postStream.posts) ? postStream.posts : [])
			.map((post) => Number(post?.id)).filter((postId) => postId && (!streamIds.size || streamIds.has(postId))));
		return {
			total: stream.length || Math.max(0, Number(topic?.posts_count) || 0),
			loaded: loadedIds.size,
		};
	}

	function syncReaderQueueReadHistory(entries) {
		const historyByTopic = new Map((Array.isArray(entries) ? entries : [])
			.map((historyEntry) => [String(Number(historyEntry?.topicId) || 0), historyEntry]));
		let changed = false;
		READER_QUEUE_ENTRIES.forEach((entry) => {
			const historyEntry = historyByTopic.get(String(entry.topicId));
			const nextReadPostNumbers = historyEntry
				? new Set([...entry.seenPostNumbers, ...historyEntryReadPostNumbers(historyEntry)])
				: new Set();
			if (nextReadPostNumbers.size !== entry.seenPostNumbers.size ||
					[...nextReadPostNumbers].some((postNumber) => !entry.seenPostNumbers.has(postNumber))) {
				entry.seenPostNumbers = nextReadPostNumbers;
				changed = true;
			}
			if (!historyEntry && entry.progressPersistTimer) {
				clearTimeout(entry.progressPersistTimer);
				entry.progressPersistTimer = 0;
			}
		});
		if (changed) syncReaderQueueSurfaces();
	}

	function persistReaderQueueReadProgress(entry) {
		if (!entry) return;
		const readPostNumbers = normalizeReadPostNumbers(entry.seenPostNumbers);
		if (!readPostNumbers.length) return;
		const history = readTopicHistory();
		const previousEntry = history.find((item) => Number(item.topicId) === Number(entry.topicId));
		const previousReadPostNumbers = historyEntryReadPostNumbers(previousEntry);
		const cumulativeReadPostNumbers = normalizeReadPostNumbers([
			...previousReadPostNumbers,
			...readPostNumbers,
		]);
		const postsCount = Math.max(
			cumulativeReadPostNumbers[cumulativeReadPostNumbers.length - 1] || 0,
			Number(entry.totalCount) || 0,
			Number(previousEntry?.postsCount) || 0,
		);
		if (cumulativeReadPostNumbers.length === previousReadPostNumbers.length &&
				Number(previousEntry?.postsCount) >= postsCount) return;
		const now = Date.now();
		const nextEntry = {
			...(previousEntry || {}),
			topicId: Number(entry.topicId),
			title: String(entry.title || previousEntry?.title || `帖子 #${entry.topicId}`),
			postsCount,
			avatarTemplate: String(entry.avatarTemplate || previousEntry?.avatarTemplate || ''),
			ownerUsername: String(entry.ownerUsername || previousEntry?.ownerUsername || ''),
			postNumber: Math.max(
				1,
				Number(previousEntry?.postNumber) ||
					Number(entry.viewport?.postNumber) ||
					cumulativeReadPostNumbers[cumulativeReadPostNumbers.length - 1] || 1,
			),
			readPostNumbers: cumulativeReadPostNumbers,
			firstViewedAt: historyEntryFirstViewedAt(previousEntry) || now,
			viewedAt: Number(previousEntry?.viewedAt) || now,
		};
		delete nextEntry.readPostNumber;
		writeTopicHistory([
			nextEntry,
			...history.filter((item) => Number(item.topicId) !== Number(entry.topicId)),
		]);
	}

	function scheduleReaderQueueReadProgressPersist(entry) {
		if (!entry || entry.progressPersistTimer) return;
		entry.progressPersistTimer = setTimeout(() => {
			entry.progressPersistTimer = 0;
			persistReaderQueueReadProgress(entry);
		}, 500);
	}

	function flushReaderQueueReadProgress(entry) {
		if (!entry) return;
		if (entry.progressPersistTimer) clearTimeout(entry.progressPersistTimer);
		entry.progressPersistTimer = 0;
		persistReaderQueueReadProgress(entry);
	}

	function markReaderQueuePostRead(entry, postNumber) {
		const nextPostNumber = Math.max(0, Math.floor(Number(postNumber) || 0));
		if (!entry || !nextPostNumber || entry.seenPostNumbers.has(nextPostNumber)) return false;
		entry.seenPostNumbers.add(nextPostNumber);
		scheduleReaderQueueReadProgressPersist(entry);
		return true;
	}

	function readerQueueProgress(entry) {
		const total = Math.max(0, Number(entry?.totalCount) || 0);
		if (!entry || !total) return 0;
		return Math.max(0, Math.min(100, entry.seenPostNumbers.size / total * 100));
	}

	function readerQueueStartPostNumber(entry) {
		if (!entry || PREFS.openTopicsAtFirstPost === true) return 1;
		return Math.max(
			1,
			Number(entry.urlPostNumber) ||
			Number(entry.viewport?.postNumber) ||
			1
		);
	}

	function readerQueueStatusText(entry) {
		const total = Math.max(0, Number(entry.totalCount) || 0);
		const currentFloor = Math.max(0, Number(entry.viewport?.postNumber) || 0);
		const readCount = entry.seenPostNumbers.size;
		const progress = Math.round(readerQueueProgress(entry));
		const pinned = entry.pinned ? '已固定 · ' : '';
		if (String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID) {
			return `${pinned}阅读进度 ${progress}% · 已读 ${readCount}/${total || '?'}` +
				`${currentFloor ? ` · 当前 #${currentFloor}` : ''}`;
		}
		const preloadDetails = `正文 ${Math.max(0, Number(entry.loadedCount) || 0)}/${total || '?'}` +
			`${entry.nestedTotalCount > 0
				? ` · 楼中楼 ${Math.max(0, Number(entry.nestedLoadedCount) || 0)}/${entry.nestedTotalCount}`
				: ''}` +
			`${entry.mediaTotalCount > 0
				? ` · 图片 ${Math.max(0, Number(entry.mediaLoadedCount) || 0)}/${entry.mediaTotalCount}`
				: ''}`;
		if (entry.loadState === 'ready') {
			return `${pinned}已预加载 · ${preloadDetails} · 阅读进度 ${progress}%`;
		}
		if (entry.loadState === 'loading') {
			return `${pinned}正在预加载 · ${preloadDetails} · 阅读进度 ${progress}%`;
		}
		if (entry.loadState === 'partial') {
			return `${pinned}已分层预加载 · ${preloadDetails} · 阅读进度 ${progress}%`;
		}
		if (entry.loadState === 'error') return `${pinned}预加载失败 · 阅读进度 ${progress}%,点击重试`;
		return `${pinned}等待预加载 · 阅读进度 ${progress}%`;
	}

	function readerQueueAvatarHtml(entry) {
		const fallback = String(entry.title || '?').trim().slice(0, 1).toUpperCase() || '?';
		const avatar = persistentAvatarHtml(
			entry.avatarTemplate || entry.avatarSource,
			64,
			'ldp-reader-queue-avatar-image',
			fallback,
		);
		if (avatar) return avatar;
		return `<span>${esc(fallback)}</span>`;
	}

	function syncReaderQueueProgressSurface(entry) {
		if (!entry || !CURRENT_OVERLAY) return;
		const rail = CURRENT_OVERLAY.querySelector('.ldp-reader-queue');
		if (!rail) return;
		const topicId = String(Number(entry.topicId));
		const progress = readerQueueProgress(entry);
		const status = readerQueueStatusText(entry);
		const bubble = rail.querySelector(`.ldp-reader-queue-bubble[data-reader-queue-topic-id="${topicId}"]`);
		if (bubble) {
			['queued', 'loading', 'partial', 'ready', 'error']
				.forEach((state) => bubble.classList.toggle(`is-${state}`, entry.loadState === state));
			bubble.style.setProperty('--ldp-reader-queue-progress', `${progress * 3.6}deg`);
			bubble.classList.toggle('is-progress-complete', progress >= 100);
			setLabel(bubble, `${entry.title},${status}`);
			bubble.dataset.ldpTooltipLabel = `${entry.title} · ${status}`;
		}
		const row = rail.querySelector(`.ldp-reader-queue-row[data-reader-queue-topic-id="${topicId}"]`);
		if (row) {
			const rowProgress = row.querySelector('.ldp-reader-queue-row-progress');
			rowProgress?.style.setProperty('--ldp-reader-queue-progress', `${progress * 3.6}deg`);
			rowProgress?.classList.toggle('is-progress-complete', progress >= 100);
			const titleNode = row.querySelector('.ldp-reader-queue-row-copy strong');
			if (titleNode) titleNode.textContent = entry.title;
			const statusNode = row.querySelector('.ldp-reader-queue-row-copy small');
			if (statusNode) statusNode.textContent = status;
		}
	}

	function syncReaderQueueScrollHint(rail) {
		const bubbles = rail?.querySelector('.ldp-reader-queue-bubbles');
		const hint = rail?.querySelector('.ldp-reader-queue-scroll-hint');
		if (!bubbles || !hint) return;
		const maxScroll = Math.max(0, bubbles.scrollHeight - bubbles.clientHeight);
		const hasOverflow = maxScroll >= 2;
		hint.hidden = !hasOverflow;
		if (!hasOverflow) return;
		const scrollUp = bubbles.scrollTop >= maxScroll - 2;
		hint.classList.toggle('is-up', scrollUp);
		hint.dataset.scrollDirection = scrollUp ? '-1' : '1';
		setLabel(hint, scrollUp ? '回到队列上方头像' : '显示下方更多队列头像');
	}

	function syncReaderQueueToggleState(rail) {
		const toggle = rail?.querySelector('.ldp-reader-queue-toggle');
		const count = toggle?.querySelector('b');
		if (!toggle || !count) return;
		const previewExpanded = !rail.classList.contains('is-preview-collapsed');
		setPressed(toggle, previewExpanded);
		setLabel(toggle, `${previewExpanded ? '收纳' : '展开'}队列头像预览;拖动可移动,贴边可隐藏;悬停显示队列详情,共 ${count.textContent || 0} 篇`);
	}

	function applyReaderQueueSurfacePosition(rail) {
		if (!rail) return;
		const modal = rail.closest('.ldp-modal');
		const state = normalizeReaderQueueSurfaceState(READER_QUEUE_SURFACE_STATE);
		const customized = readerQueueSurfaceStateCustomized(state);
		rail.classList.toggle('is-docked-left', customized && state.dock === 'left');
		rail.classList.toggle('is-docked-right', customized && state.dock === 'right');
		if (!customized || !modal || rail.hidden) {
			if (!customized) {
				rail.style.removeProperty('left');
				rail.style.removeProperty('top');
				rail.style.removeProperty('bottom');
			}
			return;
		}
		const maxLeft = Math.max(0, modal.clientWidth - rail.offsetWidth);
		const maxTop = Math.max(0, modal.clientHeight - rail.offsetHeight);
		rail.style.left = `${Math.round(maxLeft * state.x)}px`;
		rail.style.top = `${Math.round(maxTop * state.y)}px`;
		rail.style.bottom = 'auto';
	}

	function renderReaderQueueSurface(overlay) {
		const rail = overlay?.querySelector('.ldp-reader-queue');
		if (!rail) return;
		const toggle = rail.querySelector('.ldp-reader-queue-toggle');
		const count = toggle?.querySelector('b');
		const bubbles = rail.querySelector('.ldp-reader-queue-bubbles');
		const scrollHint = rail.querySelector('.ldp-reader-queue-scroll-hint');
		const panel = rail.querySelector('.ldp-reader-queue-panel');
		const list = rail.querySelector('.ldp-reader-queue-list');
		const panelCount = rail.querySelector('.ldp-reader-queue-panel-count');
		const clear = rail.querySelector('.ldp-reader-queue-clear');
		const panelWasOpen = panel && !panel.hidden;
		const previousActiveTopicId = rail.dataset.readerQueueActiveTopicId || '';
		const previousBubbleScrollTop = bubbles ? bubbles.scrollTop : 0;
		rail.hidden = READER_QUEUE_ENTRIES.length === 0;
		if (!toggle || !count || !bubbles || !scrollHint || !panel || !list || !panelCount || !clear) return;
		count.textContent = String(READER_QUEUE_ENTRIES.length);
		syncReaderQueueToggleState(rail);
		panelCount.textContent = `${READER_QUEUE_ENTRIES.length} 篇`;
		const activeEntry = readerQueueEntry(READER_QUEUE_ACTIVE_TOPIC_ID);
		clear.disabled = !READER_QUEUE_ENTRIES.some((entry) => entry !== activeEntry && !entry.pinned);
		releasePersistentAvatarImages(bubbles);
		bubbles.innerHTML = READER_QUEUE_ENTRIES.map((entry) => {
			const active = String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID;
			const progress = readerQueueProgress(entry);
			const removable = READER_QUEUE_ENTRIES.length > 1 || !active;
			return `<span class="ldp-reader-queue-bubble-shell${entry.pinned ? ' is-pinned' : ''}">
					<button class="ldp-reader-queue-bubble${active ? ' is-active' : ''}${progress >= 100 ? ' is-progress-complete' : ''} is-${entry.loadState}" type="button"
						data-reader-queue-topic-id="${entry.topicId}" aria-current="${active ? 'true' : 'false'}"
						aria-label="${escAttr(`${entry.title},${readerQueueStatusText(entry)}`)}"
						data-ldp-tooltip-label="${escAttr(`${entry.title} · ${readerQueueStatusText(entry)}`)}"
						style="--ldp-reader-queue-progress:${progress * 3.6}deg">
						<span class="ldp-reader-queue-avatar">${readerQueueAvatarHtml(entry)}</span>
						<i aria-hidden="true"></i>
					</button>
					${entry.pinned ? `<span class="ldp-reader-queue-bubble-pin" aria-hidden="true">${icon('pin')}</span>` : ''}
					<button class="ldp-reader-queue-bubble-remove" type="button"
						data-reader-queue-topic-id="${entry.topicId}" aria-label="${escAttr(`从阅读队列移除 ${entry.title}`)}"
						${removable ? '' : 'disabled'}>${icon('x')}</button>
				</span>`;
		}).join('');
		releasePersistentAvatarImages(list);
		list.innerHTML = READER_QUEUE_ENTRIES.map((entry) => {
			const active = String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID;
			const progress = readerQueueProgress(entry);
			const removable = READER_QUEUE_ENTRIES.length > 1 || !active;
			return `<div class="ldp-reader-queue-row${active ? ' is-active' : ''}${entry.pinned ? ' is-pinned' : ''}" role="option" tabindex="0"
					aria-selected="${active ? 'true' : 'false'}" data-reader-queue-topic-id="${entry.topicId}">
					<span class="ldp-reader-queue-row-progress${progress >= 100 ? ' is-progress-complete' : ''}" style="--ldp-reader-queue-progress:${progress * 3.6}deg">
						<span class="ldp-reader-queue-avatar">${readerQueueAvatarHtml(entry)}</span>
					</span>
					<span class="ldp-reader-queue-row-copy">
						<strong>${esc(entry.title)}</strong>
						<small>${esc(readerQueueStatusText(entry))}</small>
					</span>
					<span class="ldp-reader-queue-row-actions">
						${entry.loadState === 'error'
							? `<button class="ldp-reader-queue-retry" type="button" aria-label="重新预加载">${icon('rotateCcw')}</button>`
							: ''}
						<button class="ldp-reader-queue-pin${entry.pinned ? ' active' : ''}" type="button"
							aria-label="${entry.pinned ? '取消固定,离开后自动移出队列' : '固定文章,离开后保留在队列'}"
							aria-pressed="${entry.pinned ? 'true' : 'false'}">${icon('pin')}</button>
						<button class="ldp-reader-queue-remove" type="button" aria-label="从阅读队列移除"${removable ? '' : ' disabled'}>${icon('x')}</button>
					</span>
				</div>`;
		}).join('');
		hydratePersistentAvatarImages(bubbles, POST_REQUEST_PRIORITY.auxiliary);
		hydratePersistentAvatarImages(list, POST_REQUEST_PRIORITY.auxiliary);
		const activeChanged = previousActiveTopicId !== READER_QUEUE_ACTIVE_TOPIC_ID;
		rail.dataset.readerQueueActiveTopicId = READER_QUEUE_ACTIVE_TOPIC_ID;
		if (activeChanged) {
			const activeBubble = bubbles.querySelector('.ldp-reader-queue-bubble.is-active');
			if (activeBubble) {
				const top = activeBubble.offsetTop;
				const bottom = top + activeBubble.offsetHeight;
				if (top < bubbles.scrollTop) bubbles.scrollTop = Math.max(0, top - 4);
				else if (bottom > bubbles.scrollTop + bubbles.clientHeight) {
					bubbles.scrollTop = Math.max(0, bottom - bubbles.clientHeight + 4);
				}
			}
		} else {
			bubbles.scrollTop = Math.min(previousBubbleScrollTop, Math.max(0, bubbles.scrollHeight - bubbles.clientHeight));
		}
		syncReaderQueueScrollHint(rail);
		panel.hidden = !panelWasOpen;
		setExpanded(toggle, panelWasOpen);
		applyReaderQueueSurfacePosition(rail);
	}

	function syncReaderQueueSurfaces() {
		document.querySelectorAll('.ldp-reader-queue-add[data-reader-queue-topic-id]').forEach((button) => {
			const added = !!readerQueueEntry(button.dataset.readerQueueTopicId);
			if (button.dataset.readerQueueAdded === String(added)) return;
			syncReaderQueueAddButton(button, added);
		});
		if (isFunction(CURRENT_OVERLAY?._ldpSyncReaderQueue)) {
			CURRENT_OVERLAY._ldpSyncReaderQueue();
		}
	}

	function syncReaderQueueAddButton(button, added) {
		const label = added ? '从阅读队列移除' : '加入阅读队列并后台预加载';
		button.dataset.readerQueueAdded = String(added);
		button.classList.toggle('is-added', added);
		button.innerHTML = icon(added ? 'check' : 'plus');
		setLabel(button, label);
		button.setAttribute('title', label);
	}

	function updateReaderQueueEntryFromTopic(entry, topic, totalHint = 0, options = {}) {
		if (!entry || !topic) return;
		const summary = readerQueueTopicSummary(entry.topicId, entry, topic);
		entry.title = summary.title;
		entry.avatarTemplate = summary.avatarTemplate || entry.avatarTemplate;
		entry.avatarSource = summary.avatarSource || entry.avatarSource;
		entry.ownerUsername = summary.ownerUsername || entry.ownerUsername;
		entry.navMetadata = summary.navMetadata;
		entry.urlPostNumber = summary.urlPostNumber || entry.urlPostNumber || 0;
		const counts = readerQueueLoadedCounts(topic);
		entry.totalCount = Math.max(0, Number(totalHint) || counts.total || entry.totalCount || 0);
		entry.loadedCount = Math.min(entry.totalCount || counts.loaded, counts.loaded);
		if (entry.totalCount > 0 && entry.loadedCount >= entry.totalCount && entry.prefetchEnriched) {
			entry.loadState = 'ready';
		}
		else if (entry.loadState === 'ready') entry.loadState = 'queued';
		entry.error = '';
		scheduleReaderQueueStatePersist();
		if (options.progressOnly === true) syncReaderQueueProgressSurface(entry);
		else syncReaderQueueSurfaces();
	}

	function ensureReaderQueueEntry(topicId, metadata = {}, options = {}) {
		const safeTopicId = Number(topicId);
		if (!(safeTopicId > 0)) return null;
		let entry = readerQueueEntry(safeTopicId);
		const summary = readerQueueTopicSummary(safeTopicId, metadata);
		if (!entry) {
			entry = {
				topicId: safeTopicId,
				title: summary.title,
				avatarTemplate: summary.avatarTemplate,
				avatarSource: summary.avatarSource,
				ownerUsername: summary.ownerUsername,
				navMetadata: summary.navMetadata,
				addedAt: Date.now(),
				loadState: 'queued',
				loadedCount: 0,
				nestedLoadedCount: 0,
				nestedTotalCount: 0,
				mediaLoadedCount: 0,
				mediaTotalCount: 0,
				totalCount: summary.totalCount,
				urlPostNumber: summary.urlPostNumber,
				viewport: summary.historyPostNumber
					? normalizeReaderHistoryViewport(summary.historyPostNumber)
					: null,
				seenPostNumbers: new Set(summary.readPostNumbers),
				progressPersistTimer: 0,
				prefetchController: null,
				prefetchEnriched: false,
				switching: false,
				pinned: false,
				error: '',
			};
			READER_QUEUE_ENTRIES.push(entry);
			READER_QUEUE_BY_TOPIC.set(String(safeTopicId), entry);
			const cachedTopic = getCachedTopicData(safeTopicId);
			if (cachedTopic) updateReaderQueueEntryFromTopic(entry, cachedTopic);
		} else {
			entry.title = summary.title || entry.title;
			entry.avatarTemplate = summary.avatarTemplate || entry.avatarTemplate;
			entry.avatarSource = summary.avatarSource || entry.avatarSource;
			entry.ownerUsername = summary.ownerUsername || entry.ownerUsername;
			entry.navMetadata = mergeTopicNavMetadata(summary.navMetadata, entry.navMetadata);
			entry.urlPostNumber = summary.urlPostNumber || entry.urlPostNumber || 0;
			entry.totalCount = Math.max(Number(entry.totalCount) || 0, summary.totalCount);
			summary.readPostNumbers.forEach((postNumber) => entry.seenPostNumbers.add(postNumber));
			if (!entry.viewport && summary.historyPostNumber) {
				entry.viewport = normalizeReaderHistoryViewport(summary.historyPostNumber);
			}
			if (entry.loadState === 'error' && options.retry === true) {
				entry.loadState = 'queued';
				entry.error = '';
			}
		}
		scheduleReaderQueueStatePersist();
		syncReaderQueueSurfaces();
		if (options.prefetch !== false) scheduleReaderQueuePrefetch();
		return entry;
	}

	function saveReaderQueueViewport(topicId, viewport) {
		const entry = readerQueueEntry(topicId);
		const normalized = normalizeReaderHistoryViewport(viewport);
		if (!entry || !normalized) return;
		entry.viewport = normalized;
		scheduleReaderQueueStatePersist();
		syncReaderQueueSurfaces();
	}

	function activateReaderQueueEntry(entry) {
		if (!entry) return;
		READER_QUEUE_ACTIVE_TOPIC_ID = String(entry.topicId);
		if (entry.prefetchController) {
			entry.prefetchController.abort();
			entry.prefetchController = null;
			if (entry.loadState === 'loading') entry.loadState = 'queued';
		}
		syncReaderQueueSurfaces();
	}

	async function switchReaderQueueTopic(topicId) {
		const entry = readerQueueEntry(topicId);
		if (!entry || entry.switching) return;
		if (String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID &&
				CURRENT_OVERLAY && Number(CURRENT_OVERLAY.dataset.topicId) === entry.topicId) {
			const panel = CURRENT_OVERLAY.querySelector('.ldp-reader-queue-panel');
			const toggle = CURRENT_OVERLAY.querySelector('.ldp-reader-queue-toggle');
			if (panel) panel.hidden = true;
			if (toggle) setExpanded(toggle, false);
			return;
		}
		const activeTopicId = Number(CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId);
		if (activeTopicId > 0 && isFunction(CURRENT_OVERLAY._ldpCaptureHistoryViewport)) {
			saveReaderQueueViewport(activeTopicId, CURRENT_OVERLAY._ldpCaptureHistoryViewport());
		}
		entry.switching = true;
		syncReaderQueueSurfaces();
		try {
			const startPostNumber = readerQueueStartPostNumber(entry);
			const explicitStart = PREFS.openTopicsAtFirstPost === true || entry.urlPostNumber > 0;
			await openModal(entry.topicId, {
				targetPostNumber: startPostNumber,
				restoreViewport: explicitStart
					? { postNumber: startPostNumber, postOffset: 0 }
					: entry.viewport || undefined,
				topicNavMetadata: entry.navMetadata,
				readerQueueMetadata: entry,
				settleTargetJump: true,
			});
		} finally {
			entry.switching = false;
			syncReaderQueueSurfaces();
		}
	}

	function removeReaderQueueEntry(topicId) {
		const entry = readerQueueEntry(topicId);
		if (!entry) return false;
		const index = READER_QUEUE_ENTRIES.indexOf(entry);
		const active = String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID;
		const currentReaderShowsEntry = CURRENT_OVERLAY?.isConnected &&
			Number(CURRENT_OVERLAY.dataset.topicId) === Number(entry.topicId);
		if (active && currentReaderShowsEntry && READER_QUEUE_ENTRIES.length === 1) {
			showSelectionToast('当前文章是队列中的最后一篇');
			return false;
		}
		flushReaderQueueReadProgress(entry);
		if (entry.prefetchController) entry.prefetchController.abort();
		READER_QUEUE_BY_TOPIC.delete(String(entry.topicId));
		if (index >= 0) READER_QUEUE_ENTRIES.splice(index, 1);
		scheduleReaderQueueStatePersist();
		let switching = false;
		if (active) {
			READER_QUEUE_ACTIVE_TOPIC_ID = '';
			const next = READER_QUEUE_ENTRIES[Math.min(index, READER_QUEUE_ENTRIES.length - 1)] ||
				READER_QUEUE_ENTRIES[index - 1] || null;
			if (next && currentReaderShowsEntry) {
				switching = true;
				void switchReaderQueueTopic(next.topicId);
			} else if (next) {
				READER_QUEUE_ACTIVE_TOPIC_ID = String(next.topicId);
			}
		}
		syncReaderQueueSurfaces();
		if (!switching) scheduleReaderQueuePrefetch();
		return true;
	}

	function toggleReaderQueuePinned(topicId) {
		const entry = readerQueueEntry(topicId);
		if (!entry) return false;
		entry.pinned = !entry.pinned;
		scheduleReaderQueueStatePersist();
		syncReaderQueueSurfaces();
		showSelectionToast(entry.pinned ? '已固定,离开后仍保留在队列' : '已取消固定,离开后将自动出队');
		return true;
	}

	function autoDequeueReaderQueueEntry(topicId) {
		const entry = readerQueueEntry(topicId);
		if (!entry || entry.pinned) return false;
		return removeReaderQueueEntry(entry.topicId);
	}

	function clearReaderQueueEntries(ctx) {
		const currentTopicId = Number(CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId) ||
			Number(READER_QUEUE_ACTIVE_TOPIC_ID);
		const keep = readerQueueEntry(currentTopicId);
		const retained = READER_QUEUE_ENTRIES.filter((entry) => entry === keep || entry.pinned);
		const retainedEntries = new Set(retained);
		const removable = READER_QUEUE_ENTRIES.filter((entry) => !retainedEntries.has(entry));
		if (!removable.length) {
			showSelectionToast('队列中没有可清理的未固定文章');
			return false;
		}
		const removableTopicIds = new Set(removable.map((entry) => String(entry.topicId)));
		return !!openReaderConfirmDialog(ctx, {
			title: '清理其他队列文章?',
			message: `将从阅读队列移除 ${removable.length} 篇未固定文章。`,
			details: [
				{ label: '移出队列', value: `${removable.length} 篇` },
				{ label: '继续保留', value: `${retained.length} 篇` },
			],
			note: '当前文章和已固定文章会保留;正文、头像和图片缓存不会删除。',
			icon: 'trash',
			confirmLabel: `清理 ${removable.length} 篇`,
			onConfirm: () => {
				const latestCurrentTopicId = Number(CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId) ||
					Number(READER_QUEUE_ACTIVE_TOPIC_ID);
				const latestKeep = readerQueueEntry(latestCurrentTopicId);
				const confirmedRemovable = READER_QUEUE_ENTRIES.filter((entry) =>
					removableTopicIds.has(String(entry.topicId)) && entry !== latestKeep && !entry.pinned
				);
				if (!confirmedRemovable.length) {
					showSelectionToast('队列中没有可清理的未固定文章');
					return;
				}
				const confirmedRemovableEntries = new Set(confirmedRemovable);
				confirmedRemovable.forEach((entry) => {
					flushReaderQueueReadProgress(entry);
					if (entry.prefetchController) entry.prefetchController.abort();
					READER_QUEUE_BY_TOPIC.delete(String(entry.topicId));
				});
				const latestRetained = READER_QUEUE_ENTRIES.filter((entry) => !confirmedRemovableEntries.has(entry));
				READER_QUEUE_ENTRIES.splice(0, READER_QUEUE_ENTRIES.length, ...latestRetained);
				READER_QUEUE_ACTIVE_TOPIC_ID = latestKeep ? String(latestKeep.topicId) : '';
				scheduleReaderQueueStatePersist();
				syncReaderQueueSurfaces();
				scheduleReaderQueuePrefetch();
				showSelectionToast(`已从队列清理 ${confirmedRemovable.length} 篇文章`);
			},
		});
	}

	function readerQueuePrefetchStream(entry, topic) {
		const postStream = topicPostStream(topic);
		const stream = Array.isArray(postStream.stream) ? postStream.stream.filter(Boolean) : [];
		if (stream.length <= READER_QUEUE_PREFETCH_MAX_POSTS) return stream;
		const posts = Array.isArray(postStream.posts) ? postStream.posts : [];
		const startPostNumber = readerQueueStartPostNumber(entry);
		const targetPost = posts.find((post) => +(post?.post_number || 0) === startPostNumber);
		const targetPostId = +(targetPost?.id || 0);
		const foundIndex = targetPostId ? stream.indexOf(targetPostId) : -1;
		const targetIndex = foundIndex >= 0
			? foundIndex
			: Math.max(0, Math.min(stream.length - 1, startPostNumber - 1));
		if (PREFS.openTopicsAtFirstPost !== true && +(entry?.urlPostNumber || 0) > 0) {
			const before = Math.min(targetIndex, READER_QUEUE_PREFETCH_URL_BEFORE_POSTS);
			let start = Math.max(0, targetIndex - before);
			let end = Math.min(
				stream.length,
				targetIndex + READER_QUEUE_PREFETCH_MAX_POSTS - before
			);
			start = Math.max(0, end - READER_QUEUE_PREFETCH_MAX_POSTS);
			end = Math.min(stream.length, start + READER_QUEUE_PREFETCH_MAX_POSTS);
			return stream.slice(start, end);
		}
		if (targetIndex === 0) return stream.slice(0, READER_QUEUE_PREFETCH_MAX_POSTS);
		const headCount = Math.min(READER_QUEUE_PREFETCH_HEAD_POSTS, READER_QUEUE_PREFETCH_MAX_POSTS);
		const localBudget = READER_QUEUE_PREFETCH_MAX_POSTS - headCount;
		const before = Math.min(targetIndex, READER_QUEUE_PREFETCH_BEFORE_POSTS);
		let localStart = Math.max(0, targetIndex - before);
		let localEnd = Math.min(stream.length, localStart + localBudget);
		localStart = Math.max(0, localEnd - localBudget);
		const selected = new Set(stream.slice(0, headCount));
		stream.slice(localStart, localEnd).forEach((postId) => selected.add(postId));
		if (selected.size < READER_QUEUE_PREFETCH_MAX_POSTS) {
			stream.slice(localEnd, localEnd + READER_QUEUE_PREFETCH_MAX_POSTS - selected.size)
				.forEach((postId) => selected.add(postId));
		}
		return [...selected].slice(0, READER_QUEUE_PREFETCH_MAX_POSTS);
	}

	function mergeReaderQueuePostPayload(topic, payload, parentPostNumber = 0) {
		const topicPosts = topic?.post_stream && Array.isArray(topic.post_stream.posts)
			? topic.post_stream.posts : null;
		if (!topicPosts) return 0;
		const indexes = new Map(topicPosts.map((post, index) => [+(post?.id || 0), index]));
		let merged = 0;
		postsFromPayload(payload).forEach((incoming) => {
			const postId = +(incoming?.id || 0);
			if (!postId) return;
			if (parentPostNumber > 0 && !(+(incoming.reply_to_post_number || 0) > 0)) {
				incoming.reply_to_post_number = +parentPostNumber;
			}
			const index = indexes.get(postId);
			if (index == null) {
				indexes.set(postId, topicPosts.length);
				topicPosts.push(incoming);
			} else {
				Object.assign(topicPosts[index], incoming);
			}
			merged++;
		});
		return merged;
	}

	function readerQueueDirectReplies(topic, parentPostNumber) {
		const parent = +parentPostNumber;
		const postStream = topicPostStream(topic);
		return (Array.isArray(postStream.posts) ? postStream.posts : [])
			.filter((reply) => +(reply?.reply_to_post_number || 0) === parent);
	}

	async function prefetchReaderQueueNestedReplies(entry, topic, controller, scopePosts) {
		const collected = new Map();
		const parents = [];
		const parentIds = new Set();
		const addPost = (post) => {
			const postId = +(post?.id || 0);
			if (!postId) return;
			collected.set(postId, post);
			if (!(+(post.reply_count || 0) > 0) || parentIds.has(postId)) return;
			parentIds.add(postId);
			parents.push(post);
		};
		(Array.isArray(scopePosts) ? scopePosts : []).forEach(addPost);
		const syncNestedProgress = () => {
			entry.nestedTotalCount = parents.reduce(
				(total, post) => total + Math.max(0, +(post.reply_count || 0)),
				0
			);
			entry.nestedLoadedCount = parents.reduce(
				(total, post) => total + readerQueueDirectReplies(topic, post.post_number).length,
				0
			);
			syncReaderQueueProgressSurface(entry);
		};
		syncNestedProgress();
		for (let parentIndex = 0; parentIndex < parents.length; parentIndex++) {
			const parent = parents[parentIndex];
			if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
			const expected = Math.max(0, +(parent.reply_count || 0));
			let known = readerQueueDirectReplies(topic, parent.post_number);
			known.forEach(addPost);
			syncNestedProgress();
			if (known.length >= expected) continue;
			let after = 0;
			const seenCursors = new Set();
			while (known.length < expected) {
				const url = new URL(`${BASE}/posts/${parent.id}/replies.json`);
				if (after > 0) url.searchParams.set('after', String(after));
				let payload;
				try {
					payload = await fetchJSON(url.toString(), {
						signal: controller.signal,
						key: `reader-queue-replies:${entry.topicId}:${parent.id}:${after}`,
						priority: POST_REQUEST_PRIORITY.background,
					});
				} catch (error) {
					if (error && error.name === 'AbortError') throw error;
					break;
				}
				const pagePosts = postsFromPayload(payload);
				const direct = directRepliesForParent(pagePosts, parent.post_number);
				if (direct.length) {
					mergeReaderQueuePostPayload(topic, direct, parent.post_number);
					setCachedTopicData(entry.topicId, topic);
				}
				known = readerQueueDirectReplies(topic, parent.post_number);
				known.forEach(addPost);
				syncNestedProgress();
				const nextAfter = replyPageCursor(pagePosts);
				if (!pagePosts.length || pagePosts.length < SUB_REPLY_FETCH_SIZE ||
						nextAfter <= after || seenCursors.has(nextAfter)) break;
				seenCursors.add(nextAfter);
				after = nextAfter;
			}
		}
		syncNestedProgress();
		return [...collected.values()];
	}

	function readerQueueMediaSources(posts, topic = null) {
		const sources = new Set();
		const addSource = (value) => {
			const source = absoluteImageUrl(value);
			if (/^https?:/i.test(source)) sources.add(source);
		};
		const addCookedSources = (cooked) => {
			if (!cooked) return;
			const template = makeElement('template');
			template.innerHTML = String(cooked);
			template.content.querySelectorAll('img').forEach((img) => {
				addSource(
					img.getAttribute('src') ||
					img.getAttribute('data-src') ||
					img.getAttribute('data-large-src')
				);
			});
		};
		(Array.isArray(posts) ? posts : []).forEach((post) => {
			addCookedSources(post?.cooked);
			normalizeBoosts(post?.boosts).forEach((boost) => addCookedSources(boost.cooked));
			(Array.isArray(post?.reactions) ? post.reactions : []).forEach((reaction) => {
				if (reactionEmojiUrls) addSource(reactionEmojiUrls.get(String(reaction?.id || '')));
			});
		});
		(Array.isArray(topic?.valid_reactions) ? topic.valid_reactions : []).forEach((reaction) => {
			const id = String(typeof reaction === 'string'
				? reaction
				: reaction && (reaction.id || reaction.name) || '');
			if (reactionEmojiUrls) addSource(reactionEmojiUrls.get(id));
		});
		return [...sources];
	}

	async function prefetchReaderQueueMedia(entry, topic, posts, controller) {
		await loadReactionEmojiRegistry();
		if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
		const candidates = readerQueueMediaSources(posts, topic);
		entry.mediaTotalCount = candidates.length;
		entry.mediaLoadedCount = 0;
		if (!candidates.length) {
			syncReaderQueueProgressSurface(entry);
			return;
		}
		let cursor = 0;
		const worker = async () => {
			while (cursor < candidates.length) {
				const source = candidates[cursor++];
				if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError');
				try {
					const cached = await cachedPersistentLightboxBlob(source);
					if (cached) {
						entry.mediaLoadedCount++;
						syncReaderQueueProgressSurface(entry);
						continue;
					}
					const resource = await fetchReaderPublicResource(
						source,
						POST_REQUEST_PRIORITY.background,
						'media',
						controller.signal
					);
					if (!resource) continue;
					try {
						const response = resource.response;
						const contentType = String(response.headers.get('content-type') || '');
						if (!response.ok || contentType && !contentType.toLowerCase().startsWith('image/')) continue;
						const blob = await response.blob();
						if (!blob.size) continue;
						await persistLightboxBlob(source, blob, contentType, controller.signal);
						entry.mediaLoadedCount++;
						syncReaderQueueProgressSurface(entry);
					} finally {
						resource.release();
					}
				} catch (error) {
					if (error && error.name === 'AbortError') throw error;
				}
			}
		};
		await Promise.all(Array.from(
			{ length: Math.min(READER_QUEUE_PREFETCH_MEDIA_CONCURRENCY, candidates.length) },
			worker
		));
	}

	async function prefetchReaderQueueEntry(entry) {
		if (!entry || !CURRENT_OVERLAY || String(entry.topicId) === READER_QUEUE_ACTIVE_TOPIC_ID ||
				!READER_QUEUE_BY_TOPIC.has(String(entry.topicId))) return;
		const controller = new AbortController();
		entry.prefetchController = controller;
		entry.prefetchEnriched = false;
		entry.loadState = 'loading';
		entry.nestedLoadedCount = 0;
		entry.nestedTotalCount = 0;
		entry.mediaLoadedCount = 0;
		entry.mediaTotalCount = 0;
		entry.error = '';
		syncReaderQueueProgressSurface(entry);
		try {
			let topic = getCachedTopicData(entry.topicId);
			const cachedStream = topic?.post_stream && Array.isArray(topic.post_stream.stream)
				? topic.post_stream.stream : [];
			const expectedCount = Math.max(0, Number(topic?.posts_count) || 0);
			if (!topic || !cachedStream.length || expectedCount > cachedStream.length) {
				const freshTopic = await fetchJSON(`${BASE}/t/${entry.topicId}.json?forceLoad=true`, {
					signal: controller.signal,
					cacheMode: 'refresh',
					key: `reader-queue-topic:${entry.topicId}`,
					priority: POST_REQUEST_PRIORITY.background,
					fetchOptions: discourseJSONFetchOptions(),
				});
				if (topic) mergeMissingSnapshotPosts(freshTopic, topic);
				topic = freshTopic;
				setCachedTopicData(entry.topicId, topic, { fetchedAt: Date.now(), source: 'network' });
			}
			updateReaderQueueEntryFromTopic(entry, topic, 0, { progressOnly: true });
			const postStream = topicPostStream(topic);
			const stream = Array.isArray(postStream.stream) ? postStream.stream.filter(Boolean) : [];
			const preloadStream = readerQueuePrefetchStream(entry, topic);
			let loadedIds = new Set((Array.isArray(postStream.posts) ? postStream.posts : [])
				.map((post) => Number(post?.id)).filter(Boolean));
			let missing = preloadStream.filter((postId) => !loadedIds.has(Number(postId)));
			while (missing.length && READER_QUEUE_BY_TOPIC.has(String(entry.topicId)) &&
					String(entry.topicId) !== READER_QUEUE_ACTIVE_TOPIC_ID) {
				const ids = missing.slice(0, READER_QUEUE_PREFETCH_BATCH_SIZE);
				const query = ids.map((postId) => `post_ids[]=${encodeURIComponent(postId)}`).join('&');
				const part = await fetchJSON(`${BASE}/t/${entry.topicId}/posts.json?${query}`, {
					signal: controller.signal,
					key: `reader-queue-posts:${entry.topicId}:${ids.join(',')}`,
					priority: POST_REQUEST_PRIORITY.background,
				});
				const before = loadedIds.size;
				mergeReaderQueuePostPayload(topic, part);
				loadedIds = new Set((Array.isArray(topic.post_stream?.posts)
					? topic.post_stream.posts : []).map((post) => Number(post?.id)).filter(Boolean));
				if (loadedIds.size <= before) throw new Error('预加载未返回新的楼层');
				setCachedTopicData(entry.topicId, topic);
				updateReaderQueueEntryFromTopic(entry, topic, stream.length, { progressOnly: true });
				missing = preloadStream.filter((postId) => !loadedIds.has(Number(postId)));
			}
			if (!missing.length) {
				const scopePostStream = topicPostStream(topic);
				const scopePostsById = new Map(
					(Array.isArray(scopePostStream.posts) ? scopePostStream.posts : [])
						.map((post) => [+(post?.id || 0), post])
				);
				const scopePosts = preloadStream.map((postId) => scopePostsById.get(+postId)).filter(Boolean);
				const enrichedPosts = await prefetchReaderQueueNestedReplies(
					entry,
					topic,
					controller,
					scopePosts
				);
				await prefetchReaderQueueMedia(entry, topic, enrichedPosts, controller);
				entry.prefetchEnriched = true;
				const mainComplete = stream.every((postId) => loadedIds.has(Number(postId)));
				const nestedComplete = entry.nestedLoadedCount >= entry.nestedTotalCount;
				const mediaComplete = entry.mediaLoadedCount >= entry.mediaTotalCount;
				entry.loadState = mainComplete && nestedComplete && mediaComplete ? 'ready' : 'partial';
			}
			else if (entry.loadState === 'loading') entry.loadState = 'queued';
		} catch (error) {
			if (error && error.name === 'AbortError') {
				if (entry.loadState === 'loading') entry.loadState = 'queued';
			} else {
				entry.loadState = 'error';
				entry.error = String(error?.message || '预加载失败');
			}
		} finally {
			if (entry.prefetchController === controller) entry.prefetchController = null;
			if (entry.loadState === 'error') syncReaderQueueSurfaces();
			else syncReaderQueueProgressSurface(entry);
		}
	}

	function scheduleReaderQueuePrefetch() {
		if (READER_QUEUE_PREFETCH_RUNNING || !CURRENT_OVERLAY || !ACTIVE_READER_REQUEST_SCHEDULER) return;
		const entry = READER_QUEUE_ENTRIES.find((candidate) =>
			String(candidate.topicId) !== READER_QUEUE_ACTIVE_TOPIC_ID &&
			candidate.loadState === 'queued' && !candidate.prefetchController
		);
		if (!entry) return;
		READER_QUEUE_PREFETCH_RUNNING = true;
		Promise.resolve(prefetchReaderQueueEntry(entry)).finally(() => {
			READER_QUEUE_PREFETCH_RUNNING = false;
			if (CURRENT_OVERLAY && ACTIVE_READER_REQUEST_SCHEDULER) {
				setTimeout(scheduleReaderQueuePrefetch, 300);
			}
		});
	}

	function createReaderQueueViewportTracker(ctx, entry) {
		let progressSyncFrame = 0;
		const qualifies = (intersectionEntry) => {
			if (!intersectionEntry.isIntersecting || intersectionEntry.intersectionRect.width <= 0) return false;
			const nodeHeight = Math.max(1, intersectionEntry.boundingClientRect.height || 1);
			const requiredHeight = Math.min(nodeHeight, 120, Math.max(36, nodeHeight * .3));
			return intersectionEntry.intersectionRect.height >= requiredHeight;
		};
		const scheduleProgressSync = () => {
			if (progressSyncFrame) return;
			progressSyncFrame = requestAnimationFrame(() => {
				progressSyncFrame = 0;
				if (String(ctx.topicId) !== String(entry.topicId)) return;
				syncReaderQueueProgressSurface(entry);
			});
		};
		const observer = new IntersectionObserver((entries) => {
			let readProgressChanged = false;
			entries.forEach((intersectionEntry) => {
				const node = intersectionEntry.target;
				const postNumber = Number(node && node.dataset.postNumber);
				if (postNumber > 0 && qualifies(intersectionEntry)) {
					readProgressChanged = markReaderQueuePostRead(entry, postNumber) || readProgressChanged;
				}
			});
			if (readProgressChanged) scheduleProgressSync();
		}, { root: ctx.scrollRoot, threshold: [0, .1, .3] });
		return {
			observe(node) {
				if (node && !node.classList.contains('ldp-nested-preview')) observer.observe(node);
			},
			unobserve(node) {
				if (!node) return;
				observer.unobserve(node);
			},
			sync(postNumber) {
				const viewport = embeddedReaderPostViewportState(ctx, postNumber);
				if (viewport) entry.viewport = normalizeReaderHistoryViewport(viewport);
				syncReaderQueueProgressSurface(entry);
			},
			stop() {
				if (progressSyncFrame) cancelAnimationFrame(progressSyncFrame);
				progressSyncFrame = 0;
				observer.disconnect();
				flushReaderQueueReadProgress(entry);
			},
		};
	}

	function bindReaderQueueSurface(overlay) {
		const rail = overlay?.querySelector('.ldp-reader-queue');
		if (!rail || overlay._ldpReaderQueueBound) return;
		overlay._ldpReaderQueueBound = true;
		const panel = rail.querySelector('.ldp-reader-queue-panel');
		const toggle = rail.querySelector('.ldp-reader-queue-toggle');
		const bubbles = rail.querySelector('.ldp-reader-queue-bubbles');
		const scrollHint = rail.querySelector('.ldp-reader-queue-scroll-hint');
		const close = rail.querySelector('.ldp-reader-queue-close');
		const clear = rail.querySelector('.ldp-reader-queue-clear');
		const queueScope = createLifecycleScope();
		const on = (target, type, listener, options) =>
			queueScope.listen(target, type, listener, options);
		let hoverCloseTimer = 0;
		let queueDrag = null;
		let suppressToggleClickUntil = 0;
		const cancelPanelClose = () => {
			if (hoverCloseTimer) clearTimeout(hoverCloseTimer);
			hoverCloseTimer = 0;
		};
		const setPanelOpen = (open) => {
			cancelPanelClose();
			panel.hidden = !open;
			setExpanded(toggle, open);
		};
		const schedulePanelClose = () => {
			cancelPanelClose();
			if (panel.contains(readerActiveElement())) return;
			hoverCloseTimer = setTimeout(() => {
				hoverCloseTimer = 0;
				setPanelOpen(false);
			}, 180);
		};
		const setPreviewExpanded = (expanded) => {
			rail.classList.toggle('is-preview-collapsed', !expanded);
			syncReaderQueueToggleState(rail);
			if (expanded) requestAnimationFrame(() => syncReaderQueueScrollHint(rail));
		};
		const finishQueueDrag = (event, cancelled = false) => {
			if (!queueDrag || event.pointerId !== queueDrag.pointerId) return;
			const drag = queueDrag;
			queueDrag = null;
			rail.classList.remove('is-dragging');
			if (toggle.hasPointerCapture(drag.pointerId)) toggle.releasePointerCapture(drag.pointerId);
			if (!drag.moved) return;
			suppressToggleClickUntil = Date.now() + 350;
			if (cancelled) {
				READER_QUEUE_SURFACE_STATE = drag.previousState;
				applyReaderQueueSurfacePosition(rail);
				return;
			}
			const modal = rail.closest('.ldp-modal');
			if (!modal) return;
			const maxLeft = Math.max(0, modal.clientWidth - rail.offsetWidth);
			const maxTop = Math.max(0, modal.clientHeight - rail.offsetHeight);
			const left = Math.max(0, Math.min(maxLeft, Number.parseFloat(rail.style.left) || 0));
			const top = Math.max(0, Math.min(maxTop, Number.parseFloat(rail.style.top) || 0));
			const distanceLeft = left;
			const distanceRight = maxLeft - left;
			const dockThreshold = modal.clientWidth * READER_QUEUE_DOCK_THRESHOLD_RATIO;
			const dock = Math.min(distanceLeft, distanceRight) <= dockThreshold
				? (distanceLeft <= distanceRight ? 'left' : 'right')
				: '';
			READER_QUEUE_SURFACE_STATE = normalizeReaderQueueSurfaceState({
				x: maxLeft > 0 ? (dock === 'left' ? 0 : dock === 'right' ? 1 : left / maxLeft) : 0,
				y: maxTop > 0 ? top / maxTop : 0,
				dock,
			});
			applyReaderQueueSurfacePosition(rail);
			scheduleReaderQueueStatePersist();
		};
		on(toggle, 'pointerdown', (event) => {
			if (event.button !== 0 || event.isPrimary === false) return;
			const modal = rail.closest('.ldp-modal');
			if (!modal) return;
			const modalRect = modal.getBoundingClientRect();
			const railRect = rail.getBoundingClientRect();
			queueDrag = {
				pointerId: event.pointerId,
				startX: event.clientX,
				startY: event.clientY,
				startLeft: railRect.left - modalRect.left,
				startTop: railRect.top - modalRect.top,
				previousState: normalizeReaderQueueSurfaceState(READER_QUEUE_SURFACE_STATE),
				moved: false,
			};
			toggle.setPointerCapture(event.pointerId);
		});
		on(toggle, 'pointermove', (event) => {
			if (!queueDrag || event.pointerId !== queueDrag.pointerId) return;
			const deltaX = event.clientX - queueDrag.startX;
			const deltaY = event.clientY - queueDrag.startY;
			if (!queueDrag.moved && Math.hypot(deltaX, deltaY) < 5) return;
			if (!queueDrag.moved) {
				queueDrag.moved = true;
				cancelPanelClose();
				setPanelOpen(false);
				rail.classList.remove('is-docked-left', 'is-docked-right', 'is-dock-revealed');
				rail.classList.add('is-dragging');
			}
			const modal = rail.closest('.ldp-modal');
			if (!modal) return;
			const maxLeft = Math.max(0, modal.clientWidth - rail.offsetWidth);
			const maxTop = Math.max(0, modal.clientHeight - rail.offsetHeight);
			rail.style.left = `${Math.round(Math.max(0, Math.min(maxLeft, queueDrag.startLeft + deltaX)))}px`;
			rail.style.top = `${Math.round(Math.max(0, Math.min(maxTop, queueDrag.startTop + deltaY)))}px`;
			rail.style.bottom = 'auto';
			event.preventDefault();
		});
		on(toggle, 'pointerup', (event) => finishQueueDrag(event));
		on(toggle, 'pointercancel', (event) => finishQueueDrag(event, true));
		on(toggle, 'click', (event) => {
			if (Date.now() < suppressToggleClickUntil) {
				consumeEvent(event);
				return;
			}
			setPreviewExpanded(rail.classList.contains('is-preview-collapsed'));
		});
		on(toggle, 'pointerenter', () => {
			if (!queueDrag) setPanelOpen(true);
		});
		on(toggle, 'keydown', (event) => {
			if (event.key !== 'ArrowDown') return;
			event.preventDefault();
			setPanelOpen(true);
			panel.querySelector('.ldp-reader-queue-row')?.focus();
		});
		on(rail, 'pointerenter', () => {
			rail.classList.add('is-dock-revealed');
			cancelPanelClose();
		});
		on(rail, 'pointerleave', () => {
			rail.classList.remove('is-dock-revealed');
			schedulePanelClose();
		});
		on(panel, 'focusin', cancelPanelClose);
		on(panel, 'focusout', (event) => {
			if (event.relatedTarget instanceof Node && panel.contains(event.relatedTarget)) return;
			schedulePanelClose();
		});
		bindFloatingSurfaceWheel(bubbles);
		on(bubbles, 'scroll', () => syncReaderQueueScrollHint(rail), { passive: true });
		on(scrollHint, 'click', () => {
			const direction = Number(scrollHint.dataset.scrollDirection) || 1;
			const reduceMotion = prefersReducedMotion();
			if (direction < 0) {
				bubbles.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' });
				return;
			}
			const distance = Math.max(38, Math.round(bubbles.clientHeight * .72));
			bubbles.scrollBy({ top: distance, behavior: reduceMotion ? 'auto' : 'smooth' });
		});
		on(close, 'click', () => {
			setPanelOpen(false);
			toggle.focus();
		});
		on(clear, 'click', () => clearReaderQueueEntries(overlay._ldpReaderShell?.activeContext));
		on(rail, 'click', (event) => {
			const bubbleRemove = event.target.closest('.ldp-reader-queue-bubble-remove');
			if (bubbleRemove) {
				removeReaderQueueEntry(bubbleRemove.dataset.readerQueueTopicId);
				return;
			}
			const bubble = event.target.closest('.ldp-reader-queue-bubble');
			if (bubble) {
				void switchReaderQueueTopic(bubble.dataset.readerQueueTopicId);
				return;
			}
			const row = event.target.closest('.ldp-reader-queue-row');
			if (!row) return;
			const topicId = Number(row.dataset.readerQueueTopicId);
			if (event.target.closest('.ldp-reader-queue-remove')) {
				removeReaderQueueEntry(topicId);
				return;
			}
			if (event.target.closest('.ldp-reader-queue-pin')) {
				toggleReaderQueuePinned(topicId);
				return;
			}
			if (event.target.closest('.ldp-reader-queue-retry')) {
				ensureReaderQueueEntry(topicId, {}, { retry: true });
				return;
			}
			void switchReaderQueueTopic(topicId);
		});
		on(rail, 'keydown', (event) => {
			const row = event.target.closest('.ldp-reader-queue-row');
			if (!row) return;
			if (event.key === 'Escape') {
				event.preventDefault();
				setPanelOpen(false);
				toggle.focus();
				return;
			}
			if (event.key === 'Enter' || event.key === ' ') {
				event.preventDefault();
				void switchReaderQueueTopic(row.dataset.readerQueueTopicId);
				return;
			}
			if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return;
			event.preventDefault();
			const rows = [...panel.querySelectorAll('.ldp-reader-queue-row')];
			const index = rows.indexOf(row);
			rows[Math.max(0, Math.min(rows.length - 1, index + (event.key === 'ArrowDown' ? 1 : -1)))]?.focus();
		});
		on(overlay, 'pointerdown', (event) => {
			if (!panel.hidden && !event.target.closest('.ldp-reader-queue')) setPanelOpen(false);
		});
		overlay._ldpSyncReaderQueue = () => renderReaderQueueSurface(overlay);
		overlay._ldpSyncReaderQueuePosition = () => applyReaderQueueSurfacePosition(rail);
		overlay._ldpDestroyReaderQueueSurface = () => {
			cancelPanelClose();
			if (queueDrag) finishQueueDrag({ pointerId: queueDrag.pointerId }, true);
			queueScope.destroy();
			delete overlay._ldpSyncReaderQueue;
			delete overlay._ldpSyncReaderQueuePosition;
			delete overlay._ldpDestroyReaderQueueSurface;
			overlay._ldpReaderQueueBound = false;
		};
		renderReaderQueueSurface(overlay);
	}

	function cachePayload(key) {
		if (key === 'history') return readTopicHistory();
		if (key === 'topics') return [...TOPIC_NAV_ICON_CACHE.entries()];
		const cache = cacheMapForKey(key);
		if (cache) return [...cache.entries()];
		return [];
	}

	function estimateCacheBytes(value) {
		try {
			const text = JSON.stringify(value);
			return text.length * 2;
		} catch (e) {
			return 0;
		}
	}

	async function runInBatches(values, batchSize, worker) {
		for (let index = 0; index < values.length; index += batchSize) {
			await Promise.all(values.slice(index, index + batchSize).map(worker));
		}
	}

	async function cacheStorageStats(names) {
		const groups = Object.fromEntries(names.map((name) => [name, { count: 0, bytes: 0 }]));
		if (typeof caches === 'undefined') return { count: 0, bytes: 0, groups };
		let count = 0;
		let bytes = 0;
		let existingNames = [];
		try { existingNames = await caches.keys(); } catch (error) { return { count, bytes, groups }; }
		for (const name of names.filter((entry) => existingNames.includes(entry))) {
			try {
				const storage = await caches.open(name);
				const requests = await storage.keys();
				let groupBytes = 0;
				await runInBatches(requests, RESOURCE_CACHE_PRUNE_BATCH_SIZE, async (request) => {
					const response = await storage.match(request);
					if (!response) return;
					let size = Number(response.headers.get('content-length'));
					if (!(Number.isFinite(size) && size >= 0)) {
						try { size = (await response.blob()).size; } catch (error) { size = 0; }
					}
					groupBytes += size;
				});
				groups[name] = { count: requests.length, bytes: groupBytes };
				count += groups[name].count;
				bytes += groupBytes;
			} catch (error) {}
		}
		return { count, bytes, groups };
	}

	async function cacheTypeStats(key, responseStatsRequest) {
		if (key === 'assets') {
			return cacheStorageStats(RESOURCE_CACHE_GROUPS.map(({ cacheName }) => cacheName));
		}
		const payload = cachePayload(key);
		const responseKind = ['topics', 'users', 'notifications', 'responses'].includes(key) ? key : '';
		const responseStat = responseKind ? (await responseStatsRequest)[responseKind] : { count: 0, bytes: 0 };
		const payloadBytes = Array.isArray(payload) && !payload.length ? 0 : estimateCacheBytes(payload);
		const stat = {
			count: (Array.isArray(payload) ? payload.length : 0) + responseStat.count,
			bytes: payloadBytes + responseStat.bytes,
		};
		if (key === 'history') {
			stat.topicCount = new Set(payload.map((entry) => Number(entry?.topicId)).filter(Boolean)).size;
			stat.historyRecordCount = payload.length;
		} else if (key === 'topics') {
			const topicIds = new Set(responseStat.topicIds || []);
			stat.topicCount = topicIds.size;
			stat.postCount = Math.max(0, Number(responseStat.postCount) || 0);
			stat.topicSnapshotCount = Math.max(0, Number(responseStat.topicSnapshotCount) || 0);
			stat.topicRequestCount = Math.max(0, Number(responseStat.topicRequestCount) || 0);
			stat.topicNavIconCount = payload.length;
		} else if (key === 'users') {
			const userNames = new Set(responseStat.userNames || []);
			payload.forEach(([username, entry]) => {
				const resolvedUsername = cleanUsernameValue(entry?.user && entry.user.username || username)
					.toLocaleLowerCase();
				if (resolvedUsername) userNames.add(resolvedUsername);
			});
			stat.userCount = userNames.size;
			stat.userProfileCount = payload.length;
			stat.userRequestCount = responseStat.count;
		} else if (key === 'notifications') {
			const notificationIds = new Set(responseStat.notificationIds || []);
			payload.forEach(([, entry]) => {
				const notifications = entry?.value && Array.isArray(entry.value.notifications)
					? entry.value.notifications
					: [];
				addNotificationStatsIdentities(notificationIds, notifications);
			});
			stat.notificationCount = notificationIds.size;
			stat.notificationPageCount = payload.length;
			stat.notificationRequestCount = responseStat.count;
		} else if (key === 'responses') {
			stat.responseRecords = responseStat.responseRecords || {};
		}
		return stat;
	}

	function formatCacheBytes(bytes) {
		const value = Math.max(0, Number(bytes) || 0);
		if (value < 1024) return `${value} B`;
		if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
		return `${(value / (1024 * 1024)).toFixed(1)} MB`;
	}

	function formatCacheTypeStats(key, stat) {
		const config = PERSISTENT_CACHE_CONFIG[key];
		const storageSuffix = config ? ' · 本机保存' : '';
		if (key === 'history') {
			return `${stat.topicCount || 0} 个主题 · ${stat.historyRecordCount || 0} 条浏览记录 · ` +
				`${formatCacheBytes(stat.bytes)}${storageSuffix}`;
		}
		if (key === 'topics') {
			return `${stat.topicCount || 0} 个主题 · 已缓存 ${stat.postCount || 0} 个楼层 · ` +
				`快照 ${stat.topicSnapshotCount || 0} / 接口 ${stat.topicRequestCount || 0} / ` +
				`图标 ${stat.topicNavIconCount || 0} · ` +
				`共 ${stat.count || 0} 条记录 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`;
		}
		if (key === 'users') {
			return `${stat.userCount || 0} 个用户 · 资料卡 ${stat.userProfileCount || 0} / ` +
				`接口 ${stat.userRequestCount || 0} · 共 ${stat.count || 0} 条记录 · ` +
				`${formatCacheBytes(stat.bytes)}${storageSuffix}`;
		}
		if (key === 'notifications') {
			return `${stat.notificationCount || 0} 条消息 · 分页 ${stat.notificationPageCount || 0} / ` +
				`接口 ${stat.notificationRequestCount || 0} · 共 ${stat.count || 0} 条记录 · ` +
				`${formatCacheBytes(stat.bytes)}${storageSuffix}`;
		}
		if (key === 'responses') {
			const records = stat.responseRecords || {};
			return `收藏 ${records.bookmarks || 0} / 回应 ${records.reactions || 0} / ` +
				`其他 ${records.other || 0} · ` +
				`共 ${stat.count || 0} 条响应 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`;
		}
		if (key === 'assets') {
			const groups = stat.groups || {};
			const resourceSummary = (label, cacheName) => {
				const group = groups[cacheName] || { count: 0, bytes: 0 };
				return `${label} ${group.count || 0} 个(${formatCacheBytes(group.bytes)})`;
			};
			return [
				...RESOURCE_CACHE_GROUPS.map(({ label, cacheName }) => resourceSummary(label, cacheName)),
				`共 ${stat.count || 0} 个资源 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`,
			].join(' · ');
		}
		return `${stat.count || 0} 条 · ${formatCacheBytes(stat.bytes)}${storageSuffix}`;
	}

	function cacheRetentionLabel(key) {
		const config = PERSISTENT_CACHE_CONFIG[key];
		if (!config) return '';
		if (key === 'responses') return '保留 30 天–1 年';
		if (key === 'assets') return '头像与表情 90 天 · 原图 7 天';
		const days = Math.max(1, Math.round(config.maxAge / (24 * 60 * 60 * 1000)));
		return `最多 ${days} 天`;
	}

	async function clearSelectedCaches(keys) {
		const selected = new Set(Array.isArray(keys) ? keys : []);
		const kinds = ['topics', 'users', 'notifications', 'responses'].filter((kind) => selected.has(kind));
		const mapKeys = ['topics', 'topicNavIcons', 'users', 'notifications'].filter((key) =>
			selected.has(key) || (key === 'topicNavIcons' && selected.has('topics')));
		const pendingCacheRequests = new Set([
			...(selected.has('users')
				? Array.from(USER_CARD_REQUESTS.values(), (entry) => entry?.promise).filter(Boolean) : []),
			...(selected.has('users') ? USER_FOLLOW_LIST_REQUESTS.values() : []),
			...(selected.has('notifications') ? NOTIFICATION_REQUESTS.values() : []),
			...(kinds.length ? RESPONSE_CACHE_REQUESTS.values() : []),
		]);
		if (pendingCacheRequests.size) await Promise.allSettled(pendingCacheRequests);
		if (selected.has('history')) {
			writeTopicHistory([]);
		}
		clearPersistentCacheMaps(mapKeys);
		mapKeys.forEach((key) => {
			const storageKey = PERSISTENT_CACHE_CONFIG[key]?.storageKey;
			if (!storageKey) return;
			try { localStorage.removeItem(storageKey); } catch (e) {}
		});
		if (selected.has('assets')) {
			RESOURCE_CACHE_CLEARING = true;
			RESOURCE_CACHE_ABORT_CONTROLLER.abort();
			if (RESOURCE_CACHE_PRUNE_REQUESTS.size) {
				await Promise.allSettled(RESOURCE_CACHE_PRUNE_REQUESTS.values());
			}
			const pendingResourceRequests = [
				...RESOURCE_CACHE_GROUPS.flatMap(({ requests }) => [...requests.values()]),
				...RESOURCE_CACHE_WRITES,
			];
			if (pendingResourceRequests.length) {
				await Promise.allSettled(pendingResourceRequests);
			}
			RESOURCE_CACHE_GROUPS.forEach(({ objectUrls, requests }) => {
				clearResourceObjectUrlCache(objectUrls);
				requests.clear();
			});
			RESOURCE_CACHE_WRITES.clear();
			RESOURCE_CACHE_PRUNE_REQUESTS.clear();
			if (typeof caches !== 'undefined') {
				await Promise.all(RESOURCE_CACHE_GROUPS.map(({ cacheName }) => caches.delete(cacheName).catch(() => false)));
			}
			RESOURCE_CACHE_ABORT_CONTROLLER = new AbortController();
			RESOURCE_CACHE_CLEARING = false;
		}
		if (kinds.length) await clearStoredResponseCache({ kinds });
		if (SHARED_REQUEST_COORDINATOR && mapKeys.length) {
			SHARED_REQUEST_COORDINATOR.publishCache({ action: 'map-clear', keys: mapKeys });
		}
	}

	function topicResponseCacheTags(topicId, ...topicSources) {
		const safeTopicId = Number(topicId);
		const tags = new Set(safeTopicId > 0 ? [`topic:${safeTopicId}`] : []);
		topicSources.filter(Boolean).forEach((topic) => {
			const stream = topic?.post_stream && Array.isArray(topic.post_stream.stream)
				? topic.post_stream.stream : [];
			stream.forEach((postId) => {
				const safePostId = Number(postId);
				if (safePostId > 0) tags.add(`post:${safePostId}`);
			});
			postsFromPayload(topic).forEach((post) => {
				const safePostId = Number(post?.id);
				if (safePostId > 0) tags.add(`post:${safePostId}`);
			});
		});
		return [...tags];
	}

	async function clearCurrentTopicCaches(topicId, topicData) {
		const key = String(topicId || '');
		const cachedEntry = TOPIC_DATA_CACHE.get(key);
		const tags = topicResponseCacheTags(topicId, topicData, cachedEntry?.topic);
		cancelTopicSnapshotWrites(topicId);
		HOST_TOPIC_DATA_CACHE.delete(key);
		TOPIC_DATA_CACHE.delete(key);
		if (tags.length) await clearStoredResponseCache({ tags });
	}

	restorePersistentCaches();
	const stopSharedCacheSync = SHARED_REQUEST_COORDINATOR
		? SHARED_REQUEST_COORDINATOR.subscribeCache(applySharedCacheMessage)
		: () => {};
	scheduleIdleWork(() => {
		void pruneStoredResponseCache();
		void prunePersistentResourceCacheOnce(LDP_AVATAR_CACHE_NAME, LDP_AVATAR_CACHE_MAX_AGE)
			.then(() => prunePersistentResourceCacheOnce(LDP_EMOJI_IMAGE_CACHE_NAME, LDP_EMOJI_IMAGE_CACHE_MAX_AGE));
	}, 2000);
	window.addEventListener('pagehide', (event) => {
		READER_QUEUE_ENTRIES.forEach(flushReaderQueueReadProgress);
		flushReaderQueueState();
		flushPersistentCacheWrites();
		void flushTopicSnapshotWrites();
		if (!event.persisted) {
			stopSharedCacheSync();
			if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.destroy();
		}
		saveEmbeddedReaderReloadState();
	});

	/* ============ 1. 样式 ============ */
	const style = makeElement('style');
	const readerStaticStyle = (() => {
		const getResourceText = globalThis.GM_getResourceText;
		if (!isFunction(getResourceText)) return '';
		try {
			return getResourceText('ldpReaderStyles') || '';
		} catch (error) {
			console.error('[Awesome LinuxDo Reader] 静态样式资源读取失败。', error);
			return '';
		}
	})();
	if (!readerStaticStyle.trim()) {
		console.error('[Awesome LinuxDo Reader] 静态样式资源为空,已停止初始化。');
		return;
	}
	style.textContent = readerStaticStyle;
	function ensureReaderStyleMounted() {
		const target = document.head || PAGE_ROOT;
		if (!target) return;
		if (style.parentNode !== target || target.lastElementChild !== style) target.appendChild(style);
	}
	ensureReaderStyleMounted();

	function destroyReaderPortal() {
		if (READER_ICON_TOOLTIP_CLEANUP) READER_ICON_TOOLTIP_CLEANUP();
		READER_ICON_TOOLTIP_CLEANUP = null;
		const host = READER_PORTAL_HOST;
		if (host) releasePersistentAvatarImages(host);
		if (host) host.replaceChildren();
		READER_PORTAL_HOST = null;
		if (host) host.remove();
	}

	function clearReaderResourceObjectUrlsIfIdle() {
		if (CURRENT_OVERLAY?.isConnected) return false;
		[AVATAR_OBJECT_URL_CACHE, EMOJI_IMAGE_OBJECT_URL_CACHE, LIGHTBOX_IMAGE_OBJECT_URL_CACHE]
			.forEach(clearResourceObjectUrlCache);
		return true;
	}

	function releaseReaderPortalIfIdle() {
		if (CURRENT_OVERLAY?.isConnected) return false;
		destroyReaderPortal();
		const pendingResourceRequests = [
			...AVATAR_RESOURCE_REQUESTS.values(),
			...EMOJI_IMAGE_RESOURCE_REQUESTS.values(),
			...LIGHTBOX_IMAGE_RESOURCE_REQUESTS.values(),
		];
		clearReaderResourceObjectUrlsIfIdle();
		if (pendingResourceRequests.length) {
			void Promise.allSettled(pendingResourceRequests).then(clearReaderResourceObjectUrlsIfIdle);
		}
		return true;
	}

	function ensureReaderPortal() {
		if (READER_PORTAL_HOST?.isConnected) return READER_PORTAL_HOST;
		if (!PAGE_ROOT || !document.body) return null;
		if (READER_PORTAL_HOST) destroyReaderPortal();
		let host = PAGE_ROOT.querySelector(':scope > .ldp-reader-portal-host');
		if (!host) {
			host = makeElement('div');
			host.className = 'ldp-reader-portal-host sciapp-ldp-owned';
			PAGE_ROOT.appendChild(host);
		}
		host.classList.add('sciapp-ldp-owned');
		READER_PORTAL_HOST = host;
		syncReaderFontRenderingState();
		return host;
	}

	function appendReaderPortalNode(node) {
		const root = ensureReaderPortal();
		if (!root) throw new Error('reader portal unavailable');
		root.appendChild(node);
		applyReaderThemeVariables(node);
		return node;
	}

	function readerPortalQuery(selector) {
		return (READER_PORTAL_HOST?.querySelector(selector)) || document.querySelector(selector);
	}

	function readerPortalQueryAll(selector) {
		const nodes = READER_PORTAL_HOST ? [...READER_PORTAL_HOST.querySelectorAll(selector)] : [];
		document.querySelectorAll(selector).forEach((node) => {
			if (!nodes.includes(node)) nodes.push(node);
		});
		return nodes;
	}

	function ensureReaderIconTooltip() {
		const root = ensureReaderPortal();
		if (!root || root._ldpReaderIconTooltipReady) return;
		root._ldpReaderIconTooltipReady = true;
		const tooltip = makeElement('div');
		tooltip.className = 'ldp-reader-icon-tooltip ldp-transient-surface';
		tooltip.setAttribute('role', 'tooltip');
		tooltip.hidden = true;
		root.appendChild(tooltip);
		const tooltipScope = createLifecycleScope();
		const on = (target, type, listener, options) =>
			tooltipScope.listen(target, type, listener, options);
		let activeControl = null;
		const hide = () => {
			activeControl = null;
			tooltip.hidden = true;
			tooltip.textContent = '';
		};
		const hasVisibleText = (control) => [...control.childNodes].some((node) => {
			if (node.nodeType === Node.TEXT_NODE) return !!String(node.textContent || '').trim();
			if (!(node instanceof Element) || node.matches('.ldp-icon,.ldp-logo,.ldp-notification-unread-badge')) return false;
			const computed = getComputedStyle(node);
			if (computed.display === 'none' || computed.visibility === 'hidden') return false;
			return !!String(node.innerText || node.textContent || '').trim();
		});
		const tooltipControl = (target) => {
			if (!(target instanceof Element)) return null;
			const control = target.closest('button,a,[role="button"],[data-ldp-tooltip-label],.ldp-nested-branch-toggle,.ldp-avatar-flair,.ldp-user-card-badge');
			const inReaderSurface = control && (control.closest('.ldp-overlay,.ldp-lightbox') ||
				control.closest('.ldp-user-card-fallback,.ldp-avatar-viewer') || control.matches('.ldp-native-reader-trigger'));
			if (!inReaderSurface || control.hasAttribute('data-tooltip') ||
				control.matches('.ldp-topic-timeline-track,[data-reaction-picker][aria-expanded="true"]')) return null;
			const isFunctionalButton = control.matches('button,[role="button"],.ldp-nested-branch-toggle');
			const isIconOnlyLink = control.matches('a') && control.querySelector('.ldp-icon,.ldp-logo,img') && !hasVisibleText(control);
			const isNamedCopyTarget = control.matches('.ldp-avatar-flair,.ldp-user-card-badge');
			const isNamedTooltipTarget = control.hasAttribute('data-ldp-tooltip-label');
			if (!isFunctionalButton && !isIconOnlyLink && !isNamedCopyTarget && !isNamedTooltipTarget) return null;
			const isTruncatedNarrowTitle = control.matches('.ldp-title-jump') &&
				(control.closest('.ldp-modal')?.getBoundingClientRect().width || 0) <= 480 &&
				control.scrollWidth > control.clientWidth + 1;
			const label = String(isTruncatedNarrowTitle
				? control.textContent
				: control.getAttribute('aria-label') || control.dataset.ldpTooltipLabel || '').trim();
			return label ? { control, label } : null;
		};
		const position = (control, pointerEvent = null) => {
			const rect = control.getBoundingClientRect();
			const tipRect = tooltip.getBoundingClientRect();
			const gap = 6;
			const edge = 8;
			if (control.matches('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle') && pointerEvent &&
				Number.isFinite(pointerEvent.clientX) && Number.isFinite(pointerEvent.clientY)) {
				const pointerGap = 12;
				let left = pointerEvent.clientX + pointerGap;
				if (left + tipRect.width > window.innerWidth - edge) {
					left = pointerEvent.clientX - tipRect.width - pointerGap;
				}
				let top = pointerEvent.clientY + pointerGap;
				if (top + tipRect.height > window.innerHeight - edge) {
					top = pointerEvent.clientY - tipRect.height - pointerGap;
				}
				tooltip.style.left = `${Math.round(Math.max(edge, Math.min(left, window.innerWidth - tipRect.width - edge)))}px`;
				tooltip.style.top = `${Math.round(Math.max(edge, Math.min(top, window.innerHeight - tipRect.height - edge)))}px`;
				return;
			}
			let left = rect.left + (rect.width - tipRect.width) / 2;
			left = Math.max(edge, Math.min(left, window.innerWidth - tipRect.width - edge));
			let top = rect.top - tipRect.height - gap;
			if (top < edge) top = Math.min(window.innerHeight - tipRect.height - edge, rect.bottom + gap);
			tooltip.style.left = `${Math.round(left)}px`;
			tooltip.style.top = `${Math.round(Math.max(edge, top))}px`;
		};
		const show = (target, pointerEvent = null, resolvedMatch = null) => {
			const match = resolvedMatch || tooltipControl(target);
			if (!match) return;
			activeControl = match.control;
			tooltip.textContent = match.label;
			tooltip.classList.toggle('ldp-reader-history-tooltip', match.control.matches('.ldp-reader-history-nav'));
			tooltip.classList.toggle('ldp-connect-help-tooltip', match.control.matches('.ldp-connect-metric'));
			tooltip.hidden = false;
			position(match.control, pointerEvent);
		};
		const keepOpen = (control) => control && (control.matches(':hover') || control.matches(':focus-visible'));
		on(root, 'ldp-tooltip-refresh', (event) => {
			if (!(event.target instanceof Element)) return;
			const control = event.target;
			if (activeControl && !activeControl.isConnected) hide();
			const match = tooltipControl(control);
			if (!match || control.hidden || !keepOpen(control)) {
				if (control === activeControl) hide();
				return;
			}
			show(control, null, match);
		});
		on(root, 'click', (event) => {
			const copyTarget = event.target instanceof Element &&
				event.target.closest('.ldp-avatar-flair,.ldp-user-card-badge');
			if (!copyTarget) return;
			consumeEvent(event);
			const label = String(copyTarget.dataset.ldpTooltipLabel || copyTarget.getAttribute('aria-label') || '').trim();
			if (!label) return;
			const showCopyState = (message) => {
				copyTarget.dataset.ldpTooltipLabel = message;
				setLabel(copyTarget, message);
				refreshTooltips(copyTarget);
				setTimeout(() => {
					if (!copyTarget.isConnected || copyTarget.dataset.ldpTooltipLabel !== message) return;
					copyTarget.dataset.ldpTooltipLabel = label;
					setLabel(copyTarget, label);
					refreshTooltips(copyTarget);
				}, 900);
			};
			void writeSelectionQuoteToClipboard(label)
				.then(() => showCopyState('已复制'))
				.catch(() => showCopyState('复制失败'));
		}, true);
		on(root, 'pointerover', (event) => {
			const match = tooltipControl(event.target);
			if (!match || (event.relatedTarget instanceof Node && match.control.contains(event.relatedTarget))) return;
			show(match.control, event, match);
		});
		on(root, 'pointerdown', (event) => {
			if (event.target instanceof Element && event.target.closest('.ldp-reader-drag-handle')) hide();
		}, true);
		on(root, 'pointermove', (event) => {
			if (!activeControl?.matches('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle') ||
				!(event.target instanceof Node) || !activeControl.contains(event.target)) return;
			position(activeControl, event);
		});
		on(root, 'pointerout', (event) => {
			if (!activeControl || (event.relatedTarget instanceof Node && activeControl.contains(event.relatedTarget))) return;
			if (!activeControl.matches(':focus-visible')) hide();
		});
		on(root, 'focusin', (event) => show(event.target));
		on(root, 'focusout', () => {
			queueMicrotask(() => {
				if (!keepOpen(activeControl)) hide();
			});
		});
		on(root, 'scroll', () => {
			const hoveredHistoryControl = root.querySelector('.ldp-reader-history-nav:hover');
			if (hoveredHistoryControl) show(hoveredHistoryControl);
			else hide();
		}, true);
		on(window, 'resize', hide);
		READER_ICON_TOOLTIP_CLEANUP = () => {
			hide();
			tooltipScope.destroy();
			tooltip.remove();
			root._ldpReaderIconTooltipReady = false;
		};
	}

	function readerActiveElement() {
		return document.activeElement;
	}

	function readerSelection() {
		try {
			return window.getSelection();
		} catch (error) {
			return null;
		}
	}

	function eventPathClosest(event, selector) {
		const path = isFunction(event?.composedPath) ? event.composedPath() : [];
		const matched = path.find((node) => node && node.nodeType === Node.ELEMENT_NODE && node.matches(selector));
		if (matched) return matched;
		return event?.target && event.target.closest ? event.target.closest(selector) : null;
	}

	function eventPathIncludes(event, node) {
		if (!event || !node) return false;
		const path = isFunction(event.composedPath) ? event.composedPath() : [];
		return path.includes(node) || node.contains(event.target);
	}

	function containFloatingSurfaceWheel(surface, event) {
		event.stopPropagation();
		const target = event.target instanceof Element ? event.target : null;
		const lineScale = 40;
		const deltaX = event.deltaMode === 1
			? event.deltaX * lineScale
			: event.deltaMode === 2 ? event.deltaX * surface.clientWidth : event.deltaX;
		const deltaY = event.deltaMode === 1
			? event.deltaY * lineScale
			: event.deltaMode === 2 ? event.deltaY * surface.clientHeight : event.deltaY;
		if (!target || !surface.contains(target)) {
			event.preventDefault();
			return;
		}
		let scrollTarget = target;
		while (scrollTarget && surface.contains(scrollTarget)) {
			const style = getComputedStyle(scrollTarget);
			const maxScrollLeft = scrollTarget.scrollWidth - scrollTarget.clientWidth;
			const maxScrollTop = scrollTarget.scrollHeight - scrollTarget.clientHeight;
			const canScrollX = deltaX && maxScrollLeft > 1 && /(auto|scroll|overlay)/.test(style.overflowX)
				&& (deltaX < 0 ? scrollTarget.scrollLeft > 0 : scrollTarget.scrollLeft < maxScrollLeft - 1);
			const canScrollY = deltaY && maxScrollTop > 1 && /(auto|scroll|overlay)/.test(style.overflowY)
				&& (deltaY < 0 ? scrollTarget.scrollTop > 0 : scrollTarget.scrollTop < maxScrollTop - 1);
			if (canScrollX || canScrollY) return;
			if (scrollTarget === surface) break;
			scrollTarget = scrollTarget.parentElement;
		}
		if (deltaX || deltaY) event.preventDefault();
	}

	function bindFloatingSurfaceWheel(surface) {
		if (!surface || surface._ldpFloatingSurfaceWheelBound) return;
		surface._ldpFloatingSurfaceWheelBound = true;
		surface.addEventListener('wheel', (event) => containFloatingSurfaceWheel(surface, event), { passive: false });
	}

	function createLifecycleScope() {
		const cleanups = [];
		let destroyed = false;
		const scope = {
			add(cleanup) {
				if (destroyed) cleanup();
				else cleanups.push(cleanup);
				return cleanup;
			},
			listen(target, type, listener, options) {
				target.addEventListener(type, listener, options);
				scope.add(() => target.removeEventListener(type, listener, options));
				return listener;
			},
			observe(observer, target, options) {
				observer.observe(target, options);
				scope.add(() => observer.disconnect());
				return observer;
			},
			timer(callback, delay) {
				const timerId = setTimeout(callback, delay);
				scope.add(() => clearTimeout(timerId));
				return timerId;
			},
			raf(callback) {
				const frameId = requestAnimationFrame(callback);
				scope.add(() => cancelAnimationFrame(frameId));
				return frameId;
			},
			destroy() {
				if (destroyed) return;
				destroyed = true;
				cleanups.splice(0).reverse().forEach((cleanup) => {
					try { cleanup(); } catch (error) { console.warn('[LDP] lifecycle cleanup failed', error); }
				});
			},
		};
		return scope;
	}

	function syncTabSelection(tabs, dataKey, selectedValue, roving = false) {
		tabs.forEach((tab) => {
			const active = tab.dataset[dataKey] === selectedValue;
			tab.classList.toggle('active', active);
			tab.setAttribute('aria-selected', String(active));
			if (roving) tab.tabIndex = active ? 0 : -1;
		});
	}

	function bindReaderPanelSearch(input, clearButton, update, delay = 0) {
		let pending = 0;
		const cancel = () => {
			if (!pending) return;
			if (delay) clearTimeout(pending);
			else cancelAnimationFrame(pending);
			pending = 0;
		};
		const commit = () => {
			pending = 0;
			update(input.value.trim());
		};
		input.addEventListener('input', () => {
			clearButton.hidden = !input.value;
			cancel();
			pending = delay ? setTimeout(commit, delay) : requestAnimationFrame(commit);
		});
		clearButton.addEventListener('click', () => {
			cancel();
			input.value = '';
			clearButton.hidden = true;
			update('');
			input.focus();
		});
		return cancel;
	}

	function bindReaderPanelPager(previousButton, nextButton, state, update) {
		previousButton.closest('.ldp-notification-pager').addEventListener('click', (event) => {
			const button = event.target instanceof Element ? event.target.closest('button') : null;
			if (button === previousButton) {
				if (state.page <= 0) return;
				state.page--;
			} else if (button === nextButton) {
				if (state.hasNext === false) return;
				state.page++;
			} else return;
			update();
		});
	}

	function toggleReaderPanelSelection(selection, ids) {
		const fullySelected = ids.length > 0 && ids.every((id) => selection.has(id));
		ids.forEach((id) => {
			if (fullySelected) selection.delete(id);
			else selection.add(id);
		});
	}

	function bindReaderPanelSelectionList(list, checkboxSelector, itemSelector, idKey, selection, render) {
		list.addEventListener('change', (event) => {
			const checkbox = event.target.closest(checkboxSelector);
			const id = Number(checkbox?.closest(itemSelector)?.dataset[idKey]);
			if (!(id > 0)) return;
			selection[checkbox.checked ? 'add' : 'delete'](id);
			render();
		});
	}

	function bindReaderPanelMultiMode(root, state, selection, startButton, doneButton, scopeSelect, render, reset = () => {}) {
		const clear = () => {
			reset();
			selection.clear();
			render();
		};
		root.addEventListener('click', (event) => {
			const button = event.target instanceof Element ? event.target.closest('button') : null;
			if (button === startButton) {
				state.multi = true;
				state.scope = 'page';
			} else if (button === doneButton) {
				state.multi = false;
			} else return;
			clear();
		});
		scopeSelect.addEventListener('change', () => {
			state.scope = scopeSelect.value === 'all' ? 'all' : 'page';
			selection.clear();
			render();
		});
	}

	function handleSearchableMenuKeyDown(event, searchInput, optionsRoot, optionSelector, closeMenu) {
		if (event.key === 'Escape') {
			consumeEvent(event);
			closeMenu(true);
			return;
		}
		if (event.key === 'Enter' && event.target === searchInput) {
			event.preventDefault();
			optionsRoot.querySelector(optionSelector)?.click();
			return;
		}
		if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
		const options = [...optionsRoot.querySelectorAll(optionSelector)];
		if (!options.length) return;
		event.preventDefault();
		const currentIndex = options.indexOf(document.activeElement);
		const direction = event.key === 'ArrowDown' ? 1 : -1;
		const nextIndex = currentIndex < 0
			? (direction > 0 ? 0 : options.length - 1)
			: (currentIndex + direction + options.length) % options.length;
		options[nextIndex].focus();
	}

	/* ============ 2. 工具 ============ */
	const esc = (s) => String(s ?? '').replace(/[<>&]/g, (c) =>
			({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
	const escAttr = (s) => esc(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
	function consumeEvent(event) {
		event.preventDefault();
		event.stopPropagation();
	}
	function refreshTooltips(target) {
		target.dispatchEvent(new CustomEvent('ldp-tooltip-refresh', { bubbles: true, composed: true }));
	}
	function setExpanded(target, expanded) {
		target.setAttribute('aria-expanded', String(expanded));
	}
	function setPressed(target, pressed) {
		target.setAttribute('aria-pressed', String(pressed));
	}
	function setLabel(target, label) {
		target.setAttribute('aria-label', label);
	}
	function isFunction(value) {
		return typeof value === 'function';
	}
	function prefersReducedMotion() {
		return isFunction(window.matchMedia) &&
			window.matchMedia('(prefers-reduced-motion: reduce)').matches;
	}

	const ICONS = {
		activity: '<path d="M3 12h4l2-7 4 14 2-7h6"/>',
		database: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6"/>',
		image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
		type: '<path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/>',
		alertTriangle: '<path d="M21.73 18 13.73 4a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
		bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
		headerBell: '<path d="M5 17h14c-1.3-1.6-2-3.8-2-7a5 5 0 0 0-10 0c0 3.2-.7 5.4-2 7Z"/><path d="M10 20a2 2 0 0 0 4 0"/>',
		bellOff: '<path d="M13.73 21a2 2 0 0 1-3.46 0"/><path d="M18.63 18H4a1 1 0 0 1-.74-1.673C4.59 14.956 6 13.499 6 9c0-.666.108-1.306.308-1.904"/><path d="M10.268 3.061A6 6 0 0 1 18 9a11.525 11.525 0 0 0 .52 3.882"/><path d="m2 2 20 20"/>',
		bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>',
		headerBookmark: '<path d="M6.5 4.5h11A1.5 1.5 0 0 1 19 6v14l-7-3.8L5 20V6a1.5 1.5 0 0 1 1.5-1.5Z"/>',
		check: '<path d="m20 6-11 11-5-5"/>',
		square: '<rect width="18" height="18" x="3" y="3" rx="2"/>',
		squareCheck: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
		circleX: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
		arrowUp: '<path d="M12 17V7"/><path d="M7.5 11.5 12 7l4.5 4.5"/>',
		chevronDown: '<path d="m6 9 6 6 6-6"/>',
		chevronUp: '<path d="m18 15-6-6-6 6"/>',
		chevronRight: '<path d="m9 18 6-6-6-6"/>',
		download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>',
		upload: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
		externalLink: '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>',
		copy: '<rect width="14" height="14" x="8" y="8" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
		eyeOff: '<path d="M10.733 5.076A10.744 10.744 0 0 1 21.938 11.65a1 1 0 0 1 0 .696 10.8 10.8 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499A10.75 10.75 0 0 1 2.062 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>',
		mail: '<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
		palette: '<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" stroke="none"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor" stroke="none"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor" stroke="none"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor" stroke="none"/><path d="M12 2a10 10 0 0 0 0 20c1.1 0 2-.9 2-2 0-.5-.2-1-.6-1.4-.4-.4-.6-.9-.6-1.4a2 2 0 0 1 2-2H17a5 5 0 0 0 5-5C22 5.7 17.5 2 12 2z"/>',
		award: '<circle cx="12" cy="8" r="6"/><path d="M15.5 13 17 22l-5-3-5 3 1.5-9"/>',
		tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor" stroke="none"/>',
		heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7z"/>',
		hand: '<path d="M18 11V6a2 2 0 0 0-4 0v5"/><path d="M14 10V4a2 2 0 0 0-4 0v6"/><path d="M10 9.5V6a2 2 0 0 0-4 0v8"/><path d="M6 14v-2a2 2 0 0 0-4 0v2a8 8 0 0 0 8 8h2c5.523 0 10-4.477 10-10V8a2 2 0 0 0-4 0v3"/>',
		history: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
		headerHistory: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v4.7l3.2 1.8"/>',
		info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
		lock: '<rect width="14" height="10" x="5" y="11" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
		unlock: '<rect width="14" height="10" x="5" y="11" rx="2"/><path d="M8 11V7a4 4 0 0 1 7.9-1"/>',
		pin: '<path d="M12 17v5"/><path d="M5 17h14"/><path d="m6 3 1 7-3 3h16l-3-3 1-7z"/>',
		pencil: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
		trash: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="m19 6-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/>',
		wrench: '<path d="M14.7 6.3a4 4 0 0 0-5-5L7.4 3.6l3 3-3.8 3.8-3-3-2.3 2.3a4 4 0 0 0 5 5L15.6 24l4-4-8.7-9.3 3.8-4.4Z"/>',
		loader: '<path d="M21 12a9 9 0 1 1-6.219-8.56"/>',
		maximize2: '<path d="M15 3h6v6"/><path d="m21 3-7 7"/><path d="M9 21H3v-6"/><path d="m3 21 7-7"/>',
		layoutGrid: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16"/><path d="M3 12h18"/>',
		panelLeft: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>',
		panelRight: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/>',
		floatingWindow: '<rect x="4" y="5" width="16" height="14" rx="2"/><path d="M4 9h16"/><path d="M7 7h.01"/><path d="M10 7h.01"/>',
		messageSquare: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
		minus: '<path d="M5 12h14"/>',
		minimize2: '<path d="M4 14h6v6"/><path d="m10 14-7 7"/><path d="M20 10h-6V4"/><path d="m14 10 7-7"/>',
		cornerDownRight: '<path d="m15 10 4 4-4 4"/><path d="M5 4v7a3 3 0 0 0 3 3h11"/>',
		reply: '<path d="m9 17-5-5 5-5"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/>',
		plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
		rocket: '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
		sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.42 1.42"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
		moon: '<path d="M20.99 12.8A9 9 0 1 1 11.2 3.01 7 7 0 0 0 20.99 12.8Z"/>',
		monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/>',
		settings: '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831 2.34 2.34 0 0 1 2.33-4.033 2.34 2.34 0 0 0 3.32-1.915z"/><circle cx="12" cy="12" r="3"/>',
		headerSettings: '<path d="M4 6h5M13 6h7"/><circle cx="11" cy="6" r="2"/><path d="M4 12h10M18 12h2"/><circle cx="16" cy="12" r="2"/><path d="M4 18h2M10 18h10"/><circle cx="8" cy="18" r="2"/>',
		smile: '<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><path d="M9 9h.01"/><path d="M15 9h.01"/>',
		search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
		flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><path d="M4 22v-7"/>',
		link2: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
		lightbulb: '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.84.65-1.64 1.35-2.35a6 6 0 1 0-8.88 0c.7.71 1.17 1.51 1.35 2.35Z"/>',
		list: '<path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/>',
		layers: '<path d="m12 2 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5"/><path d="m3 17 9 5 9-5"/>',
		listChecks: '<path d="m3 5 2 2 4-4"/><path d="M11 6h10"/><path d="m3 12 2 2 4-4"/><path d="M11 13h10"/><path d="m3 19 2 2 4-4"/><path d="M11 20h10"/>',
		rotateCcw: '<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>',
		share2: '<path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7"/><path d="m16 6-4-4-4 4"/><path d="M12 2v13"/>',
		shieldHalved: '<path d="M12 2 4 5v6c0 5 3.3 9.4 8 11 4.7-1.6 8-6 8-11V5l-8-3Z"/><path d="M12 2 4 5v6c0 5 3.3 9.4 8 11V2Z" fill="currentColor" stroke="none"/>',
		userPlus: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6"/><path d="M22 11h-6"/>',
		userRound: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
		x: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
	};

	function icon(name, extraClass) {
		const cls = `ldp-icon ldp-icon-${name}${extraClass ? ` ${extraClass}` : ''}`;
		return `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] || ''}</svg>`;
	}
	function iconActionButtons(className, actionAttribute, actions) {
		return actions.map(([action, label, iconName, attributes = '']) =>
			`<button class="${className}" type="button" ${actionAttribute}="${action}" aria-label="${label}"${attributes}>${icon(iconName)}</button>`
		).join('');
	}

	const APPEARANCE_SETTING_FIELDS = [
		{ key: 'accentColor', section: 'interaction', label: '界面强调色',
			help: '控制阅读器的主强调色,包括主要按钮、选中态、焦点轮廓、开关开启态、时间轴和操作反馈;不会改变错误、警告、成功和点赞等语义颜色。选择时实时预览,确认后保存。' },
		{ key: 'linkColor', section: 'interaction', label: '正文链接色',
			help: '控制帖子正文中的 URL 链接颜色,与按钮和选中态的界面强调色分开设置;选择时实时预览,确认后保存。' },
		{ key: 'zebraColor', section: 'zebra', label: '颜色', ariaLabel: '楼层斑马线颜色',
			help: '设置阅读器正文楼层隔行出现的淡色背景,用来更容易分清相邻楼层。选择时实时预览,点击“确认应用”后才保存。' },
		{ key: 'zebraRadius', section: 'zebra', label: '圆角', ariaLabel: '楼层斑马线圆角', range: [ZEBRA_RADIUS_MIN, ZEBRA_RADIUS_MAX, ZEBRA_RADIUS_STEP], normalize: (value, fallback = ZEBRA_RADIUS_DEFAULT) => normalizeSteppedValue(value, fallback, ZEBRA_RADIUS_MIN, ZEBRA_RADIUS_MAX, ZEBRA_RADIUS_STEP),
			help: `设置阅读器正文斑马线楼层的圆角,${ZEBRA_RADIUS_MIN}px 为直角,最高 ${ZEBRA_RADIUS_MAX}px。拖动时实时预览,点击“确认应用”后才保存。` },
		{ key: 'listZebraColor', section: 'list-zebra', label: '列表斑马线', ariaLabel: 'Linux DO 列表斑马线颜色', shareLabel: '列表斑马线',
			help: '设置 LINUX DO 帖子列表卡片的隔行色调;所选颜色会作为主色与宿主背景适度混合,兼顾辨识度和文字清晰度。选择时实时预览,点击“确认应用”后才保存。' },
		{ key: 'replyLineColor', section: 'reply-line', label: '颜色', ariaLabel: '回复关系线颜色',
			help: '控制阅读器里的关系线颜色,包括二级回复连线、父楼层预览、时间轴、特殊提示边框和灯箱评论层级线。选择时实时预览,确认后保存。' },
		{ key: 'replyLineWidth', section: 'reply-line', label: '粗细', ariaLabel: '回复关系线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = REPLY_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback),
			help: `控制回复关系线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;二级回复树线、父子回复连接线和相关层级线会同步变化。拖动时实时预览,确认后保存。` },
		{ key: 'replyLineRadius', section: 'reply-line', label: '圆角', ariaLabel: '父子与二级回复线圆角', shareLabel: '回复关系线圆角', range: [REPLY_LINE_RADIUS_MIN, REPLY_LINE_RADIUS_MAX, REPLY_LINE_RADIUS_STEP], normalize: normalizeReplyLineRadius,
			help: `控制父子回复和二级回复连接线转折处的圆角,${REPLY_LINE_RADIUS_MIN}px 为直角,最高 ${REPLY_LINE_RADIUS_MAX}px。拖动时实时预览,确认后保存。` },
		{ key: 'quoteLineColor', section: 'quote-line', label: '颜色', ariaLabel: '引用线颜色',
			help: '控制帖子正文引用块和“引用某楼层”卡片左侧竖线的颜色,只改变引用提示线,不改变引用文字或背景。选择时实时预览,确认后保存。' },
		{ key: 'quoteLineWidth', section: 'quote-line', label: '粗细', ariaLabel: '引用线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = QUOTE_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback),
			help: `控制引用线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;不同引用样式会保留原有粗细层级。拖动时实时预览,确认后保存。` },
		{ key: 'dividerLineColor', section: 'divider-line', label: '颜色', ariaLabel: '界面分隔线颜色',
			help: '控制楼层边界、标题栏、设置与通知面板、嵌入阅读边界和灯箱结构分隔线的颜色;按钮和输入框外框不会跟着改变。选择时实时预览,确认后保存。' },
		{ key: 'dividerLineWidth', section: 'divider-line', label: '粗细', ariaLabel: '界面分隔线粗细', range: [LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP], normalize: (value, fallback = DIVIDER_LINE_WIDTH_DEFAULT) => normalizeLineWidth(value, fallback),
			help: `控制界面结构分隔线的基础粗细,可在 ${LINE_WIDTH_MIN}–${LINE_WIDTH_MAX}px 之间调整;按钮和输入框外框不会跟着改变。拖动时实时预览,确认后保存。` },
		{ key: 'floorPreviewColor', section: 'floor-preview', label: '预览颜色', ariaLabel: '父回复插入预览颜色', shareLabel: '父回复预览颜色',
			help: '控制父楼层号悬停后插入的预览卡片边框、淡色背景和楼层标记颜色;不改变二级回复关系线。选择时实时预览,确认后保存。' },
		{ key: 'floorPreviewHeight', section: 'floor-preview', label: '最大高度', ariaLabel: '父回复插入预览最大高度', shareLabel: '父回复预览高度', range: [FLOOR_PREVIEW_HEIGHT_MIN, FLOOR_PREVIEW_HEIGHT_MAX, FLOOR_PREVIEW_HEIGHT_STEP], normalize: (value, fallback = FLOOR_PREVIEW_HEIGHT_DEFAULT) => normalizeSteppedValue(value, fallback, FLOOR_PREVIEW_HEIGHT_MIN, FLOOR_PREVIEW_HEIGHT_MAX, FLOOR_PREVIEW_HEIGHT_STEP),
			help: `控制父回复插入预览在长内容时允许占用的最大高度,最低 ${FLOOR_PREVIEW_HEIGHT_MIN}px;内容较短时仍按实际高度显示,并始终受当前窗口可用高度限制。拖动时实时预览,确认后保存。` },
	];
	const APPEARANCE_SETTING_GROUPS = [
		{ id: 'interaction', title: '强调与交互', description: '控制按钮、选中态、焦点、时间轴和正文链接,不改变错误、警告、成功等状态色。', sections: ['interaction'] },
		{ id: 'background', title: '内容背景', description: '区分相邻楼层和列表卡片,只改变淡色背景,不改变正文文字。',
			sections: [{ id: 'zebra', title: '楼层斑马线' }, 'list-zebra'] },
		{ id: 'structure', title: '结构与关系', description: '分别控制回复层级、引用关系和界面边界;关闭后隐藏这些配色,已设置的颜色、粗细和圆角会保留。',
			toggleKey: 'structureColorsEnabled', toggleLabel: '显示结构配色',
			sections: [{ id: 'reply-line', title: '回复关系线' }, { id: 'quote-line', title: '引用线' }, { id: 'divider-line', title: '界面分隔线' }] },
		{ id: 'floor-preview', title: '父回复插入预览', description: '单独控制悬停父楼层号后插入的预览卡片,不影响回复关系线和其他内容样式。', sections: ['floor-preview'] },
	];

	function appearanceSettingClassName(key) {
		return `ldp-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
	}

	function appearanceSettingRowMarkup(field) {
		const className = appearanceSettingClassName(field.key);
		const input = field.range
			? `<input class="${className}-range" type="range" min="${field.range[0]}" max="${field.range[1]}" step="${field.range[2]}" aria-label="${field.ariaLabel || field.label}">`
			: `<input class="${className}" type="color" aria-label="${field.ariaLabel || field.label}">`;
		return `<div class="ldp-setting-row">
			<span class="ldp-setting-label">${field.label}</span>
			<span class="${field.range ? 'ldp-font-scale-control' : 'ldp-color-control'}">
				${input}<span class="${className}-value"></span>
				<button class="ldp-color-reset" type="button" data-color-reset="${field.key}">${icon('rotateCcw')}<span>恢复默认</span></button>
			</span>
		</div>`;
	}

	function appearanceSettingGroupMarkup(group) {
		const sections = group.sections.map((section) => {
			const { id, title } = typeof section === 'string' ? { id: section } : section;
			const rows = APPEARANCE_SETTING_FIELDS.filter((field) => field.section === id)
				.map(appearanceSettingRowMarkup).join('');
			if (!title) return rows;
			const titleId = `ldp-${id}-setting-group-title`;
			return `<div class="ldp-setting-group" role="group" aria-labelledby="${titleId}">
				<div class="ldp-setting-group-title" id="${titleId}">${title}</div>
				${rows}
			</div>`;
		}).join('');
		const toggle = group.toggleKey
			? `<span class="ldp-color-group-head-actions">
					<span class="ldp-setting-switch">
						<input type="checkbox" role="switch" data-appearance-group-toggle="${group.toggleKey}" aria-label="${group.toggleLabel}">
						<span class="ldp-setting-switch-track" aria-hidden="true"></span>
					</span>
				</span>`
			: '';
		return `<section class="ldp-color-group" data-appearance-group="${group.id}" aria-labelledby="ldp-color-group-${group.id}">
			<header class="ldp-color-group-head">
				<div class="ldp-color-group-head-copy">
					<h4 class="ldp-color-group-title" id="ldp-color-group-${group.id}">${group.title}</h4>
					<p class="ldp-color-group-description">${group.description}</p>
				</div>
				${toggle}
			</header>
			<div class="ldp-settings-fields">${sections}</div>
		</section>`;
	}

	function jumpHighlightSettingsMarkup() {
		return JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => {
			const className = `ldp-flash-${field.key}`;
			const limits = JUMP_HIGHLIGHT_LIMITS[field.key];
			const input = field.type === 'color'
				? `<input class="${className}" type="color" aria-label="${field.ariaLabel || field.label}">`
				: `<input class="${className} ldp-flash-range" type="range" min="${limits.min}" max="${limits.max}" step="${limits.step}" aria-label="${field.ariaLabel || field.label}">`;
			return `<label class="ldp-setting-row">
				<span class="ldp-setting-label">${field.label}</span>
				<span class="${field.type === 'color' ? 'ldp-color-control' : 'ldp-flash-range-control'}">
					${input}<${field.type === 'color' ? 'span' : 'output'} class="${className}-value${field.type === 'color' ? '' : ' ldp-flash-value'}"></${field.type === 'color' ? 'span' : 'output'}>
				</span>
			</label>`;
		}).join('');
	}

	function settingsCategoryMarkup(id, title, description, content,
			contentClass = 'ldp-settings-category-content', descriptionClass = '') {
		const titleId = `ldp-${id}-title`;
		return `<section class="ldp-settings-category-group" aria-labelledby="${titleId}">
			<header class="ldp-settings-category-head">
				<strong id="${titleId}">${title}</strong>
				<small${descriptionClass ? ` class="${descriptionClass}"` : ''}>${description}</small>
			</header>
			<div class="${contentClass}">${content}</div>
		</section>`;
	}

	function otherSettingGroupMarkup(id, title, description, content) {
		const titleId = `ldp-other-${id}-title`;
		return `<section class="ldp-other-setting-group" aria-labelledby="${titleId}">
			<header class="ldp-other-setting-group-head">
				<strong id="${titleId}">${title}</strong>
				<small>${description}</small>
			</header>
			<div class="ldp-other-setting-list">${content}</div>
		</section>`;
	}

	function performanceSettingsMarkup() {
		return PERFORMANCE_SETTING_GROUPS.map((group) => settingsCategoryMarkup(
			`performance-${group.id}`, group.title, group.description,
			group.fields.map((field) => {
					const limits = PERFORMANCE_LIMITS[field.key];
					return `<label class="ldp-setting-row">
						<span class="ldp-performance-copy"><strong>${field.title}</strong><small>${field.description}</small></span>
						<span class="ldp-performance-control"><input type="number" data-performance-key="${field.key}" min="${limits.min}" max="${limits.max}" step="${limits.step}" inputmode="${field.inputMode}"><em>${field.unit}</em></span>
					</label>`;
				}).join(''),
			'ldp-settings-fields ldp-settings-category-list ldp-performance-fields'
		)).join('');
	}

	const SETTINGS_PANEL_META = [
		['user', 'userRound', '用户信息', '集中查看当前账号资料、社区统计与 Connect 信任级别升级进度。'],
		['image', 'image', '图片设置', '灯箱行为和帖子图片比例统一作用于阅读器;布局随容器实际宽度响应。'],
		['font', 'type', '字体设置', '字体渲染默认作用于整个增强阅读器,也可扩展到宿主页面;界面、正文和回复的字体外观继续分别设置。'],
		['layout', 'layoutGrid', '布局设置', '一套五区比例用于所有显示形态,元素根据阅读器容器实际宽度自动重排。'],
		['window', 'floatingWindow', '浮窗设置', '设置桌面浮窗的长宽和左上角坐标;拖动或缩放浮窗后会同步保存,其他显示方式下修改会在切回浮窗后生效。'],
		['appearance', 'palette', '外观设置', '一套颜色、粗细和圆角用于所有显示形态,并自动生成明亮与暗色主题的适应色。'],
		['flash', 'lightbulb', '闪烁动效', '集中设置跳转楼层时的闪烁提示,以及打开或切换帖子时的等待区域动画。'],
		['performance', 'rocket', '性能设置', '先控制“产生多少请求与 DOM”,再控制“请求何时启动”。主楼层取数与楼中楼自动请求属于需求来源;DOM 窗口只负责渲染;共享调度负责排队、双窗口预算、429 退避与恢复探测,但不会抵消过大的预取需求。设置在下次打开阅读器时生效。'],
		['resource-monitor', 'monitor', '资源监控', '状态每秒快照,长任务、长帧脚本、DOM 变更、网络和前后台切换按浏览器原始事件记录到毫秒;标签页进入后台后继续取证,计时器被节流或冻结时保留真实空档,不补造样本。仅在本页内存保留最近 10 分钟。', '实时资源监控'],
		['request-flow', 'activity', '请求数据', '同源标签页共享阅读器与 LINUX DO 宿主 API 的 10 秒/60 秒请求账本、优先级、活动槽、有效策略和限流状态;宿主请求发出后立即纳入账本,后续阅读器请求主动避让。当前页静态资源只用于网络观测,不消耗 API 配额。下方速率、类型、P95 与详细日志是当前标签页口径,只在本页内存保留最近 15 分钟、最多 1200 条;“全局”执行、排队和窗口来自共享账本。不会记录查询参数、请求正文、Cookie 或响应内容。'],
		['other', 'wrench', '其他功能', '按历史导航、帖子打开、回复展示和 Boost 复制分类管理,修改后自动保存在当前浏览器。'],
		['cache', 'database', '数据管理', '备份或恢复阅读器设置,并查看、清理当前浏览器保存的本地缓存。'],
		['about', 'info', '关于', '专注于长帖阅读、楼层关系和原生社区操作的 LINUX DO 阅读工作台。'],
	];

	function settingsNavigationMarkup() {
		return SETTINGS_PANEL_META.map(([id, iconName, title], index) =>
			`<button class="ldp-settings-tab${index ? '' : ' active'}" type="button" data-settings-panel="${id}">${icon(iconName)}<span>${title}</span><span class="ldp-settings-tab-draft-count" aria-hidden="true" hidden></span></button>`
		).join('');
	}

	function settingsPanelIntroMarkup(id) {
		const panel = SETTINGS_PANEL_META.find(([panelId]) => panelId === id);
		if (!panel) return '';
		const extra = id === 'user'
			? `<span class="ldp-user-info-last-refresh">最后刷新 · 暂无</span><button class="ldp-user-info-title-refresh" type="button" aria-label="刷新用户信息">${icon('rotateCcw')}</button>`
			: '';
		return `<div class="ldp-settings-intro"><h3 class="ldp-settings-title">${panel[4] || panel[2]}</h3><p class="ldp-settings-description">${panel[3]}</p>${extra}</div>`;
	}

	function mountSettingsPanelIntros(root) {
		root.querySelectorAll('.ldp-settings-section[data-settings-panel]').forEach((section) => {
			section.insertAdjacentHTML('afterbegin', settingsPanelIntroMarkup(section.dataset.settingsPanel));
		});
	}

	function settingsApplyFooterMarkup(statusClass, applyClass, resetLabel = '', resetClass = '') {
		const resetButton = resetLabel
			? `<button class="ldp-layout-reset${resetClass ? ` ${resetClass}` : ''}" type="button">${icon('rotateCcw')}<span>${resetLabel}</span></button>`
			: '';
		return `<div class="ldp-layout-footer"><span class="ldp-layout-total${statusClass ? ` ${statusClass}` : ''}" role="status"></span>
			<span class="ldp-layout-footer-actions">${resetButton}<button class="ldp-layout-apply${applyClass ? ` ${applyClass}` : ''}" type="button" disabled>${icon('check')}<span>确认应用</span></button></span></div>`;
	}

	function readerWindowGeometryMarkup() {
		return [
			['width', `宽度(最小 ${READER_WINDOW_MIN_WIDTH}px)`, READER_WINDOW_MIN_WIDTH],
			['height', `高度(最小 ${READER_WINDOW_MIN_HEIGHT}px)`, READER_WINDOW_MIN_HEIGHT],
			['x', '左上角 X', READER_WINDOW_MARGIN],
			['y', '左上角 Y', READER_WINDOW_MARGIN],
		].map(([key, label, min]) => `<label class="ldp-reader-window-field"><span>${label}</span>
			<span class="ldp-reader-window-input-wrap"><input class="ldp-reader-window-input ldp-reader-window-${key}" type="number" min="${min}" step="1" inputmode="numeric"><span>px</span></span></label>`).join('');
	}

	function aboutFeaturesMarkup() {
		return [
			['layoutGrid', '响应式专注阅读', '同一阅读内核支持浮窗、全屏和左右嵌入,元素随容器宽度自动重排。'],
			['image', '完整内容与楼层关系', '楼中楼、引用、时间轴、图片、视频、音频和 Markdown 提示块连贯呈现。'],
			['heart', '原生社区互动', '回复、点赞、Boost、回应、收藏、通知和帖子编辑无需离开阅读器。'],
			['rocket', '长帖数据与性能', '按需加载、缓存和请求节奏控制,并集中管理历史、收藏与回应。'],
		].map(([iconName, title, description]) => `<article class="ldp-about-feature"><span class="ldp-about-feature-icon">${icon(iconName)}</span>
			<div class="ldp-about-feature-copy"><strong>${title}</strong><p>${description}</p></div></article>`).join('');
	}

	function popoverSearchMarkup(type, placeholder, label, clearLabel) {
		return `<label class="ldp-popover-search">
			${icon('search')}
			<input class="ldp-popover-search-input ldp-${type}-search" type="search" autocomplete="off" spellcheck="false" placeholder="${placeholder}" aria-label="${label}">
			<button class="ldp-popover-search-clear ldp-${type}-search-clear" type="button" aria-label="${clearLabel}" hidden>${icon('x')}</button>
		</label>`;
	}

	function popoverPagerMarkup(type) {
		const prefix = `ldp-${type}-page`;
		const shared = type === 'notification' ? '' : ' ldp-notification-page';
		return `<div class="ldp-notification-pager">
			<button class="${prefix}-prev${shared ? `${shared}-prev` : ''}" type="button" aria-label="上一页" disabled>${icon('chevronRight', 'ldp-notification-prev-icon')}</button>
			<span class="${prefix}-info${shared ? `${shared}-info` : ''}">第 1 页</span>
			<button class="${prefix}-next${shared ? `${shared}-next` : ''}" type="button" aria-label="下一页" disabled>${icon('chevronRight')}</button>
		</div>`;
	}

	function collectionBulkActionsMarkup(type, scopeLabel, itemLabel, deleteLabel) {
		return `<div class="ldp-collection-title-actions ldp-${type}-bulk-actions" hidden>
			<select class="ldp-reader-select ldp-collection-scope ldp-${type}-select-scope" aria-label="${scopeLabel}全选范围">
				<option value="page">本页</option>
				<option value="all">全部页</option>
			</select>
			<button class="ldp-collection-action ldp-${type}-select-toggle" type="button" aria-label="全选本页${itemLabel}" aria-pressed="false">${icon('square')}</button>
			<button class="ldp-collection-action danger ldp-${type}-delete-selected" type="button" aria-label="${deleteLabel}" disabled>${icon('trash')}<b class="ldp-collection-count ldp-${type}-delete-selected-label" hidden>0</b></button>
			<button class="ldp-collection-action ldp-${type}-multi-done" type="button" aria-label="退出多选">${icon('x')}</button>
		</div>`;
	}

	const RESOURCE_MONITOR_ROWS = [
		['heap', '页面内存估计', 'UA 专用内存 API;不可按脚本拆分'],
		['longTasks', '10 秒共享长任务', '浏览器主线程;次数 / 累计耗时'],
		['dom', '阅读器 DOM', '外壳内全部节点'],
		['floors', '主楼层 DOM', '已挂载 / 内存保留'],
		['nested', '楼中楼缓存', '已建立状态的父楼层组'],
		['media', '正文媒体 DOM', '图片、音视频与 iframe'],
		['requests', '当前阅读器请求', '执行中 / 排队'],
		['network', '60 秒阅读器网络', '请求数 / 已知传输量'],
	];
	const RESOURCE_MONITOR_STAT_KEYS = Object.freeze([
		'heapUsed', 'longTaskCount', 'longTaskDuration', 'dom', 'mountedFloors', 'retainedFloors',
		'nested', 'media', 'activeRequests', 'queuedRequests', 'readerRequestCount', 'readerTransfer',
	]);

	function resourceMonitorRowMarkup([key, label, detail]) {
		return `<div class="ldp-resource-monitor-row" role="row" data-resource-monitor-row="${key}">
			<span class="ldp-resource-monitor-copy"><strong>${label}</strong><small>${detail}</small></span>
			<strong class="ldp-resource-monitor-current" data-resource-monitor-metric="${key}">—</strong>
			<span class="ldp-resource-monitor-summary"><span data-resource-monitor-average="${key}">—</span><span data-resource-monitor-peak="${key}">—</span><span data-resource-monitor-trend="${key}">—</span></span>
		</div>`;
	}

	const SETTING_SWITCH_FIELDS = [
		{ section: 'image-lightbox', className: 'ldp-lightbox-comments-expanded', label: '默认展开图片评论', description: '开启后,打开图片灯箱时直接显示右侧评论面板;关闭时仍可通过顶部评论按钮展开。' },
		{ section: 'image-lightbox', className: 'ldp-lightbox-description-expanded', label: '默认展开图片描述', description: '描述可在灯箱内收纳,也可拖动底边调整高度;收纳状态与高度会自动记住。' },
		{ section: 'image-lightbox', className: 'ldp-lightbox-original-by-default', label: '灯箱浏览默认使用原图', description: '开启后,打开灯箱和前后翻图都会自动加载原图;关闭时先显示预览图,仍可手动查看原图。' },
		{ section: 'font-rendering', className: 'ldp-font-rendering-enabled', label: '内置字体渲染', detailHtml: '<small class="ldp-font-rendering-status" role="status">正在检测字体渲染环境…</small>' },
		{ section: 'font-rendering', className: 'ldp-font-rendering-on-host', label: '扩展到 LINUX DO 宿主页面', ariaLabel: '扩展字体渲染到 LINUX DO 宿主页面', description: '默认开启;主题列表、帖子原页和其它宿主界面也使用相同渲染。' },
		{ section: 'history', className: 'ldp-history-buttons-always-visible', label: '长显左右按钮', description: '始终显示浏览历史的左右导航按钮;开启后不再使用边缘触发区域。' },
		{ section: 'topic-opening', className: 'ldp-open-topics-first-post', label: '打开帖子起始楼层号为 #1', description: '普通帖子链接默认从 #1 打开;消息、历史和收藏仍按各自目标楼层打开。' },
		{ section: 'replies', className: 'ldp-expand-nested-replies-default', label: '默认展开二级回复', description: '开启后,父楼层下默认铺开直属二级回复;关闭时必须保留下面的对应楼层展开。' },
		{ section: 'replies', className: 'ldp-expand-leaf-nested-replies', label: '展开二级回复对应楼层', description: '开启后,二级回复在主信息流中的正式楼层默认完整展开;关闭时必须保留上面的直属回复展开。' },
	];

	function settingSwitchMarkup(field) {
		return `<label class="ldp-setting-row ldp-setting-option-row">
			<span class="ldp-setting-option-copy">
				<strong>${field.label}</strong>
				${field.detailHtml || `<small>${field.description}</small>`}
			</span>
			<span class="ldp-setting-switch">
				<input class="${field.className}" type="checkbox" role="switch" aria-label="${field.ariaLabel || field.label}">
				<span class="ldp-setting-switch-track" aria-hidden="true"></span>
			</span>
		</label>`;
	}

	function settingSwitchesMarkup(section) {
		return SETTING_SWITCH_FIELDS.filter((field) => field.section === section)
			.map(settingSwitchMarkup).join('');
	}

	const BOOST_COPY_SETTING_ROWS = [
		{ label: '小尾巴类型', control: '<select class="ldp-reader-select ldp-boost-copy-mode ldp-boost-rule-control" aria-label="Boost 小尾巴类型"><option value="counter">数字递增</option><option value="text">固定文字</option></select>' },
		{ label: '开头文字', control: '<input class="ldp-boost-copy-prefix ldp-boost-rule-control" type="text" maxlength="16" autocomplete="off" placeholder="可选,例如:赞同:" aria-label="Boost 开头文字">' },
		{ label: '数字前内容', className: ' ldp-boost-counter-row', control: '<input class="ldp-boost-copy-counter-marker ldp-boost-rule-control" type="text" maxlength="16" autocomplete="off" placeholder="默认 +,也可填文字" aria-label="Boost 数字前内容">' },
		{ label: '递增步长', className: ' ldp-boost-counter-row', control: '<input class="ldp-boost-copy-counter-step ldp-boost-rule-control" type="number" min="1" max="99" step="1" inputmode="numeric" aria-label="Boost 递增步长">' },
		{ label: '固定尾巴', className: ' ldp-boost-text-row', hidden: true, control: '<input class="ldp-boost-copy-fixed-suffix ldp-boost-rule-control" type="text" maxlength="16" autocomplete="off" placeholder="例如:俺也一样" aria-label="Boost 固定尾巴">' },
	];

	function boostCopySettingsMarkup() {
		return BOOST_COPY_SETTING_ROWS.map((row) => `<label class="ldp-setting-row ldp-boost-rule-row${row.className || ''}"${row.hidden ? ' hidden' : ''}>
			<span class="ldp-setting-label">${row.label}</span>
			${row.control}
		</label>`).join('');
	}

	function fontFieldRowsMarkup(scope, config) {
		const host = scope === 'host';
		const rowClass = 'ldp-setting-row';
		const reset = (field) => `<button class="ldp-color-reset ${host ? 'ldp-host-font-reset' : 'ldp-font-field-reset'}" type="button" ${host ? 'data-host-font-reset' : 'data-font-field-reset'}="${host ? field : config[field]}">${icon('rotateCcw')}<span>默认</span></button>`;
		const defaultFamily = host ? HOST_FONT_DEFAULTS.hostFontFamily : FONT_FAMILY_DEFAULT;
		const row = (label, controlClass, control, field) => `<div class="${rowClass}">
			<span class="ldp-setting-label">${label}</span>
			<span class="${controlClass}">${control}${reset(field)}</span>
		</div>`;
		return [
			row('字体', 'ldp-font-option-control', `
				<span class="ldp-font-family-picker" data-font-scope="${scope}">
					<button class="ldp-font-family-trigger ldp-picker-trigger" type="button" data-font-scope="${scope}" aria-haspopup="listbox" aria-expanded="false" aria-controls="ldp-font-family-menu">
						<span class="ldp-font-family-value">${FONT_FAMILY_OPTIONS[defaultFamily].label}</span>${icon('chevronDown')}
					</button>
				</span>
				<input class="ldp-font-family-custom" type="text" data-font-scope="${scope}" maxlength="64" autocomplete="off" spellcheck="false" placeholder="输入字体名称" aria-label="${config.label}自定义字体名称" hidden>`, 'family'),
			row('字重', 'ldp-font-option-control', `
				<select class="ldp-reader-select ldp-font-weight-select" data-font-scope="${scope}" aria-label="${config.label}字重">
					${FONT_WEIGHT_OPTIONS.map((value) => `<option value="${value}">${value} · ${FONT_WEIGHT_LABELS[value]}</option>`).join('')}
				</select>`, 'weight'),
			row('颜色', 'ldp-color-control ldp-font-color-control', `
				<input class="ldp-font-color" type="color" data-font-scope="${scope}" value="${READER_THEME_VARIABLES.light['--primary']}" aria-label="${config.label}${host ? '颜色' : '文字颜色'}">
				<span class="ldp-accent-color-value ldp-font-color-value" data-font-scope="${scope}">跟随主题</span>`, 'color'),
		].join('');
	}

	function fontScopeSettingsMarkup(scope) {
		const config = FONT_SCOPE_CONFIG[scope];
		const scaleClass = scope === 'interface' ? 'ldp-interface-font-scale' : scope === 'post' ? 'ldp-font-scale' : 'ldp-composer-font-scale';
		return `<div class="ldp-setting-group ldp-font-scope-group" id="ldp-font-scope-panel-${scope}" role="tabpanel" data-font-scope-panel="${scope}" aria-labelledby="ldp-font-scope-tab-${scope}"${scope === 'interface' ? '' : ' hidden'}>
			${fontFieldRowsMarkup(scope, config)}
			<div class="ldp-setting-row">
				<span class="ldp-setting-label">字号</span>
				<span class="ldp-font-scale-control">
					<input class="${scaleClass}-range" type="range" data-font-scope="${scope}" min="${FONT_SCALE_MIN}" max="${FONT_SCALE_MAX}" step="1" aria-label="${config.label}字号">
					<span class="${scaleClass}-value"></span>
					<button class="ldp-color-reset ldp-font-field-reset" type="button" data-font-field-reset="${config.scale}">${icon('rotateCcw')}<span>默认</span></button>
				</span>
			</div>
		</div>`;
	}

	function normalizePerformanceValue(name, value, fallback) {
		const limits = PERFORMANCE_LIMITS[name];
		const numeric = Number(value);
		if (!limits || !Number.isFinite(numeric)) return fallback;
		const clamped = Math.min(limits.max, Math.max(limits.min, numeric));
		return limits.integer ? Math.round(clamped) : Math.round(clamped * 100) / 100;
	}

	function performanceConfigFromPrefs(prefs) {
		const source = prefs || {};
		const fallback = BALANCED_PERFORMANCE;
		return PERFORMANCE_SETTING_KEYS.reduce((config, name) => {
			config[name] = normalizePerformanceValue(
				name,
				source[PERFORMANCE_PREF_KEYS[name]],
				fallback[name]
			);
			return config;
		}, {});
	}

	function performancePresetForConfig(config) {
		const match = Object.entries(PERFORMANCE_PRESETS).find(([, preset]) =>
			PERFORMANCE_SETTING_KEYS.every((name) => config[name] === preset[name])
		);
		return match ? match[0] : 'custom';
	}

	function performancePrefsPatch(config, presetName = performancePresetForConfig(config)) {
		return PERFORMANCE_SETTING_KEYS.reduce((patch, name) => {
			patch[PERFORMANCE_PREF_KEYS[name]] = config[name];
			return patch;
		}, { performancePreset: presetName });
	}

	function roundLayoutRatio(value) {
		return Math.round(Number(value) * 100) / 100;
	}

	function layoutRatioTotal(ratios) {
		return roundLayoutRatio(LAYOUT_REGION_KEYS.reduce((sum, name) => sum + Number(ratios[name] || 0), 0));
	}

	function normalizeLayoutRatios(source, fallback = READER_LAYOUT_DEFAULT) {
		const values = source || {};
		const result = LAYOUT_REGION_KEYS.reduce((ratios, name) => {
			const numeric = Number(values[name]);
			const safe = Number.isFinite(numeric) ? numeric : fallback[name];
			ratios[name] = roundLayoutRatio(Math.min(100, Math.max(LAYOUT_MIN_RATIOS[name], safe)));
			return ratios;
		}, {});
		const total = layoutRatioTotal(result);
		if (total <= 100) return result;
		const minimumTotal = LAYOUT_REGION_KEYS.reduce((sum, name) => sum + LAYOUT_MIN_RATIOS[name], 0);
		const extraBudget = 100 - minimumTotal;
		const extras = LAYOUT_REGION_KEYS.reduce((map, name) => {
			map[name] = Math.max(0, result[name] - LAYOUT_MIN_RATIOS[name]);
			return map;
		}, {});
		const extraTotal = LAYOUT_REGION_KEYS.reduce((sum, name) => sum + extras[name], 0) || 1;
		LAYOUT_REGION_KEYS.forEach((name) => {
			result[name] = roundLayoutRatio(LAYOUT_MIN_RATIOS[name] + extraBudget * extras[name] / extraTotal);
		});
		let overflow = roundLayoutRatio(layoutRatioTotal(result) - 100);
		if (overflow > 0) {
			const correctionKey = LAYOUT_REGION_KEYS.find((name) => result[name] - overflow >= LAYOUT_MIN_RATIOS[name]);
			if (correctionKey) result[correctionKey] = roundLayoutRatio(result[correctionKey] - overflow);
			overflow = roundLayoutRatio(layoutRatioTotal(result) - 100);
			if (overflow > 0) result.main = roundLayoutRatio(result.main - overflow);
		}
		return result;
	}

	function normalizeListReaderMode(value) {
		return LIST_READER_MODES.includes(value) ? value : 'floating';
	}

	function normalizeTopicReaderMode(value) {
		return TOPIC_READER_MODES.includes(value) ? value : 'fullpage';
	}

	function normalizeBoostCopyString(value, fallback = '') {
		const normalized = String(value == null ? fallback : value).replace(/\s+/g, ' ');
		return [...normalized].slice(0, BOOST_COPY_MAX_LENGTH).join('');
	}

	function boostCopyConfigFromPrefs(prefs) {
		const source = prefs || {};
		const mode = BOOST_COPY_MODES.includes(source.boostCopyMode)
			? source.boostCopyMode
			: BOOST_COPY_DEFAULTS.mode;
		const counterMarker = normalizeBoostCopyString(
			source.boostCopyCounterMarker,
			BOOST_COPY_DEFAULTS.counterMarker
		).trim() || BOOST_COPY_DEFAULTS.counterMarker;
		const counterStep = Number(source.boostCopyCounterStep);
		return {
			mode,
			prefix: normalizeBoostCopyString(source.boostCopyPrefix, BOOST_COPY_DEFAULTS.prefix),
			counterMarker,
			counterStep: Number.isFinite(counterStep)
				? Math.min(99, Math.max(1, Math.round(counterStep)))
				: BOOST_COPY_DEFAULTS.counterStep,
			fixedSuffix: normalizeBoostCopyString(source.boostCopyFixedSuffix, BOOST_COPY_DEFAULTS.fixedSuffix),
		};
	}

	function normalizeJumpHighlightValue(name, value) {
		const limits = JUMP_HIGHLIGHT_LIMITS[name];
		const fallback = JUMP_HIGHLIGHT_DEFAULTS[name];
		const numeric = Number(value);
		if (!limits || !Number.isFinite(numeric)) return fallback;
		const clamped = Math.min(limits.max, Math.max(limits.min, numeric));
		if (limits.integer) return Math.round(clamped);
		const stepped = Math.round(clamped / limits.step) * limits.step;
		return Math.round(stepped * 100) / 100;
	}

	function jumpHighlightConfigFromPrefs(prefs) {
		const source = prefs || {};
		const color = String(source.jumpHighlightColor || '').trim().toLowerCase();
		return {
			color: /^#[0-9a-f]{6}$/.test(color) ? color : JUMP_HIGHLIGHT_DEFAULTS.color,
			radius: normalizeJumpHighlightValue('radius', source.jumpHighlightRadius),
			borderWidth: normalizeJumpHighlightValue('borderWidth', source.jumpHighlightBorderWidth),
			rate: normalizeJumpHighlightValue('rate', source.jumpHighlightRate),
			count: normalizeJumpHighlightValue('count', source.jumpHighlightCount),
		};
	}

	function jumpHighlightPrefsPatch(config) {
		return Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => [field.pref, config[field.key]]));
	}

	function jumpHighlightDurationMs(config = jumpHighlightConfigFromPrefs(PREFS)) {
		return Math.round(1000 / config.rate);
	}

	function normalizeListReaderEmbedWidth(value) {
		const numeric = Number(value);
		if (!Number.isFinite(numeric)) return READER_EMBED_DEFAULT_WIDTH;
		return Math.max(READER_EMBED_MIN_WIDTH, Math.round(numeric));
	}

	function normalizeReaderWindowPreferenceGroup(prefs, prefix) {
		const width = Number(prefs[`${prefix}Width`]);
		const height = Number(prefs[`${prefix}Height`]);
		const x = Number(prefs[`${prefix}X`]);
		const y = Number(prefs[`${prefix}Y`]);
		return {
			[`${prefix}Width`]: Number.isFinite(width) && width > 0 ? Math.max(READER_WINDOW_MIN_WIDTH, Math.round(width)) : 0,
			[`${prefix}Height`]: Number.isFinite(height) && height > 0 ? Math.max(READER_WINDOW_MIN_HEIGHT, Math.round(height)) : 0,
			[`${prefix}X`]: Number.isFinite(x) && x > 0 ? Math.round(x) : 0,
			[`${prefix}Y`]: Number.isFinite(y) && y > 0 ? Math.round(y) : 0,
			[`${prefix}Locked`]: !!prefs[`${prefix}Locked`],
			[`${prefix}Pinned`]: !!prefs[`${prefix}Pinned`],
		};
	}

	function normalizeWindowGeometryPreferenceGroup(prefs, prefix) {
		const normalized = ['Width', 'Height', 'X', 'Y'].reduce((result, suffix) => {
			const value = Number(prefs[`${prefix}${suffix}`]);
			result[`${prefix}${suffix}`] = Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
			return result;
		}, {});
		return normalized;
	}

	function normalizeReaderThemeMode(value) {
		return READER_THEME_MODES.includes(value) ? value : 'system';
	}

	function normalizeReaderLoadingAnimation(value) {
		return value === 'random' || READER_LOADING_ANIMATION_KEYS.includes(value) ? value : 'random';
	}

	function normalizeBookmarkTabOrder(value) {
		const stored = Array.isArray(value)
			? [...new Set(value.filter((type) => BOOKMARK_TAB_TYPES.includes(type)))]
			: [];
		return [...stored, ...BOOKMARK_TAB_TYPES.filter((type) => !stored.includes(type))];
	}

	function normalizeReaderPrefs(prefs) {
		const knownPrefs = Object.keys(DEFAULT_PREFS).reduce((result, key) => {
			if (Object.hasOwn(prefs || {}, key)) result[key] = prefs[key];
			return result;
		}, {});
		const performanceConfig = performanceConfigFromPrefs(knownPrefs);
		const performancePrefs = Object.assign({}, DEFAULT_PREFS, knownPrefs, performancePrefsPatch(performanceConfig));
		const boostCopy = boostCopyConfigFromPrefs(performancePrefs);
		const jumpHighlight = jumpHighlightConfigFromPrefs(performancePrefs);
		const expandLeafNestedReplies = performancePrefs.expandLeafNestedReplies === true;
		const expandNestedRepliesByDefault = performancePrefs.expandNestedRepliesByDefault !== false ||
			!expandLeafNestedReplies;
		return Object.assign({}, performancePrefs, {
			imageProfile: normalizeImageProfile(performancePrefs.imageProfile, IMAGE_PROFILE_DEFAULT),
			lightboxOriginalByDefault: performancePrefs.lightboxOriginalByDefault === true,
			lightboxCommentsExpandedByDefault: performancePrefs.lightboxCommentsExpandedByDefault === true,
			lightboxDescriptionExpanded: performancePrefs.lightboxDescriptionExpanded === true,
			lightboxDescriptionHeight: normalizeLightboxDescriptionHeight(performancePrefs.lightboxDescriptionHeight),
			lightboxCommentsWidthPercent: normalizeLightboxCommentsWidth(performancePrefs.lightboxCommentsWidthPercent),
			fontProfile: normalizeFontProfile(performancePrefs.fontProfile, READER_FONT_DEFAULT),
			layoutProfile: normalizeLayoutRatios(performancePrefs.layoutProfile, READER_LAYOUT_DEFAULT),
			appearanceProfile: normalizeAppearanceProfile(performancePrefs.appearanceProfile, APPEARANCE_PROFILE_DEFAULT),
			themeMode: normalizeReaderThemeMode(performancePrefs.themeMode),
			fontRenderingEnabled: performancePrefs.fontRenderingEnabled !== false,
			fontRenderingOnHost: performancePrefs.fontRenderingOnHost === true,
			hostFontFamily: normalizeFontFamily(performancePrefs.hostFontFamily, HOST_FONT_DEFAULTS.hostFontFamily),
			hostFontCustomFamily: normalizeCustomFontFamily(performancePrefs.hostFontCustomFamily, HOST_FONT_DEFAULTS.hostFontCustomFamily),
			hostFontWeight: normalizeFontWeight(performancePrefs.hostFontWeight, HOST_FONT_DEFAULTS.hostFontWeight),
			hostFontColor: normalizeHexColor(performancePrefs.hostFontColor, HOST_FONT_DEFAULTS.hostFontColor),
			...Object.fromEntries(Object.values(HOST_EMBED_SIZE_SETTINGS).map((config) => [
				config.pref, normalizeHostEmbeddedSizeScale(performancePrefs[config.pref], config.defaultScale),
			])),
			...normalizeReaderWindowPreferenceGroup(performancePrefs, 'readerWindow'),
			...normalizeWindowGeometryPreferenceGroup(performancePrefs, COMPOSER_WINDOW_PREFIX),
			topicReaderMode: normalizeTopicReaderMode(performancePrefs.topicReaderMode),
			listReaderMode: normalizeListReaderMode(performancePrefs.listReaderMode),
			listReaderEmbedWidth: normalizeListReaderEmbedWidth(performancePrefs.listReaderEmbedWidth),
			historySortMode: performancePrefs.historySortMode === 'first-viewed' ? 'first-viewed' : 'recent-viewed',
			bookmarkTabOrder: normalizeBookmarkTabOrder(performancePrefs.bookmarkTabOrder),
			historyButtonsAlwaysVisible: performancePrefs.historyButtonsAlwaysVisible === true,
			historyEdgeTriggerPercent: normalizeHistoryEdgeTriggerPercent(performancePrefs.historyEdgeTriggerPercent),
			loadingAnimation: normalizeReaderLoadingAnimation(performancePrefs.loadingAnimation),
			openTopicsAtFirstPost: performancePrefs.openTopicsAtFirstPost === true,
			expandNestedRepliesByDefault,
			expandLeafNestedReplies,
			...jumpHighlightPrefsPatch(jumpHighlight),
			boostCopyMode: boostCopy.mode,
			boostCopyPrefix: boostCopy.prefix,
			boostCopyCounterMarker: boostCopy.counterMarker,
			boostCopyCounterStep: boostCopy.counterStep,
			boostCopyFixedSuffix: boostCopy.fixedSuffix,
		});
	}

	function readPrefs() {
		try {
			const stored = JSON.parse(localStorage.getItem(LDP_PREF_KEY) || '{}');
			const merged = Object.assign({}, DEFAULT_PREFS, stored);
			const storedPreset = Object.hasOwn(stored, 'performancePreset')
				? stored.performancePreset
				: '';
			if (PERFORMANCE_PRESETS[storedPreset]) {
				Object.assign(merged, performancePrefsPatch(PERFORMANCE_PRESETS[storedPreset], storedPreset));
			}
			return normalizeReaderPrefs(merged);
		} catch (e) {
			return normalizeReaderPrefs({ ...DEFAULT_PREFS });
		}
	}

	let PREFS = readPrefs();

	function syncReaderFontRenderingState() {
		applyHostEmbeddedSizeSettings();
		applyHostFontAppearance();
		const root = PAGE_ROOT;
		const external = root.hasAttribute(EXTERNAL_FONT_RENDERING_MARKER) &&
			getComputedStyle(root).getPropertyValue('--fr-render-text').trim() !== '';
		const mode = external ? 'external' : PREFS.fontRenderingEnabled ? 'builtin' : 'off';
		root.dataset.ldpFontRendering = mode;
		root.dataset.ldpFontRenderingHost = String(
			mode === 'builtin' && PREFS.fontRenderingOnHost === true
		);
		if (READER_PORTAL_HOST) READER_PORTAL_HOST.dataset.ldpFontRendering = mode;
		readerPortalQueryAll('.ldp-overlay,.ldp-native-boost-menu,.ldp-user-card-fallback,.ldp-lightbox')
			.forEach((surface) => { surface.dataset.ldpFontRendering = mode; });
		readerPortalQueryAll('.ldp-font-rendering-enabled').forEach((input) => {
			input.checked = PREFS.fontRenderingEnabled !== false;
			input.disabled = external;
			if (external) input.dataset.ldpTooltipLabel = '已由 F9y4ng 字体渲染工具接管';
			else delete input.dataset.ldpTooltipLabel;
		});
		readerPortalQueryAll('.ldp-font-rendering-on-host').forEach((input) => {
			input.checked = PREFS.fontRenderingOnHost === true;
			input.disabled = external || PREFS.fontRenderingEnabled === false;
			if (external) input.dataset.ldpTooltipLabel = '已由 F9y4ng 字体渲染工具接管';
			else delete input.dataset.ldpTooltipLabel;
		});
		readerPortalQueryAll('.ldp-font-rendering-status').forEach((status) => {
			status.dataset.mode = mode;
			status.textContent = external
				? '检测到 F9y4ng 字体渲染,内置效果已自动让位,避免双重描边与阴影。'
				: mode === 'builtin'
					? PREFS.fontRenderingOnHost
						? '内置渲染已作用于增强阅读器和 LINUX DO 宿主页面。'
						: '内置渲染已作用于增强阅读器;宿主页面保持原样。'
					: '内置字体渲染已关闭。';
		});
	}

	new MutationObserver((mutations) => {
		if (mutations.some((mutation) => mutation.attributeName === EXTERNAL_FONT_RENDERING_MARKER)) {
			syncReaderFontRenderingState();
			[50, 250, 1000].forEach((delay) => {
				setTimeout(syncReaderFontRenderingState, delay);
			});
		}
	}).observe(PAGE_ROOT, {
		attributes: true,
		attributeFilter: [EXTERNAL_FONT_RENDERING_MARKER],
	});
	window.addEventListener('load', syncReaderFontRenderingState, { once: true });
	syncReaderFontRenderingState();

	function setPrefs(patch) {
		PREFS = normalizeReaderPrefs(Object.assign({}, PREFS, patch));
		try { localStorage.setItem(LDP_PREF_KEY, JSON.stringify(PREFS)); } catch (e) {}
		if (Object.keys(patch).some((key) => /^(?:fontRendering|hostFont|hostEmbedded)/.test(key))) syncReaderFontRenderingState();
	}

	function replacePrefsAndReload(nextPrefs, failureMessage) {
		const normalized = normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, nextPrefs || {}));
		try {
			localStorage.setItem(LDP_PREF_KEY, JSON.stringify(normalized));
			PREFS = normalized;
			syncReaderFontRenderingState();
		} catch (e) {
			showSelectionToast(failureMessage);
			return;
		}
		location.reload();
	}

	const READER_CONFIG_SETTING_KEYS = Object.freeze(
		Object.keys(normalizeReaderPrefs({ ...DEFAULT_PREFS }))
	);

	function readerConfigExportPayload() {
		const normalized = normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, PREFS));
		const settings = READER_CONFIG_SETTING_KEYS.reduce((result, key) => {
			result[key] = normalized[key];
			return result;
		}, {});
		return {
			format: LDP_CONFIG_EXPORT_FORMAT,
			schemaVersion: LDP_CONFIG_EXPORT_VERSION,
			scriptVersion: READER_VERSION,
			exportedAt: new Date().toISOString(),
			settingsCount: Object.keys(settings).length,
			settings,
		};
	}

	function readerPrefsFromConfigExport(payload) {
		const schemaVersion = Number(payload?.schemaVersion);
		const settingKeys = READER_CONFIG_SETTING_KEYS;
		const payloadSettingKeys = payload?.settings && typeof payload.settings === 'object' &&
			!Array.isArray(payload.settings)
			? Object.keys(payload.settings)
			: [];
		if (!payload || payload.format !== LDP_CONFIG_EXPORT_FORMAT ||
			schemaVersion !== LDP_CONFIG_EXPORT_VERSION ||
			!payload.settings || typeof payload.settings !== 'object' || Array.isArray(payload.settings) ||
			Number(payload.settingsCount) !== payloadSettingKeys.length ||
			payloadSettingKeys.some((key) => !settingKeys.includes(key)) ||
			settingKeys.some((key) => !Object.hasOwn(payload.settings, key))) {
			throw new Error('invalid_config');
		}
		const importedSettings = settingKeys.reduce((settings, key) => {
			if (Object.hasOwn(payload.settings, key)) settings[key] = payload.settings[key];
			return settings;
		}, {});
		return normalizeReaderPrefs(Object.assign({}, DEFAULT_PREFS, importedSettings));
	}

	function downloadReaderConfigExport() {
		const blob = new window.Blob([`${JSON.stringify(readerConfigExportPayload(), null, 2)}\n`], { type: 'application/json' });
		triggerBrowserDownload(blob, `awesome-linuxdo-reader-settings-${new Date().toISOString().slice(0, 10)}.json`);
	}

	function setPref(key, value) {
		setPrefs({ [key]: value });
	}

	const READER_SYSTEM_THEME_QUERY = isFunction(window.matchMedia)
		? window.matchMedia('(prefers-color-scheme: dark)')
		: null;

	let linuxDoThemeEventService = null;
	let linuxDoThemeChangeInProgress = false;

	function linuxDoInterfaceColorService() {
		return lookupDiscourse('service:interface-color');
	}

	function linuxDoNativeThemeMode(service = linuxDoInterfaceColorService()) {
		if (service && service.colorMode === 'light') return 'light';
		if (service && service.colorMode === 'dark') return 'dark';
		if (service && service.colorMode === 'auto') return 'system';
		if (service?.lightModeForced) return 'light';
		if (service?.darkModeForced) return 'dark';
		if (service?.selectorAvailable) return 'system';
		if (service?.session) return service.session.defaultColorSchemeIsDark ? 'dark' : 'light';
		return '';
	}

	function readerUsesOwnThemePalette() {
		return !!CURRENT_OVERLAY;
	}

	function currentReaderThemeMode() {
		return readerUsesOwnThemePalette()
			? normalizeReaderThemeMode(PREFS.themeMode)
			: linuxDoNativeThemeMode() || normalizeReaderThemeMode(PREFS.themeMode);
	}

	function resolvedReaderThemeMode(mode = currentReaderThemeMode()) {
		const normalized = normalizeReaderThemeMode(mode);
		return normalized === 'system' && READER_SYSTEM_THEME_QUERY && READER_SYSTEM_THEME_QUERY.matches
			? 'dark'
			: normalized === 'dark' ? 'dark' : 'light';
	}

	function applyReaderThemeVariables(node, mode = currentReaderThemeMode(), ownPalette = readerUsesOwnThemePalette()) {
		if (!node || !node.style) return;
		const normalized = normalizeReaderThemeMode(mode);
		const resolved = resolvedReaderThemeMode(normalized);
		node.dataset.ldpThemeMode = normalized;
		node.dataset.ldpTheme = resolved;
		node.style.colorScheme = resolved;
		Object.entries(READER_THEME_VARIABLES[resolved]).forEach(([property, value]) => {
			if (ownPalette || property.startsWith('--ldp-')) node.style.setProperty(property, value);
			else node.style.removeProperty(property);
		});
		applyAppearanceInteractionColors(node, currentAppearanceProfile(), normalized);
	}

	function bindLinuxDoNativeThemeEvents(service = linuxDoInterfaceColorService()) {
		if (service && linuxDoThemeEventService !== service && isFunction(service.appEvents?.on)) {
			if (linuxDoThemeEventService && isFunction(linuxDoThemeEventService.appEvents?.off)) {
				linuxDoThemeEventService.appEvents.off('interface-color:changed', document, onLinuxDoInterfaceColorChanged);
			}
			service.appEvents.on('interface-color:changed', document, onLinuxDoInterfaceColorChanged);
			linuxDoThemeEventService = service;
		}
	}

	function applyLinuxDoNativeTheme(mode = currentReaderThemeMode()) {
		const service = linuxDoInterfaceColorService();
		bindLinuxDoNativeThemeEvents(service);
		const normalized = normalizeReaderThemeMode(mode);
		const actions = normalized === 'light'
			? ['forceLightMode']
			: normalized === 'dark' ? ['forceDarkMode'] : ['useAutoMode'];
		const action = actions.find((name) => isFunction(service?.[name]));
		if (!action) return false;
		linuxDoThemeChangeInProgress = true;
		try {
			service[action]();
			return true;
		} catch (error) {
			return false;
		} finally {
			linuxDoThemeChangeInProgress = false;
		}
	}

	function syncReaderThemeButtons(
		buttons = readerPortalQueryAll('.ldp-settings-theme-button'),
		mode = currentReaderThemeMode()
	) {
		const normalizedMode = normalizeReaderThemeMode(mode);
		buttons.forEach((button) => {
			const buttonMode = normalizeReaderThemeMode(button.dataset.readerThemeMode);
			const active = buttonMode === normalizedMode;
			button.classList.toggle('active', active);
			setPressed(button, active);
			setLabel(button, `主题:${READER_THEME_LABELS[buttonMode]}${active ? '(当前)' : ''}`);
		});
	}

	function syncReaderThemeSurfaces(themeMode = currentReaderThemeMode()) {
		const mode = normalizeReaderThemeMode(themeMode);
		const ownPalette = readerUsesOwnThemePalette();
		bindLinuxDoNativeThemeEvents();
		[...READER_PORTAL_HOST?.children || []].forEach((node) =>
			applyReaderThemeVariables(node, mode, ownPalette));
		if (CURRENT_OVERLAY && !CURRENT_OVERLAY.isConnected) applyReaderThemeVariables(CURRENT_OVERLAY, mode, ownPalette);
		document.querySelectorAll('[data-ldp-reader-composer-root]').forEach((node) => applyReaderThemeVariables(node, mode, ownPalette));
		syncReaderThemeButtons(undefined, mode);
		if (isFunction(CURRENT_OVERLAY?._ldpApplyThemeDependentColors)) {
			CURRENT_OVERLAY._ldpApplyThemeDependentColors();
		}
	}

	function onLinuxDoInterfaceColorChanged() {
		if (linuxDoThemeChangeInProgress) return;
		const mode = linuxDoNativeThemeMode();
		if (!mode) return;
		if (normalizeReaderThemeMode(PREFS.themeMode) !== mode) setPref('themeMode', mode);
		syncReaderThemeSurfaces(mode);
	}

	function transitionReaderTheme(mode, options = {}) {
		const nextMode = normalizeReaderThemeMode(mode);
		const persist = options.persist !== false;
		const applyHost = options.applyHost !== false;
		if (persist) setPref('themeMode', nextMode);
		if (applyHost) applyLinuxDoNativeTheme(nextMode);
		syncReaderThemeSurfaces(nextMode);
	}

	const onReaderSystemThemeChange = () => {
		if (currentReaderThemeMode() === 'system') {
			transitionReaderTheme('system', { persist: false, applyHost: false });
		}
	};
	READER_SYSTEM_THEME_QUERY?.addEventListener('change', onReaderSystemThemeChange);

	function balanceLayoutRatios(source, adjustable) {
		const result = { ...source };
		const difference = roundLayoutRatio(100 - layoutRatioTotal(result));
		const direction = difference > 0 ? 1 : -1;
		let remaining = Math.abs(difference);
		adjustable.forEach((name) => {
			if (remaining <= 0) return;
			const capacity = roundLayoutRatio(direction > 0
				? 100 - result[name]
				: Math.max(0, result[name] - LAYOUT_MIN_RATIOS[name]));
			const change = Math.min(capacity, remaining);
			result[name] = roundLayoutRatio(result[name] + direction * change);
			remaining = roundLayoutRatio(remaining - change);
		});
		return result;
	}

	function rebalanceLayoutRatiosAfterChange(source, editedName) {
		const preferred = ['main', 'left', 'right', 'gap', 'timeline'].filter((name) => name !== editedName);
		return balanceLayoutRatios(source, preferred);
	}

	function currentLayoutRatios() {
		return normalizeLayoutRatios(PREFS.layoutProfile, READER_LAYOUT_DEFAULT);
	}

	function currentFontProfile() {
		return normalizeFontProfile(PREFS.fontProfile, READER_FONT_DEFAULT);
	}

	function currentPerformanceConfig() {
		return performanceConfigFromPrefs(PREFS);
	}

	function setPerformancePreset(name) {
		const preset = PERFORMANCE_PRESETS[name];
		if (!preset) return;
		setPrefs(performancePrefsPatch(preset, name));
	}

	function setPerformanceValue(name, value) {
		const prefKey = PERFORMANCE_PREF_KEYS[name];
		if (!prefKey) return;
		const current = currentPerformanceConfig();
		const next = normalizePerformanceValue(name, value, current[name]);
		setPrefs({ [prefKey]: next });
	}

	function normalizeImageScalePreset(value) {
		if (value === 'custom') return 'custom';
		const numeric = Number(value);
		return IMAGE_SCALE_OPTIONS.includes(numeric) ? String(numeric) : '100';
	}

	function normalizeRoundedRange(value, fallback, minimum, maximum) {
		const numeric = Number(value);
		if (!Number.isFinite(numeric)) return fallback;
		return Math.min(maximum, Math.max(minimum, Math.round(numeric)));
	}

	function normalizeImageScalePercent(value, fallback = 100) { return normalizeRoundedRange(value, fallback, IMAGE_SCALE_MIN, IMAGE_SCALE_MAX); }

	function normalizeImageProfile(source, fallback = IMAGE_PROFILE_DEFAULT) {
		const values = source || {};
		return {
			preset: normalizeImageScalePreset(values.preset == null ? fallback.preset : values.preset),
			custom: normalizeImageScalePercent(values.custom, fallback.custom),
		};
	}

	function currentImageProfile() {
		return normalizeImageProfile(PREFS.imageProfile, IMAGE_PROFILE_DEFAULT);
	}

	function currentImageScalePercent() {
		const values = currentImageProfile();
		const preset = normalizeImageScalePreset(values.preset);
		if (preset === 'custom') return normalizeImageScalePercent(values.custom, 100);
		return normalizeImageScalePercent(preset, 100);
	}

	function normalizeFontScale(value, fallback) { return normalizeRoundedRange(value, fallback, FONT_SCALE_MIN, FONT_SCALE_MAX); }

	function normalizeHostEmbeddedSizeScale(value, fallback = HOST_EMBED_SIZE_DEFAULT) { return normalizeRoundedRange(value, fallback, HOST_EMBED_SIZE_MIN, HOST_EMBED_SIZE_MAX); }

	function applyHostEmbeddedSizeSettings() {
		const style = PAGE_ROOT.style;
		Object.values(HOST_EMBED_SIZE_SETTINGS).forEach((setting) => {
			setting.properties.forEach(([property, basePixels]) => {
				const pixels = Math.round(basePixels * normalizeHostEmbeddedSizeScale(PREFS[setting.pref]) * 10 / 100) / 10;
				style.setProperty(property, `${pixels}px`);
			});
		});
	}

	function applyHostFontAppearance() {
		const style = PAGE_ROOT.style;
		const family = normalizeFontFamily(PREFS.hostFontFamily, HOST_FONT_DEFAULTS.hostFontFamily);
		const color = normalizeHexColor(PREFS.hostFontColor, HOST_FONT_DEFAULTS.hostFontColor);
		if (family === 'site') style.removeProperty('--ldp-host-font-family');
		else style.setProperty('--ldp-host-font-family', resolvedFontFamily(PREFS, 'host'));
		style.setProperty('--ldp-host-font-weight', String(normalizeFontWeight(PREFS.hostFontWeight, HOST_FONT_DEFAULTS.hostFontWeight)));
		if (color) style.setProperty('--ldp-host-font-color', color);
		else style.removeProperty('--ldp-host-font-color');
	}

	function normalizeFontFamily(value, fallback = FONT_FAMILY_DEFAULT) {
		return Object.hasOwn(FONT_FAMILY_OPTIONS, value) ? value : fallback;
	}

	function normalizeCustomFontFamily(value, fallback = '') {
		const source = String(value == null ? fallback : value)
			.replace(/[\u0000-\u001f\u007f"'`,;{}<>\\]/g, '')
			.replace(/\s+/g, ' ')
			.trim();
		return [...source].slice(0, 64).join('');
	}

	function normalizeFontWeight(value, fallback = FONT_WEIGHT_DEFAULT) {
		const numeric = Number(value);
		return FONT_WEIGHT_OPTIONS.includes(numeric) ? numeric : fallback;
	}

	function normalizeHexColor(value, fallback = '') {
		const color = String(value || '').trim().toLowerCase();
		if (/^#[0-9a-f]{6}$/.test(color)) return color;
		const fallbackColor = String(fallback || '').trim().toLowerCase();
		return /^#[0-9a-f]{6}$/.test(fallbackColor) ? fallbackColor : '';
	}

	function resolvedSiteFontFamily() {
		try {
			const target = document.body || PAGE_ROOT;
			const family = target && getComputedStyle(target).fontFamily;
			return family && family !== 'inherit' ? family : 'inherit';
		} catch (e) {
			return 'inherit';
		}
	}

	function resolvedFontFamily(values, scope = 'interface') {
		const config = scope === 'host' ? HOST_FONT_CONFIG : FONT_SCOPE_CONFIG[scope] || FONT_SCOPE_CONFIG.interface;
		const family = normalizeFontFamily(values?.[config.family]);
		if (family !== 'custom') return family === 'site' ? resolvedSiteFontFamily() : FONT_FAMILY_OPTIONS[family].stack;
		const customFamily = normalizeCustomFontFamily(values?.[config.customFamily]);
		return customFamily
			? `${JSON.stringify(customFamily)},${FONT_FAMILY_OPTIONS.system.stack}`
			: FONT_FAMILY_OPTIONS.system.stack;
	}

	function applyReaderFontAppearance(styleDeclaration, values) {
		if (!styleDeclaration) return;
		FONT_SCOPE_KEYS.forEach((scope) => {
			const config = FONT_SCOPE_CONFIG[scope];
			const prefix = `--ldp-${scope}-font`;
			const color = normalizeHexColor(values?.[config.color]);
			styleDeclaration.setProperty(`${prefix}-family`, resolvedFontFamily(values, scope));
			styleDeclaration.setProperty(`${prefix}-weight`, String(normalizeFontWeight(values?.[config.weight])));
			if (color) styleDeclaration.setProperty(`${prefix}-color`, color);
			else styleDeclaration.removeProperty(`${prefix}-color`);
		});
		styleDeclaration.setProperty('--ldp-reader-font-weight-base', String(normalizeFontWeight(values?.weight)));
	}

	function clearReaderFontAppearance(styleDeclaration) {
		if (!styleDeclaration) return;
		FONT_SCOPE_KEYS.forEach((scope) => {
			const prefix = `--ldp-${scope}-font`;
			styleDeclaration.removeProperty(`${prefix}-family`);
			styleDeclaration.removeProperty(`${prefix}-weight`);
			styleDeclaration.removeProperty(`${prefix}-color`);
		});
		styleDeclaration.removeProperty('--ldp-reader-font-weight-base');
	}

	function normalizeFontProfile(source, fallback = READER_FONT_DEFAULT) {
		const values = source || {};
		const profile = {};
		const interfaceConfig = FONT_SCOPE_CONFIG.interface;
		FONT_SCOPE_KEYS.forEach((scope) => {
			const config = FONT_SCOPE_CONFIG[scope];
			const inherited = scope === 'interface' ? fallback : profile;
			profile[config.family] = normalizeFontFamily(values[config.family], inherited[interfaceConfig.family]);
			profile[config.customFamily] = normalizeCustomFontFamily(values[config.customFamily], inherited[interfaceConfig.customFamily]);
			profile[config.weight] = normalizeFontWeight(values[config.weight], inherited[interfaceConfig.weight]);
			profile[config.color] = normalizeHexColor(values[config.color], fallback[config.color]);
			profile[config.scale] = normalizeFontScale(values[config.scale], fallback[config.scale]);
		});
		return profile;
	}

	function fontScalePixels(basePixels, value, fallback = FONT_SCALE_DEFAULT) {
		const scale = normalizeFontScale(value, fallback) / 100;
		return `${Math.round(basePixels * scale * 100) / 100}px`;
	}

	function applyInterfaceFontScale(styleDeclaration, value) {
		if (!styleDeclaration) return;
		const scale = normalizeFontScale(value, FONT_SCALE_DEFAULT);
		Object.entries(INTERFACE_FONT_TOKEN_BASES).forEach(([property, basePixels]) => {
			styleDeclaration.setProperty(property, fontScalePixels(basePixels, scale));
		});
	}

	function clearInterfaceFontScale(styleDeclaration) {
		if (!styleDeclaration) return;
		Object.keys(INTERFACE_FONT_TOKEN_BASES).forEach((property) => styleDeclaration.removeProperty(property));
	}

	function normalizeSteppedValue(value, fallback, minimum, maximum, step) {
		const parsed = Number(value);
		const safeValue = Number.isFinite(parsed) ? parsed : fallback;
		return Math.min(maximum, Math.max(minimum, Math.round(safeValue / step) * step));
	}

	function appearanceDarkColorKey(key) {
		return `${key}Dark`;
	}

	function appearanceSettingValueFields(key) {
		return APPEARANCE_COLOR_KEYS.includes(key) ? [key, appearanceDarkColorKey(key)] : [key];
	}

	function hexColorToHsl(value) {
		const color = normalizeHexColor(value);
		if (!color) return null;
		const [red, green, blue] = [1, 3, 5].map((index) =>
			parseInt(color.slice(index, index + 2), 16) / 255);
		const maximum = Math.max(red, green, blue);
		const minimum = Math.min(red, green, blue);
		const delta = maximum - minimum;
		const lightness = (maximum + minimum) / 2;
		const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
		let hue = 0;
		if (delta !== 0) {
			if (maximum === red) hue = 60 * (((green - blue) / delta) % 6);
			else if (maximum === green) hue = 60 * ((blue - red) / delta + 2);
			else hue = 60 * ((red - green) / delta + 4);
		}
		return {
			hue: hue < 0 ? hue + 360 : hue,
			saturation: saturation * 100,
			lightness: lightness * 100,
		};
	}

	function hslColorToHex(hue, saturation, lightness) {
		const normalizedHue = ((Number(hue) % 360) + 360) % 360;
		const normalizedSaturation = Math.min(100, Math.max(0, Number(saturation))) / 100;
		const normalizedLightness = Math.min(100, Math.max(0, Number(lightness))) / 100;
		const chroma = (1 - Math.abs(2 * normalizedLightness - 1)) * normalizedSaturation;
		const intermediate = chroma * (1 - Math.abs((normalizedHue / 60) % 2 - 1));
		const offset = normalizedLightness - chroma / 2;
		const sector = Math.floor(normalizedHue / 60);
		const channels = [
			[chroma, intermediate, 0], [intermediate, chroma, 0], [0, chroma, intermediate],
			[0, intermediate, chroma], [intermediate, 0, chroma], [chroma, 0, intermediate],
		][sector] || [0, 0, 0];
		return `#${channels.map((channel) =>
			Math.round((channel + offset) * 255).toString(16).padStart(2, '0')).join('')}`;
	}

	function adaptAppearanceColorToTheme(key, value, mode) {
		const resolved = mode === 'dark' ? 'dark' : 'light';
		const valueKey = resolved === 'dark' ? appearanceDarkColorKey(key) : key;
		const normalized = normalizeHexColor(value, APPEARANCE_PROFILE_DEFAULT[valueKey]);
		if (normalized === APPEARANCE_PROFILE_DEFAULT[key]) {
			return APPEARANCE_PROFILE_DEFAULT[valueKey];
		}
		const limits = APPEARANCE_COLOR_THEME_LIMITS[key];
		const hsl = limits && hexColorToHsl(normalized);
		if (!hsl) return normalized;
		const [minimumLightness, maximumLightness] = limits[resolved];
		const saturation = Math.min(hsl.saturation, limits.saturationMax);
		const lightness = Math.min(maximumLightness, Math.max(minimumLightness, hsl.lightness));
		if (Math.abs(saturation - hsl.saturation) < 0.01 && Math.abs(lightness - hsl.lightness) < 0.01) {
			return normalized;
		}
		return hslColorToHex(hsl.hue, saturation, lightness);
	}

	function appearanceThemeValueKey(key, mode = resolvedReaderThemeMode()) {
		return APPEARANCE_COLOR_KEYS.includes(key) && mode === 'dark'
			? appearanceDarkColorKey(key)
			: key;
	}

	function appearanceThemeColor(values, key, mode = resolvedReaderThemeMode()) {
		return adaptAppearanceColorToTheme(key, values?.[key], mode);
	}

	function normalizeAppearanceProfile(source, fallback = APPEARANCE_PROFILE_DEFAULT) {
		const values = source || {};
		const profile = APPEARANCE_SETTING_FIELDS.reduce((result, field) => {
			if (field.range) {
				result[field.key] = field.normalize(values[field.key], fallback[field.key]);
				return result;
			}
			const lightFallback = normalizeHexColor(fallback[field.key], APPEARANCE_PROFILE_DEFAULT[field.key]);
			const storedLight = normalizeHexColor(values[field.key]);
			const darkKey = appearanceDarkColorKey(field.key);
			const storedDark = normalizeHexColor(values[darkKey]);
			const sourceColor = storedLight || storedDark || lightFallback;
			result[field.key] = sourceColor;
			result[darkKey] = sourceColor;
			return result;
		}, {});
		profile.structureColorsEnabled = typeof values.structureColorsEnabled === 'boolean'
			? values.structureColorsEnabled
			: fallback.structureColorsEnabled !== false;
		return profile;
	}

	function currentAppearanceProfile() {
		return normalizeAppearanceProfile(PREFS.appearanceProfile, APPEARANCE_PROFILE_DEFAULT);
	}

	function applyAppearanceInteractionColors(node, values = currentAppearanceProfile(), mode = currentReaderThemeMode()) {
		if (!node || !node.style) return;
		const appearance = values || APPEARANCE_PROFILE_DEFAULT;
		const resolved = resolvedReaderThemeMode(mode);
		const accent = appearanceThemeColor(appearance, 'accentColor', resolved);
		const accentDefault = APPEARANCE_PROFILE_DEFAULT[appearanceThemeValueKey('accentColor', resolved)];
		node.style.setProperty('--tertiary', accent);
		node.style.setProperty('--tertiary-low', accent === accentDefault
			? READER_THEME_VARIABLES[resolved]['--tertiary-low']
			: `color-mix(in srgb,${accent} 18%,var(--secondary,#fff))`);
		node.style.setProperty('--d-link-color', appearanceThemeColor(appearance, 'linkColor', resolved));
	}

	function normalizeLineWidth(value, fallback) {
		return normalizeSteppedValue(value, fallback, LINE_WIDTH_MIN, LINE_WIDTH_MAX, LINE_WIDTH_STEP);
	}

	function normalizeReplyLineRadius(value, fallback = REPLY_LINE_RADIUS_DEFAULT) {
		return normalizeSteppedValue(
			value, fallback, REPLY_LINE_RADIUS_MIN, REPLY_LINE_RADIUS_MAX, REPLY_LINE_RADIUS_STEP
		);
	}

	function applyComposerFontScale(value = currentFontProfile().composer) {
		const scale = normalizeFontScale(value, COMPOSER_FONT_SCALE_DEFAULT);
		PAGE_ROOT.style.setProperty('--ldp-composer-font-size', `${scale}%`);
	}
	applyComposerFontScale();

	function setPageScrollLocked(locked) {
		PAGE_ROOT.classList.toggle(LDP_SCROLL_LOCK_CLASS, locked);
	}

	function currentListReaderMode() {
		return normalizeListReaderMode(PREFS.listReaderMode);
	}

	function currentTopicReaderMode() {
		return normalizeTopicReaderMode(PREFS.topicReaderMode);
	}

	function currentListReaderEmbedWidth() {
		return normalizeListReaderEmbedWidth(PREFS.listReaderEmbedWidth);
	}

	function readerPresentation(mode) {
		return READER_PRESENTATIONS[mode] || READER_PRESENTATIONS.floating;
	}

	function chromeExtensionRuntime() {
		try {
			if (typeof chrome === 'undefined' || !chrome.runtime || !chrome.runtime.id ||
				!isFunction(chrome.runtime.sendMessage)) return null;
			return chrome.runtime;
		} catch (e) {
			return null;
		}
	}

	async function sendChromeExtensionMessage(message) {
		const runtime = chromeExtensionRuntime();
		if (!runtime) return { ok: false, reason: 'extension-unavailable' };
		try {
			const response = await runtime.sendMessage(message);
			return response && typeof response === 'object'
				? response
				: { ok: false, reason: 'empty-response' };
		} catch (error) {
			return {
				ok: false,
				reason: error?.message ? error.message : 'extension-message-failed',
			};
		}
	}

	function setChromeSidePanelActive(active) {
		PAGE_ROOT.classList.toggle('ldp-reader-sidepanel-active', !!active);
		PAGE_ROOT.classList.toggle('ldp-reader-workspace', !!active);
	}

	async function closeChromeSidePanel() {
		const result = await sendChromeExtensionMessage({ type: 'ldp-sidepanel-close' });
		setChromeSidePanelActive(false);
		return result || { ok: false, reason: 'sidepanel-close-failed' };
	}

	async function closeChromeSidePanelForReader() {
		const result = await closeChromeSidePanel();
		if (!chromeExtensionRuntime() || result.ok) return true;
		showSelectionToast('请先关闭 Chrome 侧边栏,再打开浮窗阅读器');
		return false;
	}

	function installChromeSidePanelBridge() {
		const runtime = chromeExtensionRuntime();
		if (!runtime || !runtime.onMessage || !isFunction(runtime.onMessage.addListener)) return;
		runtime.onMessage.addListener((message) => {
			if (!message || message.type !== 'ldp-sidepanel-state') return;
			setChromeSidePanelActive(!!message.active);
		});
	}
	installChromeSidePanelBridge();

	function userProfileUrl(username) {
		const discourseUrl = lookupDiscourseModule('discourse/lib/url');
		return isFunction(discourseUrl?.userPath)
			? discourseUrl.userPath(encodeURIComponent(username || ''))
			: '';
	}

	function userActivityUrl(username, path) {
		const suffix = String(path || '').replace(/^\/+/, '');
		const discourseUrl = lookupDiscourseModule('discourse/lib/url');
		const userPath = encodeURIComponent(username || '');
		return isFunction(discourseUrl?.userPath)
			? discourseUrl.userPath(suffix ? `${userPath}/${suffix}` : userPath)
			: '';
	}

	function discoursePageUrl(path) {
		const getUrlModule = lookupDiscourseModule('discourse/lib/get-url');
		const getURL = getUrlModule?.default;
		if (!isFunction(getURL)) return '';
		const resolved = getURL(String(path || ''));
		return resolved ? new URL(resolved, location.origin).href : '';
	}

	function topicPageUrl(topicId, postNumber = 0, bypassReader = false) {
		const safeTopicId = Math.max(0, Number(topicId) || 0);
		const safePostNumber = Math.max(0, Number(postNumber) || 0);
		if (!safeTopicId) return '';
		const href = discoursePageUrl(`/t/${safeTopicId}${safePostNumber ? `/${safePostNumber}` : ''}`);
		if (!href || !bypassReader) return href;
		const url = new URL(href);
		url.searchParams.set(NATIVE_BYPASS_PARAM, '1');
		return url.href;
	}

	function topicNavText(value) {
		if (value == null) return '';
		if (typeof value === 'object') return String(value.name || value.slug || value.id || '');
		return String(value);
	}

	function cleanTopicCategoryName(value) {
		return topicNavText(value)
			.replace(/\s*[,,]\s*Lv\s*\d+\s*$/i, '')
			.replace(/\s+/g, ' ')
			.trim();
	}

	function safeTopicNavIconHtml(value) {
		const html = String(value || '').trim();
		if (!/^<(?:svg|img)\b/i.test(html)) return '';
		if (/<(?:script|foreignObject)\b/i.test(html) || /\son[a-z:-]*\s*=/i.test(html) ||
			/(?:href|src)\s*=\s*["']?\s*javascript:/i.test(html)) return '';
		return html;
	}

	function topicNavIconCacheKeys(type, value) {
		if (type === 'tag') {
			const name = topicNavText(value && typeof value === 'object' ? value.name : value).trim().toLocaleLowerCase();
			return name ? [`tag:${name}`] : [];
		}
		const metadata = value && typeof value === 'object' ? value : { categoryName: value };
		const keys = [];
		const categoryId = Number(metadata.categoryId || metadata.category_id) || 0;
		const categorySlug = topicNavText(metadata.categorySlug || metadata.category_slug).trim().toLocaleLowerCase();
		const categoryName = cleanTopicCategoryName(metadata.categoryName || metadata.category_name).toLocaleLowerCase();
		if (categoryId) keys.push(`category:id:${categoryId}`);
		if (categorySlug) keys.push(`category:slug:${categorySlug}`);
		if (categoryName) keys.push(`category:name:${categoryName}`);
		return keys;
	}

	function rememberTopicNavIcon(type, value, iconHtml) {
		const html = safeTopicNavIconHtml(iconHtml);
		const keys = topicNavIconCacheKeys(type, value);
		if (!html || !keys.length) return '';
		let changed = false;
		keys.forEach((key) => {
			const existing = TOPIC_NAV_ICON_CACHE.get(key);
			TOPIC_NAV_ICON_CACHE.delete(key);
			if (existing && existing.html === html) {
				TOPIC_NAV_ICON_CACHE.set(key, existing);
				return;
			}
			TOPIC_NAV_ICON_CACHE.set(key, { html, at: Date.now() });
			changed = true;
		});
		if (changed) {
			trimPersistentCacheMap('topicNavIcons');
			schedulePersistentCacheWrite('topicNavIcons', 500);
		}
		return html;
	}

	function cachedTopicNavIcon(type, value) {
		const keys = topicNavIconCacheKeys(type, value);
		for (const key of keys) {
			const entry = TOPIC_NAV_ICON_CACHE.get(key);
			if (!entry) continue;
			const html = safeTopicNavIconHtml(entry.html);
			if (!html) {
				TOPIC_NAV_ICON_CACHE.delete(key);
				schedulePersistentCacheWrite('topicNavIcons');
				continue;
			}
			TOPIC_NAV_ICON_CACHE.delete(key);
			TOPIC_NAV_ICON_CACHE.set(key, entry);
			return html;
		}
		return '';
	}

	function topicNavMetadataWithCachedIcons(metadata = {}) {
		const tagIcons = new Map();
		(Array.isArray(metadata.tagIcons) ? metadata.tagIcons : []).forEach((item) => {
			const name = topicNavText(item?.name).trim();
			const iconHtml = safeTopicNavIconHtml(item?.iconHtml);
			if (name && iconHtml) tagIcons.set(name, { name, iconHtml });
		});
		(Array.isArray(metadata.tags) ? metadata.tags : []).map(topicNavText).map((tag) => tag.trim()).filter(Boolean)
			.forEach((name) => {
				if (tagIcons.has(name)) return;
				const iconHtml = cachedTopicNavIcon('tag', name);
				if (iconHtml) tagIcons.set(name, { name, iconHtml });
			});
		return Object.assign({}, metadata, {
			categoryIconHtml: safeTopicNavIconHtml(metadata.categoryIconHtml) || cachedTopicNavIcon('category', metadata),
			tagIcons: [...tagIcons.values()],
		});
	}

	function cacheTopicNavMetadataIcons(metadata = {}) {
		rememberTopicNavIcon('category', metadata, metadata.categoryIconHtml);
		(Array.isArray(metadata.tagIcons) ? metadata.tagIcons : []).forEach((item) => {
			rememberTopicNavIcon('tag', item?.name, item?.iconHtml);
		});
	}

	function topicCategoryLevel(value) {
		const match = topicNavText(value).trim().match(/(?:^|[,,]\s*)Lv\s*(\d+)\s*$/i);
		return match ? `Lv${match[1]}` : '';
	}

	function topicNavIconHtml(link) {
		if (!link) return '';
		return [...link.querySelectorAll('svg,img')]
			.filter((node) => node.closest('a') === link)
			.map((node) => {
				const copy = node.cloneNode(true);
				copy.querySelectorAll('script,foreignObject').forEach((unsafe) => unsafe.remove());
				[copy, ...copy.querySelectorAll('*')].forEach((part) => {
					[...part.attributes || []].forEach((attribute) => {
						if (/^on/i.test(attribute.name)) part.removeAttribute(attribute.name);
					});
				});
				copy.setAttribute('aria-hidden', 'true');
				if (copy.tagName === 'IMG') copy.setAttribute('alt', '');
				return copy.outerHTML;
			})
			.join('');
	}

	function topicCategoryLinkMetadata(link) {
		if (!link) return {};
		let categoryId = Number(link.dataset.categoryId || 0) || 0;
		let categorySlug = String(link.dataset.categorySlug || '').trim();
		try {
			const url = new URL(link.getAttribute('href') || link.href || '', location.href);
			const parts = url.pathname.split('/').filter(Boolean);
			const categoryIndex = parts.indexOf('c');
			const categoryParts = categoryIndex >= 0 ? parts.slice(categoryIndex + 1) : [];
			const idIndex = categoryParts.findIndex((part) => /^\d+$/.test(part));
			if (!categoryId && idIndex >= 0) categoryId = Number(categoryParts[idIndex]) || 0;
			if (!categorySlug && categoryParts.length) {
				const slugPart = categoryParts.find((part, index) => index !== idIndex && !/^\d+$/.test(part));
				if (slugPart) categorySlug = decodeURIComponent(slugPart);
			}
		} catch (e) {}
		const nameNode = link.querySelector('[data-category-name],.badge-category__name,.category-name');
		const categoryText = (nameNode && (nameNode.dataset.categoryName || nameNode.textContent)) ||
			link.dataset.categoryName || link.getAttribute('title') || link.textContent || '';
		const categoryName = cleanTopicCategoryName(categoryText);
		const categoryLevel = topicCategoryLevel(categoryText) || topicCategoryLevel(link.textContent) ||
			topicCategoryLevel(link.getAttribute('title'));
		return { categoryName, categoryLevel, categorySlug, categoryId, categoryIconHtml: topicNavIconHtml(link) };
	}

	function readTopicNavMetadata(sourceElement = null, expectedTopicId = 0) {
		const source = sourceElement && sourceElement.nodeType === Node.ELEMENT_NODE ? sourceElement : null;
		let root = null;
		if (source && !source.closest('.ldp-overlay')) {
			root = source.closest('tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result,.category-topic-link')
				|| source.parentElement;
		}
		if (!root) {
			const currentRoute = extractTopicRouteFromUrl(location.href);
			if (expectedTopicId && (!currentRoute || Number(currentRoute.topicId) !== Number(expectedTopicId))) return {};
			root = [...document.querySelectorAll('#topic-title,.topic-title,.title-wrapper')]
				.find((node) => !node.closest('.ldp-overlay')) || null;
		}
		if (!root) return {};
		const categoryLink = root.querySelector(
			'a.badge-category__wrapper,a.badge-category,a.topic-category,a[href^="/c/"],a[href*="/c/"]',
		);
		const metadata = topicCategoryLinkMetadata(categoryLink);
		const tagLinks = [...root.querySelectorAll('a.discourse-tag,.discourse-tags a,.topic-tags a')]
			.filter((node) => !node.closest('.ldp-overlay'));
		metadata.tags = tagLinks
			.map((node) => topicNavText(node.dataset.tagName || node.textContent).trim())
			.filter(Boolean);
		metadata.tagIcons = tagLinks.map((node) => ({
			name: topicNavText(node.dataset.tagName || node.textContent).trim(),
			iconHtml: topicNavIconHtml(node),
		})).filter((item) => item.name && item.iconHtml);
		cacheTopicNavMetadataIcons(metadata);
		return topicNavMetadataWithCachedIcons(metadata);
	}

	function mergeTopicNavMetadata(primary = {}, fallback = {}) {
		const primaryCategoryName = cleanTopicCategoryName(primary.categoryName || '');
		const fallbackCategoryName = cleanTopicCategoryName(fallback.categoryName || '');
		const sameCategory = !primaryCategoryName || !fallbackCategoryName || primaryCategoryName === fallbackCategoryName;
		const tagIcons = new Map();
		[fallback.tagIcons, primary.tagIcons].forEach((items) => {
			(Array.isArray(items) ? items : []).forEach((item) => {
				const name = topicNavText(item?.name).trim();
				const iconHtml = String(item?.iconHtml || '').trim();
				if (name && iconHtml) tagIcons.set(name, { name, iconHtml });
			});
		});
		const primaryTags = Array.isArray(primary.tags) ? primary.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean) : [];
		const fallbackTags = Array.isArray(fallback.tags) ? fallback.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean) : [];
		return topicNavMetadataWithCachedIcons({
			categoryName: primaryCategoryName || fallbackCategoryName,
			categoryLevel: topicCategoryLevel(primary.categoryLevel || fallback.categoryLevel || ''),
			categorySlug: topicNavText(primary.categorySlug || fallback.categorySlug || '').trim(),
			categoryId: Number(primary.categoryId || fallback.categoryId) || 0,
			categoryIconHtml: String(primary.categoryIconHtml || (sameCategory ? fallback.categoryIconHtml : '') || '').trim(),
			tags: primaryTags.length ? primaryTags : fallbackTags,
			tagIcons: [...tagIcons.values()],
		});
	}

	function topicNavMetadataHasIcons(metadata) {
		return !!(metadata && (metadata.categoryIconHtml || (Array.isArray(metadata.tagIcons) && metadata.tagIcons.length)));
	}

	function cachedTopicNavMetadata(topic) {
		if (!topic) return {};
		const category = topic.category || {};
		const categoryName = cleanTopicCategoryName(
			topic.category_name || topic.categoryName || category.name || topic.category_slug || topic.categorySlug || category.slug,
		);
		const categoryLevel = [topic.category_level, topic.categoryLevel, topic.category_name, topic.categoryName, category.name]
			.map(topicCategoryLevel).find(Boolean) || '';
		const categorySlug = topicNavText(topic.category_slug || topic.categorySlug || category.slug).trim();
		const categoryId = Number(topic.category_id || topic.categoryId || category.id) || 0;
		const tags = (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).map((tag) => tag.trim()).filter(Boolean);
		const wantedTags = new Set(tags);
		let cached = {};
		const collect = (candidate) => {
			if (!candidate || typeof candidate !== 'object') return;
			const matchingCategory = categoryName && cleanTopicCategoryName(candidate.categoryName) === categoryName;
			cached = mergeTopicNavMetadata(cached, {
				categoryName,
				categoryLevel,
				categorySlug,
				categoryId,
				categoryIconHtml: matchingCategory ? candidate.categoryIconHtml : '',
				tags,
				tagIcons: (Array.isArray(candidate.tagIcons) ? candidate.tagIcons : [])
					.filter((item) => wantedTags.has(topicNavText(item?.name).trim())),
			});
		};
		collect(topic._ldpTopicNavMetadata);
		TOPIC_DATA_CACHE.forEach((entry) => collect(entry?.topic && entry.topic._ldpTopicNavMetadata));
		return mergeTopicNavMetadata({ categoryName, categoryLevel, categorySlug, categoryId, tags }, cached);
	}

	function rememberTopicNavMetadata(topicId, topic, metadata) {
		if (!topic || !topicNavMetadataHasIcons(metadata)) return false;
		cacheTopicNavMetadataIcons(metadata);
		const cacheable = cachedTopicNavMetadata(topic);
		const next = mergeTopicNavMetadata(metadata, cacheable);
		if (!topicNavMetadataHasIcons(next)) return false;
		const previous = topic._ldpTopicNavMetadata || {};
		if (JSON.stringify(previous) === JSON.stringify(next)) return false;
		topic._ldpTopicNavMetadata = next;
		setCachedTopicData(topicId, topic);
		return true;
	}

	function fillTopicNavMetadata(topic, fallback = {}) {
		if (!topic) return false;
		const category = topic.category || {};
		const existingId = Number(topic.category_id || topic.categoryId || discourseModelValue(category, 'id')) || 0;
		const categoryName = cleanTopicCategoryName(
			topic.category_name || topic.categoryName || discourseModelValue(category, 'name') ||
			fallback.categoryName || '',
		);
		const categoryLevel = [
			topic.category_level,
			topic.categoryLevel,
			topic.category_name,
			topic.categoryName,
			discourseModelValue(category, 'name'),
			fallback.categoryLevel,
		].map(topicCategoryLevel).find(Boolean) || '';
		const categorySlug = topicNavText(
			topic.category_slug || topic.categorySlug || discourseModelValue(category, 'slug') ||
			fallback.categorySlug || '',
		).trim();
		const categoryId = existingId || Number(fallback.categoryId) || 0;
		let changed = false;
		if (categoryName && topic.category_name !== categoryName) {
			topic.category_name = categoryName;
			changed = true;
		}
		if (categoryLevel && topic.category_level !== categoryLevel) {
			topic.category_level = categoryLevel;
			changed = true;
		}
		if (categorySlug && topic.category_slug !== categorySlug) {
			topic.category_slug = categorySlug;
			changed = true;
		}
		if (categoryId && Number(topic.category_id) !== categoryId) {
			topic.category_id = categoryId;
			changed = true;
		}
		if ((!Array.isArray(topic.tags) || !topic.tags.length) && Array.isArray(fallback.tags) && fallback.tags.length) {
			topic.tags = [...new Set(fallback.tags.map(topicNavText).map((tag) => tag.trim()).filter(Boolean))];
			changed = true;
		}
		return changed;
	}

	function renderTopicNavLinks(topic, metadata = {}) {
		const items = [];
		const category = topic.category || {};
		const categoryName = cleanTopicCategoryName(topic.category_name || topic.categoryName || category.name || topic.category_slug || topic.categorySlug || category.slug);
		const categoryLevel = [topic.category_level, topic.categoryLevel, topic.category_name, topic.categoryName, category.name]
			.map(topicCategoryLevel).find(Boolean) || '';
		const categoryLabel = categoryName && categoryLevel ? `${categoryName}, ${categoryLevel}` : categoryName;
		const categorySlug = topicNavText(topic.category_slug || topic.categorySlug || category.slug);
		const categoryId = topicNavText(topic.category_id || topic.categoryId || category.id);
		const navMetadata = topicNavMetadataWithCachedIcons(Object.assign({}, metadata, {
			categoryName,
			categorySlug,
			categoryId,
			tags: (Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).filter(Boolean),
		}));
		const discourseUrl = lookupDiscourseModule('discourse/lib/url');
		const site = lookupDiscourse('service:site');
		const nativeCategory = [...discourseModelValue(site, 'categories') || []]
			.find((candidate) => Number(discourseModelValue(candidate, 'id')) === Number(categoryId));
		const categoryHref = isFunction(discourseUrl?.getCategoryAndTagUrl) &&
			nativeCategory
			? discourseUrl.getCategoryAndTagUrl(nativeCategory, true)
			: '';
		if (categoryLabel) {
			const categoryIconHtml = navMetadata.categoryIconHtml || '';
			const categoryTextHtml = `<span class="ldp-topic-category-text"><span class="ldp-topic-category-name">${esc(categoryName || categoryLabel)}</span>${categoryLevel ? `<span class="ldp-topic-category-level">, ${esc(categoryLevel)}</span>` : ''}</span>`;
			items.push(categoryHref
				? `<a class="ldp-topic-tag ldp-topic-category" href="${categoryHref}" aria-label="${escAttr(categoryLabel)}">${categoryIconHtml}${categoryTextHtml}</a>`
				: `<span class="ldp-topic-tag ldp-topic-category" aria-label="${escAttr(categoryLabel)}">${categoryIconHtml}${categoryTextHtml}</span>`);
		}
		(Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).filter(Boolean).forEach((tag) => {
			const tagIcon = (Array.isArray(navMetadata.tagIcons) ? navMetadata.tagIcons : [])
				.find((item) => item && item.name === tag);
			const tagHref = isFunction(discourseUrl?.getCategoryAndTagUrl)
				? discourseUrl.getCategoryAndTagUrl(null, true, tag)
				: '';
			const tagContent = `${tagIcon ? tagIcon.iconHtml : ''}${esc(tag)}`;
			items.push(tagHref
				? `<a class="ldp-topic-tag ldp-topic-label" href="${escAttr(tagHref)}" aria-label="${escAttr(tag)}">${tagContent}</a>`
				: `<span class="ldp-topic-tag ldp-topic-label" aria-label="${escAttr(tag)}">${tagContent}</span>`);
		});
		return items.join('');
	}

	function avatarUrl(template, size) {
		const avatarUtils = lookupDiscourseModule('discourse/lib/avatar-utils');
		return isFunction(avatarUtils?.avatarUrl)
			? avatarUtils.avatarUrl(template, size || 48)
			: '';
	}

	function persistentAvatarHtml(template, size, className, fallbackText = '?') {
		const source = avatarUrl(template, size);
		if (!source) return '';
		const cachedSource = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source);
		if (cachedSource || source.startsWith('data:') || source.startsWith('blob:')) {
			return `<img class="${escAttr(className)}" src="${escAttr(cachedSource || source)}" alt="" loading="lazy" decoding="async">`;
		}
		return `<span class="${escAttr(className)} ldp-persistent-avatar-fallback" data-ldp-avatar-template="${escAttr(template)}" data-ldp-avatar-size="${Math.max(1, Number(size) || 48)}" data-ldp-avatar-class="${escAttr(className)}">${esc(String(fallbackText || '?').slice(0, 1).toUpperCase())}</span>`;
	}

	function safeFlairColor(value) {
		const color = String(value || '').trim();
		if (!/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?(?:[0-9a-f]{2})?$/i.test(color)) return '';
		return color.startsWith('#') ? color : `#${color}`;
	}

	function userFlairHtml(user) {
		const rawUrl = String(user && (
			user.flair_url || user.flairUrl || user.avatar_flair_url || user.avatarFlairUrl
		) || '').trim();
		if (!rawUrl) return '';
		const label = String(user && (
			user.flair_name || user.flairName || user.primary_group_name || user.primaryGroupName
		) || '资质').trim();
		const background = safeFlairColor(user && (user.flair_bg_color || user.flairBgColor));
		const color = safeFlairColor(user && (user.flair_color || user.flairColor));
		const style = [
			background ? `--ldp-flair-bg:${background}` : '',
			color ? `--ldp-flair-color:${color}` : '',
		].filter(Boolean).join(';');
		const styleAttr = style ? ` style="${style}"` : '';
		if (/^(?:https?:)?\/\//i.test(rawUrl) || rawUrl.startsWith('/') || rawUrl.startsWith('data:image/')) {
			const source = absoluteImageUrl(rawUrl);
			return `<span class="ldp-avatar-flair" data-ldp-tooltip-label="${escAttr(label)}" aria-label="${escAttr(label)}"${styleAttr}><img class="ldp-avatar-flair-image" src="${escAttr(source)}" alt="" loading="lazy" decoding="async"></span>`;
		}
		const iconId = rawUrl.replace(/^fa-/, '');
		if (!/^[a-z0-9_-]+$/i.test(iconId)) return '';
		return `<span class="ldp-avatar-flair" data-ldp-tooltip-label="${escAttr(label)}" aria-label="${escAttr(label)}"${styleAttr}><svg class="ldp-avatar-flair-icon" aria-hidden="true"><use href="#${escAttr(iconId)}"></use></svg></span>`;
	}

	function avatarWithFlairHtml(avatarHtml, user) {
		if (!avatarHtml) return '';
		return `<span class="ldp-avatar-with-flair">${avatarHtml}${userFlairHtml(user)}</span>`;
	}

	function userCardAvatarHtml(avatarHtml, username) {
		const cleanUsername = cleanUsernameValue(username);
		if (!avatarHtml || !cleanUsername) return avatarHtml;
		return `<span class="ldp-user-avatar-card" data-user-card="${escAttr(cleanUsername)}">${avatarHtml}</span>`;
	}

	async function waitForReaderRequestPermit(priority, options = {}) {
		const queuedAt = requestFlowNow();
		const callSite = requestFlowCallSite();
		let scheduler = options.surviveReaderSwitch === true ? null : ACTIVE_READER_REQUEST_SCHEDULER;
		if (!scheduler) return { queuedAt, permittedAt: queuedAt, waitReason: '', callSite, scheduler };
		let untrackController = options.controller && scheduler !== options.trackedScheduler &&
				isFunction(scheduler.trackExternalController)
			? scheduler.trackExternalController(options.controller)
			: () => {};
		for (let attempt = 0; attempt < 2; attempt++) {
			try {
				const permit = await scheduler.waitForPermit(priority, options);
				return {
					queuedAt,
					permittedAt: Number(permit?.startedAt) || requestFlowNow(),
					waitReason: String(permit?.waitReason || ''),
					callSite,
					recoveryProbe: permit && permit.recoveryProbe === true,
					scheduler,
					release: () => {
						if (isFunction(permit?.release)) permit.release();
						untrackController();
					},
				};
			} catch (error) {
				const replacementScheduler = ACTIVE_READER_REQUEST_SCHEDULER;
				if (attempt > 0 || options.signal?.aborted || !error || error.name !== 'AbortError' ||
						!replacementScheduler || replacementScheduler === scheduler) {
					untrackController();
					throw error;
				}
				untrackController();
				scheduler = replacementScheduler;
				untrackController = options.controller && isFunction(scheduler.trackExternalController)
					? scheduler.trackExternalController(options.controller)
					: () => {};
			}
		}
	}

	function persistentResourceResponseIsFresh(
		response,
		maxAge,
		at = Date.now(),
		cachedAt = Number(response && response.headers.get('x-ldp-cached-at')) || 0
	) {
		return !!response && cachedAt > 0 && at - cachedAt <= maxAge;
	}

	function createPersistentResourceResponse(blob, contentType = '') {
		const headers = {
			'content-type': blob.type || contentType || 'application/octet-stream',
			'content-length': String(blob.size),
		};
		headers['x-ldp-cached-at'] = String(Date.now());
		return new Response(blob, { headers });
	}

	async function persistentResourceCacheEntry(cacheName, source, maxAge) {
		const storage = await caches.open(cacheName);
		const response = await storage.match(source);
		if (response && !persistentResourceResponseIsFresh(response, maxAge)) {
			await storage.delete(source);
			return { storage, response: null };
		}
		return { storage, response: response || null };
	}

	async function prunePersistentResourceCache(cacheName, maxAge) {
		try {
			const storage = await caches.open(cacheName);
			const now = Date.now();
			const requests = await storage.keys();
			const entries = [];
			await runInBatches(requests, RESOURCE_CACHE_PRUNE_BATCH_SIZE, async (request) => {
				const response = await storage.match(request);
				const cachedAt = Number(response && response.headers.get('x-ldp-cached-at')) || 0;
				if (!persistentResourceResponseIsFresh(response, maxAge, now, cachedAt)) {
					await storage.delete(request);
					return;
				}
				entries.push({
					request,
					cachedAt,
					bytes: Math.max(0, Number(response.headers.get('content-length')) || 0),
				});
			});
			const group = RESOURCE_CACHE_GROUPS.find((entry) => entry.cacheName === cacheName);
			const maxEntries = Math.max(0, Number(group?.maxEntries) || 0);
			const maxBytes = Math.max(0, Number(group?.maxBytes) || 0);
			if (!maxEntries && !maxBytes) return;
			let keptEntries = 0;
			let keptBytes = 0;
			const removeRequests = [];
			entries.sort((left, right) => right.cachedAt - left.cachedAt).forEach((entry) => {
				const exceedsEntries = maxEntries > 0 && keptEntries >= maxEntries;
				const exceedsBytes = maxBytes > 0 && keptBytes + entry.bytes > maxBytes;
				if (exceedsEntries || exceedsBytes) {
					removeRequests.push(entry.request);
					return;
				}
				keptEntries++;
				keptBytes += entry.bytes;
			});
			await runInBatches(removeRequests, RESOURCE_CACHE_PRUNE_BATCH_SIZE,
				(request) => storage.delete(request));
		} catch {
			// Persistent resource cleanup is best-effort and must not block the reader.
		}
	}

	function prunePersistentResourceCacheOnce(cacheName, maxAge) {
		if (typeof caches === 'undefined') return Promise.resolve();
		let request = RESOURCE_CACHE_PRUNE_REQUESTS.get(cacheName);
		if (!request) {
			request = prunePersistentResourceCache(cacheName, maxAge);
			RESOURCE_CACHE_PRUNE_REQUESTS.set(cacheName, request);
		}
		return request;
	}

	function cacheResourceObjectUrl(cache, source, objectUrl, maxEntries) {
		const previousUrl = cache.get(source);
		if (previousUrl) {
			URL.revokeObjectURL(previousUrl);
			cache.delete(source);
		}
		while (cache.size >= maxEntries) {
			const oldestSource = cache.keys().next().value;
			const oldestUrl = cache.get(oldestSource);
			if (oldestUrl) URL.revokeObjectURL(oldestUrl);
			cache.delete(oldestSource);
		}
		cache.set(source, objectUrl);
		return objectUrl;
	}

	function touchResourceObjectUrl(cache, source) {
		const objectUrl = cache.get(source);
		if (!objectUrl) return '';
		cache.delete(source);
		cache.set(source, objectUrl);
		return objectUrl;
	}

	function clearResourceObjectUrlCache(cache) {
		cache.forEach((objectUrl) => {
			if (objectUrl) URL.revokeObjectURL(objectUrl);
		});
		cache.clear();
	}

	function trackSingleFlightRequest(requests, key, request) {
		const trackedRequest = request.finally(() => {
			if (requests.get(key) === trackedRequest) requests.delete(key);
		});
		requests.set(key, trackedRequest);
		return trackedRequest;
	}

	function trackResourceCacheWrite(request) {
		const trackedRequest = Promise.resolve(request).catch(() => {}).finally(() => {
			RESOURCE_CACHE_WRITES.delete(trackedRequest);
		});
		RESOURCE_CACHE_WRITES.add(trackedRequest);
		return trackedRequest;
	}

	async function fetchReaderPublicResource(source, priority, type, signal) {
		const flowPermit = await waitForReaderRequestPermit(priority, {
			countsTowardApi: false, type, signal,
		});
		if (!flowPermit) return null;
		const { release, ...requestFlowMeta } = flowPermit;
		try {
			const response = await trackedReaderFetch(source, {
				// Public assets may redirect to a wildcard-CORS CDN, which is incompatible with credentials.
				credentials: 'omit',
				cache: 'force-cache',
				signal,
				[REQUEST_FLOW_FETCH_META]: { source: 'reader', priority, type, ...requestFlowMeta },
			});
			return { response, release: isFunction(release) ? release : () => {} };
		} catch (error) {
			if (isFunction(release)) release();
			throw error;
		}
	}

	async function avatarResponseBlob(response, onProgress) {
		if (!isFunction(onProgress) || !response.body || !isFunction(response.body.getReader)) {
			const blob = await response.blob();
			if (isFunction(onProgress)) onProgress(blob.size, blob.size);
			return blob;
		}
		const total = Math.max(0, Number(response.headers.get('content-length')) || 0);
		const reader = response.body.getReader();
		const chunks = [];
		let loaded = 0;
		onProgress(0, total);
		while (true) {
			const { done, value } = await reader.read();
			if (done) break;
			if (!value || !value.byteLength) continue;
			chunks.push(value);
			loaded += value.byteLength;
			onProgress(loaded, total);
		}
		const bytes = new Uint8Array(loaded);
		let offset = 0;
		chunks.forEach((chunk) => {
			bytes.set(chunk, offset);
			offset += chunk.byteLength;
		});
		const blob = await new Response(bytes, {
			headers: { 'content-type': response.headers.get('content-type') || 'application/octet-stream' },
		}).blob();
		onProgress(loaded, total || loaded);
		return blob;
	}

	async function persistentAvatarSource(template, size = 64, priority = POST_REQUEST_PRIORITY.visible, options = {}) {
		const source = avatarUrl(template, size);
		const onProgress = isFunction(options.onProgress) ? options.onProgress : null;
		const completeProgress = () => onProgress && onProgress(1, 1);
		if (!source || source.startsWith('data:') || source.startsWith('blob:')) {
			if (source) completeProgress();
			return source;
		}
		if (RESOURCE_CACHE_CLEARING) return source;
		const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
		const cachedObjectUrl = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source);
		if (cachedObjectUrl) {
			completeProgress();
			return cachedObjectUrl;
		}
		const pendingRequest = AVATAR_RESOURCE_REQUESTS.get(source);
		if (pendingRequest) {
			const sharedSource = await pendingRequest;
			if (sharedSource) completeProgress();
			return sharedSource;
		}
		const request = (async () => {
			if (typeof caches === 'undefined') return source;
			try {
				let { storage, response } = await persistentResourceCacheEntry(
					LDP_AVATAR_CACHE_NAME, source, LDP_AVATAR_CACHE_MAX_AGE
				);
				let blob = null;
				if (!response) {
					const resource = await fetchReaderPublicResource(
						new URL(source, location.href).href, priority, 'avatar', resourceCacheSignal
					);
					if (!resource) return '';
					try {
						const fetched = resource.response;
						// Keep uncached avatar starts in the reader cadence. If Cache Storage
						// cannot fill them, let the browser load the URL normally.
						if (!fetched.ok) return source;
						if (options.rejectBlankAvatar) {
							const contentType = fetched.headers.get('content-type') || '';
							const lastModified = Date.parse(fetched.headers.get('last-modified') || '');
							const isDiscourseBlank = Number.isFinite(lastModified) && new Date(lastModified).getUTCFullYear() === 1990;
							if ((contentType && !contentType.startsWith('image/')) || isDiscourseBlank) return '';
						}
						blob = await avatarResponseBlob(fetched, onProgress);
						if (!blob.size || resourceCacheSignal.aborted) return source;
						response = createPersistentResourceResponse(blob, fetched.headers.get('content-type'));
						void trackResourceCacheWrite(storage.put(source, response.clone()));
					} finally {
						resource.release();
					}
				}
				if (!blob) blob = await avatarResponseBlob(response, onProgress);
				if (!blob.size || resourceCacheSignal.aborted) return source;
				return cacheResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 256);
			} catch (error) {
				return source;
			}
		})();
		return trackSingleFlightRequest(AVATAR_RESOURCE_REQUESTS, source, request);
	}

	function replacePersistentAvatarPlaceholder(placeholder, source) {
		if (!source || !placeholder || !placeholder.isConnected) return;
		const image = makeElement('img');
		image.className = placeholder.dataset.ldpAvatarClass ||
			(placeholder.classList.contains('ldp-notification-avatar') ? 'ldp-notification-avatar' : 'ldp-avatar');
		image.src = source;
		image.alt = '';
		image.loading = 'lazy';
		image.decoding = 'async';
		image.dataset.ldpAvatarOriginalTemplate = placeholder.dataset.ldpAvatarTemplate || '';
		replaceOwnedNode(placeholder, image);
	}

	function hydratePersistentAvatarImages(container, priority = POST_REQUEST_PRIORITY.visible) {
		if (!container) return;
		container.querySelectorAll('[data-ldp-avatar-template]').forEach((placeholder) => {
			if (!placeholder.isConnected || AVATAR_HYDRATION_PENDING.has(placeholder)) return;
			const template = placeholder.dataset.ldpAvatarTemplate;
			if (!template) return;
			AVATAR_HYDRATION_PENDING.add(placeholder);
			persistentAvatarSource(template, Number(placeholder.dataset.ldpAvatarSize) || 64, priority)
				.then((source) => replacePersistentAvatarPlaceholder(placeholder, source))
				.finally(() => AVATAR_HYDRATION_PENDING.delete(placeholder));
		});
	}

	function releasePersistentAvatarImages(container) {
		if (!container) return;
		const placeholders = new Set(container.querySelectorAll('[data-ldp-avatar-template]'));
		if (container.matches && container.matches('[data-ldp-avatar-template]')) placeholders.add(container);
		placeholders.forEach((placeholder) => {
			AVATAR_HYDRATION_PENDING.delete(placeholder);
		});
	}

	function userAvatarDownloadFilename(source, username, kind = 'avatar') {
		let extension = 'png';
		try {
			const match = new URL(source, location.href).pathname.match(/\.([a-z0-9]{2,5})$/i);
			if (match && /^(?:png|apng|jpe?g|gif|webp|avif|svg|bmp|ico)$/i.test(match[1])) extension = match[1].toLowerCase();
		} catch (error) {
			void error;
		}
		const basename = String(username || 'user').replace(/[^a-z0-9._-]+/gi, '-') || 'user';
		return `${basename}-${kind === 'background' ? 'background' : 'avatar'}.${extension === 'jpeg' ? 'jpg' : extension}`;
	}

	function createImageTransformController(stage, image, options = {}) {
		const captureTarget = options.captureTarget || stage;
		const transformScope = createLifecycleScope();
		let scale = 1;
		let panX = 0;
		let panY = 0;
		let containedPan = false;
		let pointerId = null;
		let dragX = 0;
		let dragY = 0;
		let pendingPanX = 0;
		let pendingPanY = 0;
		let dragFrame = 0;

		const clampPan = () => {
			if (!image.clientWidth || !image.clientHeight) {
				panX = 0;
				panY = 0;
				return;
			}
			const padding = Math.max(0, Number(options.overflowPadding) || 0);
			const scaledWidth = image.clientWidth * scale;
			const scaledHeight = image.clientHeight * scale;
			const maxX = scaledWidth > stage.clientWidth
				? (scaledWidth - stage.clientWidth) / 2 + padding
				: containedPan ? Math.max(0, (stage.clientWidth - scaledWidth) / 2 - padding) : 0;
			const maxY = scaledHeight > stage.clientHeight
				? (scaledHeight - stage.clientHeight) / 2 + padding
				: containedPan ? Math.max(0, (stage.clientHeight - scaledHeight) / 2 - padding) : 0;
			panX = Math.max(-maxX, Math.min(maxX, panX));
			panY = Math.max(-maxY, Math.min(maxY, panY));
		};
		const render = () => {
			clampPan();
			options.render({ scale, panX, panY });
			image.classList.toggle('is-zoomed', scale > 1.01);
			if (options.zoomValue) options.zoomValue.textContent = `${Math.round(scale * 100)}%`;
			if (options.zoomOutButton) options.zoomOutButton.disabled = scale <= 0.25;
			if (options.zoomInButton) options.zoomInButton.disabled = scale >= 8;
		};
		const setZoom = (value, clientX = null, clientY = null) => {
			const nextScale = Math.max(0.25, Math.min(8, Number(value) || 1));
			const anchored = Number.isFinite(clientX) && Number.isFinite(clientY) && image.clientWidth && image.clientHeight;
			if (anchored && nextScale !== scale) {
				const imageRect = image.getBoundingClientRect();
				const scaleRatio = nextScale / scale;
				panX += (clientX - (imageRect.left + imageRect.width / 2)) * (1 - scaleRatio);
				panY += (clientY - (imageRect.top + imageRect.height / 2)) * (1 - scaleRatio);
			}
			containedPan = options.allowContainedPan === true && anchored;
			scale = nextScale;
			if (options.resetPanAtFit !== false && scale <= 1.01) panX = panY = 0;
			render();
		};
		const reset = () => {
			scale = 1;
			panX = panY = 0;
			containedPan = false;
			render();
		};
		const flushDrag = () => {
			dragFrame = 0;
			panX = pendingPanX;
			panY = pendingPanY;
			render();
		};
		const onPointerDown = (event) => {
			if (scale <= 1.01 || event.button !== 0 || event.target !== image) return;
			pointerId = event.pointerId;
			dragX = event.clientX - panX;
			dragY = event.clientY - panY;
			pendingPanX = panX;
			pendingPanY = panY;
			captureTarget.setPointerCapture(pointerId);
			image.classList.add('is-dragging');
			if (options.preventDragDefault) event.preventDefault();
		};
		const onPointerMove = (event) => {
			if (pointerId !== event.pointerId) return;
			pendingPanX = event.clientX - dragX;
			pendingPanY = event.clientY - dragY;
			if (!dragFrame) dragFrame = requestAnimationFrame(flushDrag);
		};
		const onPointerEnd = (event) => {
			if (pointerId !== event.pointerId) return;
			if (dragFrame) {
				cancelAnimationFrame(dragFrame);
				flushDrag();
			}
			if (captureTarget.hasPointerCapture(pointerId)) captureTarget.releasePointerCapture(pointerId);
			pointerId = null;
			image.classList.remove('is-dragging');
		};
		transformScope.listen(captureTarget, 'pointerdown', onPointerDown);
		transformScope.listen(captureTarget, 'pointermove', onPointerMove);
		transformScope.listen(captureTarget, 'pointerup', onPointerEnd);
		transformScope.listen(captureTarget, 'pointercancel', onPointerEnd);
		return {
			get scale() { return scale; },
			render,
			setZoom,
			reset,
			destroy() {
				if (dragFrame) cancelAnimationFrame(dragFrame);
				transformScope.destroy();
			},
		};
	}

	function handleImageTransformShortcut(event, controller, reset = controller.reset) {
		if (event.key === '+' || event.key === '=') {
			controller.setZoom(controller.scale * 1.2);
		} else if (event.key === '-') {
			controller.setZoom(controller.scale / 1.2);
		} else if (event.key === '0') {
			reset();
		} else return false;
		event.preventDefault();
		return true;
	}

	function positionUserAvatarViewer(viewer, anchor) {
		if (!viewer || !viewer.isConnected) return;
		const margin = 10;
		const gap = 10;
		const rect = anchor?.isConnected ? anchor.getBoundingClientRect() : null;
		const imageViewerSize = Math.min(480, window.innerWidth - margin * 2, window.innerHeight - margin * 2);
		const width = viewer.offsetWidth || (viewer.classList.contains('is-image') ? imageViewerSize : 320);
		const height = viewer.offsetHeight || (viewer.classList.contains('is-image') ? imageViewerSize : 358);
		const batchDialog = viewer.classList.contains('is-image') && anchor && anchor.closest
			? anchor.closest('.ldp-lb-batch-dialog')
			: null;
		if (batchDialog) {
			batchDialog.style.removeProperty('transform');
			const batchRect = batchDialog.getBoundingClientRect();
			const combinedWidth = batchRect.width + gap + width;
			if (combinedWidth <= window.innerWidth - margin * 2) {
				const groupLeft = Math.max(margin, Math.round((window.innerWidth - combinedWidth) / 2));
				batchDialog.style.transform = `translateX(${Math.round(groupLeft - batchRect.left)}px)`;
				viewer.style.left = `${Math.round(groupLeft + batchRect.width + gap)}px`;
				viewer.style.top = `${Math.round(batchRect.top)}px`;
				viewer.style.height = `${Math.round(batchRect.height)}px`;
				viewer._ldpBatchDialog = batchDialog;
				return;
			}
		}
		viewer.style.removeProperty('height');
		let left = Math.max(margin, Math.round((window.innerWidth - width) / 2));
		let top = Math.max(margin, Math.round((window.innerHeight - height) / 2));
		if (rect) {
			const rightSide = rect.right + gap;
			const leftSide = rect.left - width - gap;
			if (rightSide + width <= window.innerWidth - margin) left = rightSide;
			else if (leftSide >= margin) left = leftSide;
			else left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
			top = Math.max(margin, Math.min(rect.top, window.innerHeight - height - margin));
		}
		viewer.style.left = `${Math.round(left)}px`;
		viewer.style.top = `${Math.round(top)}px`;
	}

	function openUserAvatarViewer(template, username, anchorCard, previewSource = '', onDismiss = null, user = null, options = {}) {
		const isBackground = options.kind === 'background';
		const isImage = options.kind === 'image';
		const highResolutionSource = isBackground || isImage ? String(options.source || '').trim() : avatarUrl(template, 1000);
		const loadImageOriginal = isImage && options.loadOriginal === true && Boolean(highResolutionSource);
		if (!highResolutionSource && !previewSource) return null;
		const currentViewer = readerPortalQuery('.ldp-avatar-viewer');
		if (currentViewer && isImage && options.reuseImageViewer === true && currentViewer._ldpKind === 'image' &&
			isFunction(currentViewer._ldpUpdateImage)) {
			currentViewer._ldpUpdateImage({ template, previewSource, highResolutionSource, options });
			return currentViewer;
		}
		if (currentViewer && currentViewer._ldpUsername === username && currentViewer._ldpTemplate === template &&
			currentViewer._ldpKind === (isBackground ? 'background' : isImage ? 'image' : 'avatar')) {
			positionUserAvatarViewer(currentViewer, anchorCard);
			return currentViewer;
		}
		if (isFunction(currentViewer?._ldpClose)) currentViewer._ldpClose();

		const viewer = makeElement('div');
		const viewerScope = createLifecycleScope();
		const mediaLabel = isBackground ? '背景图' : isImage ? '图片' : '头像';
		const accessibleName = username ? `${username} 的${mediaLabel}预览` : `${mediaLabel}预览`;
		viewer.className = `ldp-avatar-viewer${isBackground ? ' is-background' : isImage ? ' is-image' : ''}`;
		viewer.setAttribute('role', 'dialog');
		setLabel(viewer, accessibleName);
		viewer.innerHTML = `
			<div class="ldp-avatar-viewer-toolbar" role="toolbar" aria-label="${mediaLabel}工具">
				${isImage && options.selectable ? `<label class="ldp-avatar-viewer-selection"><input type="checkbox"${options.selected ? ' checked' : ''}><span>${esc(options.selectionLabel || '选择当前图片')}</span></label>` : ''}
				<div class="ldp-avatar-viewer-progress is-indeterminate" role="progressbar" aria-label="${mediaLabel}原图加载进度" aria-valuemin="0" aria-valuemax="100" aria-valuetext="正在加载${mediaLabel}原图"${highResolutionSource && !isBackground && (!isImage || loadImageOriginal) ? '' : ' hidden'}>
					<span class="ldp-avatar-viewer-progress-track" aria-hidden="true"><span class="ldp-avatar-viewer-progress-fill"></span></span>
					<span class="ldp-avatar-viewer-progress-value">原图加载中</span>
				</div>
				${isImage ? `${iconActionButtons('ldp-lb-btn', 'data-avatar-viewer-action', [['zoom-out', '缩小(-)', 'minus']])}
				<button class="ldp-lb-btn ldp-avatar-viewer-zoom-value" type="button" data-avatar-viewer-action="zoom-reset" aria-label="恢复 100%">100%</button>
				${iconActionButtons('ldp-lb-btn', 'data-avatar-viewer-action', [['zoom-in', '放大(+)', 'plus']])}` : ''}
				${iconActionButtons('ldp-lb-btn', 'data-avatar-viewer-action', [
					['download', `下载当前${mediaLabel}`, 'download', ' disabled'],
					['close', `关闭${mediaLabel}预览(Esc)`, 'x'],
				])}
			</div>
			<div class="ldp-avatar-viewer-stage">
				${isImage && isFunction(options.onPrevious) ? `<button class="ldp-avatar-viewer-nav ldp-avatar-viewer-prev" type="button" data-avatar-viewer-action="previous" aria-label="上一张(←)"${options.previousDisabled ? ' disabled' : ''}>${icon('chevronRight')}</button>` : ''}
				<img class="ldp-avatar-viewer-image" alt="${escAttr(accessibleName)}" draggable="false" decoding="async" hidden>
				${isImage && isFunction(options.onNext) ? `<button class="ldp-avatar-viewer-nav ldp-avatar-viewer-next" type="button" data-avatar-viewer-action="next" aria-label="下一张(→)"${options.nextDisabled ? ' disabled' : ''}>${icon('chevronRight')}</button>` : ''}
				${isBackground || isImage ? '' : userFlairHtml(user)}
				<div class="ldp-avatar-viewer-status" role="status" aria-live="polite">正在加载${mediaLabel}…</div>
			</div>`;
		const image = viewer.querySelector('.ldp-avatar-viewer-image');
		const stage = viewer.querySelector('.ldp-avatar-viewer-stage');
		const status = viewer.querySelector('.ldp-avatar-viewer-status');
		const progress = viewer.querySelector('.ldp-avatar-viewer-progress');
		const progressValue = viewer.querySelector('.ldp-avatar-viewer-progress-value');
		const downloadButton = viewer.querySelector('[data-avatar-viewer-action="download"]');
		const selectionInput = viewer.querySelector('.ldp-avatar-viewer-selection input');
		const selectionLabel = viewer.querySelector('.ldp-avatar-viewer-selection span');
		const previousButton = viewer.querySelector('[data-avatar-viewer-action="previous"]');
		const nextButton = viewer.querySelector('[data-avatar-viewer-action="next"]');
		const zoomValue = viewer.querySelector('.ldp-avatar-viewer-zoom-value');
		const zoomOutButton = viewer.querySelector('[data-avatar-viewer-action="zoom-out"]');
		const zoomInButton = viewer.querySelector('[data-avatar-viewer-action="zoom-in"]');
		let activeOptions = options;
		let activePreviewSource = previewSource;
		let activeHighResolutionSource = highResolutionSource;
		let imageSourceToken = 0;
		let displayedSource = '';
		let loadingHighResolution = false;
		let highResolutionPending = Boolean(highResolutionSource) && !isBackground && (!isImage || loadImageOriginal);
		let closed = false;
		const imageTransform = isImage ? createImageTransformController(stage, image, {
			captureTarget: image,
			preventDragDefault: true,
			zoomValue,
			zoomOutButton,
			zoomInButton,
			render: ({ scale, panX, panY }) => {
				image.style.transform = `translate3d(${Math.round(panX)}px,${Math.round(panY)}px,0) scale(${scale})`;
			},
		}) : null;

		const close = (dismissed = false) => {
			if (closed) return;
			closed = true;
			viewerScope.destroy();
			if (imageTransform) imageTransform.destroy();
			if (viewer._ldpBatchDialog) viewer._ldpBatchDialog.style.removeProperty('transform');
			viewer.remove();
			if (dismissed && isFunction(onDismiss)) onDismiss();
		};
		const download = () => {
			if (isFunction(activeOptions.onDownload)) {
				void activeOptions.onDownload();
				return;
			}
			const link = makeElement('a');
			link.href = displayedSource;
			link.download = userAvatarDownloadFilename(activeHighResolutionSource || activePreviewSource, username, isBackground ? 'background' : 'avatar');
			link.target = '_blank';
			link.rel = 'noopener';
			appendReaderPortalNode(link);
			link.click();
			link.remove();
		};
		function onKey(event) {
			if (!viewer.isConnected) return;
			if (event.key === 'Escape') {
				event.preventDefault();
				event.stopImmediatePropagation();
				close(true);
			} else if (isImage && event.key === 'ArrowLeft' && isFunction(activeOptions.onPrevious) && !activeOptions.previousDisabled) {
				event.preventDefault();
				event.stopImmediatePropagation();
				activeOptions.onPrevious();
			} else if (isImage && event.key === 'ArrowRight' && isFunction(activeOptions.onNext) && !activeOptions.nextDisabled) {
				event.preventDefault();
				event.stopImmediatePropagation();
				activeOptions.onNext();
			} else if (isImage && handleImageTransformShortcut(event, imageTransform)) {
				event.stopImmediatePropagation();
			}
		}
		function onOutside(event) {
			if (eventPathIncludes(event, viewer) || eventPathIncludes(event, anchorCard)) return;
			const batchDialog = viewer._ldpBatchDialog || (anchorCard?.closest && anchorCard.closest('.ldp-lb-batch-dialog'));
			if (isImage && activeOptions.reuseImageViewer === true && batchDialog && eventPathIncludes(event, batchDialog)) return;
			close(true);
		}
		function onViewerWheel(event) {
			if (!viewer.isConnected || !isImage || !eventPathIncludes(event, viewer)) return;
			event.preventDefault();
			event.stopImmediatePropagation();
			const zoomAtPointer = event.target === image;
			imageTransform.setZoom(
				imageTransform.scale * (event.deltaY < 0 ? 1.15 : 1 / 1.15),
				zoomAtPointer ? event.clientX : null,
				zoomAtPointer ? event.clientY : null
			);
		}
		function reposition() {
			positionUserAvatarViewer(viewer, anchorCard);
		}
		function updateProgress(loaded, total) {
			if (closed || !highResolutionPending) return;
			const hasTotal = Number.isFinite(total) && total > 0;
			const percent = hasTotal ? Math.max(0, Math.min(100, Math.round((loaded / total) * 100))) : null;
			progress.hidden = false;
			progress.classList.toggle('is-indeterminate', percent === null);
			if (percent === null) {
				progress.style.removeProperty('--ldp-avatar-progress');
				progress.removeAttribute('aria-valuenow');
				progress.setAttribute('aria-valuetext', `正在加载${mediaLabel}原图`);
				progressValue.textContent = `${mediaLabel}原图加载中`;
				return;
			}
			progress.style.setProperty('--ldp-avatar-progress', `${percent}%`);
			progress.setAttribute('aria-valuenow', String(percent));
			progress.setAttribute('aria-valuetext', `${mediaLabel}原图已加载 ${percent}%`);
			progressValue.textContent = `原图 ${percent}%`;
		}
		function finishProgress() {
			highResolutionPending = false;
			progress.hidden = true;
		}
		const loadViewerImageSources = (nextPreviewSource, nextHighResolutionSource, shouldLoadOriginal) => {
			const token = ++imageSourceToken;
			activePreviewSource = String(nextPreviewSource || '');
			activeHighResolutionSource = String(nextHighResolutionSource || '');
			loadingHighResolution = false;
			highResolutionPending = Boolean(activeHighResolutionSource) && shouldLoadOriginal === true;
			progress.hidden = !highResolutionPending;
			progress.classList.add('is-indeterminate');
			progress.removeAttribute('aria-valuenow');
			progressValue.textContent = '原图加载中';
			displayedSource = '';
			downloadButton.disabled = true;
			status.hidden = Boolean(activePreviewSource);
			if (activePreviewSource) image.src = activePreviewSource;
			imageTransform.reset();
			if (!highResolutionPending) return;
			void persistentLightboxImageSource(activeHighResolutionSource).then((source) => {
				if (closed || token !== imageSourceToken) return;
				if (!source || source === activeHighResolutionSource) {
					finishProgress();
					return;
				}
				loadingHighResolution = true;
				image.src = source;
			}).catch(() => {
				if (!closed && token === imageSourceToken) finishProgress();
			});
		};

		viewer._ldpClose = close;
		viewer._ldpUsername = username;
		viewer._ldpTemplate = template;
		viewer._ldpKind = isBackground ? 'background' : isImage ? 'image' : 'avatar';
		viewer._ldpAnchorCard = anchorCard;
		viewer._ldpUpdateImage = (next) => {
			activeOptions = next.options || {};
			viewer._ldpTemplate = next.template;
			if (selectionInput) selectionInput.checked = activeOptions.selected === true;
			if (selectionLabel) selectionLabel.textContent = activeOptions.selectionLabel || '选择当前图片';
			if (previousButton) previousButton.disabled = activeOptions.previousDisabled === true;
			if (nextButton) nextButton.disabled = activeOptions.nextDisabled === true;
			loadViewerImageSources(next.previewSource, next.highResolutionSource, activeOptions.loadOriginal === true);
		};
		viewer.addEventListener('click', (event) => {
			const action = event.target.closest && event.target.closest('[data-avatar-viewer-action]')?.dataset.avatarViewerAction;
			if (action === 'download') download();
			else if (action === 'close') close(true);
			else if (action === 'previous' && isFunction(activeOptions.onPrevious)) activeOptions.onPrevious();
			else if (action === 'next' && isFunction(activeOptions.onNext)) activeOptions.onNext();
			else if (action === 'zoom-out') imageTransform.setZoom(imageTransform.scale / 1.2);
			else if (action === 'zoom-in') imageTransform.setZoom(imageTransform.scale * 1.2);
			else if (action === 'zoom-reset') imageTransform.reset();
		});
		if (selectionInput) selectionInput.addEventListener('change', () => {
			if (isFunction(activeOptions.onSelectionChange)) activeOptions.onSelectionChange(selectionInput.checked);
		});
		image.addEventListener('load', () => {
			if (closed) return;
			const completedHighResolution = loadingHighResolution;
			displayedSource = image.currentSrc || image.src;
			loadingHighResolution = false;
			if (completedHighResolution) finishProgress();
			image.hidden = false;
			status.hidden = true;
			downloadButton.disabled = false;
			if (!isImage) reposition();
		});
		image.addEventListener('error', () => {
			if (closed) return;
			if (loadingHighResolution && activePreviewSource) {
				loadingHighResolution = false;
				finishProgress();
				image.src = activePreviewSource;
				return;
			}
			if (loadingHighResolution) finishProgress();
			if (highResolutionPending) {
				image.hidden = true;
				status.hidden = false;
				status.textContent = `正在加载${mediaLabel}原图…`;
				return;
			}
			image.hidden = true;
			status.hidden = false;
			status.textContent = `未获取到可用${mediaLabel}`;
			downloadButton.disabled = true;
		});
		if (isImage) {
			stage.addEventListener('dblclick', (event) => {
				if (event.target !== image) return;
				imageTransform.setZoom(
					imageTransform.scale > 1.05 ? 1 : Math.max(2, Math.min(8, image.naturalWidth / Math.max(1, image.clientWidth))),
					event.clientX,
					event.clientY
				);
			});
		}
		viewerScope.listen(document, 'keydown', onKey);
		appendReaderPortalNode(viewer);
		reposition();
		if (imageTransform) imageTransform.render();
		viewerScope.listen(document, 'pointerdown', onOutside, true);
		if (isImage) viewerScope.listen(
			document, 'wheel', onViewerWheel, { capture: true, passive: false }
		);
		viewerScope.listen(window, 'resize', reposition);
		if (isBackground) {
			loadingHighResolution = true;
			image.src = highResolutionSource;
			return viewer;
		}
		if (isImage) {
			loadViewerImageSources(previewSource, highResolutionSource, loadImageOriginal);
			return viewer;
		}
		if (previewSource) image.src = previewSource;
		if (!highResolutionSource) return viewer;
		void persistentAvatarSource(template, 1000, POST_REQUEST_PRIORITY.auxiliary, {
			rejectBlankAvatar: true,
			onProgress: updateProgress,
		}).then((source) => {
			if (closed) return;
			if (!source) {
				finishProgress();
				if (!previewSource) status.textContent = '未获取到高清头像';
				return;
			}
			loadingHighResolution = true;
			image.src = source;
		});
		return viewer;
	}

	function discourseRelativeTime(iso) {
		const d = new Date(iso);
		if (!Number.isFinite(d.getTime())) return '';
		const formatter = lookupDiscourseModule('discourse/lib/formatter');
		return isFunction(formatter?.relativeAge)
			? String(formatter.relativeAge(d, { format: 'medium-with-ago', wrapInSpan: false }) || '')
			: '';
	}

	function discourseExactTime(iso) {
		const d = new Date(iso);
		if (!Number.isFinite(d.getTime())) return '';
		const formatter = lookupDiscourseModule('discourse/lib/formatter');
		return isFunction(formatter?.longDate)
			? String(formatter.longDate(d) || '')
			: '';
	}

	function refreshPostRelativeTimes(container) {
		if (!container) return;
		container.querySelectorAll('.ldp-time-relative').forEach((relativeEl) => {
			const createdAt = relativeEl.closest('.ldp-post')?.dataset.createdAt || '';
			const time = discourseRelativeTime(createdAt);
			if (!time) return;
			const nextText = `· ${time}`;
			if (relativeEl.textContent !== nextText) relativeEl.textContent = nextText;
		});
	}

	async function discourseCsrfToken() {
		const sessionModule = lookupDiscourseModule('discourse/models/session');
		const Session = sessionModule?.default;
		const ajaxModule = lookupDiscourseModule('discourse/lib/ajax');
		if (!Session || !isFunction(Session.currentProp) ||
				!ajaxModule || !isFunction(ajaxModule.updateCsrfToken)) {
			throw new Error('Discourse 会话服务尚未就绪');
		}
		let token = String(Session.currentProp('csrfToken') || '');
		if (!token) {
			await ajaxModule.updateCsrfToken();
			token = String(Session.currentProp('csrfToken') || '');
		}
		if (!token) throw new Error('无法获取 Discourse CSRF Token');
		return token;
	}

	function createRequestAbortScope(externalSignal) {
		const controller = new AbortController();
		let timeout = 0;
		let timedOut = false;
		const abortFromExternal = () => controller.abort(externalSignal?.reason);
		if (externalSignal) {
			if (externalSignal.aborted) abortFromExternal();
			else externalSignal.addEventListener('abort', abortFromExternal, { once: true });
		}
		return {
			controller,
			signal: controller.signal,
			startTimeout(delay = POST_REQUEST_TIMEOUT) {
				if (timeout) clearTimeout(timeout);
				if (controller.signal.aborted) return;
				timeout = setTimeout(() => {
					timedOut = true;
					controller.abort();
				}, Math.max(1, Number(delay) || POST_REQUEST_TIMEOUT));
			},
			stopTimeout() {
				if (timeout) clearTimeout(timeout);
				timeout = 0;
			},
			timedOut() { return timedOut; },
			cleanup() {
				if (timeout) clearTimeout(timeout);
				timeout = 0;
				if (externalSignal) externalSignal.removeEventListener('abort', abortFromExternal);
			},
		};
	}

	function requestStatusError(message, status) {
		const error = new Error(message);
		error.status = status;
		return error;
	}

	function requestTimeoutError(message = '请求超时') {
		return requestStatusError(message, 408);
	}

	function acquireSharedReaderRequestPermit(priority, signal, type, scheduler) {
		if (!SHARED_REQUEST_COORDINATOR) return null;
		let requestState = null;
		try { requestState = scheduler?.snapshot(); } catch (error) {}
		return SHARED_REQUEST_COORDINATOR.acquirePermit({
			priority,
			signal,
			shortBudget: requestState ? requestState.shortWindowBudget : 40,
			longBudget: requestState ? requestState.longWindowBudget : 160,
			minInterval: requestState ? requestState.minInterval : 90,
			maxConcurrent: requestState ? requestState.maxConcurrent : 3,
			type,
		});
	}

	function discourseJSONFetchOptions(headers) {
		return {
			method: 'GET',
			credentials: 'include',
			cache: 'no-store',
			headers: {
				'Accept': 'application/json, text/javascript, */*; q=0.01',
				'X-Requested-With': 'XMLHttpRequest',
				'Discourse-Present': 'true',
				...headers,
			},
		};
	}

	async function fetchJSON(url, options = {}) {
		const scheduler = options.scheduler || ACTIVE_READER_REQUEST_SCHEDULER;
		const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible;
		const requestCallSite = requestFlowCallSite();
		const fetchOptions = {
			credentials: 'include',
			...(options.fetchOptions || {}),
		};
		fetchOptions.headers = Object.assign(
			{ 'Accept': 'application/json' },
			options.fetchOptions?.headers || {}
		);
		if (options.signal) fetchOptions.signal = options.signal;
		return requestCachedJSON(url, fetchOptions, options, async () => {
			if (scheduler && options.bypassScheduler !== true) {
				return scheduler.requestJSON(url, fetchOptions, {
					key: options.key || `reader:${url}`,
					priority,
					callSite: requestCallSite,
				});
			}
			const queuedAt = requestFlowNow();
			const requestScope = createRequestAbortScope(fetchOptions.signal);
			const requestType = requestFlowType(requestFlowUrl(url), 'fetch', fetchOptions.method || 'GET');
			let sharedPermit = null;
			try {
				sharedPermit = await acquireSharedReaderRequestPermit(
					priority, requestScope.signal, requestType, scheduler);
				requestScope.startTimeout();
				const res = await trackedReaderFetch(url, {
					...fetchOptions,
					signal: requestScope.signal,
					[REQUEST_FLOW_FETCH_META]: {
						source: 'reader',
						priority,
						recoveryProbe: sharedPermit && sharedPermit.recoveryProbe === true,
						queuedAt,
						permittedAt: sharedPermit?.startedAt || requestFlowNow(),
						waitReason: sharedPermit?.waitReason || '',
						callSite: requestCallSite,
						attempt: 1,
						type: requestType,
					},
				});
				noteSharedRequestRateLimit(res);
				if (!res.ok) {
					throw requestStatusError('HTTP ' + res.status, res.status);
				}
				return res.json();
			} catch (error) {
				if (requestScope.timedOut() && !(fetchOptions.signal?.aborted)) {
					throw requestTimeoutError();
				}
				throw error;
			} finally {
				requestScope.cleanup();
				if (sharedPermit && SHARED_REQUEST_COORDINATOR) {
					SHARED_REQUEST_COORDINATOR.finishPermit(sharedPermit.permitId);
				}
			}
		});
	}

	function ensureKatexStyles() {
		if (chromeExtensionRuntime()) return;
		if (document.querySelector('link[data-ldp-katex]')) return;
		const link = makeElement('link');
		link.rel = 'stylesheet';
		link.href = `https://cdn.jsdelivr.net/npm/katex@${KATEX_VERSION}/dist/katex.min.css`;
		link.dataset.ldpKatex = '1';
		(document.head || PAGE_ROOT).appendChild(link);
	}

	function latexTokenInfo(token) {
		if (token.startsWith('$$') && token.endsWith('$$')) return { tex: token.slice(2, -2), displayMode: true };
		if (token.startsWith('\\[') && token.endsWith('\\]')) return { tex: token.slice(2, -2), displayMode: true };
		if (token.startsWith('\\(') && token.endsWith('\\)')) return { tex: token.slice(2, -2), displayMode: false };
		return { tex: token.slice(1, -1), displayMode: false };
	}

	function textContainsLatex(value) {
		return /\\(?:frac|sum|sqrt|int|prod|lim|begin|left|right|mathbf|mathrm|text)|\$\$|\\\[|\\\(|[_^]\{/.test(value || '');
	}

	function renderLatexInPost(post) {
		if (!post || post.dataset.ldpLatexRendered === '1') return;
		if (post.dataset.ldpContentHydrated === '0') return;
		const content = post.querySelector(':scope > .ldp-content');
		const katex = globalThis.katex;
		if (!content) return;
		if (!textContainsLatex(content.textContent)) {
			post.dataset.ldpLatexRendered = '1';
			return;
		}
		if (!katex) return;
		ensureKatexStyles();

		content.querySelectorAll('p').forEach((paragraph) => {
			if (paragraph.children.length || paragraph.closest('pre,code')) return;
			const text = paragraph.textContent.trim();
			if (!/\\(?:frac|sum|sqrt|int|prod|lim|begin)|^[A-Za-z][^\n=]{0,40}=/.test(text)) return;
			try {
				katex.render(text, paragraph, { displayMode: true, throwOnError: false, strict: 'ignore' });
			} catch (error) {}
		});

		const tokenPattern = /(\$\$[\s\S]+?\$\$|\\\[[\s\S]+?\\\]|\\\([\s\S]+?\\\)|\$(?!\s)(?:\\.|[^$\\])+?\$)/g;
		const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
		const textNodes = [];
		while (walker.nextNode()) {
			const node = walker.currentNode;
			const parent = node.parentElement;
			if (!parent || parent.closest('pre,code,a,.katex')) continue;
			if (tokenPattern.test(node.nodeValue || '')) textNodes.push(node);
			tokenPattern.lastIndex = 0;
		}
		textNodes.forEach((textNode) => {
			const value = textNode.nodeValue || '';
			const fragment = document.createDocumentFragment();
			let lastIndex = 0;
			value.replace(tokenPattern, (token, offset) => {
				if (offset > lastIndex) fragment.appendChild(document.createTextNode(value.slice(lastIndex, offset)));
				const info = latexTokenInfo(token);
				const holder = makeElement(info.displayMode ? 'div' : 'span');
				try {
					katex.render(info.tex, holder, { displayMode: info.displayMode, throwOnError: false, strict: 'ignore' });
					fragment.appendChild(holder);
				} catch (error) {
					fragment.appendChild(document.createTextNode(token));
				}
				lastIndex = offset + token.length;
				return token;
			});
			if (lastIndex < value.length) fragment.appendChild(document.createTextNode(value.slice(lastIndex)));
			textNode.replaceWith(fragment);
			tokenPattern.lastIndex = 0;
		});
		post.dataset.ldpLatexRendered = '1';
	}

	function notificationData(notification) {
		const raw = notification?.data;
		if (!raw) return {};
		if (typeof raw === 'object') return raw;
		try { return JSON.parse(raw); } catch (e) { return {}; }
	}

	function notificationTypeName(notification) {
		const site = lookupDiscourse('service:site');
		const lookup = site && discourseModelValue(site, 'notificationLookup');
		return String(lookup?.[Number(notification && notification.notification_type)] || '');
	}

	function notificationTypeLabel(notification) {
		return String(notification?._ldp_type_label || notificationTypeName(notification) || '通知');
	}

	function consolidatedReplyInfo(notification) {
		if (notificationTypeName(notification) !== 'replied') return null;
		const data = notificationData(notification);
		const count = Math.max(0, Number(data.consolidated_count) || 0);
		const parentPostNumber = Math.max(0, Number(data.reply_to_post_number) || 0);
		if (count <= 1 || !parentPostNumber) return null;
		return { count, parentPostNumber };
	}

	function notificationPresentationText(value) {
		const html = isFunction(value?.toHTML) ? value.toHTML() : String(value || '');
		if (!html) return '';
		const template = makeElement('template');
		template.innerHTML = html;
		return String(template.content.textContent || '').replace(/\s+/g, ' ').trim();
	}

	async function applyDiscourseNotificationPresentation(notifications) {
		const notificationModule = lookupDiscourseModule('discourse/models/notification');
		const Notification = notificationModule?.default;
		const manager = lookupDiscourseModule('discourse/lib/notification-types-manager');
		const currentUser = lookupDiscourse('service:current-user');
		const siteSettings = lookupDiscourse('service:site-settings');
		const site = lookupDiscourse('service:site');
		if (!Notification || !isFunction(Notification.initializeNotifications) ||
				!manager || !isFunction(manager.getRenderDirector) ||
				!currentUser || !siteSettings || !site) {
			throw new Error('Discourse 通知渲染服务尚未就绪');
		}
		const source = (notifications || []).map((notification) => Object.assign(
			{}, notification, { data: notificationData(notification) }
		));
		const models = await Notification.initializeNotifications(source);
		models.forEach((model, index) => {
			const notification = notifications[index];
			const typeName = notificationTypeName(model);
			const director = manager.getRenderDirector(typeName, model, currentUser, siteSettings, site);
			const label = notificationPresentationText(director.label);
			const typeLabel = notificationPresentationText(director.linkTitle) || typeName || '通知';
			const description = notificationPresentationText(director.description);
			const href = String(director.linkHref || '').trim();
			notification._ldp_actor = cleanUsernameValue(
				notificationData(model).display_username || notificationData(model).username || model.username
			);
			notification._ldp_type_label = typeLabel;
			notification._ldp_summary = [label, typeLabel, description].filter(Boolean).join(' · ');
			notification._ldp_href = href || discoursePageUrl('/my/notifications');
		});
		return notifications;
	}

	function notificationHref(notification) {
		return String(notification?._ldp_href || discoursePageUrl('/my/notifications'));
	}

	function notificationSummary(notification) {
		return String(notification?._ldp_summary || notificationTypeLabel(notification));
	}

	function notificationActor(notification) {
		if (notification?._ldp_actor) return String(notification._ldp_actor);
		const data = notificationData(notification);
		return cleanUsernameValue(data.display_username || data.original_username || data.acting_user_name ||
			data.username || notification.username);
	}

	function normalizeSearchText(value) {
		return String(value || '').toLocaleLowerCase().replace(/\s+/g, '').trim();
	}

	function pinyinSearchForms(value) {
		const source = String(value || '');
		if (!source) return [];
		const cached = SEARCH_PINYIN_CACHE.get(source);
		if (cached) {
			SEARCH_PINYIN_CACHE.delete(source);
			SEARCH_PINYIN_CACHE.set(source, cached);
			return cached;
		}
		const forms = [normalizeSearchText(source)];
		const api = globalThis.pinyinPro;
		if (isFunction(api?.pinyin)) {
			try {
				forms.push(normalizeSearchText(api.pinyin(source, { toneType: 'none', nonZh: 'consecutive' })));
				forms.push(normalizeSearchText(api.pinyin(source, {
					pattern: 'first', toneType: 'none', nonZh: 'consecutive',
				})));
			} catch (e) {}
		}
		const result = [...new Set(forms.filter(Boolean))];
		SEARCH_PINYIN_CACHE.set(source, result);
		while (SEARCH_PINYIN_CACHE.size > SEARCH_PINYIN_CACHE_MAX_ENTRIES) {
			SEARCH_PINYIN_CACHE.delete(SEARCH_PINYIN_CACHE.keys().next().value);
		}
		return result;
	}

	function addNotificationStatsIdentities(notificationIds, notifications) {
		notifications.forEach((notification) => {
			const notificationId = String(notification?.id || '') || [
				notification?.topic_id,
				notification?.post_number,
				notification?.created_at,
			].filter(Boolean).join(':');
			if (notificationId) notificationIds.add(notificationId);
		});
	}

	function notificationDisplayIdentity(notification) {
		return String(notification?._ldp_display_identity || '') ||
			String(notification?.id || '') || [
			notificationHref(notification), notification?.created_at, notificationActor(notification),
		].join('|');
	}

	function notificationReadIdentity(notification) {
		return String(notification?._ldp_source_notification_id || '') ||
			String(notification?.id || '') ||
			notificationDisplayIdentity(notification);
	}

	function notificationMatchesSearch(notification, needle) {
		if (!needle) return true;
		const data = notificationData(notification);
		return [
			notificationActor(notification),
			data.display_username,
			data.original_username,
			data.acting_user_name,
			data.username,
			notification?.username,
			...[
				notification?.user_id,
				notification?.acting_user_id,
				data.user_id,
				data.acting_user_id,
				data.original_user_id,
			].filter((value) => value != null && value !== '').map(String),
		].some((value) => normalizeSearchText(value).includes(needle));
	}

	function historyMatchesSearch(entry, query) {
		const needle = normalizeSearchText(query);
		if (!needle) return true;
		return pinyinSearchForms(entry?.title).some((value) => value.includes(needle));
	}

	function reactionRecordMatchesSearch(entry, needle) {
		if (!needle) return true;
		return [
			entry?.reaction,
			entry?.authorUsername,
			entry?.postNumber,
			...pinyinSearchForms(entry?.title),
		].some((value) => normalizeSearchText(value).includes(needle));
	}

	function compareGivenReactionRecords(left, right) {
		return (Date.parse(right?.reactedAt || '') || 0) -
			(Date.parse(left?.reactedAt || '') || 0) ||
			Number(right?.id || 0) - Number(left?.id || 0);
	}

	function forEachCachedNotification(callback) {
		if (ME_USERNAME === null) return;
		const authPrefix = authScopedCacheKey('tabs-v5:');
		NOTIFICATION_CACHE.forEach((entry, cacheKey) => {
			if (String(cacheKey).startsWith(authPrefix) && Array.isArray(entry?.value?.notifications)) {
				entry.value.notifications.forEach(callback);
			}
		});
	}

	function cachedNotificationsForGroup(groupKey, sort = true) {
		const seen = new Set();
		const notifications = [];
		forEachCachedNotification((notification) => {
			if (!notificationMatchesGroup(notification, groupKey)) return;
			const key = notificationDisplayIdentity(notification);
			if (seen.has(key)) return;
			seen.add(key);
			notifications.push(notification);
		});
		return sort ? sortNotificationsNewestFirst(notifications) : notifications;
	}

	function notificationMatchesGroup(notification, groupKey) {
		const group = NOTIFICATION_GROUPS[groupKey] || NOTIFICATION_GROUPS.all;
		return !group.types.length || group.types.includes(notificationTypeName(notification));
	}

	function currentUserUnreadNotificationCount(currentUser) {
		if (!currentUser || typeof currentUser !== 'object') return null;
		const readCount = (key) => {
			const value = discourseModelValue(currentUser, key);
			if (value == null || value === '') return null;
			const count = Number(value);
			if (Number.isFinite(count)) return Math.max(0, Math.floor(count));
			return null;
		};
		const allUnread = readCount('all_unread_notifications_count');
		if (allUnread != null) return allUnread;
		const normalUnread = readCount('unread_notifications');
		const highPriorityUnread = readCount('unread_high_priority_notifications');
		if (normalUnread != null || highPriorityUnread != null) {
			return (normalUnread || 0) + (highPriorityUnread || 0);
		}
		return readCount('unread_notification_count');
	}

	function cachedUnreadNotificationCount(groupKey = 'all') {
		const unread = new Set();
		cachedNotificationsForGroup(groupKey, false).forEach((notification) => {
			if (notification && notification.read === false) unread.add(notificationReadIdentity(notification));
		});
		return unread.size;
	}

	function decrementCurrentUserNotificationType(typeId) {
		const currentUser = lookupDiscourse('service:current-user');
		if (!currentUser || !isFunction(currentUser.set) || !(Number(typeId) > 0)) return;
		const grouped = {
			...(discourseModelValue(currentUser, 'grouped_unread_notifications') || {}),
		};
		const unreadCount = Number(grouped[typeId]) || 0;
		if (unreadCount > 0) {
			grouped[typeId] = unreadCount - 1;
			currentUser.set('grouped_unread_notifications', grouped);
		}
	}

	function syncCurrentUserNotificationsDismissed(groupKey) {
		const currentUser = lookupDiscourse('service:current-user');
		if (!currentUser || !isFunction(currentUser.set)) return;
		const group = NOTIFICATION_GROUPS[groupKey] || NOTIFICATION_GROUPS.all;
		if (!group.types.length) {
			currentUser.set('all_unread_notifications_count', 0);
			currentUser.set('unread_high_priority_notifications', 0);
			currentUser.set('grouped_unread_notifications', {});
			return;
		}
		const site = lookupDiscourse('service:site');
		const notificationTypes = discourseModelValue(site, 'notification_types') || {};
		const grouped = {
			...(discourseModelValue(currentUser, 'grouped_unread_notifications') || {}),
		};
		group.types.forEach((type) => {
			const typeId = Number(notificationTypes[type]) || 0;
			if (typeId) delete grouped[typeId];
		});
		currentUser.set('grouped_unread_notifications', grouped);
	}

	function syncNotificationUnreadUi(count = NOTIFICATION_UNREAD_COUNT) {
		NOTIFICATION_UNREAD_COUNT = Math.max(0, Math.floor(Number(count) || 0));
		const badgeText = NOTIFICATION_UNREAD_COUNT > 99 ? '99+' : String(NOTIFICATION_UNREAD_COUNT);
		readerPortalQueryAll('.ldp-notification-unread-badge').forEach((badge) => {
			badge.hidden = NOTIFICATION_UNREAD_COUNT <= 0;
			badge.textContent = badgeText;
		});
		readerPortalQueryAll('.ldp-notification-unread-status').forEach((status) => {
			status.textContent = NOTIFICATION_UNREAD_COUNT > 0 ? `未读 ${NOTIFICATION_UNREAD_COUNT} 条` : '没有未读消息';
		});
		readerPortalQueryAll('.ldp-notification-mark-all').forEach((button) => {
			if (button.dataset.ldpRequestBusy !== '1') button.disabled = NOTIFICATION_UNREAD_COUNT <= 0;
		});
	}

	function markCachedNotificationsRead(groupKey = 'all') {
		const markedNotificationKeys = new Set();
		let markedCount = 0;
		let changed = false;
		forEachCachedNotification((notification) => {
			if (!notification || notification.read !== false || !notificationMatchesGroup(notification, groupKey)) return;
			const notificationKey = notificationReadIdentity(notification);
			if (!markedNotificationKeys.has(notificationKey)) {
				markedNotificationKeys.add(notificationKey);
				markedCount++;
			}
			notification.read = true;
			changed = true;
		});
		if (changed) schedulePersistentCacheWrite('notifications', 0);
		syncNotificationUnreadUi(groupKey === 'all'
			? 0
			: Math.max(0, NOTIFICATION_UNREAD_COUNT - markedCount));
		return markedCount;
	}

	function markClickedNotificationRead(item) {
		if (!item || !item.classList.contains('unread')) return 0;
		const notificationKey = String(item.dataset.notificationReadKey || item.dataset.notificationKey || '');
		let notificationTypeId = 0;
		let cacheChanged = false;
		forEachCachedNotification((notification) => {
			if (!notification || notification.read !== false ||
					notificationReadIdentity(notification) !== notificationKey) return;
			notification.read = true;
			notificationTypeId ||= Number(notification.notification_type) || 0;
			cacheChanged = true;
		});
		if (cacheChanged) schedulePersistentCacheWrite('notifications', 0);
		decrementCurrentUserNotificationType(notificationTypeId);
		readerPortalQueryAll('.ldp-notification-item').forEach((notificationItem) => {
			if (String(notificationItem.dataset.notificationReadKey || notificationItem.dataset.notificationKey || '') !==
					notificationKey) return;
			notificationItem.classList.remove('unread');
			const readState = notificationItem.querySelector('.ldp-notification-read-state');
			if (readState) readState.textContent = '已读';
		});
		syncNotificationUnreadUi(Math.max(0, NOTIFICATION_UNREAD_COUNT - 1));
		return Math.max(0, Number(item.dataset.notificationId) || 0);
	}

	function notificationDateGroup(createdAt) {
		const date = new Date(createdAt || 0);
		if (Number.isNaN(date.getTime())) return '更早';
		const today = new Date();
		const startToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
		const startDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
		if (startDate === startToday) return '今天';
		if (startDate === startToday - 86400000) return '昨天';
		return '更早';
	}

	function groupNotificationsByDate(notifications) {
		const buckets = new Map([['今天', []], ['昨天', []], ['更早', []]]);
		(notifications || []).forEach((notification) => {
			buckets.get(notificationDateGroup(notification.created_at)).push(notification);
		});
		return [...buckets.entries()].filter(([, items]) => items.length);
	}

	function notificationAvatarHtml(notification) {
		const data = notificationData(notification);
		const actor = notificationActor(notification);
		const template = notification.acting_user_avatar_template || notification.avatar_template ||
			data.acting_user_avatar_template || data.avatar_template || cachedActorAvatar(actor);
		let avatarHtml;
		if (template) {
			const cachedSource = touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, avatarUrl(template, 64));
			avatarHtml = cachedSource
				? `<img class="ldp-notification-avatar" src="${escAttr(cachedSource)}" alt="" loading="lazy" decoding="async">`
				: `<span class="ldp-notification-avatar ldp-notification-avatar-fallback" data-ldp-avatar-template="${escAttr(template)}">${esc(String(actor || '?').slice(0, 1).toUpperCase())}</span>`;
		} else {
			avatarHtml = `<span class="ldp-notification-avatar ldp-notification-avatar-fallback">${esc(String(actor || '?').slice(0, 1).toUpperCase())}</span>`;
		}
		return userCardAvatarHtml(avatarHtml, actor);
	}

	function cachedActorAvatar(actor, historySource = readTopicHistory) {
		actor = cleanUsernameValue(actor);
		if (!actor) return '';
		const cachedTemplate = NOTIFICATION_AVATAR_CACHE.get(actor);
		if (cachedTemplate) {
			rememberNotificationAvatar(actor, cachedTemplate);
			return cachedTemplate;
		}
		const cachedUser = getCachedUserCard(actor);
		let template = String(cachedUser?.avatar_template || '').trim();
		if (!template) {
			const actorKey = actor.toLocaleLowerCase();
			const historyEntry = historySource().find((entry) =>
				cleanUsernameValue(entry?.ownerUsername).toLocaleLowerCase() === actorKey && entry.avatarTemplate
			);
			template = String(historyEntry?.avatarTemplate || '').trim();
		}
		if (template) rememberNotificationAvatar(actor, template);
		return template;
	}

	function rememberNotificationAvatar(actor, template) {
		actor = cleanUsernameValue(actor);
		template = String(template || '').trim();
		if (!actor || !template) return;
		NOTIFICATION_AVATAR_CACHE.delete(actor);
		NOTIFICATION_AVATAR_CACHE.set(actor, template);
		while (NOTIFICATION_AVATAR_CACHE.size > NOTIFICATION_AVATAR_CACHE_MAX_ENTRIES) {
			NOTIFICATION_AVATAR_CACHE.delete(NOTIFICATION_AVATAR_CACHE.keys().next().value);
		}
	}

	function notificationCacheSnapshot(notification) {
		if (!notification || typeof notification !== 'object') return notification;
		const data = notificationData(notification);
		const snapshot = Object.assign({}, notification, { data });
		if (!snapshot._ldp_actor) snapshot._ldp_actor = notificationActor(snapshot);
		const actor = notificationActor(snapshot);
		const template = notification.acting_user_avatar_template || notification.avatar_template ||
			data.acting_user_avatar_template || data.avatar_template || cachedActorAvatar(actor) || '';
		if (template) {
			snapshot.avatar_template = template;
			if (actor) rememberNotificationAvatar(actor, template);
		}
		return snapshot;
	}

	function topicReplyMatchesConsolidatedBucket(post, parentPostNumber, latestPostNumber) {
		const postNumber = Math.max(0, Number(post?.post_number) || 0);
		if (!postNumber || postNumber > latestPostNumber) return false;
		const replyTo = Math.max(0, Number(post?.reply_to_post_number) || 0);
		return parentPostNumber === 1 ? replyTo <= 1 && postNumber > 1 : replyTo === parentPostNumber;
	}

	function expandedReplyNotification(notification, post) {
		const data = { ...notificationData(notification) };
		const actor = cleanUsernameValue(post?.username);
		const postNumber = Math.max(0, Number(post?.post_number) || 0);
		Object.assign(data, {
			consolidated_count: 1,
			display_username: actor,
			original_username: actor,
			acting_user_name: actor,
			username: actor,
			post_number: postNumber,
		});
		const expanded = Object.assign({}, notification, {
			data,
			post_number: postNumber,
			created_at: post?.created_at || notification.created_at,
			username: actor,
			acting_user_avatar_template: post?.avatar_template ||
				notification.acting_user_avatar_template,
			_ldp_display_identity: `${notification.id || 'reply'}:reply:${postNumber}`,
			_ldp_source_notification_id: notification.id || '',
		});
		delete expanded._ldp_actor;
		delete expanded._ldp_summary;
		delete expanded._ldp_href;
		delete expanded._ldp_type_label;
		return expanded;
	}

	async function expandConsolidatedReplyNotification(notification, options = {}) {
		const info = consolidatedReplyInfo(notification);
		const topicId = Math.max(0, Number(notification?.topic_id) || 0);
		const latestPostNumber = Math.max(
			0,
			Number(notification?.post_number) ||
			Number(notificationData(notification).post_number) ||
			0
		);
		if (!info || !topicId || !latestPostNumber) return [notification];
		try {
			const payload = await fetchJSON(`${BASE}/t/${topicId}.json?post_number=${latestPostNumber}`, {
				key: `notification-replies:${topicId}:${latestPostNumber}`,
				priority: POST_REQUEST_PRIORITY.auxiliary,
				cacheMode: options.force ? 'refresh' : 'default',
			});
			const replies = postsFromPayload(payload)
				.filter((post) => topicReplyMatchesConsolidatedBucket(
					post,
					info.parentPostNumber,
					latestPostNumber
				))
				.sort((left, right) => Number(right.post_number) - Number(left.post_number))
				.slice(0, info.count)
				.sort((left, right) => Number(right.post_number) - Number(left.post_number));
			if (replies.length !== info.count) return [notification];
			return replies.map((post) => expandedReplyNotification(notification, post));
		} catch (error) {
			return [notification];
		}
	}

	async function expandConsolidatedReplyNotifications(notifications, options = {}) {
		const groups = await Promise.all((notifications || []).map((notification) =>
			expandConsolidatedReplyNotification(notification, options)));
		const expanded = groups.flat();
		await applyDiscourseNotificationPresentation(expanded);
		return expanded.map(notificationCacheSnapshot);
	}

	function persistNotificationActorAvatar(actor, template) {
		actor = cleanUsernameValue(actor);
		template = String(template || '').trim();
		if (!actor || !template) return;
		rememberNotificationAvatar(actor, template);
		let changed = false;
		forEachCachedNotification((notification) => {
			if (notificationActor(notification) !== actor || notification.avatar_template === template) return;
			notification.avatar_template = template;
			changed = true;
		});
		if (changed) schedulePersistentCacheWrite('notifications', 0);
	}

	function hydrateActorAvatarNodes(container, templates, attribute) {
		if (!container.isConnected || !templates.size) return;
		container.querySelectorAll(`[${attribute}]`).forEach((item) => {
			const template = templates.get(item.getAttribute(attribute));
			const current = template && item.querySelector('.ldp-notification-avatar');
			if (!current || current.tagName === 'IMG') return;
			current.dataset.ldpAvatarTemplate = template;
		});
	}

	function cachedActorAvatars(actors, historySource) {
		const templates = new Map();
		actors.forEach((actor) => {
			const template = cachedActorAvatar(actor, historySource);
			if (template) templates.set(actor, template);
		});
		return templates;
	}

	function hydrateNotificationAvatars(container, notifications) {
		if (!container) return;
		const actors = new Set();
		(notifications || []).forEach((notification) => {
			const actor = notificationActor(notification);
			const data = notificationData(notification);
			if (actor && !(notification.acting_user_avatar_template || notification.avatar_template ||
				data.acting_user_avatar_template || data.avatar_template)) {
				actors.add(actor);
			}
		});
		let history;
		const templates = cachedActorAvatars(actors, () => history || (history = readTopicHistory()));
		if (container.isConnected) {
			templates.forEach((template, actor) => persistNotificationActorAvatar(actor, template));
		}
		hydrateActorAvatarNodes(container, templates, 'data-notification-actor');
		hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.auxiliary);
	}

	function hydrateHistoryAvatars(container, entries, storedHistory = null) {
		if (!container) return;
		const actors = new Set();
		(entries || []).forEach((entry) => {
			const actor = cleanUsernameValue(entry?.ownerUsername);
			if (actor && !entry.avatarTemplate) actors.add(actor);
		});
		const history = actors.size ? storedHistory || readTopicHistory() : [];
		const templates = cachedActorAvatars(actors, () => history);
		let historyChanged = false;
		history.forEach((entry) => {
			const template = templates.get(cleanUsernameValue(entry?.ownerUsername));
			if (!template || entry.avatarTemplate === template) return;
			entry.avatarTemplate = template;
			historyChanged = true;
		});
		if (historyChanged) writeTopicHistory(history);
		hydrateActorAvatarNodes(container, templates, 'data-history-actor');
		hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.auxiliary);
	}

	function renderNotificationList(notifications) {
		const groups = groupNotificationsByDate(notifications);
		if (!groups.length) return '<div class="ldp-notification-empty">暂无消息</div>';
		return groups.map(([label, items]) => `
			<section class="ldp-notification-date-group">
				<div class="ldp-notification-date-label">${label}</div>
				${items.map((notification) => `
					<a class="ldp-notification-item${notification.read ? '' : ' unread'}" data-notification-id="${Math.max(0, Number(notification.id) || 0)}" data-notification-key="${escAttr(notificationDisplayIdentity(notification))}" data-notification-read-key="${escAttr(notificationReadIdentity(notification))}" data-notification-actor="${escAttr(notificationActor(notification))}" data-ldp-preserve-target-post="1" href="${escAttr(notificationHref(notification))}">
						${notificationAvatarHtml(notification)}
						<span class="ldp-notification-copy">
							<span class="ldp-notification-title">${esc(notificationSummary(notification))}</span>
							<span class="ldp-notification-footer">
								<span class="ldp-notification-meta">${esc(discourseRelativeTime(notification.created_at))}</span>
								<span class="ldp-notification-read-state">${notification.read ? '已读' : '未读'}</span>
							</span>
						</span>
					</a>`).join('')}
			</section>`).join('');
	}

	function collectionLinkMarkup(className, href, avatar, title, meta, metaIsHtml = false) {
		return `<a class="ldp-notification-item ${className}" data-ldp-preserve-target-post="1" href="${escAttr(href)}">
			${avatar}
			<span class="ldp-notification-copy">
				<span class="ldp-notification-title">${esc(title)}</span>
				<span class="ldp-notification-meta">${metaIsHtml ? meta : esc(meta)}</span>
			</span>
		</a>`;
	}

	function getCachedNotificationPage(cacheKey) {
		const entry = NOTIFICATION_CACHE.get(cacheKey);
		if (!entry) return null;
		if (Date.now() - Number(entry.at || 0) > PERSISTENT_CACHE_CONFIG.notifications.maxAge) {
			NOTIFICATION_CACHE.delete(cacheKey);
			schedulePersistentCacheWrite('notifications');
			return null;
		}
		NOTIFICATION_CACHE.delete(cacheKey);
		NOTIFICATION_CACHE.set(cacheKey, entry);
		return entry.value || null;
	}

	function setCachedNotificationPage(cacheKey, value) {
		if (!cacheKey || !value) return;
		NOTIFICATION_CACHE.delete(cacheKey);
		NOTIFICATION_CACHE.set(cacheKey, { value, at: Date.now() });
		trimPersistentCacheMap('notifications');
		schedulePersistentCacheWrite('notifications', 0);
	}

	function notificationPageCacheKey(groupKey, page) {
		return authScopedCacheKey(`tabs-v5:${groupKey}:${Math.max(0, Number(page) || 0)}`);
	}

	function expireNotificationFirstPageCaches() {
		Object.keys(NOTIFICATION_GROUPS).forEach((groupKey) => {
			const entry = NOTIFICATION_CACHE.get(notificationPageCacheKey(groupKey, 0));
			if (entry) entry.at = 0;
		});
	}

	function sortNotificationsNewestFirst(notifications) {
		return (notifications || []).slice().sort((a, b) => {
			const timeA = Date.parse(a?.created_at || '') || 0;
			const timeB = Date.parse(b?.created_at || '') || 0;
			return timeB - timeA || Number(b?.id || 0) - Number(a?.id || 0);
		});
	}

	async function fetchNotificationPage(groupKey, page, options = {}) {
		const safeGroupKey = Object.hasOwn(NOTIFICATION_GROUPS, groupKey) ? groupKey : 'all';
		page = Math.max(0, Number(page) || 0);
		const cacheKey = notificationPageCacheKey(safeGroupKey, page);
		const pendingRequest = NOTIFICATION_REQUESTS.get(cacheKey);
		if (pendingRequest) return pendingRequest;
		const request = (async () => {
			const params = new URLSearchParams();
			params.set('offset', String(page * NOTIFICATION_PAGE_SIZE));
			params.set('limit', String(NOTIFICATION_PAGE_SIZE));
			if (safeGroupKey !== 'all') {
				params.set('recent', 'true');
				params.set('silent', 'true');
				params.set('filter_by_types', NOTIFICATION_GROUPS[safeGroupKey].types.join(','));
			}
			const data = await fetchJSON(`${BASE}/notifications.json?${params}`, {
				key: `notifications:${cacheKey}`,
				priority: POST_REQUEST_PRIORITY.auxiliary,
				cacheMode: options.force ? 'refresh' : 'default',
			});
			const rawNotifications = Array.isArray(data.notifications) ? data.notifications : [];
			const matchingNotifications = rawNotifications
				.filter((notification) => notificationMatchesGroup(notification, safeGroupKey));
			const notifications = sortNotificationsNewestFirst(
				await expandConsolidatedReplyNotifications(matchingNotifications, options)
			);
			const offset = page * NOTIFICATION_PAGE_SIZE;
			const serverTotal = Number(data.total_rows_notifications || 0);
			const hasNext = !!data.load_more_notifications ||
				(serverTotal > 0 && offset + NOTIFICATION_PAGE_SIZE < serverTotal);
			const total = serverTotal > 0
				? serverTotal
				: offset + notifications.length + (hasNext ? NOTIFICATION_PAGE_SIZE : 0);
			const result = { notifications, total, hasNext };
			setCachedNotificationPage(cacheKey, result);
			return result;
		})();
		return await trackSingleFlightRequest(NOTIFICATION_REQUESTS, cacheKey, request);
	}

	function postsFromPayload(data) {
		if (!data) return [];
		if (Array.isArray(data)) return data;
		if (Array.isArray(data.post_stream?.posts)) return data.post_stream.posts;
		if (Array.isArray(data.posts)) return data.posts;
		if (data.post?.post_number) return [data.post];
		if (data.post_number) return [data];
		return [];
	}

	function shouldStopReaderFallback(error) {
		const status = Number(error?.status || 0);
		return !!(error && error.name === 'AbortError') || status === 408 || status === 429 || status >= 500;
	}

	function retryAfterMs(response, attempt) {
		const raw = requestFlowHeader(response?.headers, REQUEST_FLOW_RESPONSE_HEADERS.retryAfter);
		let wait = 0;
		if (raw) {
			const seconds = Number(raw);
			wait = Number.isFinite(seconds)
				? seconds * 1000
				: Math.max(0, Date.parse(raw) - Date.now());
		}
		if (!wait) wait = POST_REQUEST_429_BACKOFF * Math.pow(2, Math.max(0, attempt - 1));
		return Math.max(1000, Math.min(60000, wait));
	}

	function requestRateLimitWindow(response) {
		return rateLimitWindowFromCode(requestFlowHeader(
			response?.headers, REQUEST_FLOW_RESPONSE_HEADERS.rateLimitCode
		));
	}

	function noteSharedRequestRateLimit(response, attempt = 1) {
		if (!SHARED_REQUEST_COORDINATOR || !response || response.status !== 429) return;
		SHARED_REQUEST_COORDINATOR.noteRateLimit({
			wait: retryAfterMs(response, attempt),
			window: requestRateLimitWindow(response).label,
			shortBudget: 40,
			longBudget: 160,
		});
	}

	function createReaderRequestScheduler(runtimeConfig) {
		const requestMaxConcurrent = runtimeConfig.requestMaxConcurrent;
		const requestMinInterval = runtimeConfig.requestMinInterval;
		const requestRateTarget = Math.min(0.95, Math.max(0.5, runtimeConfig.requestRateTarget / 100));
		const shortWindowBudget = Math.max(1,
			Math.floor(READER_REQUEST_DEFAULT_SHORT_CEILING * requestRateTarget));
		const longWindowBudget = Math.max(1,
			Math.floor(READER_REQUEST_DEFAULT_LONG_CEILING * requestRateTarget));
		const queue = [];
		const tasksByKey = new Map();
		const endpointRateLimitCircuits = new Map();
		const unknownRateLimitEvidence = [];
		const activeControllers = new Set();
		const pendingPermitControllers = new Set();
		const externalControllers = new Set();
		let activeCount = 0;
		let sharedPermitPending = false;
		let sequence = 0;
		let responseLatencyEwma = 0;
		let responseLatencyPeak = 0;
		let responseLatencySamples = 0;
		let pumpTimer = 0;
		let disposed = false;

		const abortError = (reason = 'Aborted') => new DOMException(String(reason || 'Aborted'), 'AbortError');

		const endpointRateLimitIdentity = (input, method = 'GET') => {
			const url = requestFlowUrl(input);
			const normalizedMethod = String(method || 'GET').toUpperCase();
			if (!url) {
				const invalid = `${normalizedMethod}:invalid:${String(input || '').slice(0, 160)}`;
				return { fingerprint: invalid, route: invalid };
			}
			const params = [...url.searchParams.entries()].filter(([key]) => key !== '_ldp_retry');
			const identity = (routeOnly) => {
				const path = routeOnly
					? url.pathname
						.replace(/\b\d+\b/g, ':id')
						.replace(/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, ':uuid')
					: url.pathname;
				const sorted = params.map(([key, value]) => routeOnly ? [key, ''] : [key, value])
					.sort(([aKey, aValue], [bKey, bValue]) =>
						aKey.localeCompare(bKey) || aValue.localeCompare(bValue));
				const query = routeOnly
					? [...new Set(sorted.map(([key]) => encodeURIComponent(key)))].join('&')
					: sorted.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
				return `${normalizedMethod}:${url.origin}${path}${query ? `?${query}` : ''}`;
			};
			return { fingerprint: identity(false), route: identity(true) };
		};

		const pruneEndpointRateLimitState = (at = Date.now()) => {
			const evidenceCutoff = at - READER_ENDPOINT_429_EVIDENCE_WINDOW_MS;
			while (unknownRateLimitEvidence.length && unknownRateLimitEvidence[0].at < evidenceCutoff) {
				unknownRateLimitEvidence.shift();
			}
			endpointRateLimitCircuits.forEach((state, key) => {
				if (state.lastAt < at - READER_ENDPOINT_429_STATE_TTL) endpointRateLimitCircuits.delete(key);
			});
			if (endpointRateLimitCircuits.size > READER_ENDPOINT_429_MAX_ENTRIES) {
				[...endpointRateLimitCircuits.entries()]
					.sort(([, a], [, b]) => a.lastAt - b.lastAt)
					.slice(0, endpointRateLimitCircuits.size - READER_ENDPOINT_429_MAX_ENTRIES)
					.forEach(([key]) => endpointRateLimitCircuits.delete(key));
			}
		};

		const endpointRateLimitState = (fingerprint, at = Date.now()) => {
			pruneEndpointRateLimitState(at);
			const state = endpointRateLimitCircuits.get(fingerprint);
			return state && state.blockedUntil > at ? state : null;
		};

		const noteUnknownEndpointRateLimit = (task, response) => {
			const at = Date.now();
			pruneEndpointRateLimitState(at);
			const corroboratedGlobal = unknownRateLimitEvidence.some((event) =>
				event.route !== task.endpointRoute && event.at >= at - READER_ENDPOINT_429_EVIDENCE_WINDOW_MS);
			unknownRateLimitEvidence.push({
				at,
				route: task.endpointRoute,
			});
			if (corroboratedGlobal) {
				endpointRateLimitCircuits.clear();
				return null;
			}
			const previous = endpointRateLimitCircuits.get(task.endpointFingerprint);
			const strikes = previous && previous.lastAt >= at - READER_ENDPOINT_429_STATE_TTL
				? previous.strikes + 1
				: 1;
			const exponential = READER_ENDPOINT_429_BASE_BLOCK_MS * Math.pow(2, Math.min(6, strikes - 1));
			const wait = Math.min(
				READER_ENDPOINT_429_MAX_BLOCK_MS,
				Math.max(retryAfterMs(response, strikes), exponential)
			);
			const state = { strikes, lastAt: at, blockedUntil: at + wait };
			endpointRateLimitCircuits.set(task.endpointFingerprint, state);
			return state;
		};

		const latencyBoundConcurrent = () => {
			if (responseLatencySamples < 6 || !responseLatencyEwma) return requestMaxConcurrent;
			const latencyBudget = Math.max(responseLatencyEwma * 1.15, responseLatencyPeak);
			return Math.min(requestMaxConcurrent, Math.max(1, Math.ceil(latencyBudget / requestMinInterval)));
		};

		const effectiveMaxConcurrent = latencyBoundConcurrent;

		const sortQueue = (at = Date.now()) => queue.sort((a, b) => {
			const aAge = Math.min(2, Math.floor(Math.max(0, at - a.queuedAt) / 5000));
			const bAge = Math.min(2, Math.floor(Math.max(0, at - b.queuedAt) / 5000));
			return (a.priority - aAge) - (b.priority - bAge) || a.sequence - b.sequence;
		});

		const schedulePump = () => {
			if (disposed || pumpTimer || sharedPermitPending || !queue.length || activeCount >= effectiveMaxConcurrent()) return;
			pumpTimer = setTimeout(pump, 0);
		};

		const finishQueuedTask = (task, error) => {
			if (!task.controlRecorded) {
				task.controlRecorded = true;
				const errorReason = error && error.name === 'AbortError' && error.message && error.message !== 'Aborted'
					? error.message
					: '';
				const controlReason = String(task.controlReason || errorReason || task.waitReason || (disposed ? 'context-close' : 'cancelled'));
				const type = task.sharedType;
				recordRequestFlowControl(task.url, {
					queuedAt: task.queuedAt,
					method: task.fetchOptions.method || 'GET',
					priority: task.priority,
					waitReason: task.waitReason || controlReason,
					callSite: task.callSite,
					type,
					path: task.permitOnly ? `等待${REQUEST_FLOW_TYPE_META[type]?.label || '请求'}许可` : '',
					attempt: task.attempt + 1,
					controlReason,
				});
			}
			tasksByKey.delete(task.key);
			task.state = 'done';
			task.reject(error);
		};

		const noteRateLimit = (response, attempt = 1, options = {}) => {
			if (!response || response.status !== 429) return 0;
			const wait = retryAfterMs(response, attempt);
			const rateLimitWindow = options.rateLimitWindow || requestRateLimitWindow(response);
			if (SHARED_REQUEST_COORDINATOR) {
				SHARED_REQUEST_COORDINATOR.noteRateLimit({
					wait,
					window: rateLimitWindow.label,
					shortBudget: shortWindowBudget,
					longBudget: longWindowBudget,
				});
			}
			schedulePump();
			return wait;
		};

		const runTask = async (task) => {
			const controller = new AbortController();
			const externalSignal = task.signal;
			const abort = () => controller.abort();
			const startedAt = Date.now();
			let timedOut = false;
			const timeout = setTimeout(() => {
				timedOut = true;
				controller.abort();
			}, POST_REQUEST_TIMEOUT);
			task.state = 'active';
			task.controller = controller;
			activeCount++;
			activeControllers.add(controller);
			if (externalSignal) externalSignal.addEventListener('abort', abort, { once: true });
			try {
				if (task.permitOnly) {
					tasksByKey.delete(task.key);
					task.state = 'done';
					task.keepSharedPermit = true;
					const permitId = task.sharedPermitId;
					task.resolve({
						recoveryProbe: task.sharedRecoveryProbe === true,
						startedAt: task.permittedAt || requestFlowNow(),
						waitReason: task.waitReason,
						release: () => {
							if (!task.keepSharedPermit) return;
							task.keepSharedPermit = false;
							if (SHARED_REQUEST_COORDINATOR) SHARED_REQUEST_COORDINATOR.finishPermit(permitId);
						},
					});
					return;
				}
				const response = await trackedReaderFetch(task.url, {
					...task.fetchOptions,
					signal: controller.signal,
					[REQUEST_FLOW_FETCH_META]: {
						source: 'reader',
						priority: task.priority,
						recoveryProbe: task.sharedRecoveryProbe === true,
						queuedAt: task.queuedAt,
						permittedAt: task.permittedAt,
						waitReason: task.waitReason,
						callSite: task.callSite,
						attempt: task.attempt + 1,
						type: task.sharedType,
					},
				});
				if (response.status === 429) {
					task.attempt++;
					const rateLimitWindow = requestRateLimitWindow(response);
					const rateLimitHeader = requestFlowHeader(
						response.headers, REQUEST_FLOW_RESPONSE_HEADERS.serverLimit
					);
					const knownWindow = rateLimitWindow.known ||
						/(?:^|;)\s*w\s*=\s*"?(?:10|60)\b/i.test(rateLimitHeader);
					const endpointLimit = knownWindow
						? null
						: noteUnknownEndpointRateLimit(task, response);
					if (!endpointLimit) {
						noteRateLimit(response, task.attempt, {
							rateLimitWindow,
						});
					} else {
						const error = requestStatusError('HTTP 429(端点冷却)', 429);
						error.rateLimitScope = 'endpoint';
						error.retryAt = endpointLimit.blockedUntil;
						task.controlReason = 'endpoint-cooldown';
						throw error;
					}
					if (task.attempt <= POST_REQUEST_MAX_429_RETRIES &&
							task.priority <= POST_REQUEST_PRIORITY.visible) {
						task.state = 'queued';
						task.controller = null;
						task.queuedAt = requestFlowNow();
						task.permittedAt = 0;
						task.waitReason = 'cooldown';
						queue.push(task);
						return;
					}
				}
				if (!response.ok) {
					throw requestStatusError('HTTP ' + response.status, response.status);
				}
				const responseLatency = Math.max(1, Date.now() - startedAt);
				responseLatencyEwma = responseLatencyEwma
					? responseLatencyEwma * 0.8 + responseLatency * 0.2
					: responseLatency;
				responseLatencyPeak = Math.max(responseLatency, responseLatencyPeak * 0.9);
				responseLatencySamples++;
				endpointRateLimitCircuits.delete(task.endpointFingerprint);
				const payload = await response.json();
				tasksByKey.delete(task.key);
				task.state = 'done';
				task.resolve(payload);
			} catch (error) {
				tasksByKey.delete(task.key);
				task.state = 'done';
				if (timedOut && !(externalSignal?.aborted)) {
					task.reject(requestTimeoutError());
				} else {
					task.reject(error);
				}
			} finally {
				clearTimeout(timeout);
				if (externalSignal) externalSignal.removeEventListener('abort', abort);
				activeControllers.delete(controller);
				task.controller = null;
				if (!task.keepSharedPermit && task.sharedPermitId && SHARED_REQUEST_COORDINATOR) {
					SHARED_REQUEST_COORDINATOR.finishPermit(task.sharedPermitId);
				}
				task.sharedPermitId = '';
				activeCount = Math.max(0, activeCount - 1);
				schedulePump();
			}
		};

		function pump() {
			if (pumpTimer) clearTimeout(pumpTimer);
			pumpTimer = 0;
			if (disposed || sharedPermitPending || !queue.length || activeCount >= effectiveMaxConcurrent()) return;
			sortQueue();
			while (queue.length) {
				const task = queue.shift();
				if (task.signal?.aborted) {
					task.controlReason = 'signal';
					finishQueuedTask(task, abortError());
					continue;
				}
				task.state = 'shared-permit';
				sharedPermitPending = true;
				const permitController = new AbortController();
				const abortPermit = () => permitController.abort();
				pendingPermitControllers.add(permitController);
				if (task.signal) task.signal.addEventListener('abort', abortPermit, { once: true });
				const acquire = SHARED_REQUEST_COORDINATOR && task.countsTowardApi
					? SHARED_REQUEST_COORDINATOR.acquirePermit({
						priority: task.priority,
						signal: permitController.signal,
						shortBudget: shortWindowBudget,
						longBudget: longWindowBudget,
						minInterval: requestMinInterval,
						maxConcurrent: effectiveMaxConcurrent(),
						type: task.sharedType,
					})
					: Promise.resolve({ startedAt: Date.now(), recoveryProbe: false });
				acquire.then((permit) => {
					task.sharedPermitId = String(permit.permitId || '');
					if (disposed || permitController.signal.aborted) throw abortError();
					task.sharedRecoveryProbe = permit.recoveryProbe === true;
					const startedAt = Number(permit.startedAt) || Date.now();
					task.permittedAt = startedAt;
					task.waitReason = String(permit.waitReason || task.waitReason);
					runTask(task);
				}).catch((error) => {
					if (error && error.name === 'AbortError' && error.message && error.message !== 'Aborted') {
						task.controlReason = error.message;
					}
					if (task.sharedPermitId && SHARED_REQUEST_COORDINATOR) {
						SHARED_REQUEST_COORDINATOR.finishPermit(task.sharedPermitId);
						task.sharedPermitId = '';
					}
					if (tasksByKey.get(task.key) === task) finishQueuedTask(task, error);
				}).finally(() => {
					if (task.signal) task.signal.removeEventListener('abort', abortPermit);
					pendingPermitControllers.delete(permitController);
					sharedPermitPending = false;
					schedulePump();
				});
				break;
			}
			schedulePump();
		}

		const requestJSON = (url, fetchOptions = {}, requestOptions = {}) => {
			const key = String(requestOptions.key || url);
			const priority = Number.isFinite(requestOptions.priority)
				? requestOptions.priority
				: POST_REQUEST_PRIORITY.visible;
			const existing = tasksByKey.get(key);
			if (existing) {
				if (priority < existing.priority && existing.state === 'queued') existing.priority = priority;
				schedulePump();
				return existing.promise;
			}
			const callSite = String(requestOptions.callSite || requestFlowCallSite());
			const method = String(fetchOptions.method || 'GET').toUpperCase();
			const { fingerprint: endpointFingerprint, route: endpointRoute } = endpointRateLimitIdentity(url, method);
			const endpointLimit = endpointRateLimitState(endpointFingerprint);
			let controlError = null;
			let controlReason = '';
			if (endpointLimit) {
				controlError = requestStatusError('端点 429 冷却中', 429);
				controlError.rateLimitScope = 'endpoint';
				controlError.retryAt = endpointLimit.blockedUntil;
				controlReason = 'endpoint-cooldown';
			} else if (disposed) {
				controlError = abortError('context-close');
				controlReason = 'context-close';
			} else if (queue.length >= READER_REQUEST_QUEUE_LIMIT && priority >= POST_REQUEST_PRIORITY.nested) {
				controlError = abortError('queue-limit');
				controlReason = 'queue-limit';
			}
			if (controlError) {
				recordRequestFlowControl(url, {
					method, priority, callSite,
					type: requestOptions.sharedType,
					waitReason: controlReason === 'endpoint-cooldown' ? controlReason : '',
					controlReason,
				});
				return Promise.reject(controlError);
			}
			const { signal, ...options } = fetchOptions;
			let resolveTask;
			let rejectTask;
			const promise = new Promise((resolve, reject) => {
				resolveTask = resolve;
				rejectTask = reject;
			});
			const task = {
				key,
				url,
				fetchOptions: options,
				signal,
				priority,
				sequence: sequence++,
				queuedAt: requestFlowNow(),
				permittedAt: 0,
				waitReason: activeCount >= effectiveMaxConcurrent() ? 'concurrency' : queue.length ? 'priority' : '',
				callSite,
				attempt: 0,
				state: 'queued',
				controller: null,
				sharedRecoveryProbe: false,
				sharedPermitId: '',
				keepSharedPermit: false,
				controlRecorded: false,
				controlReason: '',
				resolve: resolveTask,
				reject: rejectTask,
				promise,
				permitOnly: requestOptions.permitOnly === true,
				countsTowardApi: requestOptions.countsTowardApi !== false,
				sharedType: String(requestOptions.sharedType ||
					requestFlowType(requestFlowUrl(url), 'fetch', options.method || 'GET')),
				endpointFingerprint,
				endpointRoute,
			};
			tasksByKey.set(key, task);
			queue.push(task);
			schedulePump();
			return promise;
		};

		const waitForPermit = (priority = POST_REQUEST_PRIORITY.visible, options = {}) => requestJSON('', {
			signal: options.signal,
		}, {
			key: `external-permit:${sequence}:${Date.now()}`,
			priority,
			permitOnly: true,
			countsTowardApi: options.countsTowardApi !== false,
			sharedType: options.type,
		});

		const trackExternalController = (controller) => {
			if (!controller || !isFunction(controller.abort)) return () => {};
			if (disposed) {
				controller.abort();
				return () => {};
			}
			externalControllers.add(controller);
			return () => externalControllers.delete(controller);
		};

		const cancelQueued = (key) => {
			const task = tasksByKey.get(String(key));
			if (!task || task.state !== 'queued') return false;
			const index = queue.indexOf(task);
			if (index >= 0) queue.splice(index, 1);
			task.controlReason = 'cancelled';
			finishQueuedTask(task, abortError('cancelled'));
			return true;
		};

		const resetRateLimits = () => {
			endpointRateLimitCircuits.clear();
			unknownRateLimitEvidence.length = 0;
			schedulePump();
			return SHARED_REQUEST_COORDINATOR?.resetRateLimits
				? SHARED_REQUEST_COORDINATOR.resetRateLimits()
				: Promise.resolve(true);
		};

		const destroy = () => {
			if (disposed) return;
			disposed = true;
			if (pumpTimer) clearTimeout(pumpTimer);
			pumpTimer = 0;
			queue.splice(0).forEach((task) => {
				task.controlReason = 'context-close';
				finishQueuedTask(task, abortError('context-close'));
			});
			pendingPermitControllers.forEach((controller) => controller.abort());
			pendingPermitControllers.clear();
			activeControllers.forEach((controller) => controller.abort());
			activeControllers.clear();
			externalControllers.forEach((controller) => controller.abort());
			externalControllers.clear();
			endpointRateLimitCircuits.clear();
			unknownRateLimitEvidence.length = 0;
		};

		const snapshot = () => {
			const at = Date.now();
			pruneEndpointRateLimitState(at);
			const endpointBlocks = [...endpointRateLimitCircuits.values()]
				.filter((state) => state.blockedUntil > at);
			sortQueue(at);
			const shared = SHARED_REQUEST_COORDINATOR?.snapshot(
				shortWindowBudget,
				longWindowBudget,
				requestMinInterval,
				effectiveMaxConcurrent(),
				at
			);
			return {
			active: activeCount,
			queued: queue.length + (sharedPermitPending ? 1 : 0),
			globalQueued: shared ? shared.queued : queue.length,
			globalActive: shared ? shared.active : activeCount,
			globalInstances: shared ? shared.instances : 1,
			globalMaxConcurrent: shared ? shared.maxConcurrent : effectiveMaxConcurrent(),
			globalMinInterval: shared ? shared.minInterval : requestMinInterval,
			coordinationMode: shared ? shared.coordinationMode : 'local',
			maxConcurrent: effectiveMaxConcurrent(),
			minInterval: requestMinInterval,
			configuredMinInterval: requestMinInterval,
			requestRateTarget: Math.round(requestRateTarget * 100),
			configuredShortWindowBudget: shortWindowBudget,
			configuredLongWindowBudget: longWindowBudget,
			shortWindowBudget: shared ? shared.shortBudget : shortWindowBudget,
			longWindowBudget: shared ? shared.longBudget : longWindowBudget,
			shortWindowCount: shared ? shared.shortCount : 0,
			longWindowCount: shared ? shared.longCount : 0,
			blockingReason: shared ? shared.blockingReason : '',
			nextPermitDelay: shared ? shared.nextPermitDelay : 0,
			coolingDown: !!(shared?.coolingDown),
			cooldownRemaining: shared ? shared.cooldownRemaining : 0,
			endpointCoolingDown: endpointBlocks.length > 0,
			endpointCooldownCount: endpointBlocks.length,
			endpointCooldownRemaining: endpointBlocks.reduce(
				(maximum, state) => Math.max(maximum, state.blockedUntil - at),
				0
			),
			learnedRateLimit: !!shared && (shared.rateLimited ||
				shared.shortBudget < shortWindowBudget || shared.longBudget < longWindowBudget),
			lastRateLimitWindow: shared ? shared.lastRateLimitWindow : '',
			};
		};

		return {
			requestJSON,
			waitForPermit,
			trackExternalController,
			cancelQueued,
			destroy,
			noteRateLimit,
			resetRateLimits,
			snapshot,
		};
	}

	async function apiSend(url, method, params, extraHeaders, requestOptions = {}) {
		const priority = Number.isFinite(requestOptions.priority)
			? requestOptions.priority
			: POST_REQUEST_PRIORITY.visible;
		const requestType = requestFlowType(requestFlowUrl(url), 'fetch', method);
		const requestScope = createRequestAbortScope();
		const initialScheduler = requestOptions.surviveReaderSwitch === true ? null : ACTIVE_READER_REQUEST_SCHEDULER;
		const untrackInitialController = isFunction(initialScheduler?.trackExternalController)
			? initialScheduler.trackExternalController(requestScope.controller)
			: () => {};
		let requestPermit = null;
		let sharedPermit = null;
		try {
			const opt = {
				method,
				credentials: 'include',
				headers: Object.assign({
					'Accept': 'application/json',
					'X-CSRF-Token': await discourseCsrfToken(),
					'X-Requested-With': 'XMLHttpRequest',
				}, extraHeaders || {}),
			};
			if (requestScope.signal.aborted) throw new DOMException('Aborted', 'AbortError');
			if (params) {
				if (requestOptions.jsonBody === true) {
					opt.headers['Content-Type'] = 'application/json; charset=UTF-8';
					opt.body = JSON.stringify(params);
				} else {
					opt.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
					const body = new URLSearchParams();
					Object.entries(params).forEach(([key, value]) => {
						if (Array.isArray(value)) value.forEach((item) => body.append(`${key}[]`, item));
						else if (value != null) body.append(key, value);
					});
					opt.body = body.toString();
				}
			}
			requestPermit = await waitForReaderRequestPermit(priority, {
				type: requestType,
				signal: requestScope.signal,
				controller: requestScope.controller,
				trackedScheduler: initialScheduler,
				surviveReaderSwitch: requestOptions.surviveReaderSwitch === true,
			});
			if (!requestPermit.scheduler) {
				sharedPermit = await acquireSharedReaderRequestPermit(
					priority, requestScope.signal, requestType, ACTIVE_READER_REQUEST_SCHEDULER);
			}
			requestScope.startTimeout();
			const res = await trackedReaderFetch(url, {
				...opt,
				signal: requestScope.signal,
				[REQUEST_FLOW_FETCH_META]: {
					source: 'reader',
					priority,
					recoveryProbe: !!(
						requestPermit.recoveryProbe ||
						(sharedPermit?.recoveryProbe)
					),
					queuedAt: requestPermit.queuedAt,
					permittedAt: requestPermit.permittedAt ||
						sharedPermit?.startedAt || requestFlowNow(),
					waitReason: requestPermit.waitReason ||
						sharedPermit?.waitReason || '',
					callSite: requestPermit.callSite,
					attempt: 1,
					type: requestType,
				},
			});
			if (res.status === 429) {
				const rateScheduler = ACTIVE_READER_REQUEST_SCHEDULER || requestPermit.scheduler;
				if (rateScheduler) {
					rateScheduler.noteRateLimit(res);
				} else noteSharedRequestRateLimit(res);
			}
			let data = {};
			try {
				data = await res.json();
			} catch (error) {
				if (requestScope.signal.aborted) throw error;
			}
			requestScope.stopTimeout();
			if (!res.ok) {
				const message = Array.isArray(data.errors) && data.errors.length
					? data.errors.join(';')
					: (data.error || data.message || `HTTP ${res.status}`);
				const statusError = requestStatusError(message, res.status);
				statusError.cloudflareMitigated = res.status === 403 &&
					requestFlowHeader(res.headers, ['cf-mitigated']).toLowerCase() === 'challenge';
				throw statusError;
			}
			const mutationTags = [...new Set([
				...responseCacheMutationTags(url, params, data),
				...(Array.isArray(requestOptions.invalidateTags) ? requestOptions.invalidateTags : []),
			])];
			if (mutationTags.length) await clearStoredResponseCache({ tags: mutationTags });
			return data;
		} catch (error) {
			if (requestScope.timedOut()) throw requestTimeoutError();
			throw error;
		} finally {
			if (isFunction(requestPermit?.release)) requestPermit.release();
			if (sharedPermit && SHARED_REQUEST_COORDINATOR) {
				SHARED_REQUEST_COORDINATOR.finishPermit(sharedPermit.permitId);
			}
			untrackInitialController();
			requestScope.cleanup();
		}
	}

	function runReaderActionOnce(ctx, key, operation) {
		if (!ctx || !key) return Promise.resolve().then(operation);
		if (!ctx.readerActionRequests) ctx.readerActionRequests = new Map();
		const normalizedKey = String(key);
		const existing = ctx.readerActionRequests.get(normalizedKey);
		if (existing) return existing;
		const request = Promise.resolve().then(operation).finally(() => {
			if (ctx.readerActionRequests && ctx.readerActionRequests.get(normalizedKey) === request) {
				ctx.readerActionRequests.delete(normalizedKey);
			}
		});
		ctx.readerActionRequests.set(normalizedKey, request);
		return request;
	}

	function setReaderControlBusy(control, busy) {
		if (!control) return;
		if (busy) {
			if (control.dataset.ldpRequestBusy !== '1') {
				control.dataset.ldpRequestWasDisabled = control.disabled ? '1' : '0';
				control.dataset.ldpRequestBusy = '1';
			}
			control.disabled = true;
			return;
		}
		if (control.dataset.ldpRequestBusy !== '1') return;
		control.disabled = control.dataset.ldpRequestWasDisabled === '1';
		delete control.dataset.ldpRequestBusy;
		delete control.dataset.ldpRequestWasDisabled;
	}

	function pollName(poll) {
		return String((poll?.name) || 'poll');
	}

	function pollSavedVotes(postData, poll) {
		const votes = postData?.polls_votes && postData.polls_votes[pollName(poll)];
		return Array.isArray(votes) ? votes.map(String) : [];
	}

	function pollIsClosed(poll, ctx) {
		if (!poll) return true;
		if (poll.status === 'closed' || (ctx?.topicData && ctx.topicData.archived)) return true;
		if (!poll.close) return false;
		const closeAt = Date.parse(poll.close);
		return Number.isFinite(closeAt) && closeAt <= Date.now();
	}

	function currentUserCanVoteInPoll(poll) {
		if (!ME_USERNAME) return false;
		const required = String((poll?.groups) || '').split(',').map((name) => name.trim().toLowerCase()).filter(Boolean);
		if (!required.length) return true;
		const groups = Array.isArray(ME_CURRENT_USER?.groups)
			? ME_CURRENT_USER.groups.map((group) => String((group && (group.name || group.full_name)) || '').toLowerCase())
			: [];
		return required.some((name) => groups.includes(name));
	}

	function pollCanShowResults(postData, poll, ctx) {
		if (!poll || !Array.isArray(poll.options) || !poll.options.some((option) => option && option.votes != null)) return false;
		const closed = pollIsClosed(poll, ctx);
		const resultRule = String(poll.results || 'always');
		if (resultRule === 'on_close' && !closed) return false;
		if (resultRule === 'staff_only' && !(ME_CURRENT_USER?.staff)) return false;
		if (resultRule === 'on_vote' && !pollSavedVotes(postData, poll).length) {
			const currentUserId = +(ME_CURRENT_USER?.id || 0);
			if (!currentUserId || +postData.user_id !== currentUserId) return false;
		}
		return true;
	}

	function pollChoiceLimits(poll) {
		const optionCount = Array.isArray(poll?.options) ? poll.options.length : 0;
		const min = Math.max(1, Number.parseInt(poll?.min, 10) || 1);
		const parsedMax = Number.parseInt(poll?.max, 10);
		const max = Math.max(min, Math.min(optionCount, Number.isFinite(parsedMax) ? parsedMax : optionCount));
		return { min, max };
	}

	function pollOptionHtml(option) {
		return String((option?.html) || '');
	}

	function renderPollChoices(postData, poll, ctx) {
		const name = pollName(poll);
		const saved = new Set(pollSavedVotes(postData, poll));
		const multiple = poll.type === 'multiple';
		const closed = pollIsClosed(poll, ctx);
		const canVote = !closed && currentUserCanVoteInPoll(poll) && poll.type !== 'ranked_choice';
		const inputType = multiple ? 'checkbox' : 'radio';
		const options = (poll.options || []).map((option, index) => {
			const id = String(option.id || '');
			return `<label class="ldp-poll-option">
				<input type="${inputType}" name="ldp-poll-${escAttr(postData.id)}-${escAttr(name)}" value="${escAttr(id)}"
					data-poll-option="${escAttr(id)}"${saved.has(id) ? ' checked' : ''}${canVote ? '' : ' disabled'}>
				<span class="ldp-poll-option-text">${pollOptionHtml(option) || `选项 ${index + 1}`}</span>
			</label>`;
		}).join('');
		return `<div class="ldp-poll-options">${options}</div>`;
	}

	function renderPollResults(poll) {
		const voters = Math.max(0, Number(poll.voters) || 0);
		const options = (poll.options || []).map((option) => {
			const votes = Math.max(0, Number(option.votes) || 0);
			const percent = voters > 0 ? Math.round(votes / voters * 100) : 0;
			return `<div class="ldp-poll-result">
				<div class="ldp-poll-result-label">${pollOptionHtml(option)}</div>
				<div class="ldp-poll-result-value">${votes} 票 · ${percent}%</div>
				<div class="ldp-poll-result-track"><span class="ldp-poll-result-bar" style="width:${Math.min(100, percent)}%"></span></div>
			</div>`;
		}).join('');
		return `<div class="ldp-poll-results">${options}</div>`;
	}

	function renderReaderPoll(container, postData, poll, ctx, showResultsOverride) {
		const name = pollName(poll);
		const existingTitle = container.querySelector('.poll-title, .ldp-poll-title');
		const title = existingTitle
			? `<div class="ldp-poll-title">${existingTitle.innerHTML}</div>`
			: poll?.title ? `<div class="ldp-poll-title">${esc(poll.title)}</div>` : '';
		const saved = pollSavedVotes(postData, poll);
		const closed = pollIsClosed(poll, ctx);
		const canShowResults = pollCanShowResults(postData, poll, ctx);
		const canVote = !closed && currentUserCanVoteInPoll(poll) && poll.type !== 'ranked_choice';
		const previousMode = container.dataset.ldpPollShowResults;
		let showResults = showResultsOverride == null
			? (previousMode === '1' || (previousMode === undefined && (saved.length > 0 || closed)))
			: !!showResultsOverride;
		if (!canShowResults) showResults = false;
		container.classList.add('ldp-reader-poll');
		container.dataset.ldpPollName = name;
		container.dataset.ldpPollShowResults = showResults ? '1' : '0';
		container.removeAttribute('aria-busy');

		let note = '';
		if (poll.type === 'ranked_choice') note = '排序投票请在原页面参与。';
		else if (closed) note = '投票已结束。';
		else if (!ME_USERNAME) note = '登录后可参与投票。';
		else if (!currentUserCanVoteInPoll(poll)) note = '你不在该投票允许参与的用户组中。';

		const { min, max } = pollChoiceLimits(poll);
		const multipleHint = poll.type === 'multiple' ? ` · 可选 ${min}${min === max ? '' : `–${max}`} 项` : '';
		const footerButtons = [];
		if (!showResults && poll.type === 'multiple' && canVote) {
			const valid = saved.length >= min && saved.length <= max;
			footerButtons.push(`<button class="ldp-poll-button ldp-poll-button-primary" type="button" data-poll-action="submit"${valid ? '' : ' disabled'}>${saved.length ? '更新投票' : '提交投票'}</button>`);
		}
		if (!showResults && saved.length && canVote) {
			footerButtons.push('<button class="ldp-poll-button" type="button" data-poll-action="remove">撤销投票</button>');
		}
		if (canShowResults && (!showResults || canVote)) {
			footerButtons.push(`<button class="ldp-poll-button" type="button" data-poll-action="toggle-results">${showResults && canVote ? '返回投票' : '结果'}</button>`);
		}
		const votersLabel = `${Math.max(0, Number(poll.voters) || 0)} 位投票人${multipleHint}`;
		container.innerHTML = `${title}
			${showResults ? renderPollResults(poll) : renderPollChoices(postData, poll, ctx)}
			${note ? `<div class="ldp-poll-note">${esc(note)}</div>` : ''}
			<div class="ldp-poll-footer"><span class="ldp-poll-meta">${votersLabel}</span>${footerButtons.join('')}</div>`;
	}

	function renderPostPolls(postNode, ctx, mode) {
		if (!postNode) return;
		const postData = postNode._ldpPostData || {};
		const polls = Array.isArray(postData.polls) ? postData.polls : [];
		if (!polls.length) return;
		const content = postNode.querySelector(':scope > .ldp-content');
		if (!content) return;
		const containers = [...content.querySelectorAll('.poll')];
		const used = new Set();
		polls.forEach((poll) => {
			const name = pollName(poll);
			let container = containers.find((item) => !used.has(item) &&
				String(item.dataset.ldpPollName || item.dataset.pollName || 'poll') === name);
			if (!container) container = containers.find((item) => !used.has(item)) || null;
			if (!container) {
				container = makeElement('div');
				container.className = 'poll';
				content.appendChild(container);
			}
			used.add(container);
			const showResults = mode && mode.name === name ? mode.showResults : null;
			renderReaderPoll(container, postData, poll, ctx, showResults);
		});
	}

	function readerPostCopies(postId, ctx, includeDetachedNested = false) {
		const copies = new Set();
		const key = String(postId);
		if (ctx?.modal) {
			ctx.modal.querySelectorAll('.ldp-post').forEach((node) => {
				if (String(node.dataset.postId || '') === key) copies.add(node);
			});
		}
		if (ctx?.streamNodeMap) {
			ctx.streamNodeMap.forEach((node) => {
				if (!node) return;
				if (String(node.dataset.postId || '') === key) copies.add(node);
				if (!includeDetachedNested || !node.querySelectorAll) return;
				node.querySelectorAll('.ldp-post').forEach((nested) => {
					if (String(nested.dataset.postId || '') === key) copies.add(nested);
				});
			});
		}
		return [...copies];
	}

	function persistReaderPostState(ctx, postData) {
		const postId = +(postData?.id || 0);
		if (!ctx || !ctx.topicId || !postId) return;
		const entry = TOPIC_DATA_CACHE.get(String(ctx.topicId));
		const topic = (entry?.topic) || ctx.topicData;
		const cachedPost = postsFromPayload(topic).find((post) => +post.id === postId);
		if (cachedPost) {
			Object.assign(cachedPost, postData);
			setCachedTopicData(ctx.topicId, topic);
			return;
		}
		if (isFunction(ctx.loader?.persistFetchedPosts)) {
			ctx.loader.persistFetchedPosts([postData]);
		}
	}

	function syncPostVotingComments(postId, ctx, comments) {
		const normalized = Array.isArray(comments) ? comments : [];
		let latestPostData = null;
		readerPostCopies(postId, ctx).forEach((copy) => {
			const postData = copy._ldpPostData || {};
			postData._ldpPvComments = normalized;
			postData.comments_count = Math.max(+postData.comments_count || 0, normalized.length);
			copy._ldpPostData = postData;
			latestPostData = postData;
			renderPostVoting(copy, ctx);
		});
		if (latestPostData) persistReaderPostState(ctx, latestPostData);
	}

	function setReaderPostControlsBusy(postId, ctx, selector, busy, sourcePost = null) {
		new Set([sourcePost, ...readerPostCopies(postId, ctx)].filter(Boolean)).forEach((copy) => {
			copy.querySelectorAll(selector).forEach((control) => setReaderControlBusy(control, busy));
		});
	}

	function updatePostLikeAction(postData, acted, count) {
		if (!Array.isArray(postData.actions_summary)) postData.actions_summary = [];
		let action = postData.actions_summary.find((item) => +item.id === 2);
		if (!action) {
			action = { id: 2, can_act: true };
			postData.actions_summary.push(action);
		}
		action.acted = !!acted;
		action.count = Math.max(0, +count || 0);
		return action;
	}

	function syncReaderLikeButton(button, action) {
		button.classList.toggle('liked', !!action.acted);
		button.dataset.acted = action.acted ? '1' : '0';
		setLabel(button, action.acted ? '取消点赞' : '点赞');
		const countEl = button.querySelector('.ldp-like-count');
		if (countEl) countEl.textContent = String(action.count);
	}

	function syncReaderPostLike(postId, ctx, acted, count) {
		let latestPostData = null;
		readerPostCopies(postId, ctx).forEach((copy) => {
			const postData = copy._ldpPostData || {};
			const action = updatePostLikeAction(postData, acted, count);
			copy._ldpPostData = postData;
			latestPostData = postData;
			copy.querySelectorAll('.ldp-like').forEach((button) => syncReaderLikeButton(button, action));
		});
		if (latestPostData) persistReaderPostState(ctx, latestPostData);
	}

	async function toggleReaderPostLike(postId, ctx, button, options) {
		const { setBusy, beforeSync, postData, persist, onError } = options;
		const countNode = button?.querySelector('.ldp-like-count');
		const acted = !!button && button.dataset.acted === '1';
		const count = Math.max(0, +(countNode?.textContent || 0));
		setBusy(true);
		try {
			const sourcePost = button?.closest('.ldp-post');
			const postModel = detachedNativePostModel(sourcePost, ctx);
			const likeAction = discourseModelValue(postModel, 'likeAction');
			if (!postModel || Number(discourseModelValue(postModel, 'id')) !== Number(postId) ||
					!likeAction || !isFunction(likeAction.togglePromise)) {
				throw new Error('Discourse 点赞接口尚未就绪');
			}
			const result = await likeAction.togglePromise(postModel);
			if (!result || typeof result.acted !== 'boolean') {
				throw new Error('Discourse 点赞结果缺失');
			}
			const currentLikeAction = discourseModelValue(postModel, 'likeAction') || likeAction;
			const nextActed = result.acted;
			const nativeCount = Number(discourseModelValue(currentLikeAction, 'count'));
			const nextCount = Math.max(
				0,
				Number.isFinite(nativeCount) ? nativeCount : count + (acted ? -1 : 1),
			);
			await clearStoredResponseCache({ tags: [`post:${postId}`, 'reactions-given'] });
			if (beforeSync) beforeSync(nextActed, nextCount);
			syncReaderPostLike(postId, ctx, nextActed, nextCount);
			if (persist) persistReaderPostState(ctx, postData);
			if (isFunction(ctx.refreshBookmarksPanel)) {
				ctx.refreshBookmarksPanel({ post: postData, topic: ctx.topicData, likeActed: nextActed });
			}
		} catch (error) {
			onError(error);
		} finally {
			setBusy(false);
		}
	}

	function syncReaderPollResponse(sourcePost, ctx, name, serverPoll, votes, showResults) {
		let latestPostData = null;
		readerPostCopies(sourcePost.dataset.postId, ctx).forEach((node) => {
			const postData = node._ldpPostData || {};
			const polls = Array.isArray(postData.polls) ? postData.polls : [];
			const current = polls.find((poll) => pollName(poll) === name);
			if (current && serverPoll) Object.assign(current, serverPoll);
			if (!postData.polls_votes) postData.polls_votes = {};
			if (votes == null) delete postData.polls_votes[name];
			else postData.polls_votes[name] = votes.map(String);
			node._ldpPostData = postData;
			latestPostData = postData;
			rememberPostData(ctx, postData);
			renderPostPolls(node, ctx, { name, showResults });
		});
		if (latestPostData) persistReaderPostState(ctx, latestPostData);
	}

	function setReaderPollBusy(post, ctx, name, busy) {
		const copies = readerPostCopies(post && post.dataset.postId, ctx);
		(copies.length ? copies : [post]).filter(Boolean).forEach((copy) => {
			copy.querySelectorAll('.ldp-reader-poll').forEach((container) => {
				if (container.dataset.ldpPollName !== name) return;
				if (busy) container.setAttribute('aria-busy', 'true');
				else container.removeAttribute('aria-busy');
				container.querySelectorAll('input,button').forEach((control) => setReaderControlBusy(control, busy));
			});
		});
	}

	async function updateReaderPollVote(post, ctx, name, options = null) {
		const removing = options == null;
		return runReaderActionOnce(ctx, `post:${post.dataset.postId}:poll:${name}`, async () => {
			setReaderPollBusy(post, ctx, name, true);
			try {
				const params = {
					post_id: post.dataset.postId,
					poll_name: name,
					...(removing ? {} : { options }),
				};
				const data = await apiSend(`${BASE}/polls/vote`, removing ? 'DELETE' : 'PUT', params);
				syncReaderPollResponse(post, ctx, name, data.poll, removing ? null : options, !removing);
			} catch (error) {
				readerPostCopies(post.dataset.postId, ctx).forEach((copy) => renderPostPolls(copy, ctx));
				alert(`${removing ? '撤销投票' : '投票'}失败:${error.message}`);
			} finally {
				setReaderPollBusy(post, ctx, name, false);
			}
		});
	}

	let reactionEmojiUrls = null;
	let reactionEmojiImageSources = null;
	let reactionEmojiHydration = null;

	function loadReactionEmojiRegistry() {
		if (reactionEmojiUrls) return Promise.resolve(reactionEmojiUrls);
		if (reactionEmojiHydration) return reactionEmojiHydration;
		const emojiStore = lookupDiscourse('service:emoji-store');
		const ajaxModule = lookupDiscourseModule('discourse/lib/ajax');
		const textModule = lookupDiscourseModule('discourse/lib/text');
		const loadEmojis = emojiStore?.list
			? Promise.resolve(emojiStore.list)
			: emojiStore && ajaxModule && isFunction(ajaxModule.ajax)
				? ajaxModule.ajax('/emojis.json').then((data) => {
					emojiStore.list = data;
					return data;
				})
				: Promise.reject(new Error('Discourse 表情服务尚未就绪'));
		reactionEmojiHydration = loadEmojis.then((data) => {
			const registry = new Map();
			Object.values(data || {}).forEach((items) => {
				if (!Array.isArray(items)) return;
				items.forEach((item) => {
					const name = String(item?.name || '');
					const url = String(item?.url ||
						(name && textModule && isFunction(textModule.emojiUrlFor)
							? textModule.emojiUrlFor(name)
							: '') || '');
					if (name && url) registry.set(name, url);
				});
			});
			reactionEmojiUrls = registry;
			reactionEmojiImageSources = new Set([...registry.values()]
				.map(comparableLightboxImageSource)
				.filter(Boolean));
			return registry;
		}).catch(() => {
			reactionEmojiUrls = new Map();
			reactionEmojiImageSources = new Set();
			return reactionEmojiUrls;
		}).finally(() => {
			reactionEmojiHydration = null;
		});
		return reactionEmojiHydration;
	}

	function reactionLabel(id) {
		const key = String(id || '').replace(/^:|:$/g, '');
		if (!key) return '';
		const textModule = lookupDiscourseModule('discourse/lib/text');
		const nativeSource = isFunction(textModule?.emojiUrlFor)
			? textModule.emojiUrlFor(key)
			: '';
		const source = reactionEmojiUrls?.get(key) || nativeSource;
		if (source) {
			const url = absoluteImageUrl(source);
			if (url) {
				return `<img class="emoji only-emoji" src="${escAttr(url)}" alt="${escAttr(key)}" loading="lazy" decoding="async">`;
			}
		}
		return '';
	}

	function renderPostReactions(postNode, ctx) {
		if (!postNode) return;
		const post = postNode._ldpPostData || {};
		const reactions = Array.isArray(post.reactions) ? post.reactions : [];
		const validSource = Array.isArray(ctx?.topicData && ctx.topicData.valid_reactions)
			? ctx.topicData.valid_reactions
			: reactions.map((item) => item.id);
		const valid = validSource.map((item) => String(
			typeof item === 'string' ? item : (item && (item.id || item.name)) || ''
		)).filter(Boolean);
		const renderedReactions = reactions.map((item) => ({ item, label: reactionLabel(item.id) }))
			.filter((entry) => entry.label);
		const renderedValid = valid.map((id) => ({ id, label: reactionLabel(id) }))
			.filter((entry) => entry.label);
		const canAdd = !!ME_USERNAME && renderedValid.length > 0;
		let root = postNode.querySelector(':scope > .ldp-reactions');
		const actions = root?.querySelector(':scope > .ldp-actions') || postNode.querySelector('.ldp-actions');
		if (!root) {
			root = makeElement('div');
			root.className = 'ldp-reactions';
			const content = postNode.querySelector('.ldp-content');
			content?.after(root);
		}
		if (!renderedReactions.length && !canAdd) {
			root.querySelector(':scope > .ldp-reaction-summary')?.remove();
			root.querySelector(':scope > .ldp-reaction-picker')?.remove();
			if (actions && actions.parentElement !== root) root.appendChild(actions);
			postNode.classList.remove('ldp-has-reactions');
			return;
		}
		postNode.classList.add('ldp-has-reactions');
		const current = String(post.current_user_reaction?.id || '');
		const summaryHtml = renderedReactions.map(({ item, label }) => {
			const id = String(item.id || '');
			return `<button class="ldp-reaction-chip${id === current ? ' on' : ''}" type="button" data-reaction="${escAttr(id)}" aria-label="${escAttr(id)}"><span>${label}</span><b>${Math.max(0, +item.count || 0)}</b></button>`;
		}).join('') + (canAdd
			? `<span class="ldp-reaction-picker-anchor"><button class="ldp-reaction-add ldp-btn" type="button" data-reaction-picker aria-expanded="false" aria-label="添加回应">${icon('heart')}</button><div class="ldp-reaction-picker" hidden>${renderedValid.map(({ id, label }) => `<button class="ldp-reaction-chip${String(id) === current ? ' on' : ''}" type="button" data-reaction="${escAttr(id)}" aria-label="${escAttr(id)}">${label}</button>`).join('')}</div></span>`
			: '');
		root.innerHTML = `<div class="ldp-reaction-summary">${summaryHtml}</div>`;
		if (actions) root.appendChild(actions);
	}

	function reactionStateSnapshot(post) {
		return {
			reactions: Array.isArray(post?.reactions)
				? post.reactions.map((item) => ({ ...item }))
				: [],
			current_user_reaction: post?.current_user_reaction
				? { ...post.current_user_reaction }
				: null,
			reaction_users_count: post?.reaction_users_count,
		};
	}

	function toggledPostReactionState(post, reaction) {
		const state = reactionStateSnapshot(post);
		const current = String(state.current_user_reaction?.id || '');
		const target = String(reaction || '');
		const adjustCount = (id, delta) => {
			if (!id) return;
			const item = state.reactions.find((entry) => String(entry.id || '') === id);
			if (item) item.count = Math.max(0, Number(item.count) + delta || 0);
			else if (delta > 0) state.reactions.push({ id, type: 'emoji', count: delta });
		};
		if (current) adjustCount(current, -1);
		if (current !== target) adjustCount(target, 1);
		state.reactions = state.reactions.filter((item) => Number(item.count) > 0);
		state.current_user_reaction = current === target
			? null
			: { id: target, type: 'emoji', can_undo: true };
		if (Number.isFinite(Number(state.reaction_users_count))) {
			state.reaction_users_count = Math.max(0, Number(state.reaction_users_count) + (current ? (current === target ? -1 : 0) : 1));
		}
		return state;
	}

	async function toggleReaderPostReaction(postNode, ctx, reaction, onSync, onError) {
		const postId = +(postNode && (postNode.dataset.postId || postNode._ldpPostData?.id) || 0);
		return runReaderActionOnce(ctx, `post:${postId}:reaction`, async () => {
			const previousState = reactionStateSnapshot(postNode._ldpPostData || {});
			const sync = (fresh) => {
				syncSpecialPostData(postNode, ctx, fresh);
				onSync?.(fresh);
			};
			setReaderPostControlsBusy(postId, ctx, '.ldp-reaction-chip,.ldp-reaction-add', true, postNode);
			sync(toggledPostReactionState(postNode._ldpPostData || {}, reaction));
			try {
				const reactionModule = lookupDiscourseModule(
					'discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction'
				);
				const CustomReaction = reactionModule?.default;
				const appEvents = lookupDiscourse('service:app-events');
				const postModel = detachedNativePostModel(postNode, ctx);
				if (!CustomReaction || !isFunction(CustomReaction.toggle) ||
						!appEvents || !isFunction(appEvents.on) ||
						!isFunction(appEvents.off) || !postModel) {
					throw new Error('Discourse 回应接口尚未就绪');
				}
				let serverPost = null;
				const onReactionToggled = (payload) => {
					const nextPost = payload?.post;
					if (Number(nextPost?.id) === postId) serverPost = nextPost;
				};
				appEvents.on('discourse-reactions:reaction-toggled', postNode, onReactionToggled);
				try {
					await CustomReaction.toggle(postModel, reaction, appEvents);
				} finally {
					appEvents.off('discourse-reactions:reaction-toggled', postNode, onReactionToggled);
				}
				if (!serverPost) throw new Error('Discourse 回应结果缺失');
				await clearStoredResponseCache({ tags: [`post:${postId}`, 'reactions-given'] });
				sync(serverPost);
				ctx.refreshBookmarksPanel?.({ post: postNode._ldpPostData, topic: ctx.topicData });
			} catch (error) {
				sync(previousState);
				onError(error);
			} finally {
				setReaderPostControlsBusy(postId, ctx, '.ldp-reaction-chip,.ldp-reaction-add', false, postNode);
			}
		});
	}

	function postVotingCommentHtml(comment) {
		const user = comment.user || {};
		const username = comment.username || user.username || '';
		const avatar = userCardAvatarHtml(
			persistentAvatarHtml(
				user.avatar_template,
				28,
				'ldp-pv-comment-avatar',
				comment.name || user.name || username || '?'
			),
			username
		);
		return `<div class="ldp-pv-comment" data-comment-id="${escAttr(comment.id)}">
			${avatar || '<span></span>'}
			<div class="ldp-pv-comment-body"><div class="ldp-pv-comment-meta"><b>${esc(comment.name || user.name || username)}</b> @${esc(username)} · ${esc(discourseRelativeTime(comment.created_at))}</div><div class="cooked">${readerMediaHtml(comment.cooked || esc(comment.raw || ''))}</div></div>
			<button class="ldp-pv-comment-vote${comment.user_voted ? ' on' : ''}" type="button" data-pv-comment-vote
				aria-label="赞同评论">${icon('chevronUp')}<span>${Math.max(0, +comment.post_voting_vote_count || 0)}</span></button>
		</div>`;
	}

	function postVotingComments(postData) {
		if (Array.isArray(postData?._ldpPvComments)) return postData._ldpPvComments;
		return Array.isArray(postData?.comments) ? postData.comments : [];
	}

	function readerPostVotingComments(postId, ctx) {
		return readerPostCopies(postId, ctx)
			.map((copy) => postVotingComments(copy._ldpPostData))
			.sort((left, right) => right.length - left.length)[0] || [];
	}

	function postVotingCapabilities(post, ctx) {
		const topicEnabled = !!(ctx?.isPostVoting);
		const voteControls = topicEnabled || ['post_voting_vote_count', 'post_voting_user_voted_direction', 'post_voting_has_votes']
			.some((key) => Object.hasOwn(post || {}, key));
		const comments = topicEnabled || ['comments_count', 'comments']
			.some((key) => Object.hasOwn(post || {}, key));
		return { voteControls, comments };
	}

	function renderPostVoting(postNode, ctx) {
		if (!postNode || !ctx) return;
		const post = postNode._ldpPostData || {};
		const capabilities = postVotingCapabilities(post, ctx);
		if (!capabilities.voteControls && !capabilities.comments) {
			postNode.classList.remove('ldp-post-voting-answer');
			postNode.querySelector(':scope > .ldp-pv-comments')?.remove();
			return;
		}
		const isQuestion = +post.post_number === 1;
		postNode.classList.toggle('ldp-post-voting-answer', !isQuestion);
		postNode.classList.remove('ldp-reply', 'ldp-reply-collapsible', 'ldp-nested-collapsed');
		postNode.querySelector(':scope > .ldp-nested-toggle')?.remove();
		postNode.querySelector(':scope > .ldp-children')?.remove();
		postNode.querySelector(':scope > .ldp-collapse-replies')?.remove();
		postNode.querySelector(':scope > .ldp-sub-loading')?.remove();
		if (isQuestion) {
			postNode.querySelector(':scope > .ldp-pv-comments')?.remove();
			return;
		}
		const directContent = postNode.querySelector(':scope > .ldp-content');
		const wrappedContent = postNode.querySelector(':scope > .ldp-pv-layout .ldp-content');
		const content = directContent || wrappedContent;
		if (capabilities.voteControls && content && !content.parentElement.classList.contains('ldp-pv-main')) {
			const main = makeElement('div');
			main.className = 'ldp-pv-main';
			content.before(main);
			main.appendChild(content);
			const layout = makeElement('div');
			layout.className = 'ldp-pv-layout';
			const votes = makeElement('div');
			votes.className = 'ldp-pv-votes';
			layout.append(votes, main);
			const head = postNode.querySelector(':scope > .ldp-post-head');
			head?.after(layout);
		}
		if (!capabilities.voteControls && wrappedContent) {
			const layout = wrappedContent.closest('.ldp-pv-layout');
			layout.before(wrappedContent);
			layout.remove();
		}
		const direction = String(post.post_voting_user_voted_direction || '');
		const votes = postNode.querySelector('.ldp-pv-votes');
		if (votes) votes.innerHTML = `<button class="ldp-pv-vote${direction === 'up' ? ' on' : ''}" type="button" data-pv-vote="up" aria-label="赞同">${icon('chevronUp')}</button><span class="ldp-pv-score">${+post.post_voting_vote_count || 0}</span><button class="ldp-pv-vote${direction === 'down' ? ' on' : ''}" type="button" data-pv-vote="down" aria-label="反对">${icon('chevronDown')}</button>`;
		let comments = postNode.querySelector(':scope > .ldp-pv-comments');
		if (!capabilities.comments) {
			comments?.remove();
			return;
		}
		const count = Math.max(0, +post.comments_count || (Array.isArray(post.comments) ? post.comments.length : 0));
		if (!comments) {
			comments = makeElement('div');
			comments.className = 'ldp-pv-comments';
			postNode.appendChild(comments);
		}
		const expanded = comments.dataset.expanded === '1';
		const loaded = postVotingComments(post);
		comments.innerHTML = `<button class="ldp-pv-comments-toggle" type="button" aria-expanded="${expanded}" aria-label="${expanded ? '收起评论' : '展开评论'}">${icon('messageSquare')}<span>${count}</span></button><div class="ldp-pv-comments-body"${expanded ? '' : ' hidden'}>${loaded.map(postVotingCommentHtml).join('')}<button class="ldp-pv-comments-more" type="button"${loaded.length < count ? '' : ' hidden'}>加载更多评论</button>${ME_USERNAME ? '<form class="ldp-pv-comment-form"><input class="ldp-pv-comment-input" name="raw" autocomplete="off" placeholder="写评论…" required><button class="ldp-pv-comment-submit" type="submit">发送</button></form>' : ''}</div>`;
		comments.dataset.expanded = expanded ? '1' : '0';
		hydratePersistentAvatarImages(comments);
	}

	function eventDateLabel(event) {
		const options = { year: 'numeric', month: 'short', day: 'numeric', hour: event.all_day ? undefined : '2-digit', minute: event.all_day ? undefined : '2-digit' };
		const start = event.starts_at ? new Date(event.starts_at) : null;
		const end = event.ends_at ? new Date(event.ends_at) : null;
		const format = (date) => date && Number.isFinite(date.getTime()) ? date.toLocaleString('zh-CN', options) : '';
		return [format(start), format(end)].filter(Boolean).join(' — ');
	}

	function renderPostEvent(postNode) {
		if (!postNode) return;
		const post = postNode._ldpPostData || {};
		const event = post.event;
		let card = postNode.querySelector(':scope > .ldp-event-card');
		if (!event) { if (card) card.remove(); return; }
		if (!card) { card = makeElement('section'); card.className = 'ldp-event-card'; postNode.querySelector(':scope > .ldp-content')?.after(card); }
		const watching = event.watching_invitee || {};
		const stats = event.stats || {};
		const closed = event.is_closed || event.is_expired || event.can_update_attendance === false;
		const location = event.location && (typeof event.location === 'string'
			? event.location
			: (event.location.name || event.location.address || event.location.display || ''));
		card.innerHTML = `<h3 class="ldp-event-title">${esc(event.name || '活动')}</h3><div class="ldp-event-grid"><div><b>${esc(eventDateLabel(event))}</b>${event.timezone ? ` <span class="ldp-event-meta">${esc(event.timezone)}</span>` : ''}</div>${location ? `<div>地点:${esc(location)}</div>` : ''}${event.description_html ? `<div>${event.description_html}</div>` : ''}<div class="ldp-event-meta">参加 ${+stats.going || 0} · 感兴趣 ${+stats.interested || 0}${event.max_attendees ? ` · 名额 ${+stats.going || 0}/${event.max_attendees}` : ''}${event.is_ongoing ? ' · 进行中' : event.is_expired ? ' · 已结束' : ''}</div></div><div class="ldp-event-actions">${['going:参加','interested:感兴趣','not_going:不参加'].map((item) => { const [status,label] = item.split(':'); return `<button class="ldp-event-action${watching.status === status ? ' on' : ''}" type="button" data-event-status="${status}"${closed ? ' disabled' : ''}>${label}</button>`; }).join('')}<a class="ldp-event-ics" href="${BASE}/discourse-post-event/events.ics?post_id=${encodeURIComponent(post.id)}" download>下载日历</a></div>`;
		prepareReaderMedia(card);
	}

	const SYSTEM_ACTION_LABELS = {
		closed: '主题已关闭', opened: '主题已重新开放', archived: '主题已归档', unarchived: '主题已取消归档',
		pinned: '主题已置顶', unpinned: '主题已取消置顶', autoclosed: '主题已自动关闭', split_topic: '帖子已拆分到新主题',
		merged: '主题已合并', moved: '帖子已移动', visible: '主题已公开', invisible: '主题已隐藏',
		'visible.enabled': '主题已公开', 'visible.disabled': '主题已取消公开', renamed: '主题标题已修改', assigned: '已指定',
	};

	const SYSTEM_ACTION_ICONS = {
		visible: 'check', invisible: 'eyeOff', 'visible.enabled': 'check', 'visible.disabled': 'eyeOff',
		assigned: 'userPlus',
	};

	function systemActionContentHtml(post, actionCode) {
		const actionLabel = SYSTEM_ACTION_LABELS[actionCode] || `系统操作:${actionCode.replace(/[._]/g, ' ')}`;
		if (actionCode !== 'assigned') return `<span>${esc(actionLabel)}</span>`;
		const username = cleanUsernameValue(post?.action_code_who);
		if (!username) return `<span>${esc(actionLabel)}</span>`;
		return `<span>${esc(`${actionLabel}给 `)}<a class="ldp-user-link ldp-system-action-user"
			href="${escAttr(userProfileUrl(username))}" target="_blank" rel="noopener" data-user-card="${escAttr(username)}">@${esc(username)}</a></span>`;
	}

	function postNoticeType(post) {
		const notice = post?.notice;
		return notice && typeof notice === 'object' ? String(notice.type || '') : '';
	}

	function postNoticeIdentity(post, type = postNoticeType(post)) {
		if (type === 'new_user') return { label: '新用户', title: '新用户,首次发帖', icon: 'userPlus' };
		if (type === 'returning_user') return { label: '回归用户', title: '回归用户,久未发帖', icon: 'rotateCcw' };
		if (type !== 'custom') return null;
		const notice = post?.notice;
		const customNotice = notice && typeof notice === 'object'
			? `${String(notice.raw || '')} ${String(notice.cooked || '')}`
			: '';
		return /\bpremium\b/i.test(customNotice)
			? { label: 'Premium', title: 'Premium', icon: 'check' }
			: { label: '富可敌国', title: '富可敌国', icon: 'tag' };
	}

	function ensureNestedReplyToggle(post, referenceNode) {
		if (post.querySelector(':scope > .ldp-nested-toggle')) return;
		const toggle = makeElement('button');
		toggle.className = 'ldp-btn ldp-nested-toggle';
		toggle.type = 'button';
		post.insertBefore(toggle, referenceNode);
	}

	function renderSpecialPostState(postNode) {
		if (!postNode) return;
		const post = postNode._ldpPostData || {};
		const head = postNode.querySelector(':scope > .ldp-post-head');
		let hiddenBadge = head?.querySelector(':scope > .ldp-hidden-badge');
		const hiddenCollapsible = !!post.hidden && !postNode.classList.contains('ldp-nested-preview');
		const wasHiddenCollapsible = postNode.classList.contains('ldp-hidden-collapsible');
		if (post.hidden) {
			if (!hiddenBadge && head) {
				hiddenBadge = makeElement('span');
				hiddenBadge.className = 'ldp-special-badge ldp-hidden-badge warn';
				hiddenBadge.textContent = '已隐藏';
				const floor = head.querySelector(':scope > .ldp-floor');
				head.insertBefore(hiddenBadge, floor || null);
			}
		} else if (hiddenBadge) hiddenBadge.remove();
		if (hiddenCollapsible) {
			postNode.classList.add('ldp-hidden-collapsible');
			ensureNestedReplyToggle(postNode, head || postNode.firstChild);
			if (!wasHiddenCollapsible) setNestedReplyCollapsed(postNode, true);
		} else if (wasHiddenCollapsible) {
			setNestedReplyCollapsed(postNode, false);
			postNode.classList.remove('ldp-hidden-collapsible');
			if (!postNode.classList.contains('ldp-reply-collapsible')) {
				postNode.querySelector(':scope > .ldp-nested-toggle')?.remove();
			}
		}
		let badges = postNode.querySelector(':scope > .ldp-special-badges');
		const items = [];
		if (+post.post_type === 4) items.push(['私信回复', '']);
		if (+post.post_type === 2) items.push(['管理操作', 'warn']);
		if (post.wiki) items.push(['Wiki', '']);
		if (post.deleted_at) items.push(['已删除', 'danger']);
		if (post.locked) items.push(['已锁定', 'warn']);
		if (items.length) {
			if (!badges) { badges = makeElement('div'); badges.className = 'ldp-special-badges'; head?.after(badges); }
			badges.innerHTML = items.map(([label, type]) => `<span class="ldp-special-badge ${type}">${label}</span>`).join('');
		} else if (badges) badges.remove();
		postNode.classList.toggle('ldp-whisper', +post.post_type === 4);
		postNode.classList.toggle('ldp-system-post', +post.post_type === 2 || +post.post_type === 3);
		let notice = postNode.querySelector(':scope > .ldp-special-notice');
		const noticeType = postNoticeType(post);
		if (post.notice && !['new_user', 'returning_user', 'custom'].includes(noticeType)) {
			if (!notice) { notice = makeElement('div'); notice.className = 'ldp-special-notice'; head?.after(notice); }
			notice.innerHTML = typeof post.notice === 'string' ? post.notice : esc(post.notice.type || '站点提示');
		} else if (notice) notice.remove();
		let action = postNode.querySelector(':scope > .ldp-system-action');
		const actionCode = String(post.action_code || '');
		const needsAction = (+post.post_type === 2 || +post.post_type === 3) && actionCode;
		postNode.classList.toggle('ldp-system-action-compact', needsAction && actionCode === 'assigned');
		if (needsAction) {
			if (!action) { action = makeElement('div'); action.className = 'ldp-system-action'; head?.after(action); }
			action.innerHTML = `${icon(SYSTEM_ACTION_ICONS[actionCode] || 'settings')}${systemActionContentHtml(post, actionCode)}`;
		} else if (action) action.remove();
	}

	function acceptedTopicAnswers(ctx) {
		const topic = ctx?.topicData;
		if (!topic) return [];
		const answersByPostNumber = new Map();
		const addAnswer = (answer) => {
			if (!answer) return;
			const candidate = typeof answer === 'object' ? answer : { post_number: answer };
			const postNumber = +(candidate.post_number || 0);
			if (postNumber <= 1) return;
			answersByPostNumber.set(postNumber, Object.assign(
				{},
				answersByPostNumber.get(postNumber) || {},
				candidate,
				{ post_number: postNumber }
			));
		};
		if (Array.isArray(topic.accepted_answers)) topic.accepted_answers.forEach(addAnswer);
		addAnswer(topic.accepted_answer);
		postsFromPayload(topic).forEach((post) => {
			if (post?.accepted_answer) addAnswer(post);
		});
		if (ctx.streamNodeMap) {
			ctx.streamNodeMap.forEach((node) => {
				const post = node?._ldpPostData;
				if (post?.accepted_answer) addAnswer(post);
			});
		}
		return [...answersByPostNumber.values()].sort((a, b) => a.post_number - b.post_number);
	}

	function renderSolvedAnswerCard(postNode, ctx) {
		if (!postNode) return;
		const post = postNode._ldpPostData || {};
		const answers = acceptedTopicAnswers(ctx);
		let card = postNode.querySelector(':scope > .ldp-solved-card');
		const isStarter = +post.post_number === 1 && !postNode.classList.contains('ldp-nested-preview');
		if (!isStarter || !answers.length) {
			if (card) card.remove();
			return;
		}

		const topicPosts = postsFromPayload(ctx.topicData);
		if (!card) {
			card = makeElement('section');
			card.className = 'ldp-solved-card';
			const content = postNode.querySelector(':scope > .ldp-content');
			if (!content) return;
			content.after(card);
		}
		card.innerHTML = `<div class="ldp-solved-head">${icon('check')}<span>${answers.length > 1 ? `已解决 · ${answers.length} 个答案` : '已解决'}</span></div>${answers.map((answer) => {
			const postNumber = +answer.post_number;
			const acceptedNode = ctx.streamNodeMap?.get(postNumber);
			const acceptedPost = acceptedNode?._ldpPostData ||
				topicPosts.find((item) => +item.post_number === postNumber) || {};
			const username = String(answer.username || acceptedPost.username || '');
			const name = String(answer.name || acceptedPost.name || username || '已解决回复');
			const profileUrl = username ? userProfileUrl(username) : '';
			const avatar = persistentAvatarHtml(
				answer.avatar_template || acceptedPost.avatar_template,
				32,
				'ldp-solved-avatar',
				name
			);
			const time = discourseRelativeTime(answer.created_at || acceptedPost.created_at);
			const answerHtml = String(answer.cooked || acceptedPost.cooked || answer.excerpt || acceptedPost.excerpt || '').trim();
			return `<div class="ldp-solved-body" data-solved-post-number="${postNumber}">
				<div class="ldp-solved-author-row">
					${avatar ? `<a class="ldp-user-link" href="${escAttr(profileUrl)}" target="_blank" rel="noopener" data-user-card="${escAttr(username)}">${avatar}</a>` : ''}
					${profileUrl ? `<a class="ldp-user-link ldp-solved-author" href="${escAttr(profileUrl)}" target="_blank" rel="noopener" data-user-card="${escAttr(username)}">${esc(name)}</a>` : `<span class="ldp-solved-author">${esc(name)}</span>`}
					${username ? `<span class="ldp-solved-username">@${esc(username)}</span>` : ''}
					${time ? `<span>· ${esc(time)}</span>` : ''}
					<button class="ldp-solved-floor ldp-jump-self" type="button" data-target-post-number="${postNumber}" aria-label="跳到楼层 #${postNumber}">#${postNumber}</button>
				</div>
				${answerHtml ? `<div class="ldp-solved-excerpt ldp-content cooked">${readerMediaHtml(answerHtml, { inlineOneboxes: true })}</div>` : '<div class="ldp-solved-excerpt">查看被采纳的完整回复。</div>'}
				<button class="ldp-solved-jump ldp-jump-self" type="button" data-target-post-number="${postNumber}">阅读更多</button>
			</div>`;
		}).join('')}`;
	}

	function prepareReaderImageCarousels(root) {
		root.querySelectorAll('.d-image-grid[data-mode="carousel"]').forEach((grid) => {
			if (grid.dataset.ldpCarouselPrepared === '1') return;
			const items = [...grid.querySelectorAll('.lightbox-wrapper')]
				.filter((item) => item.closest('.d-image-grid') === grid);
			if (items.length < 2) return;
			grid.dataset.ldpCarouselPrepared = '1';
			grid.classList.add('ldp-media-carousel');
			const track = makeElement('div');
			track.className = 'ldp-media-carousel-track';
			track.tabIndex = 0;
			track.setAttribute('role', 'region');
			setLabel(track, `多图轮播,共 ${items.length} 张`);
			items.forEach((item) => track.append(item));
			const controls = makeElement('div');
			controls.className = 'ldp-media-carousel-controls';
			const previous = makeElement('button');
			previous.type = 'button';
			setLabel(previous, '上一张图片');
			previous.innerHTML = icon('chevronRight');
			const status = makeElement('span');
			status.className = 'ldp-media-carousel-status';
			status.setAttribute('aria-live', 'polite');
			const next = makeElement('button');
			next.type = 'button';
			setLabel(next, '下一张图片');
			next.innerHTML = icon('chevronRight');
			controls.append(previous, status, next);
			grid.replaceChildren(track, controls);

			let activeIndex = 0;
			let syncFrame = 0;
			const sync = () => {
				syncFrame = 0;
				activeIndex = items.reduce((closest, item, index) => (
					Math.abs(item.offsetLeft - track.scrollLeft) < Math.abs(items[closest].offsetLeft - track.scrollLeft)
						? index
						: closest
				), 0);
				previous.disabled = activeIndex === 0;
				next.disabled = activeIndex === items.length - 1;
				status.textContent = `${activeIndex + 1} / ${items.length}`;
			};
			const scheduleSync = () => {
				if (!syncFrame) syncFrame = requestAnimationFrame(sync);
			};
			const show = (index) => {
				const target = items[Math.max(0, Math.min(items.length - 1, index))];
				if (!target) return;
				const reducedMotion = prefersReducedMotion();
				track.scrollTo({ left: target.offsetLeft, behavior: reducedMotion ? 'auto' : 'smooth' });
			};
			controls.addEventListener('click', (event) => {
				const button = event.target instanceof Element ? event.target.closest('button') : null;
				if (button === previous) show(activeIndex - 1);
				else if (button === next) show(activeIndex + 1);
			});
			track.addEventListener('scroll', scheduleSync, { passive: true });
			sync();
		});
	}

	function prepareReaderFrame(frame) {
		frame.loading = 'lazy';
		try {
			const url = new URL(frame.getAttribute('src') || '', BASE);
			if (url.hostname !== 'player.bilibili.com') return;
			url.searchParams.set('autoplay', '0');
			frame.src = url.href;
			frame.classList.add('ldp-bilibili-player');
			frame.setAttribute('allow', 'fullscreen; picture-in-picture');
			frame.setAttribute('allowfullscreen', '');
		} catch (error) {}
	}

	function readerHlsSource(video) {
		const candidates = [video, ...video.querySelectorAll('source')];
		for (const candidate of candidates) {
			const source = String(candidate.getAttribute('src') || '').trim();
			if (!source) continue;
			const type = String(candidate.getAttribute('type') || '').toLowerCase();
			let isHls = /(?:vnd\.apple\.mpegurl|x-mpegurl)/.test(type);
			if (!isHls) {
				try { isHls = /\.m3u8$/i.test(new URL(source, BASE).pathname); } catch (error) {}
			}
			if (isHls) return new URL(source, BASE).href;
		}
		return '';
	}

	function bindReaderHls(video) {
		if (!video || video.dataset.ldpHlsBound === '1') return;
		const source = readerHlsSource(video);
		if (!source) return;
		const nativeHls = !!video.canPlayType('application/vnd.apple.mpegurl') && 'ManagedMediaSource' in window;
		if (nativeHls) return;
		const HlsPlayer = window.Hls;
		if (!HlsPlayer || !isFunction(HlsPlayer.isSupported) || !HlsPlayer.isSupported()) return;
		const player = new HlsPlayer();
		video.dataset.ldpHlsBound = '1';
		video._ldpHlsPlayer = player;
		player.loadSource(source);
		player.attachMedia(video);
	}

	function destroyReaderMedia(root) {
		if (!root || !isFunction(root.querySelectorAll)) return;
		root.querySelectorAll('video').forEach((video) => {
			const player = video._ldpHlsPlayer;
			if (isFunction(player?.destroy)) player.destroy();
			video._ldpHlsPlayer = null;
			delete video.dataset.ldpHlsBound;
		});
	}

	function suspendReaderMedia(root) {
		if (!root || !isFunction(root.querySelectorAll)) return;
		root.querySelectorAll('video,audio').forEach((media) => {
			try { media.pause(); } catch (error) {}
		});
		destroyReaderMedia(root);
	}

	function activateReaderMedia(root) {
		if (!root || !isFunction(root.querySelectorAll) || document.visibilityState !== 'visible') return;
		root.querySelectorAll('video').forEach(bindReaderHls);
	}

	function prepareReaderMedia(root, options = {}) {
		if (!root || !isFunction(root.querySelectorAll)) return;
		root.querySelectorAll('[title]').forEach((element) => element.removeAttribute('title'));
		if (options.interactive !== false) prepareReaderImageCarousels(root);
		root.querySelectorAll('iframe').forEach(prepareReaderFrame);
		root.querySelectorAll('video,audio').forEach((media) => {
			media.removeAttribute('autoplay');
			media.autoplay = false;
			if (!media.hasAttribute('preload')) media.preload = 'metadata';
			if (media.tagName === 'VIDEO') media.playsInline = true;
		});
		root.querySelectorAll('img').forEach((img) => {
			if (img.classList.contains('emoji')) return;
			img.loading = 'lazy';
			img.decoding = 'async';
		});
	}

	function prepareReaderCodeBlocks(root) {
		if (!root || !isFunction(root.querySelectorAll)) return;
		root.querySelectorAll('pre').forEach((pre) => {
			if (pre.closest('.ldp-code-block')) return;
			const block = makeElement('div');
			block.className = 'ldp-code-block';
			const actions = makeElement('div');
			actions.className = 'ldp-code-block-actions';
			actions.innerHTML = `
        ${iconActionButtons('ldp-code-block-action', 'data-reader-code-action', [
					['copy', '复制文本', 'copy'], ['preview', '在阅读器内预览文本', 'maximize2'],
				])}`;
			pre.before(block);
			block.append(pre, actions);
		});
	}

	async function copyReaderCode(source, button) {
		if (!source || !button || button.disabled) return;
		button.disabled = true;
		try {
			const text = 'value' in source ? source.value : source.textContent;
			await writeSelectionQuoteToClipboard(text || '');
			button.innerHTML = icon('check');
			setLabel(button, '已复制');
			showSelectionToast('文本已复制');
			setTimeout(() => {
				if (!button.isConnected) return;
				button.innerHTML = icon('copy');
				setLabel(button, '复制文本');
				button.disabled = false;
			}, 1200);
		} catch (error) {
			button.disabled = false;
			showSelectionToast('复制失败,请重试');
		}
	}

	function readerCodeDownloadExtension(pre) {
		const code = pre?.querySelector('code');
		const classNames = `${pre?.className || ''} ${code?.className || ''}`;
		const language = (classNames.match(/(?:language|lang)-([a-z0-9_+-]+)/i) || [])[1]?.toLowerCase() || '';
		return ({
			javascript: 'js', js: 'js', typescript: 'ts', ts: 'ts', python: 'py', py: 'py',
			bash: 'sh', shell: 'sh', sh: 'sh', html: 'html', css: 'css', json: 'json', sql: 'sql',
			rust: 'rs', go: 'go', java: 'java', c: 'c', cpp: 'cpp', csharp: 'cs', php: 'php',
			ruby: 'rb', yaml: 'yml', yml: 'yml', markdown: 'md', md: 'md',
		})[language] || 'txt';
	}

	function downloadReaderCodeCopy(pre, text) {
		const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
		const extension = readerCodeDownloadExtension(pre);
		const blob = new window.Blob([String(text || '')], { type: 'text/plain;charset=utf-8' });
		triggerBrowserDownload(blob, `linuxdo-code-copy-${timestamp}.${extension}`);
		showSelectionToast('编辑副本已下载到本地');
	}

	function openReaderCodePreview(pre, modal) {
		if (!pre || !modal) return;
		const overlay = modal.closest('.ldp-overlay');
		if (isFunction(overlay?._ldpCloseCodePreview)) overlay._ldpCloseCodePreview(false, true);
		const previousFocus = readerActiveElement();
		const layer = makeElement('section');
		layer.className = 'ldp-code-preview-layer';
		layer.setAttribute('role', 'dialog');
		layer.setAttribute('aria-modal', 'true');
		setLabel(layer, '文本预览');
		const sourceStyle = getComputedStyle(pre);
		layer.style.setProperty('--ldp-code-preview-font-family', sourceStyle.fontFamily);
		layer.style.setProperty('--ldp-code-preview-font-size', sourceStyle.fontSize);
		layer.style.setProperty('--ldp-code-preview-font-weight', sourceStyle.fontWeight);
		layer.style.setProperty('--ldp-code-preview-line-height', sourceStyle.lineHeight);
		layer.style.setProperty('--ldp-code-preview-tab-size', sourceStyle.tabSize || '4');
		layer.innerHTML = `
			<header class="ldp-code-preview-head">
				<strong>文本预览</strong>
				<span class="ldp-code-preview-actions">
					${iconActionButtons('ldp-code-block-action', 'data-reader-code-preview-action', [
						['edit', '编辑文本副本', 'pencil'], ['save', '保存编辑副本到本地', 'download', ' hidden'],
						['copy', '复制文本', 'copy'], ['close', '关闭文本预览', 'x'],
					])}
				</span>
			</header>
			<div class="ldp-code-preview-body"></div>`;
		const previewPre = pre.cloneNode(true);
		const previewBody = layer.querySelector('.ldp-code-preview-body');
		const previewTitle = layer.querySelector('.ldp-code-preview-head strong');
		const editButton = layer.querySelector('[data-reader-code-preview-action="edit"]');
		const saveButton = layer.querySelector('[data-reader-code-preview-action="save"]');
		const closeButton = layer.querySelector('[data-reader-code-preview-action="close"]');
		previewBody.append(previewPre);
		let activeTextSource = previewPre;
		let editing = false;
		let closed = false;
		const restorePreview = () => {
			activeTextSource = previewPre;
			editing = false;
			previewBody.replaceChildren(previewPre);
			previewTitle.textContent = '文本预览';
			setLabel(layer, '文本预览');
			editButton.hidden = false;
			saveButton.hidden = true;
			setLabel(closeButton, '关闭文本预览');
			closeButton.focus();
		};
		const close = (restoreFocus = true, force = false) => {
			if (closed) return;
			if (editing && !force) {
				restorePreview();
				return;
			}
			closed = true;
			document.removeEventListener('keydown', onKeyDown, true);
			layer.remove();
			if (overlay && overlay._ldpCloseCodePreview === close) overlay._ldpCloseCodePreview = null;
			if (restoreFocus && previousFocus && previousFocus.isConnected) previousFocus.focus();
		};
		const onKeyDown = (event) => {
			if (event.key !== 'Escape') return;
			event.preventDefault();
			event.stopImmediatePropagation();
			close();
		};
		layer.addEventListener('click', (event) => {
			const action = event.target.closest('[data-reader-code-preview-action]');
			if (!action) return;
			consumeEvent(event);
			if (action.dataset.readerCodePreviewAction === 'copy') {
				void copyReaderCode(activeTextSource, action);
			} else if (action.dataset.readerCodePreviewAction === 'save') {
				downloadReaderCodeCopy(pre, activeTextSource.value);
			} else if (action.dataset.readerCodePreviewAction === 'edit') {
				const editor = makeElement('textarea');
				editor.className = 'ldp-code-preview-editor';
				editor.value = previewPre.textContent || '';
				editor.spellcheck = false;
				setLabel(editor, '文本编辑副本');
				editor.addEventListener('keydown', (keyEvent) => {
					if (keyEvent.key !== 'Tab') return;
					keyEvent.preventDefault();
					editor.setRangeText('\t', editor.selectionStart, editor.selectionEnd, 'end');
				});
				activeTextSource = editor;
				editing = true;
				previewBody.replaceChildren(editor);
				previewTitle.textContent = '文本编辑(副本)';
				setLabel(layer, '文本编辑副本');
				saveButton.hidden = false;
				editButton.hidden = true;
				setLabel(closeButton, '返回文本预览');
				editor.focus();
			} else {
				close();
			}
		});
		modal.append(layer);
		bindFloatingSurfaceWheel(layer);
		if (overlay) overlay._ldpCloseCodePreview = close;
		document.addEventListener('keydown', onKeyDown, true);
		layer.querySelector('[data-reader-code-preview-action="close"]')?.focus();
	}

	function prepareReaderHashtags(root) {
		if (!root || !isFunction(root.querySelectorAll)) return;
		const iconTemplate = makeElement('template');
		iconTemplate.innerHTML = icon('tag', 'ldp-hashtag-icon');
		root.querySelectorAll('.hashtag-cooked').forEach((hashtag) => {
			const iconHost = hashtag.matches('a') ? hashtag : hashtag.querySelector('a') || hashtag;
			if (iconHost.querySelector('img.emoji')) return;
			const existingSvg = iconHost.querySelector('svg');
			if (existingSvg?.querySelector('path,circle,rect,ellipse,line,polyline,polygon')) return;
			const tagIcon = iconTemplate.content.firstElementChild.cloneNode(true);
			tagIcon.setAttribute('focusable', 'false');
			const placeholder = iconHost.querySelector('.hashtag-icon-placeholder');
			if (placeholder) placeholder.replaceWith(tagIcon);
			else if (existingSvg) existingSvg.replaceWith(tagIcon);
			else iconHost.prepend(tagIcon);
		});
	}

	function prepareReaderUserMentions(root) {
		if (!root || !isFunction(root.querySelectorAll)) return;
		root.querySelectorAll('a.mention').forEach((link) => {
			let username = cleanUsernameValue(link.dataset.username);
			if (!username) {
				try {
					const url = new URL(link.getAttribute('href') || '', BASE);
					const match = url.origin === BASE && url.pathname.match(/^\/u\/([^/]+)\/?$/i);
					username = match ? decodeURIComponent(match[1]) : '';
				} catch (error) {}
			}
			if (!username) username = cleanUsernameValue(link.textContent);
			if (!username) return;
			link.classList.add('ldp-user-link');
			link.dataset.userCard = username;
		});
	}

	const READER_CALLOUT_ICONS = Object.freeze(Object.fromEntries([
		['list', ['abstract', 'example', 'summary', 'tldr']],
		['alertTriangle', ['attention', 'bug', 'caution', 'danger', 'error', 'warning']],
		['check', ['check', 'done', 'success', 'todo']],
		['messageSquare', ['cite', 'quote']],
		['circleX', ['fail', 'failure', 'missing']],
		['info', ['faq', 'help', 'info', 'question']],
		['lightbulb', ['hint', 'important', 'tip']],
		['pencil', ['note']],
	].flatMap(([iconName, types]) => types.map((type) => [type, iconName]))));

	function prepareReaderCallouts(root) {
		if (!root || !isFunction(root.querySelectorAll)) return;
		root.querySelectorAll('blockquote').forEach((quote) => {
			const firstBlock = quote.firstElementChild;
			if (!firstBlock) return;
			const walker = document.createTreeWalker(firstBlock, NodeFilter.SHOW_TEXT);
			let markerNode = null;
			while (walker.nextNode()) {
				if (String(walker.currentNode.nodeValue || '').trim()) {
					markerNode = walker.currentNode;
					break;
				}
			}
			if (!markerNode) return;
			const match = String(markerNode.nodeValue || '').match(/^\s*\[!([a-z][a-z0-9_-]*)\]([+-])?\s*/i);
			if (!match) return;
			const type = match[1].toLowerCase();
			if (!READER_CALLOUT_ICONS[type]) return;
			markerNode.nodeValue = String(markerNode.nodeValue || '').slice(match[0].length);
			quote.classList.add('ldp-callout', `ldp-callout--${type}`);
			if (match[2]) {
				const body = makeElement('div');
				body.className = 'ldp-callout-body';
				const firstBreak = firstBlock.querySelector('br');
				if (firstBreak) {
					const range = document.createRange();
					range.setStartAfter(firstBreak);
					range.setEnd(firstBlock, firstBlock.childNodes.length);
					body.append(range.extractContents());
					firstBreak.remove();
				}
				let sibling = firstBlock.nextSibling;
				while (sibling) {
					const next = sibling.nextSibling;
					body.append(sibling);
					sibling = next;
				}
				if (String(body.textContent || '').trim() || body.querySelector('*')) {
					quote.classList.add('ldp-callout--foldable');
					quote.append(body);
					const toggle = makeElement('button');
					toggle.type = 'button';
					toggle.className = 'ldp-callout-toggle';
					toggle.innerHTML = icon('chevronDown');
					const setExpanded = (expanded) => {
						body.hidden = !expanded;
						quote.classList.toggle('ldp-callout--collapsed', !expanded);
						setExpanded(toggle, expanded);
						setLabel(toggle, expanded ? '收起提示内容' : '展开提示内容');
					};
					toggle.addEventListener('click', () => setExpanded(toggle.getAttribute('aria-expanded') !== 'true'));
					quote.append(toggle);
					setExpanded(match[2] === '+');
				}
			}
			const marker = makeElement('span');
			marker.className = 'ldp-callout-icon';
			marker.innerHTML = icon(READER_CALLOUT_ICONS[type]);
			quote.prepend(marker);
		});
	}

	function prepareReaderPostQuotes(root) {
		if (!root || !isFunction(root.querySelectorAll)) return;
		root.querySelectorAll('aside.quote').forEach((quote) => {
			const title = quote.querySelector(':scope > .title');
			const body = quote.querySelector(':scope > blockquote');
			if (!title || !body) return;
			quote.classList.add('ldp-post-quote');
			quote.dataset.ldpQuoteExpanded = '0';
			quote._ldpQuoteExcerptHtml = body.innerHTML;
			title.classList.add('ldp-quote-title');
			const username = cleanUsernameValue(quote.dataset.username);
			const avatar = [...title.querySelectorAll('img')].find((image) => !image.classList.contains('emoji'));
			if (username && avatar) {
				avatar.classList.add('ldp-user-avatar-card');
				avatar.dataset.userCard = username;
			}
			const targetPostNumber = Math.max(0, +(quote.dataset.post || 0));
			const targetTopicId = Math.max(0, +(quote.dataset.topic || 0));
			let controls = title.querySelector(':scope > .quote-controls');
			if (!controls) {
				controls = makeElement('span');
				controls.className = 'quote-controls';
				title.append(controls);
			}
			controls.classList.add('ldp-quote-controls');
			controls.replaceChildren();
			const toggle = makeElement('button');
			toggle.type = 'button';
			toggle.className = 'ldp-quote-toggle';
			setLabel(toggle, '展开完整引用');
			setExpanded(toggle, false);
			toggle.innerHTML = icon('chevronDown');
			controls.append(toggle);
			if (targetPostNumber) {
				const jump = makeElement('button');
				jump.type = 'button';
				jump.className = 'ldp-quote-jump';
				jump.dataset.targetPostNumber = String(targetPostNumber);
				if (targetTopicId) jump.dataset.targetTopicId = String(targetTopicId);
				setLabel(jump, `跳到被引用楼层 #${targetPostNumber}`);
				jump.innerHTML = icon('arrowUp');
				controls.append(jump);
			}
		});
	}

	function cachedReaderQuotedPost(ctx, topicId, postNumber) {
		const targetTopicId = +topicId || +(ctx?.topicId || 0);
		const targetPostNumber = +postNumber || 0;
		if (!targetTopicId || !targetPostNumber) return null;
		if (ctx && targetTopicId === +ctx.topicId) {
			const streamPost = ctx.streamNodeMap?.get(targetPostNumber);
			const streamData = streamPost?._ldpPostData;
			if (streamData && streamData.cooked != null) return streamData;
			const topicPost = postsFromPayload(ctx.topicData).find((post) => +post.post_number === targetPostNumber);
			if (topicPost && topicPost.cooked != null) return topicPost;
		}
		return postsFromPayload(getCachedTopicData(targetTopicId))
			.find((post) => +post.post_number === targetPostNumber && post.cooked != null) || null;
	}

	async function loadReaderQuotedPost(quote, ctx) {
		if (!quote || !ctx) return null;
		if (quote._ldpQuoteFullPost && quote._ldpQuoteFullPost.cooked != null) return quote._ldpQuoteFullPost;
		const targetPostNumber = +quote.dataset.post || 0;
		const targetTopicId = +quote.dataset.topic || +ctx.topicId || 0;
		let post = cachedReaderQuotedPost(ctx, targetTopicId, targetPostNumber);
		if (!post && targetTopicId === +ctx.topicId && ctx.loadPost) {
			await ctx.loadPost(targetPostNumber, {
				mount: false,
				priority: POST_REQUEST_PRIORITY.auxiliary,
			});
			post = cachedReaderQuotedPost(ctx, targetTopicId, targetPostNumber);
		}
		if (!post) {
			const urls = [
				`${BASE}/posts/by_number/${targetTopicId}/${targetPostNumber}.json`,
				`${BASE}/t/${targetTopicId}.json?post_number=${targetPostNumber}`,
			];
			for (const url of urls) {
				try {
					const payload = await fetchJSON(url, {
						scheduler: ctx.postRequestScheduler,
						key: `quote:${targetTopicId}:${targetPostNumber}:${url}`,
						priority: POST_REQUEST_PRIORITY.auxiliary,
					});
					const posts = payload?.post_number ? [payload] : postsFromPayload(payload);
					post = posts.find((item) => +item.post_number === targetPostNumber && item.cooked != null) || null;
					if (post) break;
				} catch (error) {
					if (shouldStopReaderFallback(error)) throw error;
				}
			}
		}
		if (post) quote._ldpQuoteFullPost = post;
		return post;
	}

	function preserveReaderQuotePosition(quote, ctx, update) {
		const scrollRoot = ctx?.scrollRoot;
		if (!quote || !quote.isConnected || !scrollRoot) {
			update();
			return;
		}
		const anchorTop = quote.getBoundingClientRect().top;
		scrollRoot.classList.add('ldp-quote-positioning');
		try {
			update();
			const correction = quote.getBoundingClientRect().top - anchorTop;
			if (Math.abs(correction) >= 0.5) {
				const previousScrollTop = scrollRoot.scrollTop;
				scrollRoot.scrollTop += correction;
				markStreamInternalScroll(ctx, previousScrollTop);
			}
		} finally {
			requestAnimationFrame(() => scrollRoot.classList.remove('ldp-quote-positioning'));
		}
	}

	function syncReaderQuoteLayout(quote, ctx) {
		const post = quote?.closest('.ldp-post');
		if (!post || !ctx) return;
		bindPostMediaLayoutSync(post, ctx);
		scheduleNestedBranchSync(post, ctx);
		const owner = streamOwnerPostNode(ctx, post);
		const item = owner && ctx.streamItemMap.get(streamPostNumber(owner));
		if (!owner || !item) return;
		const sync = () => {
			if (!owner.isConnected) return;
			const measured = owner.getBoundingClientRect().height || owner.offsetHeight || 0;
			const height = Math.max(Math.round(measured), STREAM_PLACEHOLDER_MIN_HEIGHT);
			if (Math.abs(height - item.height) >= 1) {
				item.height = height;
				const index = ctx.onlyOp && Number.isInteger(item.layoutIndex) ? item.layoutIndex : item.index;
				markStreamPrefixDirty(ctx, index);
				ctx.streamNeedsRepair = true;
			}
			scheduleStreamWindowSync(ctx);
		};
		requestAnimationFrame(() => {
			sync();
			requestAnimationFrame(sync);
		});
	}

	function readerQuoteExcerptText(quote) {
		if (!quote) return '';
		const body = quote.querySelector(':scope > blockquote');
		if (body && quote.dataset.ldpQuoteExpanded !== '1') return String(body.textContent || '').trim();
		const html = quote._ldpQuoteExcerptHtml != null
			? quote._ldpQuoteExcerptHtml
			: body?.innerHTML;
		const template = makeElement('template');
		template.innerHTML = String(html || '');
		return String(template.content.textContent || '').trim();
	}

	function readerQuoteSourceFromJump(jump, ctx) {
		const preview = jump?.closest('.ldp-floor-preview');
		const sourcePost = jump && (jump.closest('.ldp-post') || (preview?._ldpRelatedPost));
		const postNumber = +(sourcePost && sourcePost.dataset.postNumber || 0);
		const topicId = +(ctx?.topicId || 0);
		if (!sourcePost || !postNumber || !topicId) return null;
		const nested = !!(sourcePost.parentElement && sourcePost.parentElement.classList.contains('ldp-children'));
		return {
			topicId,
			postNumber,
			parentPostNumber: nested ? +(sourcePost.dataset.replyToPostNumber || 0) : 0,
			nested,
			postData: sourcePost._ldpPostData || null,
		};
	}

	function quoteHighlightFromJump(jump, ctx) {
		const quote = jump?.closest('.ldp-post-quote');
		const text = readerQuoteExcerptText(quote);
		return text ? { text, source: readerQuoteSourceFromJump(jump, ctx) } : null;
	}

	function discourseComparableQuoteText(value) {
		return String(value || '').replace(/[ \t\r\n]/g, '');
	}

	function markReaderTextMatch(root, comparableText, excludedSelector, className) {
		const characters = [];
		const positions = [];
		const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
		let textNode = walker.nextNode();
		while (textNode) {
			const parent = textNode.parentElement;
			if (parent && !parent.closest(excludedSelector)) {
				const value = textNode.nodeValue || '';
				for (let offset = 0; offset < value.length; offset++) {
					const character = value[offset];
					if (/[ \t\r\n]/.test(character)) continue;
					characters.push(character);
					positions.push({ node: textNode, offset });
				}
			}
			textNode = walker.nextNode();
		}
		const matchStart = characters.join('').indexOf(comparableText);
		if (matchStart < 0) return [];
		const matchEnd = matchStart + comparableText.length;
		const groups = [];
		for (let index = matchStart; index < matchEnd; index++) {
			const position = positions[index];
			if (!position) continue;
			const previous = groups[groups.length - 1];
			if (previous && previous.node === position.node) previous.end = position.offset + 1;
			else groups.push({ node: position.node, start: position.offset, end: position.offset + 1 });
		}
		const marks = [];
		for (let index = groups.length - 1; index >= 0; index--) {
			const group = groups[index];
			const range = document.createRange();
			range.setStart(group.node, group.start);
			range.setEnd(group.node, group.end);
			const mark = makeElement('mark');
			mark.className = className;
			range.surroundContents(mark);
			marks.unshift(mark);
		}
		return marks;
	}

	function highlightReaderExpandedQuote(quote) {
		const body = quote?.querySelector(':scope > blockquote');
		const comparableQuote = discourseComparableQuoteText(readerQuoteExcerptText(quote));
		if (!body || !comparableQuote) return false;
		return markReaderTextMatch(body, comparableQuote,
			'button,script,style,noscript,.ldp-quote-title', 'ldp-expanded-quote-match').length > 0;
	}

	function clearReaderQuoteHighlight(ctx) {
		const state = ctx?.quoteHighlightState;
		if (!state) return;
		if (state.hintHideTimer) clearTimeout(state.hintHideTimer);
		if (state.hint) state.hint.remove();
		(state.marks || []).forEach((mark) => {
			if (!mark || !mark.parentNode) return;
			const parent = mark.parentNode;
			mark.replaceWith(document.createTextNode(mark.textContent || ''));
			parent.normalize();
		});
		ctx.quoteHighlightState = null;
	}

	function setReaderQuoteHighlightActive(ctx, active) {
		const state = ctx?.quoteHighlightState;
		if (!state) return;
		state.active = active !== false;
		const hintText = state.active ? '点击关闭高亮' : '点击继续高亮';
		state.marks.forEach((mark) => mark.classList.toggle('ldp-quote-match-muted', !state.active));
		if (state.lastMark) {
			setPressed(state.lastMark, state.active);
			setLabel(state.lastMark, hintText);
		}
		if (state.toggleControl) state.toggleControl.textContent = state.active ? '关闭高亮' : '继续高亮';
	}

	function applyReaderQuoteHighlight(postNumber, ctx, quoteText, options = {}) {
		clearReaderQuoteHighlight(ctx);
		const comparableQuote = discourseComparableQuoteText(quoteText);
		const target = getStreamPostNode(ctx, postNumber, { mount: true }) ||
			ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${postNumber}"]`);
		if (!target || !comparableQuote) return false;
		hydratePostContent(target, ctx);
		const content = target.querySelector(':scope > .ldp-content');
		if (!content) return false;

		const marks = markReaderTextMatch(content, comparableQuote,
			'button,script,style,noscript,.ldp-quote-highlight-close', 'ldp-quote-match');
		if (!marks.length) {
			showSelectionToast('引用原文可能已修改,未找到匹配内容');
			return false;
		}

		const firstMark = marks[0];
		const lastMark = marks[marks.length - 1];
		lastMark.classList.add('ldp-quote-match-end');
		lastMark.tabIndex = 0;
		lastMark.setAttribute('role', 'button');
		lastMark.setAttribute('data-tooltip', '');
		const hint = makeElement('span');
		hint.className = 'ldp-quote-highlight-hint ldp-action-surface';
		const toggleControl = makeElement('button');
		toggleControl.type = 'button';
		toggleControl.className = 'ldp-quote-highlight-toggle';
		hint.append(toggleControl);
		const source = options.source || null;
		let returnControl = null;
		if (source) {
			returnControl = makeElement('button');
			returnControl.type = 'button';
			returnControl.className = 'ldp-quote-highlight-return';
			returnControl.textContent = source.nested ? '返回楼中楼' : '返回引用楼层';
			hint.append(returnControl);
		}
		const hintRoot = ensureReaderPortal();
		(hintRoot || document.body).append(hint);
		ctx.quoteHighlightState = {
			marks,
			lastMark,
			hint,
			toggleControl,
			active: options.active !== false,
			postNumber: +postNumber,
			quoteText: String(quoteText || ''),
			source,
			hintHideTimer: 0,
		};
		const toggleHighlight = (event) => {
			consumeEvent(event);
			cancelHintHide();
			const state = ctx.quoteHighlightState;
			if (state) setReaderQuoteHighlightActive(ctx, !state.active);
		};
		const cancelHintHide = () => {
			const state = ctx.quoteHighlightState;
			if (!state || !state.hintHideTimer) return;
			clearTimeout(state.hintHideTimer);
			state.hintHideTimer = 0;
		};
		const showHint = (mark, event) => {
			const state = ctx.quoteHighlightState;
			if (!state || !mark) return;
			cancelHintHide();
			state.hint.classList.add('ldp-quote-hint-visible');
			const markRect = mark.getBoundingClientRect();
			const rootRect = ctx.scrollRoot.getBoundingClientRect();
			const hintRect = state.hint.getBoundingClientRect();
			const anchorX = Number.isFinite(event?.clientX) ? event.clientX : markRect.left + markRect.width / 2;
			const anchorY = Number.isFinite(event?.clientY) ? event.clientY : markRect.top;
			const edge = 8;
			const gap = 10;
			const left = Math.max(edge, Math.min(anchorX - hintRect.width / 2, window.innerWidth - hintRect.width - edge));
			const showBelow = anchorY - hintRect.height - gap < rootRect.top + edge;
			const top = showBelow ? anchorY + gap : anchorY - hintRect.height - gap;
			state.hint.style.left = `${Math.round(left)}px`;
			state.hint.style.top = `${Math.round(Math.max(edge, top))}px`;
		};
		const scheduleHintHide = () => {
			const state = ctx.quoteHighlightState;
			if (!state) return;
			cancelHintHide();
			state.hintHideTimer = setTimeout(() => {
				state.hintHideTimer = 0;
				if (state.hint.matches(':hover,:focus-within') || state.marks.some((mark) => mark.matches(':hover'))) return;
				state.hint.classList.remove('ldp-quote-hint-visible');
			}, 160);
		};
		marks.forEach((mark) => {
			mark.addEventListener('click', toggleHighlight);
			mark.addEventListener('mouseenter', (event) => showHint(mark, event));
			mark.addEventListener('mouseleave', scheduleHintHide);
		});
		hint.addEventListener('mouseenter', cancelHintHide);
		hint.addEventListener('pointerdown', cancelHintHide);
		hint.addEventListener('mouseleave', scheduleHintHide);
		hint.addEventListener('focusout', scheduleHintHide);
		toggleControl.addEventListener('click', toggleHighlight);
		if (returnControl) {
			returnControl.addEventListener('click', async (event) => {
				consumeEvent(event);
				cancelHintHide();
				if (returnControl.disabled) return;
				hint.classList.remove('ldp-quote-hint-visible');
				returnControl.blur();
				returnControl.disabled = true;
				const originalText = returnControl.textContent;
				returnControl.textContent = '返回中…';
				const returned = await returnToReaderQuoteSource(source, ctx);
				if (!returned) showSelectionToast('暂时无法返回引用位置');
				if (returnControl.isConnected) {
					returnControl.disabled = false;
					returnControl.textContent = originalText;
				}
			});
		}
		lastMark.addEventListener('keydown', (event) => {
			if (event.target === lastMark && (event.key === 'Enter' || event.key === ' ')) toggleHighlight(event);
		});
		lastMark.addEventListener('focus', (event) => showHint(lastMark, event));
		lastMark.addEventListener('blur', scheduleHintHide);
		setReaderQuoteHighlightActive(ctx, options.active !== false);
		return firstMark;
	}

	function restoreReaderQuoteHighlightForPost(post, ctx) {
		const state = ctx?.quoteHighlightState;
		const postNumber = +(post && post.dataset.postNumber);
		if (!state || !postNumber || +state.postNumber !== postNumber || !state.quoteText) return;
		if (state.marks?.length && state.marks.every((mark) => post.contains(mark))) return;
		applyReaderQuoteHighlight(postNumber, ctx, state.quoteText, { active: state.active, source: state.source });
	}

	function readerMediaHtml(value, options = {}) {
		const template = makeElement('template');
		template.innerHTML = String(value || '');
		prepareReaderHashtags(template.content);
		if (options.inlineOneboxes) prepareReaderInlineOneboxes(template.content);
		prepareReaderCodeBlocks(template.content);
		prepareReaderMedia(template.content, { interactive: false });
		return template.innerHTML;
	}

	function normalizedPostLinkUrl(value) {
		try {
			return new URL(String(value || '').trim(), BASE).href;
		} catch (error) {
			return '';
		}
	}

	function prepareReaderInlineOneboxes(root) {
		if (!root || !isFunction(root.querySelectorAll)) return;
		root.querySelectorAll('a.inline-onebox').forEach((link) => {
			if (link.querySelector(':scope > .ldp-inline-onebox-label')) return;
			const labelNodes = [...link.childNodes].filter((node) =>
				!(node.nodeType === Node.ELEMENT_NODE && node.matches('svg,.svg-icon,.ldp-link-click-count')));
			if (!labelNodes.some((node) => String(node.textContent || '').trim())) return;
			const label = makeElement('span');
			label.className = 'ldp-inline-onebox-label';
			labelNodes.forEach((node) => label.append(node));
			const linkIcon = [...link.children].find((child) => child.matches('svg,.svg-icon'));
			if (linkIcon) linkIcon.after(label);
			else link.prepend(label);
		});
	}

	function decoratePostLinkClickCounts(root, linkCounts) {
		if (!root || !Array.isArray(linkCounts) || !linkCounts.length) return;
		const countsByUrl = new Map();
		linkCounts.forEach((item) => {
			const clicks = Math.max(0, Math.trunc(Number(item?.clicks) || 0));
			const url = !item || item.reflection ? '' : normalizedPostLinkUrl(item.url);
			if (!url || !clicks) return;
			countsByUrl.set(url, Math.max(clicks, countsByUrl.get(url) || 0));
		});
		if (!countsByUrl.size) return;
		root.querySelectorAll('a[href]').forEach((link) => {
			if (link.querySelector(':scope > .ldp-link-click-count')) return;
			const onebox = link.closest('aside.onebox');
			if (onebox && link.closest('header.source')) {
				const titleLink = onebox.querySelector('.onebox-body h3 a[href]');
				if (titleLink && normalizedPostLinkUrl(titleLink.getAttribute('href')) === normalizedPostLinkUrl(link.getAttribute('href'))) return;
			}
			const clicks = countsByUrl.get(normalizedPostLinkUrl(link.getAttribute('href')));
			if (!clicks || !String(link.textContent || '').trim()) return;
			const count = makeElement('span');
			const label = `${clicks.toLocaleString('zh-CN')} 次点击`;
			count.className = 'ldp-link-click-count';
			count.setAttribute('role', 'note');
			setLabel(count, label);
			count.dataset.ldpTooltipLabel = label;
			count.textContent = clicks.toLocaleString('zh-CN');
			link.append(count);
		});
	}

	function setPostCookedContent(content, cooked, linkCounts) {
		const template = makeElement('template');
		template.innerHTML = String(cooked || '');
		prepareReaderHashtags(template.content);
		prepareReaderUserMentions(template.content);
		prepareReaderInlineOneboxes(template.content);
		prepareReaderCallouts(template.content);
		prepareReaderPostQuotes(template.content);
		prepareReaderCodeBlocks(template.content);
		prepareReaderMedia(template.content);
		decoratePostLinkClickCounts(template.content, linkCounts);
		destroyReaderMedia(content);
		content.replaceChildren(template.content);
		activateReaderMedia(content);
	}

	function renderNewUserBadge(postNode) {
		if (!postNode) return;
		const post = postNode._ldpPostData || {};
		const noticeType = postNoticeType(post);
		const isSystem = +post.post_type === 3 || String(post.username || '').trim().toLocaleLowerCase() === 'system';
		const isModerator = post.moderator === true || post.group_moderator === true;
		const identity = isSystem
			? { label: '系统', title: '系统账户', icon: 'settings' }
			: isModerator
			? { label: '版主', title: '版主', icon: 'shieldHalved' }
			: postNoticeIdentity(post, noticeType);
		const isNewUser = noticeType === 'new_user';
		postNode.classList.toggle('ldp-new-user', isNewUser);
		const head = postNode.querySelector(':scope > .ldp-post-head');
		let badge = head?.querySelector(':scope > .ldp-new-user-badge');
		if (!identity) {
			if (badge) badge.remove();
			return;
		}
		if (!badge && head) {
			badge = makeElement('span');
			badge.className = 'ldp-new-user-badge';
			const username = head.querySelector(':scope > .ldp-user');
			if (username) username.after(badge);
			else head.append(badge);
		}
		if (badge) {
			badge.setAttribute('role', 'img');
			setLabel(badge, identity.title);
			badge.innerHTML = `${icon(identity.icon)}<span>${identity.label}</span>`;
		}
	}

	function renderPostSpecialContent(postNode, ctx) {
		renderNewUserBadge(postNode);
		renderSpecialPostState(postNode);
		renderSolvedAnswerCard(postNode, ctx);
		renderPostReactions(postNode, ctx);
		renderPostEvent(postNode);
		renderPostVoting(postNode, ctx);
		const post = postNode?._ldpPostData || {};
		if (post.accepted_answer && ctx && ctx.streamNodeMap) {
			const firstPost = ctx.streamNodeMap.get(1);
			if (firstPost && firstPost !== postNode) renderSolvedAnswerCard(firstPost, ctx);
		}
		hydratePersistentAvatarImages(postNode);
	}

	function comparePostVotingStreamItems(left, right) {
		if (left.postNumber === 1 || right.postNumber === 1) {
			return left.postNumber === right.postNumber ? 0 : (left.postNumber === 1 ? -1 : 1);
		}
		return +(right.node._ldpPostData.post_voting_vote_count || 0) -
			+(left.node._ldpPostData.post_voting_vote_count || 0) || left.postNumber - right.postNumber;
	}

	function syncSpecialPostData(postNode, ctx, fresh) {
		if (!postNode || !fresh) return;
		let latestPostData = null;
		readerPostCopies(postNode.dataset.postId, ctx).forEach((copy) => {
			const previous = copy._ldpPostData || {};
			copy._ldpPostData = Object.assign({}, previous, fresh);
			latestPostData = copy._ldpPostData;
			renderPostSpecialContent(copy, ctx);
		});
		if (latestPostData) persistReaderPostState(ctx, latestPostData);
		if (ctx?.isPostVoting) {
			ctx.streamItems.sort(comparePostVotingStreamItems);
			reindexStreamItems(ctx, 0);
			invalidateStreamLayout(ctx);
			scheduleStreamWindowSync(ctx);
		}
	}

	async function fetchPostVotingComments(postNode, ctx) {
		const postId = postNode && postNode.dataset.postId;
		if (!postId) return [];
		return runReaderActionOnce(ctx, `post:${postId}:pv-comments-load`, async () => {
			const copies = readerPostCopies(postId, ctx);
			const sourcePosts = (copies.length ? copies : [postNode]).map((copy) => copy._ldpPostData || {});
			const loaded = sourcePosts.map(postVotingComments).sort((a, b) => b.length - a.length)[0] || [];
			const lastId = loaded.length ? loaded[loaded.length - 1].id : '';
			const url = new URL(`${BASE}/post_voting/comments`);
			url.searchParams.set('post_id', postId);
			if (lastId) url.searchParams.set('last_comment_id', lastId);
			const data = await fetchJSON(url.toString());
			const incoming = Array.isArray(data) ? data : (data.comments || []);
			const merged = new Map(loaded.map((comment) => [String(comment.id), comment]));
			incoming.forEach((comment) => merged.set(String(comment.id), comment));
			const comments = [...merged.values()];
			syncPostVotingComments(postId, ctx, comments);
			return comments;
		});
	}

	function normalizeBoosts(boosts) {
		const list = Array.isArray(boosts) ? boosts : boosts ? [boosts] : [];
		return list.map((boost) => {
			if (!boost) return null;
			const user = boost.user || {};
			const raw = boost.raw || '';
			const cooked = boost.cooked || (raw ? `<p>${esc(raw)}</p>` : '');
			if (!cooked) return null;
			const username = user.username || boost.username || ME_USERNAME || '';
			return {
				id: boost.id == null ? '' : String(boost.id),
				userId: +(user.id || boost.user_id || 0),
				username,
				name: user.name || boost.name || username,
				avatarTemplate: user.avatar_template || boost.avatar_template || boost.avatarTemplate || boost.avatar || '',
				admin: user.admin === true || boost.admin === true,
				moderator: user.moderator === true || user.group_moderator === true ||
					boost.moderator === true || boost.group_moderator === true,
				noticeType: String((boost.notice?.type) || boost.notice_type || ''),
				cooked,
			};
		}).filter(Boolean);
	}

	function boostBelongsToCurrentUser(boost) {
		const currentUserId = +(ME_CURRENT_USER?.id || 0);
		if (currentUserId && +boost.userId) return currentUserId === +boost.userId;
		return !!(
			ME_USERNAME && boost.username &&
			String(boost.username).toLowerCase() === String(ME_USERNAME).toLowerCase()
		);
	}

	function boostIdentities(boost, ctx) {
		const identities = [];
		const username = String(boost?.username || '');
		const usernameKey = username.toLocaleLowerCase();
		let sourcePost = {};
		if (usernameKey && ctx) {
			const cachedPosts = ctx.loader && Array.isArray(ctx.loader.cachedPosts) ? ctx.loader.cachedPosts : [];
			sourcePost = cachedPosts.concat(postsFromPayload(ctx.topicData)).find((post) => {
				if (String(post?.username || '').toLocaleLowerCase() !== usernameKey) return false;
				return post.admin === true || post.moderator === true || post.group_moderator === true ||
					['new_user', 'returning_user', 'custom'].includes(postNoticeType(post));
			}) || {};
		}
		const noticeType = postNoticeType(sourcePost) || boost.noticeType;
		const isAdmin = boost.admin === true || sourcePost.admin === true;
		const isModerator = boost.moderator === true || sourcePost.moderator === true ||
			sourcePost.group_moderator === true;
		if (boostBelongsToCurrentUser(boost)) {
			identities.push({ type: 'me', label: 'ME', title: '当前用户', icon: 'userRound' });
		}
		if (usernameKey && String(ctx?.op || '').toLocaleLowerCase() === usernameKey) {
			identities.push({ type: 'op', label: 'OP', title: '楼主', icon: 'award' });
		}
		if (isAdmin) {
			identities.push({ type: 'admin', label: '管理员', title: '管理员', icon: 'shieldHalved' });
		} else if (isModerator) {
			identities.push({ type: 'moderator', label: '版主', title: '版主', icon: 'shieldHalved' });
		}
		const noticeIdentity = postNoticeIdentity(sourcePost, noticeType);
		if (noticeIdentity) {
			const type = noticeType === 'new_user' ? 'new' : noticeType === 'returning_user' ? 'return' : 'custom';
			identities.push(Object.assign({ type }, noticeIdentity));
		}
		return identities;
	}

	function renderBoostIdentities(boost, ctx) {
		const identities = boostIdentities(boost, ctx);
		if (!identities.length) return '';
		return `<span class="ldp-boost-identities">${identities.map((identity) =>
			`<span class="ldp-boost-identity ldp-boost-identity-${identity.type}" role="img" aria-label="${escAttr(identity.title)}">${icon(identity.icon)}<span>${esc(identity.label)}</span></span>`
		).join('')}</span>`;
	}

	function renderBoostBubble(boost, allowQuickBoost = false, ctx = null) {
		const userCardAttr = boost.username ? `data-user-card="${escAttr(boost.username)}"` : '';
		const avatarLabel = boost.username ? ` aria-label="@${escAttr(boost.username)}"` : '';
		const avatar = boost.avatarTemplate
				? persistentAvatarHtml(boost.avatarTemplate, 24, 'ldp-boost-avatar', boost.name || boost.username || '?')
				: icon('rocket', 'ldp-boost-fallback-icon');
		const avatarHtml = boost.username
				? `<a class="ldp-user-link ldp-boost-avatar-link" href="${userProfileUrl(boost.username)}" target="_blank" rel="noopener" ${userCardAttr}${avatarLabel} data-tooltip>${avatar}</a>`
				: avatar;
		const quickBoost = allowQuickBoost && boost.username && (!ME_USERNAME || boost.username !== ME_USERNAME)
			? '<button class="ldp-boost-copy-action ldp-action-surface" type="button" aria-label="复制到 Boost 输入框" data-tooltip>复制</button>'
			: '';
		const isOwnBoost = boostBelongsToCurrentUser(boost);
		const signedIn = !!(ME_CURRENT_USER?.id || ME_USERNAME);
		const itemAction = signedIn && boost.id
			? (isOwnBoost
				? `<button class="ldp-boost-item-action ldp-boost-delete-action" type="button" aria-label="删除自己的 Boost">${icon('trash')}</button>`
				: `<button class="ldp-boost-item-action ldp-boost-report-action" type="button" aria-label="举报 @${escAttr(boost.username)} 的 Boost">${icon('flag')}</button>`)
			: '';
		const identities = renderBoostIdentities(boost, ctx);
		return `<span class="ldp-boost-bubble${isOwnBoost ? ' ldp-boost-own' : ''}" data-boost-id="${escAttr(boost.id)}" data-boost-user="${escAttr(boost.username)}" data-boost-user-id="${escAttr(boost.userId)}" data-boost-admin="${boost.admin ? '1' : '0'}" data-boost-moderator="${boost.moderator ? '1' : '0'}" aria-label="${boost.username ? `@${escAttr(boost.username)} 的 Boost` : 'Boost'}">${avatarHtml}${identities}<span class="ldp-boost-cooked cooked">${readerMediaHtml(boost.cooked)}</span>${quickBoost}${itemAction}</span>`;
	}

	function renderBoostList(boosts, options = {}) {
		const items = normalizeBoosts(boosts);
		const allowQuickBoost = options.canQuickBoost === true &&
			!items.some((boost) => ME_USERNAME && boost.username === ME_USERNAME);
		return `<div class="ldp-boost-list"${items.length ? '' : ' hidden'}>${items.map((boost) => renderBoostBubble(boost, allowQuickBoost, options.ctx)).join('')}</div>`;
	}

	function syncKnownBoostIdentities(ctx, usernames) {
		if (!ctx || !ctx.modal) return;
		const usernameKeys = new Set((Array.isArray(usernames) ? usernames : [usernames])
			.map((username) => String(username || '').toLocaleLowerCase()).filter(Boolean));
		if (!usernameKeys.size) return;
		ctx.modal.querySelectorAll('.ldp-boost-bubble').forEach((bubble) => {
			const username = String(bubble.dataset.boostUser || '');
			if (!usernameKeys.has(username.toLocaleLowerCase())) return;
			const boost = {
				userId: +(bubble.dataset.boostUserId || 0),
				username,
				admin: bubble.dataset.boostAdmin === '1',
				moderator: bubble.dataset.boostModerator === '1',
			};
			const holder = makeElement('template');
			holder.innerHTML = renderBoostIdentities(boost, ctx);
			const next = holder.content.firstElementChild;
			const current = bubble.querySelector(':scope > .ldp-boost-identities');
			if (current && next) current.replaceWith(next);
			else if (current) current.remove();
			else if (next) {
				const avatar = bubble.querySelector(':scope > .ldp-boost-avatar-link,:scope > .ldp-boost-fallback-icon');
				if (avatar) avatar.after(next);
				else bubble.prepend(next);
			}
		});
	}

	function boostBubblePlainText(bubble) {
		const cooked = bubble?.querySelector('.ldp-boost-cooked');
		if (!cooked) return '';
		const copy = cooked.cloneNode(true);
		copy.querySelectorAll('img[alt]').forEach((image) => image.replaceWith(document.createTextNode(image.alt || '')));
		return String(copy.textContent || '').replace(/\s+/g, ' ').trim();
	}

	function boostCopyRegExpEscape(value) {
		return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
	}

	function fitBoostCopyParts(prefix, base, suffix) {
		const suffixChars = [...suffix].slice(0, BOOST_COPY_MAX_LENGTH);
		const prefixChars = [...prefix].slice(0, BOOST_COPY_MAX_LENGTH - suffixChars.length);
		const baseChars = [...base].slice(0, BOOST_COPY_MAX_LENGTH - suffixChars.length - prefixChars.length);
		return `${prefixChars.join('')}${baseChars.join('')}${suffixChars.join('')}`;
	}

	function applyBoostCopyRule(raw, prefs = PREFS) {
		const config = boostCopyConfigFromPrefs(prefs);
		let base = String(raw || '').replace(/\s+/g, ' ').trim();
		if (config.prefix && base.startsWith(config.prefix)) base = base.slice(config.prefix.length);
		let suffix = config.fixedSuffix;
		if (config.mode === 'counter') {
			const countPattern = new RegExp(`${boostCopyRegExpEscape(config.counterMarker)}(\\d+)$`);
			const countMatch = base.match(countPattern);
			const currentCount = countMatch ? Number(countMatch[1]) : 0;
			const nextCount = Number.isSafeInteger(currentCount)
				? currentCount + config.counterStep
				: config.counterStep;
			if (countMatch) base = base.slice(0, -countMatch[0].length);
			suffix = `${config.counterMarker}${nextCount}`;
		} else if (suffix && base.endsWith(suffix)) {
			base = base.slice(0, -suffix.length);
		}
		return fitBoostCopyParts(config.prefix, base, suffix);
	}

	function syncCurrentUserFromHost(options = {}) {
		const currentUser = lookupDiscourse('service:current-user');
		if (!currentUser) return ME_USERNAME || '';
		ME_CURRENT_USER = currentUser;
		ME_USERNAME = String(discourseModelValue(currentUser, 'username') || '');
		if (options.syncUnread !== false) {
			const unreadCount = currentUserUnreadNotificationCount(currentUser);
			syncNotificationUnreadUi(unreadCount == null ? cachedUnreadNotificationCount() : unreadCount);
		}
		return ME_USERNAME;
	}
	function canShowBoostButton(p) {
		return !!(p && p.can_boost === true && (!ME_USERNAME || p.username !== ME_USERNAME));
	}

	const TOPIC_NOTIFICATION_LEVELS = [
		{ value: 1, label: '常规' },
		{ value: 2, label: '跟踪' },
		{ value: 3, label: '关注' },
		{ value: 0, label: '已屏蔽' },
	];

	function topicNotificationLevel(topic) {
		const value = Number(topic && (
			topic.notification_level ??
			(topic.details?.notification_level)
		));
		return Number.isInteger(value) && value >= 0 && value <= 3 ? value : 1;
	}

	function topicActionCapabilities(post, ctx) {
		const data = post || {};
		const topic = (ctx?.topicData) || {};
		const actions = Array.isArray(data.actions_summary) ? data.actions_summary : [];
		const hasFlagAction = actions.some((action) =>
			action && action.can_act === true && ![2, 8].includes(+action.id)
		);
		const signedIn = !!ME_USERNAME;
		const isOwnPost = signedIn && data.username === ME_USERNAME;
		const canReport = signedIn && !isOwnPost && data.can_flag !== false &&
			(data.can_flag === true || hasFlagAction || actions.length <= 1);
		const canAssign = !!(
			data.can_assign === true ||
			topic.can_assign === true ||
			(topic.details && topic.details.can_assign === true)
		);
		const currentUser = ME_CURRENT_USER || {};
		const details = topic.details || {};
		const canAdmin = signedIn && !!(
			currentUser.staff || currentUser.can_manage_topic || currentUser.canManageTopic ||
			currentUser.can_change_post_owner || currentUser.canChangePostOwner ||
			data.can_manage === true || data.can_wiki === true || details.can_edit_staff_notes === true
		);
		return {
			canReport,
			canAssign,
			canBookmark: signedIn,
			canEdit: data.can_edit === true,
			canDelete: data.can_delete === true,
			canAdmin,
		};
	}

	function postBookmarkInfo(post) {
		const data = post || {};
		const bookmarkId = data.bookmark_id || (data.bookmark?.id) || null;
		return { bookmarked: !!(data.bookmarked || bookmarkId), bookmarkId };
	}

	const POST_CONTEXT_ACTIONS = Object.freeze([
		{ name: 'share', event: 'post', action: 'share', label: '复制楼层链接', icon: 'link2' },
		{ name: 'report', event: 'reader', action: 'report', label: '举报', icon: 'flag', capability: 'canReport' },
		{ name: 'edit', event: 'post', action: 'edit', label: '编辑', icon: 'pencil', capability: 'canEdit' },
		{ name: 'bookmark', capability: 'canBookmark' },
		{ name: 'delete', event: 'post', action: 'delete', label: '删除', icon: 'trash', capability: 'canDelete' },
		{ name: 'assign', event: 'reader', action: 'assign-post', label: '指定楼层负责人', icon: 'userPlus', capability: 'canAssign' },
		{ name: 'admin', event: 'post', action: 'admin', label: '管理楼层', icon: 'wrench', capability: 'canAdmin' },
	]);

	function postContextActionsHtml(post, ctx) {
		const capabilities = topicActionCapabilities(post, ctx);
		const isTopicStarter = Number(post?.post_number) === 1;
		const bookmarked = isTopicStarter
			? !!(ctx?.topicBookmarked)
			: postBookmarkInfo(post).bookmarked;
		const bookmarkButton = isTopicStarter
			? `<button class="ldp-btn ldp-context-action ldp-topic-bookmark${bookmarked ? ' on' : ''}" type="button" data-topic-action="bookmark" aria-label="${bookmarked ? '已收藏本帖' : '收藏本帖'}" aria-pressed="${bookmarked ? 'true' : 'false'}"${capabilities.canBookmark ? '' : ' hidden'}>${icon('bookmark')}</button>`
			: `<button class="ldp-btn ldp-context-action ldp-post-bookmark${bookmarked ? ' on' : ''}" type="button" data-post-action="bookmark" aria-label="${bookmarked ? '已收藏该楼层' : '收藏该楼层'}" aria-pressed="${bookmarked ? 'true' : 'false'}"${capabilities.canBookmark ? '' : ' hidden'}>${icon('bookmark')}</button>`;
		return POST_CONTEXT_ACTIONS.map((item) => item.name === 'bookmark' ? bookmarkButton :
			`<button class="ldp-btn ldp-context-action ldp-post-${item.name}" type="button" data-${item.event}-action="${item.action}" aria-label="${item.label}"${!item.capability || capabilities[item.capability] ? '' : ' hidden'}>${icon(item.icon)}</button>`
		).join('\n');
	}

	function postContextActionCount(post, ctx) {
		const capabilities = topicActionCapabilities(post, ctx);
		return POST_CONTEXT_ACTIONS.filter((item) =>
			!item.capability || capabilities[item.capability]
		).length;
	}

	function compactPostActionsHtml(post, ctx) {
		const count = postContextActionCount(post, ctx);
		const eager = isFunction(matchMedia) && matchMedia('(hover: none)').matches;
		return `
			<button class="ldp-btn ldp-replybtn" type="button" aria-label="回复">${icon('reply')}<span>回复</span></button>
			${canShowBoostButton(post) ? `<button class="ldp-btn ldp-boostbtn" type="button" aria-label="boost">${icon('rocket')}</button>` : ''}
			<span class="ldp-context-actions-slot" data-ldp-context-actions="${eager ? '1' : '0'}"
				style="--ldp-context-action-count:${count}"${eager ? '' : ' aria-hidden="true"'}>${eager ? postContextActionsHtml(post, ctx) : ''}</span>`;
	}

	function hydratePostContextActions(postNode, ctx) {
		if (!postNode) return;
		const slot = postNode.querySelector(':scope > .ldp-reactions .ldp-context-actions-slot');
		if (!slot || slot.dataset.ldpContextActions === '1') return;
		slot.innerHTML = postContextActionsHtml(postNode._ldpPostData || {}, ctx);
		slot.dataset.ldpContextActions = '1';
		slot.removeAttribute('aria-hidden');
		syncPostActionControls(postNode, ctx);
	}

	function nativeTopicSharedIssueVisible(topic) {
		if (!topic || topic.shared_issue_visible !== true) return false;
		const hasAcceptedAnswers = Array.isArray(topic.accepted_answers) && topic.accepted_answers.length > 0;
		const siteSettings = lookupDiscourse('service:site-settings');
		return !hasAcceptedAnswers || siteSettings?.solved_allow_multiple_solutions === true;
	}

	function topicSharedIssueState(post, ctx) {
		const topic = (ctx?.topicData) || {};
		const count = Math.max(0, Number(topic.shared_issue_count) || 0);
		return {
			visible: ctx?.topicSharedIssueHostVisible === true &&
				!SHARED_ISSUE_FORBIDDEN_TOPIC_IDS.has(Number(ctx?.topicId)),
			active: topic.user_created_shared_issue === true,
			count,
			isAuthor: !!ME_USERNAME && !!post && String(post.username || '') === String(ME_USERNAME),
		};
	}

	function topicSharedIssueLabel(state) {
		return `俺也一样(${state.count})`;
	}

	function topicFooterActionsHtml(post, ctx) {
		const capabilities = topicActionCapabilities(post, ctx);
		const bookmarked = !!ctx.topicBookmarked;
		const sharedIssue = topicSharedIssueState(post, ctx);
		const sharedIssueLabel = topicSharedIssueLabel(sharedIssue);
		const level = Number.isInteger(ctx.topicNotificationLevel)
			? ctx.topicNotificationLevel
			: topicNotificationLevel(ctx.topicData);
		const notificationOptions = TOPIC_NOTIFICATION_LEVELS.map((item) =>
			`<option value="${item.value}"${item.value === level ? ' selected' : ''}>${item.label}</option>`
		).join('');
		return `
			<div class="ldp-topic-footer-actions" aria-label="主题操作">
				<button class="ldp-topic-footer-button ldp-topic-shared-issue${sharedIssue.active ? ' on' : ''}" type="button" data-topic-action="shared-issue" aria-label="${sharedIssueLabel}" aria-pressed="${sharedIssue.active ? 'true' : 'false'}"${sharedIssue.visible ? '' : ' hidden'}${sharedIssue.isAuthor ? ' disabled' : ''}>${icon('hand')}<span class="ldp-topic-shared-issue-label">俺也一样</span><span class="ldp-topic-shared-issue-count">(${sharedIssue.count})</span></button>
				<span class="ldp-topic-footer-separator" aria-hidden="true"${sharedIssue.visible ? '' : ' hidden'}></span>
				<button class="ldp-topic-footer-button ldp-topic-share" type="button" data-topic-action="share" aria-label="分享主题">${icon('share2')}<span>分享</span></button>
				<button class="ldp-topic-footer-button ldp-topic-bookmark${bookmarked ? ' on' : ''}" type="button" data-topic-action="bookmark" aria-label="${bookmarked ? '已添加主题书签' : '添加主题书签'}" aria-pressed="${bookmarked ? 'true' : 'false'}">${icon('bookmark')}<span>${bookmarked ? '已收藏' : '添加为书签'}</span></button>
				<button class="ldp-topic-footer-link ldp-topic-report" type="button" data-reader-action="report" aria-label="举报主题"${capabilities.canReport ? '' : ' hidden'}>${icon('flag')}<span>举报</span></button>
				<button class="ldp-topic-footer-link ldp-topic-assign" type="button" data-reader-action="assign-topic" aria-label="指定主题负责人"${capabilities.canAssign ? '' : ' hidden'}>${icon('userPlus')}<span>指定</span></button>
				<button class="ldp-topic-footer-button ldp-topic-reply ldp-replybtn" type="button" aria-label="回复主题">${icon('reply')}<span>回复</span></button>
				<span class="ldp-topic-notification" aria-label="通知:${escAttr((TOPIC_NOTIFICATION_LEVELS.find((item) => item.value === level) || TOPIC_NOTIFICATION_LEVELS[0]).label)}">${icon('bell')}<select class="ldp-reader-select ldp-topic-notification-select" aria-label="主题通知级别"><button></button>${notificationOptions}</select></span>
			</div>`;
	}

	function syncPostActionControls(postNode, ctx, options = {}) {
		if (!postNode) return;
		const post = postNode._ldpPostData || {};
		const capabilities = topicActionCapabilities(post, ctx);
		const { bookmarked } = postBookmarkInfo(post);
		const actions = postNode.querySelector(':scope > .ldp-reactions .ldp-actions');
		if (!actions) return;
		const contextSlot = actions.querySelector('.ldp-context-actions-slot');
		if (contextSlot) {
			contextSlot.style.setProperty('--ldp-context-action-count', String(postContextActionCount(post, ctx)));
		}
		actions.querySelectorAll('.ldp-post-bookmark').forEach((button) => {
			button.classList.toggle('on', bookmarked);
			button.hidden = !capabilities.canBookmark;
			button.disabled = options.bookmarkBusy === true;
			setPressed(button, bookmarked ? 'true' : 'false');
			setLabel(button, bookmarked ? '已收藏该楼层' : '收藏该楼层');
		});
		POST_CONTEXT_ACTIONS.forEach((item) => {
			if (!item.capability || item.name === 'bookmark') return;
			actions.querySelectorAll(`.ldp-post-${item.name}`).forEach((element) => {
				element.hidden = !capabilities[item.capability];
			});
		});
		actions.querySelectorAll('.ldp-boostbtn').forEach((element) => {
			element.hidden = !canShowBoostButton(post);
		});
	}

	function syncAllPostActionControls(ctx) {
		if (!ctx || !ctx.modal) return;
		ctx.modal.querySelectorAll('.ldp-post').forEach((postNode) => {
			syncPostActionControls(postNode, ctx);
		});
	}

	function topicActionPostNodes(ctx) {
		const nodes = new Set();
		const streamNode = ctx?.streamNodeMap && ctx.streamNodeMap.get(1);
		if (streamNode) nodes.add(streamNode);
		if (ctx?.modal) {
			ctx.modal.querySelectorAll('.ldp-post[data-post-number="1"]').forEach((node) => nodes.add(node));
		}
		return nodes;
	}

	function syncTopicActionControls(ctx, options = {}) {
		if (!ctx) return;
		const bookmarked = !!ctx.topicBookmarked;
		const level = Number.isInteger(ctx.topicNotificationLevel)
			? ctx.topicNotificationLevel
			: topicNotificationLevel(ctx.topicData);
		topicActionPostNodes(ctx).forEach((postNode) => {
			const capabilities = topicActionCapabilities(postNode._ldpPostData, ctx);
			const sharedIssue = topicSharedIssueState(postNode._ldpPostData, ctx);
			const sharedIssueLabel = topicSharedIssueLabel(sharedIssue);
			postNode.querySelectorAll('.ldp-topic-shared-issue').forEach((button) => {
				button.hidden = !sharedIssue.visible;
				button.classList.toggle('on', sharedIssue.active);
				button.disabled = ctx.topicSharedIssueBusy === true || sharedIssue.isAuthor;
				setPressed(button, sharedIssue.active ? 'true' : 'false');
				setLabel(button, sharedIssueLabel);
				const count = button.querySelector('.ldp-topic-shared-issue-count');
				if (count) count.textContent = `(${sharedIssue.count})`;
			});
			postNode.querySelectorAll('.ldp-topic-footer-separator').forEach((separator) => {
				separator.hidden = !sharedIssue.visible;
			});
			postNode.querySelectorAll('.ldp-topic-bookmark').forEach((button) => {
				button.classList.toggle('on', bookmarked);
				setPressed(button, bookmarked ? 'true' : 'false');
				setLabel(button, bookmarked ? '已收藏' : '收藏本帖');
				const label = button.querySelector('span');
				if (label) label.textContent = bookmarked ? '已收藏' : '添加为书签';
				button.disabled = options.bookmarkBusy === true;
			});
			postNode.querySelectorAll('.ldp-topic-report').forEach((element) => {
				element.hidden = !capabilities.canReport;
			});
			postNode.querySelectorAll('.ldp-topic-assign').forEach((element) => {
				element.hidden = !capabilities.canAssign;
			});
			postNode.querySelectorAll('.ldp-topic-notification').forEach((control) => {
				control.classList.toggle('busy', options.notificationBusy === true);
				const item = TOPIC_NOTIFICATION_LEVELS.find((option) => option.value === level);
				setLabel(control, `通知:${item ? item.label : '常规'}`);
			});
			postNode.querySelectorAll('.ldp-topic-notification-select').forEach((select) => {
				select.value = String(level);
				select.disabled = options.notificationBusy === true || !ME_USERNAME;
			});
		});
	}

	function ensureParentJumpControl(post, parentNumber) {
		const head = post.querySelector(':scope > .ldp-post-head');
		if (!head || head.querySelector(':scope > .ldp-jump-parent')) return;
		const currentFloor = head.querySelector(':scope > .ldp-floor');
		const parentFloor = makeElement('button');
		parentFloor.className = 'ldp-floor ldp-jump-parent';
		parentFloor.type = 'button';
		parentFloor.dataset.targetPostNumber = String(parentNumber);
		setLabel(parentFloor, `跳到父楼层 #${parentNumber}`);
		parentFloor.textContent = `@#${parentNumber}`;
		if (currentFloor) currentFloor.classList.add('ldp-current-floor');
		head.insertBefore(parentFloor, currentFloor || null);
	}

	function ensureStreamNestedReplyPresentation(post, parentPostNumber) {
		const parentNumber = +parentPostNumber;
		if (!post || !parentNumber) return;
		post.classList.add('ldp-reply', 'ldp-reply-collapsible');
		post.dataset.replyToPostNumber = String(parentNumber);

		ensureNestedReplyToggle(post, post.firstChild);
		ensureParentJumpControl(post, parentNumber);
	}

	function isEscCollapsibleNestedReply(post) {
		if (!post || !post.classList.contains('ldp-reply-collapsible') ||
				post.classList.contains('ldp-nested-preview')) return false;
		return !(+(post._ldpPostData?.reply_count || 0) > 0) &&
			!(+(post.dataset.childReplyTotal || 0) > 0);
	}

	function syncCollapsedNestedReplyZebra(post, options = {}) {
		const ctx = post?._ldpReaderContext;
		const postNumber = +(post && post.dataset.postNumber || 0);
		const item = ctx?.streamItemMap && ctx.streamItemMap.get(postNumber);
		if (!item || item.node !== post) return;
		const excluded = !!post._ldpPostData?.hidden || post.classList.contains('ldp-nested-collapsed');
		if (options.deferZebraSync) return;
		if (item.zebraExcluded === excluded) {
			if (excluded) post.classList.remove('ldp-zebra-alt');
			return;
		}
		item.zebraExcluded = excluded;
		reindexStreamItems(ctx, Math.max(0, item.index));
		if (ctx.onlyOp) invalidateStreamLayout(ctx);
		else markStreamPrefixDirty(ctx, Math.max(0, item.index));
		refreshStreamZebra(ctx);
		scheduleStreamWindowSync(ctx);
	}

	function setNestedReplyCollapsed(post, collapsed, options = {}) {
		if (!post || (!post.classList.contains('ldp-reply-collapsible') &&
				!post.classList.contains('ldp-hidden-collapsible'))) return;
		if (collapsed) {
			let line = null;
			let preview = post.previousElementSibling;
			if (preview && preview.classList.contains('ldp-floor-preview-link')) {
				line = preview;
				preview = preview.previousElementSibling;
			}
			if (line) line.classList.remove('open');
			if (preview && preview.classList.contains('ldp-floor-preview')) preview.classList.remove('open');
		}
		if (collapsed) post.querySelector(':scope > .ldp-nested-esc-hint')?.remove();
		post.classList.toggle('ldp-nested-collapsed', collapsed);
		syncCollapsedNestedReplyZebra(post, options);
		const parentFloor = post.querySelector(':scope > .ldp-post-head > .ldp-jump-parent');
		if (parentFloor) {
			const parentNumber = +parentFloor.dataset.targetPostNumber || 0;
			setLabel(parentFloor, collapsed
					? `展开楼层并预览父楼层 #${parentNumber}`
					: `跳到父楼层 #${parentNumber}`);
		}
		const btn = post.querySelector(':scope > .ldp-nested-toggle');
		if (!btn) return;
		const postNumber = post.dataset.postNumber || '';
		const controlKey = `${collapsed ? 'collapsed' : 'open'}:${postNumber}`;
		if (btn.dataset.nestedControlKey !== controlKey) {
			btn.dataset.nestedControlKey = controlKey;
			setExpanded(btn, collapsed ? 'false' : 'true');
			setLabel(btn, collapsed ? `展开楼层 #${postNumber}` : `收起楼层 #${postNumber}`);
			btn.innerHTML = `${icon(collapsed ? 'chevronRight' : 'chevronDown')}<span>${collapsed ? '展开' : '收起'}</span>`;
		}
		setNestedReplyEscapeHint(post, !collapsed && isEscCollapsibleNestedReply(post));
	}

	function setNestedReplyEscapeHint(post, visible) {
		if (!post) return;
		let hint = post.querySelector(':scope > .ldp-nested-esc-hint');
		if (!visible) {
			hint?.remove();
			return;
		}
		const toggle = post.querySelector(':scope > .ldp-nested-toggle');
		if (!toggle) return;
		if (!hint) {
			hint = makeElement('span');
			hint.className = 'ldp-nested-esc-hint';
			toggle.insertAdjacentElement('afterend', hint);
		}
		hint.textContent = 'Esc 收起';
	}

	function alignCollapsedFloorPreview(preview, post, collapsedBottom, ctx) {
		if (!preview || !post || !ctx?.scrollRoot || !preview.isConnected || !post.isConnected ||
				!Number.isFinite(collapsedBottom)) return;
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const anchoredBottom = Math.max(rootRect.top, Math.min(rootRect.bottom, collapsedBottom));
		const previewRect = preview.getBoundingClientRect();
		const postRect = post.getBoundingClientRect();
		const availableHeight = Math.max(0, anchoredBottom - rootRect.top);
		const combinedHeight = Math.max(0, postRect.bottom - previewRect.top);
		const correction = combinedHeight > availableHeight + 1
			? previewRect.top - rootRect.top
			: postRect.bottom - anchoredBottom;
		if (Math.abs(correction) < 1) return;
		const previousScrollTop = ctx.scrollRoot.scrollTop;
		ctx.scrollRoot.scrollTop += correction;
		markStreamInternalScroll(ctx, previousScrollTop);
		scheduleStreamWindowSync(ctx);
	}

	function keepOnlyOpPostExpanded(post, ctx) {
		if (!post || !ctx || !ctx.onlyOp || !ctx.op ||
				post._ldpPostData?.hidden || String(post.dataset.username || '') !== String(ctx.op)) return;
		setNestedReplyCollapsed(post, false);
		if (post.classList.contains('ldp-children-collapsed')) {
			post.classList.remove('ldp-children-collapsed');
			syncChildControls(post);
		}
	}

	let activeJumpHighlight = null;

	function syncFirstPostJumpHighlightRegion(target) {
		if (!target || !target.matches('.ldp-post[data-post-number="1"]')) return null;
		const reactions = target.querySelector(':scope > .ldp-reactions');
		if (!reactions) return null;
		const targetRect = target.getBoundingClientRect();
		const reactionsRect = reactions.getBoundingClientRect();
		const highlightHeight = Math.min(
			targetRect.height,
			Math.max(0, reactionsRect.bottom - targetRect.top)
		);
		target.style.setProperty('--ldp-first-post-jump-highlight-height', `${Math.ceil(highlightHeight)}px`);
		return reactions;
	}

	function clearActiveJumpHighlight() {
		if (!activeJumpHighlight) return;
		clearTimeout(activeJumpHighlight.timer);
		if (activeJumpHighlight.resizeObserver) activeJumpHighlight.resizeObserver.disconnect();
		activeJumpHighlight.target.classList.remove('ldp-jump-highlight');
		activeJumpHighlight.target.style.removeProperty('--ldp-first-post-jump-highlight-height');
		if (activeJumpHighlight.target.dataset.jumpHighlightToken === activeJumpHighlight.token) {
			delete activeJumpHighlight.target.dataset.jumpHighlightToken;
		}
		activeJumpHighlight = null;
	}

	function keepJumpHighlightUntilSettled(target) {
		if (!target) return;
		clearActiveJumpHighlight();
		const token = `${Date.now()}-${Math.random()}`;
		target.classList.remove('ldp-jump-highlight');
		target.style.removeProperty('--ldp-first-post-jump-highlight-height');
		const firstPostReactions = syncFirstPostJumpHighlightRegion(target);
		void target.offsetWidth;
		target.dataset.jumpHighlightToken = token;
		target.classList.add('ldp-jump-highlight');
		let resizeObserver = null;
		if (firstPostReactions && isFunction(ResizeObserver)) {
			resizeObserver = new ResizeObserver(() => {
				if (target.isConnected && target.dataset.jumpHighlightToken === token) {
					syncFirstPostJumpHighlightRegion(target);
				}
			});
			resizeObserver.observe(target);
			resizeObserver.observe(firstPostReactions);
		}
		const config = jumpHighlightConfigFromPrefs(PREFS);
		const reducedMotion = prefersReducedMotion();
		const lifetime = reducedMotion ? 1000 : jumpHighlightDurationMs(config) * config.count;
		const timer = setTimeout(() => {
			if (resizeObserver) resizeObserver.disconnect();
			if (target.dataset.jumpHighlightToken === token) {
				target.classList.remove('ldp-jump-highlight');
				target.style.removeProperty('--ldp-first-post-jump-highlight-height');
				delete target.dataset.jumpHighlightToken;
			}
			if (activeJumpHighlight && activeJumpHighlight.token === token) activeJumpHighlight = null;
		}, lifetime);
		activeJumpHighlight = { target, token, timer, resizeObserver };
	}

	function shouldSmoothScrollToPost(target, scrollRoot) {
		if (!target || !scrollRoot) return true;
		const rootRect = scrollRoot.getBoundingClientRect();
		const targetRect = target.getBoundingClientRect();
		const targetCenter = targetRect.top + targetRect.height / 2;
		const rootCenter = rootRect.top + rootRect.height / 2;
		const distance = Math.abs(targetCenter - rootCenter);
		const smoothDistance = Math.max(scrollRoot.clientHeight * 2.5, 1200);
		return distance <= smoothDistance;
	}

	function streamPostNumber(node) {
		return +(node?.dataset && node.dataset.postNumber) || 0;
	}

	function streamOwnerPostNode(ctx, node) {
		if (!ctx || !ctx.commentsEl || !node) return null;
		let owner = node;
		while (owner && owner.parentElement !== ctx.commentsEl) {
			owner = owner.parentElement?.closest('.ldp-post');
		}
		return owner && owner.parentElement === ctx.commentsEl ? owner : null;
	}

	function clearStreamProgrammaticTarget(ctx) {
		if (!ctx) return;
		if (ctx.streamProgrammaticTarget?.animationFrame) {
			cancelAnimationFrame(ctx.streamProgrammaticTarget.animationFrame);
		}
		ctx.streamProgrammaticTarget = null;
		ctx.streamWindowResetPending = false;
	}

	function beginReaderNavigation(ctx) {
		if (!ctx) return 0;
		clearStreamViewportAnchor(ctx);
		clearStreamProgrammaticTarget(ctx);
		ctx.readerNavigationSequence = +(ctx.readerNavigationSequence || 0) + 1;
		return ctx.readerNavigationSequence;
	}

	function cancelReaderNavigation(ctx) {
		if (!ctx) return;
		clearStreamViewportAnchor(ctx);
		clearStreamProgrammaticTarget(ctx);
		ctx.readerNavigationSequence = +(ctx.readerNavigationSequence || 0) + 1;
	}

	function readerNavigationIsCurrent(ctx, sequence) {
		if (!ctx || !sequence || +ctx.readerNavigationSequence !== +sequence) return false;
		return !ctx.readerSession || ctx.readerSession.isCurrent('opening', 'active');
	}

	function waitForReaderJumpLayout() {
		return new Promise((resolve) => {
			requestAnimationFrame(() => requestAnimationFrame(resolve));
		});
	}

	function waitForDelay(delay) {
		return new Promise((resolve) => {
			setTimeout(resolve, delay);
		});
	}

	async function waitForReaderInitialLayout(overlay) {
		await waitForReaderJumpLayout();
		const modal = overlay?.querySelector('.ldp-modal');
		if (!modal || !overlay.classList.contains('ldp-reader-embedded') || !isFunction(modal.getAnimations)) return;
		const motions = modal.getAnimations().filter((motion) => motion.playState !== 'finished');
		if (!motions.length) return;
		await Promise.race([
			Promise.allSettled(motions.map((motion) => motion.finished)),
			waitForDelay(400),
		]);
		await waitForReaderJumpLayout();
	}

	function readerTargetNodeIsVisible(target, ctx) {
		if (!target || !target.isConnected) return false;
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const targetRect = target.getBoundingClientRect();
		return targetRect.bottom > rootRect.top + 1 && targetRect.top < rootRect.bottom - 1;
	}

	function stableReaderViewportOffset(target, ctx, offset) {
		const requestedOffset = Number(offset);
		if (!Number.isFinite(requestedOffset) || !target || !target.isConnected || !ctx || !ctx.scrollRoot) return 0;
		const rootHeight = Math.max(0, ctx.scrollRoot.getBoundingClientRect().height);
		const targetHeight = Math.max(0, target.getBoundingClientRect().height);
		if (!rootHeight || !targetHeight) return requestedOffset;
		const minimumVisibleHeight = Math.min(40, rootHeight, targetHeight);
		return Math.max(
			minimumVisibleHeight - targetHeight,
			Math.min(rootHeight - minimumVisibleHeight, requestedOffset),
		);
	}

	function beginStreamProgrammaticTarget(ctx, postNumber, options = {}) {
		if (!ctx || !postNumber) return null;
		clearStreamViewportAnchor(ctx);
		const target = {
			postNumber: +postNumber,
			alignBottom: options.alignBottom === true,
			alignCenter: options.alignCenter === true,
			alignBottomToCenter: options.alignBottomToCenter === true,
			visualNode: options.visualNode || null,
			animationFrame: 0,
		};
		ctx.streamProgrammaticTarget = target;
		ctx.streamScrollDirection = 0;
		ctx.streamScrollVelocity = 0;
		return target;
	}

	function markStreamInternalScroll(ctx, previousScrollTop = NaN) {
		if (!ctx || !ctx.scrollRoot) return;
		const nextScrollTop = ctx.scrollRoot.scrollTop;
		if (Number.isFinite(previousScrollTop)) {
			const appliedDelta = nextScrollTop - previousScrollTop;
			if (Math.abs(appliedDelta) >= 0.01) {
				const pendingDelta = Number.isFinite(ctx.streamInternalScrollDelta)
					? ctx.streamInternalScrollDelta
					: 0;
				ctx.streamInternalScrollDelta = pendingDelta + appliedDelta;
			}
		}
		ctx.streamInternalScrollTop = nextScrollTop;
	}

	function alignStreamProgrammaticTarget(ctx, state) {
		if (!ctx || !state || ctx.streamProgrammaticTarget !== state) return false;
		const target = state.visualNode?.isConnected
			? state.visualNode
			: getStreamPostNode(ctx, state.postNumber);
		if (!target || !target.isConnected) return false;
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const targetRect = target.getBoundingClientRect();
		const correction = state.alignBottomToCenter
			? targetRect.bottom - (rootRect.top + rootRect.height / 2)
			: state.alignBottom
				? targetRect.bottom - rootRect.bottom
				: state.alignCenter
					? targetRect.top + targetRect.height / 2 - (rootRect.top + rootRect.height / 2)
					: targetRect.top - rootRect.top;
		if (Math.abs(correction) >= 1) {
			const previousScrollTop = ctx.scrollRoot.scrollTop;
			ctx.scrollRoot.scrollTop += correction;
			markStreamInternalScroll(ctx, previousScrollTop);
		}
		return true;
	}

	function animateStreamProgrammaticTarget(ctx, state) {
		if (!ctx || !state || ctx.streamProgrammaticTarget !== state) return false;
		if (prefersReducedMotion()) {
			const aligned = alignStreamProgrammaticTarget(ctx, state);
			clearStreamProgrammaticTarget(ctx);
			scheduleStreamWindowSync(ctx);
			return aligned;
		}
		const initialTarget = state.visualNode?.isConnected
			? state.visualNode
			: getStreamPostNode(ctx, state.postNumber, { mount: true });
		if (!initialTarget || !initialTarget.isConnected) {
			clearStreamProgrammaticTarget(ctx);
			return false;
		}
		state.visualNode = initialTarget;
		const initialRootRect = ctx.scrollRoot.getBoundingClientRect();
		const initialTargetRect = initialTarget.getBoundingClientRect();
		const viewportHeight = Math.max(1, initialRootRect.height);
		const travelDistance = Math.abs(
			initialTargetRect.bottom - (initialRootRect.top + viewportHeight / 2)
		);
		const extraDistance = Math.max(0, travelDistance - viewportHeight / 2);
		const duration = Math.round(220 + Math.min(1, extraDistance / viewportHeight) * 80);
		const startedAt = performance.now();
		const startScrollTop = ctx.scrollRoot.scrollTop;
		const step = (now) => {
			if (ctx.streamProgrammaticTarget !== state) return;
			const target = state.visualNode?.isConnected
				? state.visualNode
				: getStreamPostNode(ctx, state.postNumber, { mount: true });
			if (!target || !target.isConnected) {
				clearStreamProgrammaticTarget(ctx);
				return;
			}
			state.visualNode = target;
			const rootRect = ctx.scrollRoot.getBoundingClientRect();
			const targetRect = target.getBoundingClientRect();
			const targetScrollTop = Math.max(
				0,
				ctx.scrollRoot.scrollTop + targetRect.bottom - (rootRect.top + rootRect.height / 2)
			);
			const progress = Math.min(1, Math.max(0, (now - startedAt) / duration));
			const eased = 1 - Math.pow(1 - progress, 4);
			const previousScrollTop = ctx.scrollRoot.scrollTop;
			ctx.scrollRoot.scrollTop = startScrollTop + (targetScrollTop - startScrollTop) * eased;
			markStreamInternalScroll(ctx, previousScrollTop);
			scheduleStreamWindowSync(ctx);
			if (progress < 1) {
				state.animationFrame = requestAnimationFrame(step);
				return;
			}
			state.animationFrame = 0;
			alignStreamProgrammaticTarget(ctx, state);
			clearStreamProgrammaticTarget(ctx);
		};
		state.animationFrame = requestAnimationFrame(step);
		return true;
	}

	function findTopVisibleStreamPost(ctx) {
		if (!ctx || !ctx.scrollRoot || !ctx.commentsEl) return null;
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const allPosts = [...ctx.commentsEl.querySelectorAll(':scope > .ldp-post:not(.ldp-floor-preview)')];
		let posts = allPosts;
		const items = streamLayoutItems(ctx);
		rebuildStreamPrefix(ctx);
		if (items.length && Number.isFinite(ctx.streamListOffset)) {
			const localTop = Math.max(0, ctx.scrollRoot.scrollTop - ctx.streamListOffset);
			const index = findStreamIndexAtOffset(ctx.streamPrefix, localTop);
			const nearby = [];
			for (let cursor = Math.max(0, index - 2); cursor < Math.min(items.length, index + 7); cursor++) {
				const node = items[cursor]?.node;
				if (node && node.parentElement === ctx.commentsEl) nearby.push(node);
			}
			if (nearby.length) posts = nearby;
		}
		const findBest = (candidates) => {
			let best = null;
			candidates.forEach((post) => {
				const marker = post.querySelector(':scope > .ldp-post-head');
				if (!marker) return;
				const rect = marker.getBoundingClientRect();
				if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return;
				const ownerNode = streamOwnerPostNode(ctx, post);
				if (!streamPostNumber(ownerNode)) return;
				const score = Math.max(0, rect.top - rootRect.top);
				if (!best || score < best.score || (score === best.score && rect.top < best.top)) {
					best = { marker, top: rect.top, ownerNode, score };
				}
			});
			if (best) return best;
			candidates.forEach((post) => {
				const rect = post.getBoundingClientRect();
				if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return;
				const ownerNode = streamOwnerPostNode(ctx, post);
				if (!streamPostNumber(ownerNode)) return;
				const score = Math.abs(rect.top - rootRect.top);
				if (!best || score < best.score) best = { marker: post, ownerNode, score };
			});
			return best;
		};
		let best = findBest(posts);
		if (!best && posts !== allPosts) best = findBest(allPosts);
		return best && { marker: best.marker, ownerNode: best.ownerNode };
	}

	function findStreamViewportAnchorMarker(ctx) {
		const visible = findTopVisibleStreamPost(ctx);
		const owner = visible?.ownerNode;
		if (!owner || !owner.isConnected) return null;
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const viewportEdge = rootRect.top + 1;
		const candidates = [visible.marker, owner, ...owner.querySelectorAll(
			':scope > .ldp-post-head, :scope > .ldp-content > *, :scope > .ldp-reactions, ' +
			':scope > .ldp-boost-list > .ldp-boost-bubble, :scope > .ldp-children > .ldp-post, ' +
			':scope > .ldp-children > .ldp-post > .ldp-post-head, :scope > .ldp-sub-actions'
		)];
		let best = null;
		const seen = new Set();
		candidates.forEach((candidate) => {
			if (!candidate || seen.has(candidate) || !candidate.isConnected) return;
			seen.add(candidate);
			const rect = candidate.getBoundingClientRect();
			if (rect.bottom <= rootRect.top + 1 || rect.top >= rootRect.bottom - 1) return;
			const crossesTop = rect.top <= viewportEdge && rect.bottom > viewportEdge;
			const distance = crossesTop ? 0 : Math.max(0, rect.top - viewportEdge);
			if (!best || distance < best.distance ||
					(distance === best.distance && rect.height < best.height)) {
				best = { marker: candidate, distance, height: rect.height };
			}
		});
		return best?.marker;
	}

	function clearStreamViewportAnchor(ctx) {
		if (!ctx) return;
		ctx.streamViewportAnchorRestorePending = false;
		ctx.streamViewportAnchor = null;
		if (ctx.scrollRoot) ctx.scrollRoot.classList.remove('ldp-stream-viewport-anchor');
	}

	function captureStreamViewportAnchor(ctx) {
		if (!ctx || !ctx.scrollRoot || !ctx.commentsEl || ctx.streamProgrammaticTarget) return null;
		const marker = findStreamViewportAnchorMarker(ctx);
		if (!marker) {
			clearStreamViewportAnchor(ctx);
			return null;
		}
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const markerRect = marker.getBoundingClientRect();
		const markerPost = marker.closest('.ldp-post');
		const owner = streamOwnerPostNode(ctx, markerPost);
		const state = {
			marker,
			offset: markerRect.top - rootRect.top,
			owner,
			ownerOffset: owner ? owner.getBoundingClientRect().top - rootRect.top : 0,
		};
		ctx.streamViewportAnchor = state;
		ctx.scrollRoot.classList.add('ldp-stream-viewport-anchor');
		return state;
	}

	function beginStreamViewportAnchorRestoreBatch(ctx) {
		if (!ctx) return;
		ctx.streamViewportAnchorRestoreDepth = +(ctx.streamViewportAnchorRestoreDepth || 0) + 1;
	}

	function endStreamViewportAnchorRestoreBatch(ctx) {
		if (!ctx || !ctx.streamViewportAnchorRestoreDepth) return;
		ctx.streamViewportAnchorRestoreDepth--;
		if (ctx.streamViewportAnchorRestoreDepth || !ctx.streamViewportAnchorRestorePending) return;
		ctx.streamViewportAnchorRestorePending = false;
		restoreStreamViewportAnchor(ctx);
	}

	function applyStreamViewportAnchorRestore(ctx) {
		const state = ctx?.streamViewportAnchor;
		if (!state) return false;
		if (!ctx.scrollRoot || !ctx.commentsEl || ctx.streamProgrammaticTarget) {
			clearStreamViewportAnchor(ctx);
			return false;
		}
		const markerConnected = state.marker?.isConnected && ctx.commentsEl.contains(state.marker);
		const ownerConnected = state.owner?.isConnected && ctx.commentsEl.contains(state.owner);
		const marker = markerConnected ? state.marker : ownerConnected ? state.owner : null;
		if (!marker) {
			clearStreamViewportAnchor(ctx);
			return false;
		}
		const expectedOffset = marker === state.marker ? state.offset : state.ownerOffset;
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const markerRect = marker.getBoundingClientRect();
		const correction = markerRect.top - rootRect.top - expectedOffset;
		if (Math.abs(correction) < 0.5) return true;
		const previousScrollTop = ctx.scrollRoot.scrollTop;
		ctx.scrollRoot.scrollTop += correction;
		if (Math.abs(ctx.scrollRoot.scrollTop - previousScrollTop) >= 0.5) {
			markStreamInternalScroll(ctx, previousScrollTop);
		} else if (marker === state.marker) {
			state.offset = markerRect.top - rootRect.top;
		} else {
			state.ownerOffset = markerRect.top - rootRect.top;
		}
		return true;
	}

	function restoreStreamViewportAnchor(ctx) {
		if (!ctx || !ctx.streamViewportAnchor) return false;
		if (ctx.streamViewportAnchorRestoreDepth) {
			ctx.streamViewportAnchorRestorePending = true;
			return true;
		}
		ctx.streamViewportAnchorRestorePending = false;
		return applyStreamViewportAnchorRestore(ctx);
	}

	function forEachDirectNestedPost(node, callback) {
		const children = node?.querySelector(':scope > .ldp-children');
		if (!children) return;
		for (const child of children.children) {
			if (child.classList.contains('ldp-post')) callback(child);
		}
	}

	function createStreamSpacer(position) {
		const spacer = makeElement('div');
		spacer.className = `ldp-stream-spacer ldp-stream-spacer-${position}`;
		spacer.setAttribute('aria-hidden', 'true');
		setStreamSpacerHeight(spacer, 0);
		return spacer;
	}

	function setStreamSpacerHeight(spacer, height) {
		if (!spacer) return;
		const next = Math.max(0, Math.round(+height || 0));
		const value = `${next}px`;
		if (spacer.style.getPropertyValue('--ldp-stream-spacer-height') === value) return;
		spacer.style.setProperty('--ldp-stream-spacer-height', value);
	}

	function ensureStreamScaffold(ctx) {
		if (!ctx || !ctx.commentsEl || !ctx.streamTopSpacer || !ctx.streamBottomSpacer) return;
		if (!ctx.streamTopSpacer.isConnected) ctx.commentsEl.appendChild(ctx.streamTopSpacer);
		if (!ctx.streamBottomSpacer.isConnected) ctx.commentsEl.appendChild(ctx.streamBottomSpacer);
	}

	function streamItemMatchesFilter(ctx, item) {
		if (!ctx || !ctx.onlyOp) return true;
		return !!item && !!ctx.op && String(item.username || '') === String(ctx.op);
	}

	function streamLayoutItems(ctx) {
		if (!ctx || !ctx.onlyOp) return (ctx?.streamItems) || [];
		if (!ctx.streamLayoutDirty && Array.isArray(ctx.streamLayoutItems)) return ctx.streamLayoutItems;
		const items = ctx.streamItems.filter((item) => streamItemMatchesFilter(ctx, item));
		let zebraIndex = 0;
		items.forEach((item, index) => {
			item.layoutIndex = index;
			item.layoutZebraIndex = streamItemParticipatesInZebra(item) ? zebraIndex++ : -1;
		});
		ctx.streamLayoutItems = items;
		ctx.streamLayoutDirty = false;
		return items;
	}

	function invalidateStreamLayout(ctx) {
		if (!ctx) return;
		ctx.streamLayoutDirty = true;
		ctx.streamPrefixDirty = true;
		ctx.streamPrefixDirtyFrom = 0;
		ctx.streamListOffset = NaN;
		ctx.streamNeedsRepair = true;
	}

	function markStreamPrefixDirty(ctx, index = 0) {
		const items = streamLayoutItems(ctx);
		const next = Math.max(0, Math.min(items.length, +index || 0));
		ctx.streamPrefixDirty = true;
		ctx.streamPrefixDirtyFrom = Number.isInteger(ctx.streamPrefixDirtyFrom)
			? Math.min(ctx.streamPrefixDirtyFrom, next)
			: next;
	}

	function rebuildStreamPrefix(ctx) {
		if (!ctx || !ctx.streamItems) return [0];
		const items = streamLayoutItems(ctx);
		if (!ctx.streamPrefixDirty && ctx.streamPrefix.length === items.length + 1) return ctx.streamPrefix;
		const prefix = ctx.streamPrefix;
		let from = Number.isInteger(ctx.streamPrefixDirtyFrom) ? ctx.streamPrefixDirtyFrom : 0;
		if (!prefix.length || prefix[0] !== 0 || prefix.length > items.length + 1) from = 0;
		from = Math.max(0, Math.min(from, items.length, prefix.length - 1));
		if (from === 0) {
			prefix.length = 1;
			prefix[0] = 0;
		}
		else prefix.length = from + 1;
		for (let index = from; index < items.length; index++) {
			const item = items[index];
			prefix[index + 1] = prefix[index] + Math.max(+(item.height || 0), STREAM_PLACEHOLDER_MIN_HEIGHT);
		}
		ctx.streamPrefixDirty = false;
		ctx.streamPrefixDirtyFrom = null;
		return prefix;
	}

	function findStreamIndexAtOffset(prefix, offset) {
		if (!prefix || prefix.length <= 1) return 0;
		const target = Math.max(0, +offset || 0);
		let low = 0;
		let high = prefix.length - 2;
		while (low < high) {
			const mid = Math.floor((low + high) / 2);
			if (prefix[mid + 1] <= target) low = mid + 1;
			else high = mid;
		}
		return low;
	}

	function createStreamResizeObserver(ctx) {
		if (typeof ResizeObserver === 'undefined') {
			return { observe() {}, unobserve() {}, disconnect() {} };
		}
		return new ResizeObserver((entries) => {
			let changed = false;
			entries.forEach((entry) => {
				const item = ctx.streamItemMap.get(streamPostNumber(entry.target));
				if (!item) return;
				const borderBox = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize;
				const measured = (borderBox?.blockSize) || entry.target.offsetHeight || entry.contentRect.height;
				const height = Math.max(Math.round(measured || 0), STREAM_PLACEHOLDER_MIN_HEIGHT);
				if (entry.target.classList.contains('ldp-has-child-branches')) {
					scheduleNestedBranchSync(entry.target, ctx);
				}
				if (Math.abs(height - item.height) < 1) return;
				item.height = height;
				markStreamPrefixDirty(ctx, ctx.onlyOp ? item.layoutIndex : item.index);
				changed = true;
			});
			if (!changed) return;
			restoreStreamViewportAnchor(ctx);
			ctx.streamNeedsRepair = true;
			scheduleStreamWindowSync(ctx);
		});
	}

	function updateStreamSpacerHeights(ctx, start, end) {
		const total = streamLayoutItems(ctx).length;
		const totalHeight = ctx.streamPrefix[total] || 0;
		setStreamSpacerHeight(
			ctx.streamTopSpacer,
			Math.max(ctx.streamPrefix[start], +(ctx.initialTargetHeadSpacerHeight || 0))
		);
		setStreamSpacerHeight(
			ctx.streamBottomSpacer,
			Math.max(0, totalHeight - ctx.streamPrefix[end])
		);
	}

	function clearInitialTargetHeadSpacer(ctx) {
		if (!ctx || !ctx.initialTargetHeadSpacerHeight) return;
		ctx.initialTargetHeadSpacerHeight = 0;
		updateStreamSpacerHeights(ctx, ctx.streamWindowStart, ctx.streamWindowEnd);
	}

	function createPostIdleWorkState(pendingSet) {
		return { cancels: new WeakMap(), pendingSet };
	}

	const postContentHydrationWork = createPostIdleWorkState('pendingPostHydrations');
	const postLatexRenderWork = createPostIdleWorkState('pendingPostLatexRenders');

	function cancelPostIdleWork(post, ctx, work, pendingKey) {
		if (!post) return;
		const cancel = work.cancels.get(post);
		if (cancel) cancel();
		work.cancels.delete(post);
		if (ctx?.[work.pendingSet]) ctx[work.pendingSet].delete(post);
		delete post.dataset[pendingKey];
	}

	function cancelPostContentHydration(post, ctx) {
		cancelPostIdleWork(post, ctx, postContentHydrationWork, 'ldpContentHydrationPending');
	}

	function cancelPostLatexRender(post, ctx) {
		cancelPostIdleWork(post, ctx, postLatexRenderWork, 'ldpLatexPending');
	}

	function postContentPlaceholderHeight(postData) {
		const cooked = String((postData?.cooked) || '');
		if (!cooked) return 72;
		const blockCount = (cooked.match(/<(?:p|li|pre|blockquote|h[1-6]|tr|br)\b/gi) || []).length;
		const lineEstimate = Math.max(2, blockCount, Math.ceil(cooked.length / 180));
		const mediaHeight = /<(?:img|video|audio|iframe)\b|class=["'][^"']*(?:onebox|lightbox|d-image-grid)/i.test(cooked) ? 220 : 0;
		return Math.max(72, Math.min(460, 28 + lineEstimate * 22 + mediaHeight));
	}

	function postHasBoostData(boosts) {
		if (Array.isArray(boosts)) return boosts.some(Boolean);
		return !!boosts;
	}

	function hydratePostContent(post, ctx) {
		if (!post || post.dataset.ldpContentHydrated !== '0') return !!post;
		const postData = post._ldpPostData || {};
		const content = post.querySelector(':scope > .ldp-content');
		if (!content) return false;

		cancelPostContentHydration(post, ctx);

		setPostCookedContent(content, postData.cooked, postData.link_counts);
		content.classList.remove('ldp-content-pending');
		content.style.removeProperty('--ldp-content-placeholder-height');
		content.removeAttribute('aria-busy');
		renderPostPolls(post, ctx);
		renderPostSpecialContent(post, ctx);

		const pendingBoosts = post.querySelector(':scope > .ldp-boost-list.ldp-boost-pending');
		if (pendingBoosts) {
			const holder = makeElement('template');
			holder.innerHTML = renderBoostList(postData.boosts, { canQuickBoost: canShowBoostButton(postData), ctx });
			const next = holder.content.firstElementChild;
			if (next) replaceOwnedNode(pendingBoosts, next);
			else removeOwnedNode(pendingBoosts);
		}

		post.dataset.ldpContentHydrated = '1';
		if (ctx?.streamNodeMap && ctx.streamNodeMap.get(+post.dataset.postNumber) === post) {
			if (ctx.streamHydratedNodes) ctx.streamHydratedNodes.add(post);
		}
		delete post.dataset.ldpLatexRendered;
		bindPostMediaLayoutSync(post, ctx);
		if (post.isConnected) {
			schedulePostLatexRender(post, ctx);
			scheduleNestedBranchSync(post, ctx);
		}
		restoreReaderQuoteHighlightForPost(post, ctx);
		restoreStreamViewportAnchor(ctx);
		return true;
	}

	function dehydratePostContent(post, ctx) {
		if (!post || post.isConnected || post.dataset.ldpContentHydrated !== '1') return false;
		if (ctx?.floorPreview && ctx.floorPreview.isOpen()) return false;
		cancelPostContentHydration(post, ctx);
		cancelPostLatexRender(post, ctx);
		const postData = post._ldpPostData || {};
		const content = post.querySelector(':scope > .ldp-content');
		if (content) {
			suspendReaderMedia(content);
			releasePersistentAvatarImages(content);
			content.replaceChildren();
			content.classList.add('ldp-content-pending');
			content.style.setProperty('--ldp-content-placeholder-height', `${postContentPlaceholderHeight(postData)}px`);
			content.setAttribute('aria-busy', 'true');
		}
		const boosts = post.querySelector(':scope > .ldp-boost-list');
		if (boosts) {
			destroyReaderMedia(boosts);
			releasePersistentAvatarImages(boosts);
			boosts.replaceChildren();
			boosts.classList.add('ldp-boost-pending');
			boosts.hidden = !postHasBoostData(postData.boosts);
			boosts.setAttribute('aria-hidden', 'true');
		}
		post.dataset.ldpContentHydrated = '0';
		delete post.dataset.ldpLatexRendered;
		delete post.dataset.ldpLatexPending;
		if (ctx?.streamHydratedNodes) ctx.streamHydratedNodes.delete(post);
		return true;
	}

	function pruneStreamHydratedContent(ctx, items, start, end) {
		if (!ctx || !ctx.streamHydratedNodes || ctx.floorPreview?.isOpen()) return;
		const warmBuffer = Math.min(24, Math.max(8, Math.ceil(streamPerformance(ctx).streamMaxItems / 4)));
		const warmStart = Math.max(0, start - warmBuffer);
		const warmEnd = Math.min(items.length, end + warmBuffer);
		[...ctx.streamHydratedNodes].forEach((post) => {
			if (!post || post.isConnected) return;
			const item = ctx.streamItemMap.get(streamPostNumber(post));
			const index = item && (ctx.onlyOp ? item.layoutIndex : item.index);
			const participates = item && (!ctx.onlyOp || streamItemMatchesFilter(ctx, item));
			if (!participates || !Number.isInteger(index) || index < warmStart || index >= warmEnd) {
				dehydratePostContent(post, ctx);
			}
		});
	}

	function hydrateStreamNavigationNeighborhood(ctx, postNumber) {
		const targetItem = ctx?.streamItemMap && ctx.streamItemMap.get(+postNumber);
		if (!targetItem) return;
		const items = streamLayoutItems(ctx);
		const targetIndex = ctx.onlyOp ? targetItem.layoutIndex : targetItem.index;
		if (!Number.isInteger(targetIndex) || targetIndex < 0 || targetIndex >= items.length) return;
		const span = Math.min(items.length, streamPerformance(ctx).streamMaxItems);
		const beforeTarget = Math.min(targetIndex, Math.floor(span * 0.75));
		let start = Math.max(0, targetIndex - beforeTarget);
		const end = Math.min(items.length, start + span);
		start = Math.max(0, end - span);
		for (let index = start; index < end; index++) {
			const item = items[index];
			if (item?.node) hydratePostContent(item.node, ctx);
		}
	}

	function schedulePostIdleWork(post, ctx, work, options) {
		const { pendingKey, delay, settleMs, isPending, run } = options;
		if (!post || !isPending() || post.dataset[pendingKey] === '1') return;
		if (options.immediate && !streamScrollIsActive(ctx, settleMs)) return run();
		post.dataset[pendingKey] = '1';
		const cancel = () => cancelPostIdleWork(post, ctx, work, pendingKey);
		const queue = () => {
			work.cancels.set(post, scheduleIdleWork(attempt, delay));
			if (ctx?.[work.pendingSet]) ctx[work.pendingSet].add(post);
		};
		const attempt = () => {
			if (!isPending() || !post.isConnected) return cancel();
			if (streamScrollIsActive(ctx, settleMs)) return queue();
			cancel();
			run();
		};
		queue();
	}

	function schedulePostContentHydration(post, ctx, immediate = false) {
		schedulePostIdleWork(post, ctx, postContentHydrationWork, {
			pendingKey: 'ldpContentHydrationPending', delay: 320, settleMs: 180, immediate,
			isPending: () => post.dataset.ldpContentHydrated === '0',
			run: () => hydratePostContent(post, ctx),
		});
	}

	function hydrateStreamRangeContent(ctx, items, start, end) {
		if (!ctx || !items || start >= end) return;
		const pressure = scrollLoadPressure(ctx);
		const beforeLead = ctx.streamScrollDirection < 0 ? Math.min(8, Math.max(3, pressure.lookahead)) : 2;
		const afterLead = ctx.streamScrollDirection > 0 ? Math.min(6, Math.max(2, pressure.lookahead)) : 2;
		const priorityStart = Math.max(start, ctx.streamViewportTopIndex - beforeLead);
		const priorityEnd = Math.min(end - 1, ctx.streamViewportBottomIndex + afterLead);
		const idleStart = Math.max(start, priorityStart - 3);
		const idleEnd = Math.min(end - 1, priorityEnd + 3);
		const initialTargetPostNumber = +(ctx.initialTargetHydrationBoundary || 0);
		const initialTargetItem = initialTargetPostNumber && ctx.streamItemMap.get(initialTargetPostNumber);
		const initialTargetIndex = initialTargetItem
			? (ctx.onlyOp ? initialTargetItem.layoutIndex : initialTargetItem.index)
			: -1;
		beginStreamViewportAnchorRestoreBatch(ctx);
		try {
			for (let index = idleStart; index <= idleEnd; index++) {
				const item = items[index];
				if (!item || !item.node) continue;
				const beforeInitialTarget = initialTargetPostNumber && (
					initialTargetIndex >= 0 ? index < initialTargetIndex : item.postNumber < initialTargetPostNumber
				);
				if (beforeInitialTarget &&
						(ctx.initialTargetPrecedingContentDeferred || ctx.initialTargetPositionPending)) continue;
				const priority = index >= priorityStart && index <= priorityEnd;
				schedulePostContentHydration(item.node, ctx, priority);
			}
		} finally {
			endStreamViewportAnchorRestoreBatch(ctx);
		}
	}

	function tryOwnedNodeMutation(mutation) {
		try {
			mutation();
			return true;
		} catch (error) {
			if (error && error.name === 'NotFoundError') return false;
			throw error;
		}
	}

	function removeOwnedNode(node, owner = null) {
		if (!node) return false;
		const parent = node.parentNode;
		if (!parent || (owner && parent !== owner)) return false;
		releasePersistentAvatarImages(node);
		return tryOwnedNodeMutation(() => parent.removeChild(node));
	}

	function moveOwnedNodeBefore(owner, node, referenceNode = null) {
		if (!owner || !node) return false;
		const reference = referenceNode && referenceNode.parentNode === owner ? referenceNode : null;
		if (node.parentNode === owner && node.nextSibling === reference) return true;
		try {
			owner.insertBefore(node, reference);
			return true;
		} catch (error) {
			if (!error || error.name !== 'NotFoundError') throw error;
			const freshReference = referenceNode && referenceNode.parentNode === owner ? referenceNode : null;
			if (node.parentNode === owner && node.nextSibling === freshReference) return true;
			owner.insertBefore(node, freshReference);
			return true;
		}
	}

	function replaceOwnedNode(currentNode, nextNode) {
		if (!currentNode || !nextNode) return false;
		const parent = currentNode.parentNode;
		if (!parent) return false;
		return tryOwnedNodeMutation(() => parent.replaceChild(nextNode, currentNode));
	}

	function mountStreamRange(ctx, start, end) {
		if (!ctx || !ctx.streamItems || !ctx.commentsEl) return;
		ensureStreamScaffold(ctx);
		rebuildStreamPrefix(ctx);
		const items = streamLayoutItems(ctx);
		const total = items.length;
		start = Math.max(0, Math.min(total, +start || 0));
		end = Math.max(start, Math.min(total, +end || 0));

		hydrateStreamRangeContent(ctx, items, start, end);
		const firstRangeNode = items[start]?.node;
		const lastRangeNode = items[end - 1]?.node;
		const rangeEndpointsConnected = start === end ||
			(firstRangeNode && lastRangeNode &&
				firstRangeNode.parentElement === ctx.commentsEl && lastRangeNode.parentElement === ctx.commentsEl);
		if (ctx.streamMountedItemCount === total &&
				ctx.streamWindowStart === start && ctx.streamWindowEnd === end &&
				ctx.streamMountedNodes.size === end - start && rangeEndpointsConnected) {
			updateStreamSpacerHeights(ctx, start, end);
			syncCommentsHeaderPlacement(ctx);
			restoreStreamViewportAnchor(ctx);
			return;
		}

		const activeElement = readerActiveElement();
		const activePost = ctx.commentsEl.contains(activeElement) && activeElement.closest('.ldp-post');
		if (activePost) {
			const activeItem = ctx.streamItemMap.get(streamPostNumber(activePost));
			const activeIndex = activeItem && (ctx.onlyOp ? activeItem.layoutIndex : activeItem.index);
			if (activeItem && (activeIndex < start || activeIndex >= end) && activeElement.blur) {
				activeElement.blur();
			}
		}

		const rangeItems = items.slice(start, end);
		const desired = new Set(rangeItems.map((item) => item.node));
		[...ctx.streamMountedNodes].forEach((node) => {
			if (desired.has(node)) return;
			ctx.streamResizeObserver.unobserve(node);
			if (ctx.tracker?.unobserve) {
				ctx.tracker.unobserve(node);
				forEachDirectNestedPost(node, (child) => ctx.tracker.unobserve(child));
			}
			if (ctx.queueViewportTracker) ctx.queueViewportTracker.unobserve(node);
			if (ctx.repliesIO?.unobserve) ctx.repliesIO.unobserve(node);
			cancelPostContentHydration(node, ctx);
			cancelPostLatexRender(node, ctx);
			suspendReaderMedia(node);
			const bundle = ctx.floorPreview ? ctx.floorPreview.getBundleForPost(node) : [node];
			bundle.forEach((part) => removeOwnedNode(part, ctx.commentsEl));
			if (node.querySelector(':scope > .ldp-content :is(video,audio,iframe),:scope > .ldp-content .ldp-media-carousel')) {
				dehydratePostContent(node, ctx);
			}
			ctx.streamMountedNodes.delete(node);
		});
		pruneStreamHydratedContent(ctx, items, start, end);

		let cursor = ctx.streamTopSpacer.nextSibling;
		rangeItems.forEach((item) => {
			const node = item.node;
			const wasConnected = node.parentElement === ctx.commentsEl;
			const deferPreTargetWork = ctx.initialTargetPrecedingContentDeferred &&
				+item.postNumber < +(ctx.initialTargetHydrationBoundary || 0);
			if (!wasConnected) node.classList.remove('ldp-nested-branches-ready');
			keepOnlyOpPostExpanded(node, ctx);
			ctx.streamMountedNodes.add(node);
			const bundle = ctx.floorPreview ? ctx.floorPreview.getBundleForPost(node) : [node];
			bundle.forEach((part) => {
				if (part !== cursor) {
					const reference = cursor && cursor.parentNode === ctx.commentsEl
						? cursor
						: (ctx.streamBottomSpacer.parentNode === ctx.commentsEl ? ctx.streamBottomSpacer : null);
					moveOwnedNodeBefore(ctx.commentsEl, part, reference);
				}
				cursor = part.nextSibling;
			});
			if (document.visibilityState === 'visible') activateReaderMedia(node);
			restoreReaderQuoteHighlightForPost(node, ctx);
			if (node.dataset.ldpContentHydrated === '1' && node.dataset.ldpLatexRendered !== '1') {
				schedulePostLatexRender(node, ctx);
			}
			if (!wasConnected) {
				hydratePersistentAvatarImages(node);
				ctx.streamResizeObserver.observe(node);
				ctx.tracker.observe(node);
				if (ctx.queueViewportTracker) ctx.queueViewportTracker.observe(node);
				forEachDirectNestedPost(node, (child) => ctx.tracker.observe(child));
				if (!deferPreTargetWork && node.dataset.childReplyTotal && ctx.repliesIO && ctx.repliesIO.observe) {
					ctx.repliesIO.observe(node);
				}
				const renderedChildren = node.querySelector(':scope > .ldp-children > .ldp-post');
				if (renderedChildren) scheduleNestedBranchSync(node, ctx);
			}
		});

		updateStreamSpacerHeights(ctx, start, end);
		ctx.streamWindowStart = start;
		ctx.streamWindowEnd = end;
		ctx.streamMountedItemCount = total;
		refreshStreamZebra(ctx, rangeItems.map((item) => item.node));
		syncCommentsHeaderPlacement(ctx);
		if (ctx.floorPreview?.isOpen()) scheduleFloorPreviewSync(ctx);
		restoreStreamViewportAnchor(ctx);
	}

	function streamPerformance(ctx) {
		return (ctx?.performance) || BALANCED_PERFORMANCE;
	}

	function streamScrollIsActive(ctx, settleMs = 120) {
		if (!ctx || !ctx.streamLastScrollAt) return false;
		return Date.now() - ctx.streamLastScrollAt < settleMs && +(ctx.streamScrollVelocity || 0) > 0.08;
	}

	function schedulePostLatexRender(post, ctx) {
		schedulePostIdleWork(post, ctx, postLatexRenderWork, {
			pendingKey: 'ldpLatexPending', delay: 360, settleMs: 120, immediate: true,
			isPending: () => post.dataset.ldpLatexRendered !== '1',
			run: () => {
				renderLatexInPost(post);
				restoreReaderQuoteHighlightForPost(post, ctx);
			},
		});
	}

	function scrollLoadPressure(ctx) {
		const lastScrollAt = +(ctx?.streamLastScrollAt || 0);
		const age = Date.now() - lastScrollAt;
		const viewportHeight = Math.max(1, +(ctx?.scrollRoot && ctx.scrollRoot.clientHeight || 0));
		const viewportsPerSecond = age > 300
			? 0
			: Math.max(0, +(ctx?.streamScrollVelocity || 0)) * 1000 / viewportHeight;
		if (viewportsPerSecond >= 0.75) return { batches: 2, lookahead: viewportsPerSecond >= 2.5 ? 24 : 14 };
		return { batches: 1, lookahead: 8 };
	}

	function repairBlankStreamViewport(ctx, start, end) {
		if (!ctx || !ctx.streamMountedNodes.size) return;
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const items = streamLayoutItems(ctx);
		let firstNode = items[start]?.node;
		if (!firstNode || !firstNode.isConnected) return;
		let firstRect = firstNode.getBoundingClientRect();
		const leadingGap = firstRect.top - rootRect.top;
		if (start > 0 && leadingGap > 1) {
			const extraRows = Math.max(8, Math.ceil(leadingGap / STREAM_ESTIMATED_HEIGHT) + 4);
			const nextStart = Math.max(0, end - streamPerformance(ctx).streamMaxItems, start - extraRows);
			if (nextStart < start) {
				const range = stableStreamMountRange(ctx, nextStart, end, items.length);
				mountStreamRange(ctx, range.start, range.end);
				start = ctx.streamWindowStart;
				end = ctx.streamWindowEnd;
				firstNode = items[start]?.node;
				firstRect = firstNode?.getBoundingClientRect();
			}
		}
		const commentsRect = ctx.commentsEl.getBoundingClientRect();
		if (commentsRect.bottom < rootRect.top || commentsRect.top > rootRect.bottom) return;
		const lastNode = items[end - 1]?.node;
		if (!firstNode || !lastNode || !firstNode.isConnected || !lastNode.isConnected) return;
		const lastRect = lastNode === firstNode ? firstRect : lastNode.getBoundingClientRect();
		if (lastRect.bottom >= rootRect.top && firstRect.top <= rootRect.bottom) return;

		const length = Math.max(1, end - start);
		if (firstRect.top > rootRect.bottom && start > 0) {
			const gap = firstRect.top - rootRect.bottom;
			const extraRows = Math.max(6, Math.ceil(gap / STREAM_ESTIMATED_HEIGHT) + 3);
			const nextStart = Math.max(0, start - extraRows);
			const nextLength = Math.min(streamPerformance(ctx).streamMaxItems, length + extraRows);
			const range = stableStreamMountRange(
				ctx,
				nextStart,
				Math.min(items.length, nextStart + nextLength),
				items.length
			);
			mountStreamRange(ctx, range.start, range.end);
			return;
		}
		if (lastRect.bottom < rootRect.top && end < items.length) {
			const gap = rootRect.top - lastRect.bottom;
			const extraRows = Math.max(6, Math.ceil(gap / STREAM_ESTIMATED_HEIGHT) + 3);
			const nextEnd = Math.min(items.length, end + extraRows);
			const nextLength = Math.min(streamPerformance(ctx).streamMaxItems, length + extraRows);
			const range = stableStreamMountRange(
				ctx,
				Math.max(0, nextEnd - nextLength),
				nextEnd,
				items.length
			);
			mountStreamRange(ctx, range.start, range.end);
		}
	}

	function mountStreamPost(ctx, postNumber) {
		const item = ctx?.streamItemMap && ctx.streamItemMap.get(+postNumber);
		if (!item || !item.node || !streamItemMatchesFilter(ctx, item)) return null;
		const items = streamLayoutItems(ctx);
		const layoutIndex = ctx.onlyOp ? item.layoutIndex : item.index;
		rebuildStreamPrefix(ctx);
		const span = Math.min(
			streamPerformance(ctx).streamMaxItems,
			Math.max(12, Math.ceil(ctx.scrollRoot.clientHeight / STREAM_PLACEHOLDER_MIN_HEIGHT) + 8)
		);
		const start = Math.max(0, Math.min(layoutIndex - Math.floor(span / 2), items.length - span));
		const end = Math.min(items.length, start + span);
		mountStreamRange(ctx, start, end);
		hydratePostContent(item.node, ctx);
		return item.node.isConnected ? item.node : null;
	}

	function getStreamPostNode(ctx, postNumber, options = {}) {
		const num = +postNumber;
		if (!ctx || !num) return null;
		const node = ctx.streamNodeMap?.get(num);
		if (node?.isConnected) return node;
		if (options.mount) return mountStreamPost(ctx, num);
		return null;
	}

	function getStreamListOffset(ctx) {
		if (Number.isFinite(ctx.streamListOffset)) return ctx.streamListOffset;
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const commentsRect = ctx.commentsEl.getBoundingClientRect();
		ctx.streamListOffset = ctx.scrollRoot.scrollTop + commentsRect.top - rootRect.top;
		return ctx.streamListOffset;
	}

	function stableStreamMountRange(ctx, start, end, total) {
		start = Math.max(0, Math.min(total, +start || 0));
		end = Math.max(start, Math.min(total, +end || 0));
		if (ctx.streamWindowResetPending) {
			ctx.streamWindowResetPending = false;
			return { start, end };
		}

		const currentStart = +ctx.streamWindowStart || 0;
		const currentEnd = +ctx.streamWindowEnd || 0;
		const currentLength = currentEnd - currentStart;
		const currentValid = ctx.streamMountedNodes.size > 0 && currentLength > 0 &&
			currentStart >= 0 && currentEnd <= total;
		if (!currentValid) return { start, end };
		const maxItems = Math.max(1, streamPerformance(ctx).streamMaxItems);
		const anchorOwner = ctx.streamViewportAnchor?.owner;
		const anchorItem = anchorOwner?.isConnected
			? ctx.streamItemMap.get(streamPostNumber(anchorOwner))
			: null;
		const anchorIndex = anchorItem && (ctx.onlyOp ? anchorItem.layoutIndex : anchorItem.index);
		const currentContainsAnchor = Number.isInteger(anchorIndex) &&
			anchorIndex >= currentStart && anchorIndex < currentEnd;
		const nextContainsAnchor = Number.isInteger(anchorIndex) && anchorIndex >= start && anchorIndex < end;
		if (currentContainsAnchor && !nextContainsAnchor) {
			const span = Math.min(maxItems, Math.max(1, currentLength, end - start));
			if (ctx.streamScrollDirection < 0) {
				const trailingItems = Math.min(6, Math.max(2, Math.ceil(span * 0.1)));
				end = Math.min(total, anchorIndex + trailingItems + 1);
				start = Math.max(0, end - span);
				end = Math.min(total, start + span);
			} else if (ctx.streamScrollDirection > 0) {
				const leadingItems = Math.min(6, Math.max(2, Math.ceil(span * 0.1)));
				start = Math.max(0, anchorIndex - leadingItems);
				end = Math.min(total, start + span);
				start = Math.max(0, end - span);
			} else {
				start = Math.max(0, anchorIndex - Math.floor(span / 2));
				end = Math.min(total, start + span);
				start = Math.max(0, end - span);
			}
			return { start, end };
		}
		const unionStart = Math.min(currentStart, start);
		const unionEnd = Math.max(currentEnd, end);
		if (unionEnd - unionStart <= maxItems) return { start: unionStart, end: unionEnd };

		// A small overlap avoids churn while measured heights settle. Once the DOM
		// budget is full, slide the window instead of retaining every visited floor.
		if (start >= currentStart && end >= currentEnd) {
			return { start: Math.max(0, unionEnd - maxItems), end: unionEnd };
		}
		if (start <= currentStart && end <= currentEnd) {
			return { start: unionStart, end: Math.min(total, unionStart + maxItems) };
		}
		return { start, end };
	}

	function syncStreamWindow(ctx) {
		if (!ctx || !ctx.commentsEl || !ctx.scrollRoot || !ctx.streamItems.length) return;
		const items = streamLayoutItems(ctx);
		ensureStreamScaffold(ctx);
		rebuildStreamPrefix(ctx);
		if (!items.length) {
			mountStreamRange(ctx, 0, 0);
			return;
		}
		const listOffset = getStreamListOffset(ctx);
		const viewportHeight = Math.max(ctx.scrollRoot.clientHeight, 1);
		const performance = streamPerformance(ctx);
		const overscan = viewportHeight * performance.streamOverscanViewports;
		const velocityLead = Math.min(
			viewportHeight * 2,
			Math.max(0, Number(ctx.streamScrollVelocity) || 0) * 700
		);
		const beforeOverscan = overscan + (ctx.streamScrollDirection < 0 ? velocityLead : 0);
		const afterOverscan = overscan + (ctx.streamScrollDirection > 0 ? velocityLead : 0);
		const localTop = Math.max(0, ctx.scrollRoot.scrollTop - listOffset);
		ctx.streamViewportTopIndex = findStreamIndexAtOffset(ctx.streamPrefix, localTop);
		ctx.streamViewportBottomIndex = findStreamIndexAtOffset(
			ctx.streamPrefix,
			Math.max(0, localTop + viewportHeight - 10)
		);
		let start = findStreamIndexAtOffset(ctx.streamPrefix, Math.max(0, localTop - beforeOverscan));
		let end = findStreamIndexAtOffset(ctx.streamPrefix, localTop + viewportHeight + afterOverscan) + 1;
		if (end - start > performance.streamMaxItems) {
			const center = findStreamIndexAtOffset(ctx.streamPrefix, localTop + viewportHeight / 2);
			start = Math.max(0, center - Math.floor(performance.streamMaxItems / 2));
			end = Math.min(items.length, start + performance.streamMaxItems);
			start = Math.max(0, end - performance.streamMaxItems);
		}
		const stableRange = stableStreamMountRange(
			ctx,
			start,
			end,
			items.length
		);
		start = stableRange.start;
		end = stableRange.end;
		const needsRepair = ctx.streamNeedsRepair || start !== ctx.streamWindowStart || end !== ctx.streamWindowEnd;
		mountStreamRange(ctx, start, end);
		ctx.streamNeedsRepair = false;
		if (needsRepair) {
			repairBlankStreamViewport(ctx, start, end);
		}
		if (ctx.viewportPersistenceEnabled && ctx.loader && isFunction(ctx.loader.persistViewedPostNumbers)) {
			const visibleStart = Math.max(0, Math.min(items.length - 1, ctx.streamViewportTopIndex));
			const visibleEnd = Math.max(visibleStart, Math.min(items.length - 1, ctx.streamViewportBottomIndex));
			ctx.loader.persistViewedPostNumbers(
				items.slice(visibleStart, visibleEnd + 1).map((item) => item?.postNumber)
			);
		}
	}

	function scheduleStreamWindowSync(ctx) {
		if (!ctx || ctx.streamWindowFrame) return;
		ctx.streamWindowFrame = requestAnimationFrame(() => {
			ctx.streamWindowFrame = 0;
			syncStreamWindow(ctx);
			// Defer geometry reads until the mounted window has completed a layout.
			if (ctx.timeline) ctx.timeline.update();
		});
	}

	function flushStreamWindow(ctx) {
		if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame);
		ctx.streamWindowFrame = 0;
		syncStreamWindow(ctx);
	}

	function scheduleFloorPreviewSync(ctx) {
		if (!ctx || !ctx.floorPreview || ctx.floorPreviewSyncFrame) return;
		ctx.floorPreviewSyncFrame = requestAnimationFrame(() => {
			ctx.floorPreviewSyncFrame = 0;
			if (ctx.floorPreview?.isOpen()) ctx.floorPreview.syncPlacement();
		});
	}

	const PINNED_STREAM_SCROLL_OPTIONS = Object.freeze({
		preferStream: true,
		instant: true,
		pinStreamTarget: true,
	});

	function scrollToPost(postNumber, ctx, options = {}) {
		clearStreamViewportAnchor(ctx);
		ctx.streamScrollDirection = 0;
		const programmaticTarget = options.pinStreamTarget === true
			? beginStreamProgrammaticTarget(ctx, postNumber, options)
			: null;
		if (programmaticTarget) hydrateStreamNavigationNeighborhood(ctx, postNumber);
		let target = null;
		if (options.preferStream && ctx.streamNodeMap) target = getStreamPostNode(ctx, postNumber, { mount: true });
		if (!target && options.requireStream) {
			if (programmaticTarget) clearStreamProgrammaticTarget(ctx);
			return false;
		}
		if (!target) target = ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${postNumber}"]`);
		if (!target) {
			if (programmaticTarget) clearStreamProgrammaticTarget(ctx);
			return false;
		}
		const previousScrollTop = ctx.scrollRoot.scrollTop;
		if (!options.alignTop && !options.alignBottom && +postNumber < +(ctx.initialTargetHydrationBoundary || 0) &&
			isFunction(ctx.releaseInitialPrecedingContent)) {
			ctx.releaseInitialPrecedingContent();
		}
		hydratePostContent(target, ctx);
		const resolvedVisualTarget = isFunction(options.resolveVisualTarget)
			? options.resolveVisualTarget(target)
			: options.visualNode;
		const scrollTarget = resolvedVisualTarget?.isConnected ? resolvedVisualTarget : target;
		const hasVisualTarget = scrollTarget !== target;
		const highlightTarget = options.highlightTarget?.isConnected
			? options.highlightTarget
			: target;
		const alignOffset = Number(options.alignOffset);
		const expandedJumpTarget = target.classList.contains('ldp-nested-collapsed') && !options.keepCollapsed;
		if (expandedJumpTarget) {
			target.dataset.ldpJumpExpanded = '1';
			setNestedReplyCollapsed(target, false);
		}
		if (options.alignBottom) {
			const rootRect = ctx.scrollRoot.getBoundingClientRect();
			const targetRect = scrollTarget.getBoundingClientRect();
			const desiredScrollTop = ctx.scrollRoot.scrollTop + targetRect.bottom - rootRect.bottom;
			const currentTopSpacerHeight = parseFloat(
				ctx.streamTopSpacer.style.getPropertyValue('--ldp-stream-spacer-height')
			) || 0;
			ctx.initialTargetHeadSpacerHeight = Math.max(
				+(ctx.initialTargetHeadSpacerHeight || 0),
				currentTopSpacerHeight + Math.max(0, -desiredScrollTop) + 2
			);
			setStreamSpacerHeight(
				ctx.streamTopSpacer,
				Math.max(currentTopSpacerHeight, ctx.initialTargetHeadSpacerHeight)
			);
			scrollTarget.scrollIntoView({ behavior: 'auto', block: 'end' });
			const remainingOffset = scrollTarget.getBoundingClientRect().bottom - ctx.scrollRoot.getBoundingClientRect().bottom;
			if (Math.abs(remainingOffset) >= 1) ctx.scrollRoot.scrollTop += remainingOffset;
		} else if (options.alignCenter && hasVisualTarget) {
			scrollTarget.scrollIntoView({ behavior: 'auto', block: 'center' });
			const rootRect = ctx.scrollRoot.getBoundingClientRect();
			const targetRect = scrollTarget.getBoundingClientRect();
			const remainingOffset = targetRect.top + targetRect.height / 2 - (rootRect.top + rootRect.height / 2);
			if (Math.abs(remainingOffset) >= 1) ctx.scrollRoot.scrollTop += remainingOffset;
		} else if (options.alignNearest && hasVisualTarget) {
			scrollTarget.scrollIntoView({ behavior: 'auto', block: 'nearest' });
		} else if (Number.isFinite(alignOffset)) {
			scrollTarget.scrollIntoView({ behavior: 'auto', block: 'start' });
			const rootRect = ctx.scrollRoot.getBoundingClientRect();
			const remainingOffset = scrollTarget.getBoundingClientRect().top - rootRect.top - alignOffset;
			if (Math.abs(remainingOffset) >= 1) ctx.scrollRoot.scrollTop += remainingOffset;
		} else if (options.alignTop) {
			scrollTarget.scrollIntoView({ behavior: 'auto', block: 'start' });
			const remainingOffset = scrollTarget.getBoundingClientRect().top - ctx.scrollRoot.getBoundingClientRect().top;
			if (Math.abs(remainingOffset) >= 1) ctx.scrollRoot.scrollTop += remainingOffset;
		} else {
			const behavior = options.instant === true
				? 'auto'
				: (shouldSmoothScrollToPost(scrollTarget, ctx.scrollRoot) ? 'smooth' : 'auto');
			const block = +postNumber === 1 ? 'start' : 'center';
			scrollTarget.scrollIntoView({ behavior, block });
		}
		markStreamInternalScroll(ctx, previousScrollTop);
		if (programmaticTarget) clearStreamProgrammaticTarget(ctx);
		if (options.highlight !== false) {
			if (expandedJumpTarget) {
				requestAnimationFrame(() => requestAnimationFrame(() => {
					if (highlightTarget.isConnected && !target.classList.contains('ldp-nested-collapsed')) {
						keepJumpHighlightUntilSettled(highlightTarget);
					}
				}));
			} else {
				keepJumpHighlightUntilSettled(highlightTarget);
			}
		}
		scheduleStreamWindowSync(ctx);
		return true;
	}

	function clearJumpBackLinks(ctx) {
		ctx.scrollRoot.querySelectorAll('.ldp-jump-backbar').forEach((el) => el.remove());
	}

	function showJumpBackLink(targetPostNumber, sourcePostNumber, ctx) {
		const target = getStreamPostNode(ctx, targetPostNumber, { mount: true }) ||
				ctx.scrollRoot.querySelector(`.ldp-post[data-post-number="${targetPostNumber}"]`);
		if (!target || !sourcePostNumber || sourcePostNumber === targetPostNumber) return;
		clearJumpBackLinks(ctx);
		const bar = makeElement('div');
		bar.className = 'ldp-jump-linkbar ldp-jump-backbar';
		bar.innerHTML = `<button class="ldp-btn ldp-jump-back" type="button" data-target-post-number="${sourcePostNumber}" aria-label="返回刚才的楼层 #${sourcePostNumber}">${icon('cornerDownRight')}<span>返回 #${sourcePostNumber}</span></button>`;
		target.insertBefore(bar, target.firstChild);
	}

	function ensureParentJumpOnStreamPost(targetPostNumber, parentPostNumber, ctx) {
		const target = getStreamPostNode(ctx, targetPostNumber);
		const parent = +parentPostNumber;
		if (!target || !parent || parent === +targetPostNumber) return;
		target.dataset.replyToPostNumber = String(parent);
		ensureParentJumpControl(target, parent);
	}

	function findFloorPreviewSource(postNumber, ctx) {
		const num = +postNumber;
		if (!num) return null;
		// Previewing must not remount the virtual window. Detached stream nodes still
		// contain the complete post markup and are safe to clone into the preview.
		const streamNode = ctx.streamNodeMap?.get(num);
		if (streamNode) return streamNode;
		return (ctx.scrollRoot?.querySelector(`.ldp-post[data-post-number="${num}"]`)) ||
				null;
	}

	async function ensureFloorPreviewSource(postNumber, ctx) {
		const target = +postNumber;
		let source = findFloorPreviewSource(target, ctx);
		if (!target || source || !ctx) return source;

		const loaders = isFunction(ctx.loadPost)
			? ['single', 'around'].map((scope) => () => ctx.loadPost(target, {
				mount: false,
				priority: POST_REQUEST_PRIORITY.target,
				scope,
			})) : [];

		for (const load of loaders) {
			try {
				await load();
			} catch (error) {
				if (shouldStopReaderFallback(error)) break;
			}
			source = findFloorPreviewSource(target, ctx);
			if (source) return source;
		}
		return null;
	}

	function resetFloorPreview(preview) {
		preview.textContent = '';
		const body = makeElement('div');
		body.className = 'ldp-floor-preview-body';
		const close = makeElement('button');
		close.className = 'ldp-floor-preview-close';
		close.type = 'button';
		setLabel(close, '关闭预览');
		close.innerHTML = `<span class="ldp-floor-preview-close-hint">Esc 关闭</span>${icon('x')}`;
		preview.append(body, close);
		return body;
	}

	function fillFloorPreview(preview, source, postNumber) {
		const body = resetFloorPreview(preview, postNumber);
		const wrap = makeElement('div');
		wrap.className = 'ldp-floor-preview-post';
		const head = source.querySelector(':scope > .ldp-post-head')?.cloneNode(true);
		const content = source.querySelector(':scope > .ldp-content')?.cloneNode(true);
		const boosts = source.querySelector(':scope > .ldp-boost-list')?.cloneNode(true);
		if (head) {
			const readState = head.querySelector(':scope > .ldp-post-read-state');
			head.querySelectorAll(':scope > .ldp-floor, :scope > .ldp-actions').forEach((el) => el.remove());
			const floorBtn = makeElement('button');
			floorBtn.className = 'ldp-floor ldp-jump-preview-self';
			floorBtn.type = 'button';
			floorBtn.dataset.targetPostNumber = String(postNumber);
			setLabel(floorBtn, `跳到楼层 #${postNumber}`);
			floorBtn.textContent = `#${postNumber}`;
			head.appendChild(floorBtn);
			if (readState) head.appendChild(readState);
			preview.insertBefore(head, body);
			const close = preview.querySelector(':scope > .ldp-floor-preview-close');
			if (close) head.appendChild(close);
		}
		if (content) wrap.appendChild(content);
		else {
			const empty = makeElement('div');
			empty.className = 'ldp-floor-preview-tip';
			empty.textContent = `#${postNumber} 暂无可预览内容`;
			wrap.appendChild(empty);
		}
		if (boosts) wrap.appendChild(boosts);
		wrap.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
		body.appendChild(wrap);
	}

	function syncFloorPreviewLine(preview, line, layer) {
		const lineRect = line.getBoundingClientRect();
		const previewRect = preview.getBoundingClientRect();
		const layerRect = layer ? layer.getBoundingClientRect() : previewRect;
		const avatar = layer?.querySelector(':scope > .ldp-post-head .ldp-avatar');
		const avatarRect = avatar ? avatar.getBoundingClientRect() : layerRect;
		const previewAvatar = preview.querySelector(':scope > .ldp-post-head .ldp-avatar');
		const previewAvatarRect = previewAvatar ? previewAvatar.getBoundingClientRect() : previewRect;
		const previewX = Math.max(0, previewRect.left - lineRect.left);
		const childX = Math.max(0, avatarRect.left - lineRect.left);
		const childY = Math.max(0, avatarRect.top + avatarRect.height / 2 - lineRect.top);
		const previewY = previewAvatarRect.top + previewAvatarRect.height / 2 - lineRect.top;
		const baseRail = 15;
		const axisX = Math.min(previewX, childX) - baseRail;
		const branchGap = 5;
		const previewBranchGap = 5;
		const previewBranchWidth = Math.max(0, previewX - axisX - previewBranchGap);
		const childBranchWidth = Math.max(0, childX - axisX - branchGap);
		line.style.setProperty('--ldp-parent-axis-left', `${Math.round(axisX)}px`);
		line.style.setProperty('--ldp-parent-axis-top', `${Math.round(previewY)}px`);
		line.style.setProperty('--ldp-parent-preview-rail-width', `${Math.round(previewBranchWidth)}px`);
		line.style.setProperty('--ldp-parent-child-branch-width', `${Math.round(childBranchWidth)}px`);
		line.style.setProperty('--ldp-parent-child-y', `${Math.round(childY)}px`);
		line.style.setProperty('--ldp-parent-preview-y', `${Math.round(previewY)}px`);
	}

	function keepFloorPreviewAnchor(layer, scrollRoot, anchorTop, ctx = null) {
		if (!layer || !scrollRoot || !layer.isConnected) return;
		const correction = layer.getBoundingClientRect().top - anchorTop;
		if (Math.abs(correction) < 1) return;
		const previousScrollTop = scrollRoot.scrollTop;
		scrollRoot.scrollTop += correction;
		markStreamInternalScroll(ctx, previousScrollTop);
	}

	function preserveFloorPreviewAnchor(layer, scrollRoot, update, ctx = null) {
		if (!layer || !scrollRoot) {
			update();
			return;
		}
		const anchorTop = layer.getBoundingClientRect().top;
		update();
		keepFloorPreviewAnchor(layer, scrollRoot, anchorTop, ctx);
	}

	function placeFloorPreview(preview, line, anchor, scrollRoot = null, onFinish = null, ctx = null) {
		const layer = anchor.closest('.ldp-post');
		if (!layer || !layer.parentElement) return;
		preview._ldpRelatedPost = layer;
		let placed = false;
		// Insert the complete preview and correct the anchor in the same task so the
		// browser never paints the intermediate shifted position.
		preserveFloorPreviewAnchor(layer, scrollRoot, () => {
			const currentParent = layer.parentElement;
			if (!currentParent) return;
			if (!placed && (preview.parentElement !== currentParent || preview.nextElementSibling !== line || line.nextElementSibling !== layer)) {
				moveOwnedNodeBefore(currentParent, preview, layer);
				moveOwnedNodeBefore(currentParent, line, layer);
			}
			placed = true;
			preview.classList.add('open');
			line.classList.add('open');
			syncFloorPreviewLine(preview, line, layer);
		}, ctx);
		if (!placed) return;
		syncChildControls(layer);
		if (onFinish) onFinish();
	}

	function canShowFloorPreview(anchor) {
		const post = anchor?.closest('.ldp-post');
		return !!post && !post.classList.contains('ldp-nested-collapsed');
	}

	async function showFloorPreview(anchor, ctx, state, options = {}) {
		const postNumber = +anchor.dataset.targetPostNumber;
		const allowCollapsed = options.allowCollapsed === true;
		if (!postNumber || (!allowCollapsed && !canShowFloorPreview(anchor))) return;
		clearTimeout(state.hideTimer);
		state.anchor = anchor;
		const token = ++state.token;
		const preview = state.preview;
		// Do not expose a loading shell whose later height change would move the
		// virtual list. Materialize the requested floor DOM off-screen first, then
		// insert the final preview exactly once.
		const source = await ensureFloorPreviewSource(postNumber, ctx);
		if (token !== state.token || state.anchor !== anchor || !anchor.isConnected ||
				(!allowCollapsed && !canShowFloorPreview(anchor))) return;
		if (source) {
			hydratePostContent(source, ctx);
			fillFloorPreview(preview, source, postNumber);
		} else {
			const body = resetFloorPreview(preview, postNumber);
			body.innerHTML = `<div class="ldp-floor-preview-tip">${esc(`还没加载到 #${postNumber}`)}</div>`;
		}
		const relatedPost = anchor.closest('.ldp-post');
		if (isFunction(options.beforePlace)) options.beforePlace();
		if (!canShowFloorPreview(anchor)) return;
		setNestedReplyEscapeHint(relatedPost, isEscCollapsibleNestedReply(relatedPost));
		placeFloorPreview(preview, state.line, anchor, ctx.scrollRoot, () => {
			alignCollapsedFloorPreview(preview, relatedPost, options.collapsedBottom, ctx);
			if (ctx.timeline) ctx.timeline.sync();
		}, ctx);
	}

	function bindFloorPreview(modal, ctx) {
		const root = ctx.scrollRoot || modal;
		const floorPreviewScope = createLifecycleScope();
		const floorPreviewBundles = new Map();
		const openBundles = new Set();
		const bundleByPost = new WeakMap();
		let activeBundle = null;
		const bundlePost = (bundle) => bundle && (bundle.post || (bundle.anchor?.closest('.ldp-post')));
		const unregisterBundle = (bundle) => {
			if (!bundle) return;
			clearTimeout(bundle.showTimer);
			openBundles.delete(bundle);
			if (bundle.anchor) floorPreviewBundles.delete(bundle.anchor);
			const post = bundlePost(bundle);
			if (post) {
				bundleByPost.delete(post);
			}
			removeOwnedNode(bundle.preview);
			removeOwnedNode(bundle.line);
			if (activeBundle === bundle) activeBundle = null;
		};
		const cleanupClosedBundles = () => {
			[...openBundles].forEach((bundle) => {
				// Virtual-list unmounting temporarily detaches an open preview together
				// with its post. Keep that bundle so the same DOM can be mounted again.
				if (!bundle.pending && !bundle.preview.classList.contains('open')) unregisterBundle(bundle);
			});
		};
		const activateBundle = (bundle) => {
			const post = bundlePost(bundle);
			openBundles.add(bundle);
			if (post) bundleByPost.set(post, bundle);
			activeBundle = bundle;
		};
		const bundleVisibleInViewport = (bundle) => {
			if (!bundle || !bundle.preview.classList.contains('open')) return false;
			const rect = bundle.preview.getBoundingClientRect();
			const rootRect = (ctx.scrollRoot || modal).getBoundingClientRect();
			return rect.bottom > rootRect.top && rect.top < rootRect.bottom && rect.right > rootRect.left && rect.left < rootRect.right;
		};
		const hideBundle = (bundle) => {
			if (!bundle) return;
			const post = bundlePost(bundle);
			clearTimeout(bundle.hideTimer);
			bundle.token++;
			preserveFloorPreviewAnchor(post, root, () => {
				bundle.preview.classList.remove('open');
				bundle.line.classList.remove('open');
			}, ctx);
			if (post) syncChildControls(post);
			unregisterBundle(bundle);
			return post;
		};
		const hide = (targetPost = null) => {
			cleanupClosedBundles();
			const bundles = [...openBundles];
			let bundle = targetPost
				? bundles.find((item) => bundlePost(item) === targetPost)
				: null;
			if (!bundle && activeBundle && bundleVisibleInViewport(activeBundle)) bundle = activeBundle;
			if (!bundle) {
				const visibleBundles = bundles.filter(bundleVisibleInViewport);
				bundle = visibleBundles[visibleBundles.length - 1] || activeBundle || bundles[bundles.length - 1];
			}
			return hideBundle(bundle);
		};
		function createFloorPreviewBundle(anchor) {
			const preview = makeElement('div');
			preview.className = 'ldp-floor-preview';
			const line = makeElement('div');
			line.className = 'ldp-floor-preview-link';
			line.setAttribute('aria-hidden', 'true');
			root.appendChild(preview);
			root.appendChild(line);
			const bundle = {
				preview, line, anchor, post: anchor.closest('.ldp-post'), token: 0,
				hideTimer: 0, showTimer: 0, hoverPending: false, pending: false,
			};
			floorPreviewBundles.set(anchor, bundle);
			preview.addEventListener('click', async (e) => {
				if (e.target.closest('.ldp-floor-preview-close')) {
					hideBundle(bundle);
					return;
				}
				const floorBtn = e.target.closest('.ldp-jump-preview-self');
				if (!floorBtn) return;
				consumeEvent(e);
				const targetPostNumber = +floorBtn.dataset.targetPostNumber;
				if (!targetPostNumber) return;
				floorBtn.blur();
				clearJumpBackLinks(ctx);
				await ctx.timeline.jumpTo(targetPostNumber);
			});
			return bundle;
		}
		const bundleForAnchor = (anchor) => floorPreviewBundles.get(anchor) || createFloorPreviewBundle(anchor);
		const show = async (anchor, options = {}) => {
			if (!anchor || (!options.allowCollapsed && !canShowFloorPreview(anchor))) return false;
			const bundle = bundleForAnchor(anchor);
			bundle.pending = true;
			activateBundle(bundle);
			try {
				await showFloorPreview(anchor, ctx, bundle, options);
			} finally {
				bundle.pending = false;
			}
			const opened = bundle.preview.classList.contains('open');
			if (!opened) unregisterBundle(bundle);
			return opened;
		};
		const previewVisibleInViewport = () => {
			cleanupClosedBundles();
			return [...openBundles].some(bundleVisibleInViewport);
		};
		const onFloorPreviewMouseOver = (e) => {
			if (e.target.closest('.ldp-floor-preview')) return;
			const btn = e.target.closest('.ldp-jump-parent');
			if (!btn || !modal.contains(btn)) return;
			if (btn.contains(e.relatedTarget)) return;
			if (!canShowFloorPreview(btn)) {
				const bundle = floorPreviewBundles.get(btn);
				if (bundle) hideBundle(bundle);
				return;
			}
			const bundle = bundleForAnchor(btn);
			if (bundle.preview.classList.contains('open')) return;
			clearTimeout(bundle.showTimer);
			bundle.hoverPending = true;
			bundle.showTimer = setTimeout(async () => {
				bundle.showTimer = 0;
				if (!bundle.hoverPending || !btn.isConnected || !btn.matches(':hover')) {
					bundle.hoverPending = false;
					unregisterBundle(bundle);
					return;
				}
				activateBundle(bundle);
				await showFloorPreview(btn, ctx, bundle);
				bundle.hoverPending = false;
				if (!bundle.preview.classList.contains('open')) unregisterBundle(bundle);
			}, 250);
		};
		const onFloorPreviewMouseOut = (e) => {
			const btn = e.target.closest('.ldp-jump-parent');
			if (!btn || btn.contains(e.relatedTarget)) return;
			const bundle = floorPreviewBundles.get(btn);
			if (!bundle || !bundle.hoverPending) return;
			clearTimeout(bundle.showTimer);
			bundle.showTimer = 0;
			bundle.hoverPending = false;
			if (!bundle.preview.classList.contains('open')) {
				bundle.token++;
				unregisterBundle(bundle);
			}
		};
		const onFloorPreviewFocusIn = (e) => {
			const btn = e.target.closest('.ldp-jump-parent');
			if (!btn || !canShowFloorPreview(btn)) return;
			const bundle = bundleForAnchor(btn);
			clearTimeout(bundle.showTimer);
			bundle.showTimer = 0;
			bundle.hoverPending = false;
			activateBundle(bundle);
			showFloorPreview(btn, ctx, bundle);
		};
		floorPreviewScope.listen(modal, 'mouseover', onFloorPreviewMouseOver);
		floorPreviewScope.listen(modal, 'mouseout', onFloorPreviewMouseOut);
		floorPreviewScope.listen(modal, 'focusin', onFloorPreviewFocusIn);
		const getBundleForPost = (post) => {
			cleanupClosedBundles();
			const bundle = bundleByPost.get(post);
			return bundle ? [bundle.preview, bundle.line, post] : [post];
		};
		const syncPlacement = () => {
			cleanupClosedBundles();
			openBundles.forEach((bundle) => {
				const post = bundlePost(bundle);
				if (!post || !post.isConnected) return;
				if (bundle.preview.nextElementSibling !== bundle.line || bundle.line.nextElementSibling !== post) return;
				syncFloorPreviewLine(bundle.preview, bundle.line, post);
				syncChildControls(post);
			});
		};
		const destroy = () => {
			floorPreviewScope.destroy();
			floorPreviewBundles.forEach((bundle) => {
				clearTimeout(bundle.showTimer);
				clearTimeout(bundle.hideTimer);
				bundle.token++;
				removeOwnedNode(bundle.preview);
				removeOwnedNode(bundle.line);
			});
			floorPreviewBundles.clear();
			openBundles.clear();
			activeBundle = null;
		};
		return {
			show,
			hide,
			isOpen: () => {
				cleanupClosedBundles();
				return openBundles.size > 0;
			},
			isVisibleInViewport: previewVisibleInViewport,
			getBundleForPost,
			syncPlacement,
			destroy,
		};
	}

	/* ============ 3. 图片浏览器 ============ */
	function absoluteImageUrl(src) {
		try { return new URL(String(src || ''), location.href).href; }
		catch (e) { return String(src || ''); }
	}

	async function cachedEmojiImageSource(rawSource) {
		const source = absoluteImageUrl(rawSource);
		if (!source || source.startsWith('data:') || source.startsWith('blob:') ||
				typeof caches === 'undefined' || RESOURCE_CACHE_CLEARING) return '';
		const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
		const cachedObjectUrl = touchResourceObjectUrl(EMOJI_IMAGE_OBJECT_URL_CACHE, source);
		if (cachedObjectUrl) return cachedObjectUrl;
		try {
			const { response } = await persistentResourceCacheEntry(
				LDP_EMOJI_IMAGE_CACHE_NAME, source, LDP_EMOJI_IMAGE_CACHE_MAX_AGE
			);
			if (!response) return '';
			const blob = await response.blob();
			return blob.size && !resourceCacheSignal.aborted
				? cacheResourceObjectUrl(EMOJI_IMAGE_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 512)
				: '';
		} catch (error) {
			return '';
		}
	}

	function persistEmojiImage(rawSource) {
		const source = absoluteImageUrl(rawSource);
		if (!source || source.startsWith('data:') || source.startsWith('blob:') ||
				typeof caches === 'undefined' || RESOURCE_CACHE_CLEARING) {
			return Promise.resolve();
		}
		const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
		const pendingRequest = EMOJI_IMAGE_RESOURCE_REQUESTS.get(source);
		if (pendingRequest) return pendingRequest;
		const request = (async () => {
			try {
				const { storage, response: existing } = await persistentResourceCacheEntry(
					LDP_EMOJI_IMAGE_CACHE_NAME, source, LDP_EMOJI_IMAGE_CACHE_MAX_AGE
				);
				if (existing) return;
				const resource = await fetchReaderPublicResource(
					source, POST_REQUEST_PRIORITY.background, 'media', resourceCacheSignal
				);
				if (!resource) return;
				try {
					const fetched = resource.response;
					if (!fetched.ok) return;
					const blob = await fetched.blob();
					if (!blob.size || resourceCacheSignal.aborted) return;
					await storage.put(source, createPersistentResourceResponse(blob, fetched.headers.get('content-type')));
				} finally {
					resource.release();
				}
			} catch (error) {}
		})();
		return trackSingleFlightRequest(EMOJI_IMAGE_RESOURCE_REQUESTS, source, request);
	}

	function watchPersistentEmojiImages(content) {
		if (!content || content._ldpStopEmojiImageCache) return;
		const hydrate = async (image) => {
			if (!(image instanceof Element) || image.tagName !== 'IMG') return;
			const source = absoluteImageUrl(image.currentSrc || image.src);
			if (!source || source.startsWith('data:') || source.startsWith('blob:')) return;
			image.dataset.ldpEmojiCacheSource = source;
			const cachedSource = await cachedEmojiImageSource(source);
			if (cachedSource) {
				if (image.isConnected && image.dataset.ldpEmojiCacheSource === source) image.src = cachedSource;
				return;
			}
			const persist = () => {
				if (image.dataset.ldpEmojiCacheSource === source) void persistEmojiImage(source);
			};
			if (image.complete && image.naturalWidth) persist();
			else image.addEventListener('load', persist, { once: true });
		};
		const hydrateTree = (node) => {
			if (!(node instanceof Element)) return;
			if (node.matches('img[src]')) void hydrate(node);
			node.querySelectorAll('img[src]').forEach((image) => { void hydrate(image); });
		};
		const observer = new MutationObserver((mutations) => {
			mutations.forEach((mutation) => {
				if (mutation.type === 'attributes') hydrateTree(mutation.target);
				mutation.addedNodes.forEach(hydrateTree);
			});
		});
		content.querySelectorAll('.emoji-picker img[src]').forEach((image) => { void hydrate(image); });
		observer.observe(content, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
		content._ldpStopEmojiImageCache = () => {
			observer.disconnect();
			delete content._ldpStopEmojiImageCache;
		};
	}

	async function deletePersistentLightboxImage(rawSource) {
		const source = absoluteImageUrl(rawSource);
		const objectUrl = LIGHTBOX_IMAGE_OBJECT_URL_CACHE.get(source);
		if (objectUrl) URL.revokeObjectURL(objectUrl);
		LIGHTBOX_IMAGE_OBJECT_URL_CACHE.delete(source);
		if (typeof caches === 'undefined') return;
		try {
			const storage = await caches.open(LDP_LIGHTBOX_IMAGE_CACHE_NAME);
			await storage.delete(source);
		} catch (error) {}
	}

	function comparableLightboxImageSource(value) {
		const source = absoluteImageUrl(value);
		if (!source) return '';
		try {
			const url = new URL(source, location.href);
			const uploadHash = url.pathname.match(/(?:^|\/)([0-9a-f]{40})(?:\.[a-z0-9]+)?(?:$|\/)/i);
			if (uploadHash) return `upload:${uploadHash[1].toLowerCase()}`;
			return `${url.origin}${decodeURIComponent(url.pathname)}`;
		} catch (error) {
			return String(source).split(/[?#]/, 1)[0];
		}
	}

	function lightboxImageItem(img, postNumber = 0, imageOrder = 0, postData = null, topicId = 0) {
		if (!img) return null;
		const originalSrc = absoluteImageUrl(resolveOriginalSrc(img));
		if (!originalSrc) return null;
		const ownerPost = img.closest('.ldp-post');
		const sourcePost = postData || (ownerPost?._ldpPostData) || null;
		const item = {
			originalSrc,
			thumbSrc: absoluteImageUrl(img.currentSrc || img.src || originalSrc),
			alt: String(img.alt || '').trim(),
			postNumber: Math.max(0, +postNumber || +(ownerPost && ownerPost.dataset.postNumber) || 0),
			imageOrder: Math.max(0, +imageOrder || 0),
			topicId: Math.max(0, +topicId || +(sourcePost?.topic_id) || 0),
			postId: Math.max(0, +(sourcePost?.id) || +(ownerPost && ownerPost.dataset.postId) || 0),
			username: String(sourcePost?.username || ownerPost && ownerPost.dataset.username || '').trim(),
			sourcePost,
		};
		item.key = [
			Math.max(0, +(item.topicId || 0)),
			Math.max(0, +(item.postNumber || 0)),
			Math.max(0, +(item.imageOrder || 0)),
			comparableLightboxImageSource(item.originalSrc),
		].join(':');
		return item;
	}

	function isLightboxContentImage(img, quoted = false) {
		if (!img || !isFunction(img.closest)) return false;
		const contentSelector = quoted ? '.ldp-content aside.ldp-post-quote > blockquote' : '.ldp-content';
		if (!img.closest(contentSelector)) return false;
		const isBoostEmoji = img.classList.contains('emoji') ||
			!!(reactionEmojiImageSources?.size &&
				[img.currentSrc, img.src, img.getAttribute('src'), resolveOriginalSrc(img)]
					.map(comparableLightboxImageSource)
					.some((source) => source && reactionEmojiImageSources.has(source)));
		if (isBoostEmoji) return false;
		const excludedSelector = quoted
			? '.ldp-quote-title,[data-user-card],aside.onebox'
			: 'aside.quote,.ldp-quote-title,[data-user-card],aside.onebox';
		if (img.closest(excludedSelector)) return false;
		const hasAvatarClass = [...img.classList || []]
			.some((name) => /(^|[-_])avatar($|[-_])/i.test(name));
		const source = String(img.currentSrc || img.src || img.getAttribute('src') || '');
		return !hasAvatarClass && !/\/user_avatar\//i.test(source);
	}

	const isLightboxFloorImage = (img) => isLightboxContentImage(img);
	const isLightboxQuotedImage = (img) => isLightboxContentImage(img, true);

	function lightboxImageFromEventIn(event, selector, predicate) {
		const image = eventPathClosest(event, `${selector} img`);
		if (predicate(image)) return image;
		const link = eventPathClosest(event, `${selector} a.lightbox`);
		const linkedImage = link ? link.querySelector('img') : null;
		return predicate(linkedImage) ? linkedImage : null;
	}

	const lightboxImageFromEvent = (event) =>
		lightboxImageFromEventIn(event, '.ldp-content', isLightboxFloorImage);

	function collectLightboxItems(clickedImg, ctx) {
		const items = [];
		const seen = new Set();
		const add = (img, postNumber = 0, imageOrder = 0, postData = null) => {
			if (!isLightboxFloorImage(img)) return;
			const item = lightboxImageItem(img, postNumber, imageOrder, postData, ctx?.topicId);
			if (!item || seen.has(item.key)) return;
			seen.add(item.key);
			items.push(item);
		};
		const roots = ctx && Array.isArray(ctx.streamItems)
			? ctx.streamItems.map((item) => item?.node).filter(Boolean)
			: [];
		if (!roots.length && ctx && ctx.commentsEl) {
			roots.push(...ctx.commentsEl.querySelectorAll(':scope > .ldp-post'));
		}
		roots.forEach((root) => {
			const postNumber = +(root.dataset.postNumber || (root._ldpPostData?.post_number)) || 0;
			[...root.querySelectorAll(':scope > .ldp-content img')]
				.filter(isLightboxFloorImage)
				.forEach((img, imageOrder) => add(img, postNumber, imageOrder, root._ldpPostData));
		});
		const clickedContent = clickedImg?.closest('.ldp-content');
		const clickedOrder = clickedContent
			? [...clickedContent.querySelectorAll('img')].filter(isLightboxFloorImage).indexOf(clickedImg)
			: 0;
		const clickedItem = isLightboxFloorImage(clickedImg)
			? lightboxImageItem(clickedImg, 0, Math.max(0, clickedOrder), null, ctx?.topicId)
			: null;
		if (clickedItem && !seen.has(clickedItem.key)) {
			seen.add(clickedItem.key);
			items.unshift(clickedItem);
		}
		items.sort(compareLightboxItems);
		const initialIndex = clickedItem
			? Math.max(0, items.findIndex((item) => item.key === clickedItem.key))
			: 0;
		return { items, initialIndex };
	}

	function lightboxItemsFromPost(post, topicId = 0) {
		if (!post || !post.cooked) return [];
		const content = makeElement('div');
		content.className = 'ldp-content cooked';
		setPostCookedContent(content, post.cooked);
		return [...content.querySelectorAll('img')]
			.filter(isLightboxFloorImage)
			.map((img, imageOrder) => lightboxImageItem(
				img,
				post.post_number,
				imageOrder,
				post,
				post.topic_id || topicId,
			));
	}

	async function openQuotedImageLightbox(clickedImg, ctx) {
		const quote = clickedImg?.closest('aside.ldp-post-quote');
		if (!quote || !ctx) return false;
		try {
			const sourcePost = await loadReaderQuotedPost(quote, ctx);
			const sourceTopicId = Math.max(0, +quote.dataset.topic || +(sourcePost?.topic_id) || +ctx.topicId || 0);
			const items = lightboxItemsFromPost(sourcePost, sourceTopicId);
			if (!items.length) throw new Error('quoted image unavailable');
			const clickedSource = comparableLightboxImageSource(resolveOriginalSrc(clickedImg));
			const markedOrder = lightboxImageOrderFromAlt(clickedImg.alt);
			let initialIndex = items.findIndex((item) => (
				comparableLightboxImageSource(item.originalSrc) === clickedSource &&
				(markedOrder == null || +item.imageOrder === markedOrder)
			));
			if (initialIndex < 0) {
				const quoteBody = quote.querySelector(':scope > blockquote');
				const excerptOrder = quoteBody
					? [...quoteBody.querySelectorAll('img')].filter(isLightboxQuotedImage).indexOf(clickedImg)
					: -1;
				initialIndex = Math.max(0, Math.min(items.length - 1, excerptOrder));
			}
			openLightbox(null, ctx, {
				items,
				initialIndex,
				fixedItems: true,
				comments: sourceTopicId === +ctx.topicId,
			});
			return true;
		} catch (error) {
			showSelectionToast('引用源图片加载失败,请重试');
			return false;
		}
	}

	function compareLightboxItems(left, right) {
		return (+left.postNumber || 0) - (+right.postNumber || 0) ||
			(+left.imageOrder || 0) - (+right.imageOrder || 0);
	}

	function lightboxImageOrderMarker(imageOrder) {
		const bits = Math.max(0, +imageOrder || 0).toString(2).padStart(8, '0');
		return `\u2063${bits.replace(/0/g, '\u200B').replace(/1/g, '\u200C')}\u2064`;
	}

	function lightboxImageOrderFromAlt(value) {
		const match = String(value || '').match(/\u2063([\u200B\u200C]+)\u2064/);
		if (!match) return null;
		const parsed = Number.parseInt(match[1].replace(/\u200B/g, '0').replace(/\u200C/g, '1'), 2);
		return Number.isFinite(parsed) ? parsed : null;
	}

	function lightboxImageQuoteRaw(item, ctx) {
		const sourcePost = item?.sourcePost || {};
		const username = cleanUsernameValue(item?.username || sourcePost.username);
		const postNumber = Math.max(0, +(item?.postNumber || 0));
		const topicId = Math.max(0, +(item?.topicId || ctx?.topicId || 0));
		const source = String(item?.originalSrc || '').replace(/</g, '%3C').replace(/>/g, '%3E');
		const alt = String(item?.alt || '图片')
			.replace(/\\/g, '\\\\')
			.replace(/\[/g, '\\[')
			.replace(/\]/g, '\\]') + lightboxImageOrderMarker(item?.imageOrder);
		if (!username || !postNumber || !topicId || !source) return '';
		return `[quote="${username}, post:${postNumber}, topic:${topicId}"]\n![${alt}](<${source}>)\n[/quote]\n\n`;
	}

	function lightboxQuoteContainsImage(quote, item, expectedSource) {
		return [...quote.querySelectorAll(':scope > blockquote img')].some((image) => {
			if (comparableLightboxImageSource(resolveOriginalSrc(image)) !== expectedSource) return false;
			const markedOrder = lightboxImageOrderFromAlt(image.alt);
			return markedOrder == null || markedOrder === +item.imageOrder;
		});
	}

	function postQuotesLightboxImage(post, item) {
		if (!post || !item) return false;
		if (Array.isArray(post._ldpImageReferenceKeys) && post._ldpImageReferenceKeys.includes(item.key)) return true;
		if (!post.cooked || !item.postNumber) return false;
		const template = makeElement('template');
		template.innerHTML = String(post.cooked || '');
		const expectedTopicId = +(item.topicId || 0);
		const expectedSource = comparableLightboxImageSource(item.originalSrc);
		return [...template.content.querySelectorAll('aside.quote')].some((quote) => {
			if (+quote.dataset.post !== +item.postNumber) return false;
			const quotedTopicId = +(quote.dataset.topic || 0);
			if (quotedTopicId && expectedTopicId && quotedTopicId !== expectedTopicId) return false;
			return lightboxQuoteContainsImage(quote, item, expectedSource);
		});
	}

	function lightboxSourceDescription(post, item) {
		const clean = (value) => {
			const text = String(value || '')
				.replace(/https?:\/\/\S+/gi, ' ')
				.replace(/\b[^\s/\\]+\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|tiff?|webp)\b/gi, ' ')
				.replace(/\b[0-9a-f]{20,}\b/gi, ' ')
				.replace(/\b\d{2,5}\s*[x×]\s*\d{2,5}\b/gi, ' ')
				.replace(/[x×]\s*\d{2,5}\b/gi, ' ')
				.replace(/\b\d+(?:\.\d+)?\s*(?:bytes?|[kmgt]i?b)\b/gi, ' ')
				.replace(/\s+/g, ' ')
				.replace(/^[\s·•|,,;;::/_-]+|[\s·•|,,;;::/_-]+$/g, '')
				.trim();
			return text;
		};
		const altFallback = clean(item?.alt);
		const fallback = !altFallback || /^(?:该楼层)?图片$/.test(altFallback) ? '无描述' : altFallback;
		if (!post || !post.cooked) return fallback;
		const template = makeElement('template');
		template.innerHTML = String(post.cooked || '');
		template.content.querySelectorAll(
			'aside.quote,img,video,audio,iframe,canvas,svg,.quote-controls,.lightbox-wrapper .meta,a.lightbox .meta'
		).forEach((node) => node.remove());
		const text = clean(template.content.textContent);
		if (!text) return fallback;
		return text.length > 420 ? `${text.slice(0, 420).trim()}…` : text;
	}

	function normalizeLightboxDescriptionHeight(value) {
		const numeric = Math.round(Number(value));
		const maximum = Math.max(LIGHTBOX_DESCRIPTION_HEIGHT_MIN, Math.floor(window.innerHeight * 0.4));
		return Math.min(
			maximum,
			Math.max(LIGHTBOX_DESCRIPTION_HEIGHT_MIN, Number.isFinite(numeric) ? numeric : LIGHTBOX_DESCRIPTION_HEIGHT_DEFAULT)
		);
	}

	function normalizeLightboxCommentsWidth(value) {
		const numeric = Number(value);
		return Math.min(
			LIGHTBOX_COMMENTS_WIDTH_MAX,
			Math.max(LIGHTBOX_COMMENTS_WIDTH_MIN, Number.isFinite(numeric) ? numeric : LIGHTBOX_COMMENTS_WIDTH_DEFAULT)
		);
	}

	function safeDownloadFilename(value, fallback = 'download') {
		const cleaned = String(value || '')
			.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
			.replace(/[.\s]+$/g, '')
			.trim();
		return (cleaned || fallback).slice(0, 120);
	}

	function lightboxDownloadTimestamp(date = new Date()) {
		const part = (value) => String(value).padStart(2, '0');
		return `${date.getFullYear()}年${part(date.getMonth() + 1)}月${part(date.getDate())}日${part(date.getHours())}时${part(date.getMinutes())}分${part(date.getSeconds())}秒`;
	}

	function lightboxImageDownloadName(item, index, blob) {
		let sourceName = '';
		try { sourceName = decodeURIComponent(new URL(item?.originalSrc || '', location.href).pathname.split('/').pop() || ''); }
		catch (error) {}
		const mimeExtensions = {
			'image/avif': 'avif', 'image/bmp': 'bmp', 'image/gif': 'gif', 'image/heic': 'heic',
			'image/heif': 'heif', 'image/jpeg': 'jpg', 'image/png': 'png', 'image/svg+xml': 'svg',
			'image/tiff': 'tiff', 'image/webp': 'webp',
		};
		let extension = mimeExtensions[String(blob?.type || '').toLowerCase()];
		if (!extension) {
			try {
				const match = new URL(item?.originalSrc || '', location.href).pathname.match(/\.([a-z0-9]{2,5})$/i);
				if (match) extension = match[1].toLowerCase();
			} catch (error) {}
		}
		extension ||= 'img';
		const stem = safeDownloadFilename(sourceName.replace(/\.[a-z0-9]{2,5}$/i, ''), `image-${index + 1}`);
		return `${String(index + 1).padStart(3, '0')}-${stem}.${extension}`;
	}

	function triggerBrowserDownload(blob, filename) {
		const objectUrl = URL.createObjectURL(blob);
		const link = makeElement('a');
		link.href = objectUrl;
		link.download = safeDownloadFilename(filename);
		link.hidden = true;
		appendReaderPortalNode(link);
		link.click();
		link.remove();
		setTimeout(() => URL.revokeObjectURL(objectUrl), 60 * 1000);
	}

	async function cachedPersistentLightboxBlob(rawSource) {
		const source = absoluteImageUrl(rawSource);
		if (!source) return null;
		if (typeof caches !== 'undefined') {
			try {
				const { response } = await persistentResourceCacheEntry(
					LDP_LIGHTBOX_IMAGE_CACHE_NAME, source, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE
				);
				if (response) {
					const blob = await response.blob();
					if (blob.size) return blob;
				}
			} catch (error) {}
		}
		const objectUrl = touchResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source);
		if (!objectUrl) return null;
		try {
			const response = await trackedReaderFetch(objectUrl);
			const blob = await response.blob();
			return blob.size ? blob : null;
		} catch (error) {
			return null;
		}
	}

	function cachedLightboxOriginalBlob(item) {
		return cachedPersistentLightboxBlob(item?.originalSrc);
	}

	async function persistLightboxBlob(rawSource, blob, contentType = '',
			resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal) {
		const source = absoluteImageUrl(rawSource);
		if (!/^https?:/i.test(source) || !blob || !blob.size || typeof caches === 'undefined' ||
				RESOURCE_CACHE_CLEARING || resourceCacheSignal.aborted) return;
		try {
			void prunePersistentResourceCacheOnce(LDP_LIGHTBOX_IMAGE_CACHE_NAME, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE);
			const storage = await caches.open(LDP_LIGHTBOX_IMAGE_CACHE_NAME);
			if (!resourceCacheSignal.aborted) {
				await storage.put(source, createPersistentResourceResponse(blob, contentType));
			}
		} catch (error) {}
	}

	async function fetchLightboxDownloadBlob(source, priority = POST_REQUEST_PRIORITY.auxiliary, options = {}) {
		if (!source) throw new Error('图片获取失败');
		const normalizedSource = absoluteImageUrl(source);
		const persistentSource = /^https?:/i.test(normalizedSource) ? normalizedSource : '';
		const pendingRequest = LIGHTBOX_IMAGE_RESOURCE_REQUESTS.get(persistentSource);
		const request = pendingRequest || (async () => {
			const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
			const localSource = normalizedSource.startsWith('blob:') || normalizedSource.startsWith('data:');
			const abortWithCacheClear = options.abortWithCacheClear === true && !RESOURCE_CACHE_CLEARING;
			if (persistentSource) {
				if (abortWithCacheClear) {
					void prunePersistentResourceCacheOnce(LDP_LIGHTBOX_IMAGE_CACHE_NAME, LDP_LIGHTBOX_IMAGE_CACHE_MAX_AGE);
				}
				const cached = await cachedPersistentLightboxBlob(persistentSource);
				if (cached) return cached;
			}
			let resource = null;
			let response;
			try {
				if (localSource) {
					response = await trackedReaderFetch(normalizedSource, {
						credentials: 'omit',
						cache: 'force-cache',
						[REQUEST_FLOW_FETCH_META]: { source: 'reader', priority, type: 'media' },
					});
				} else {
					resource = await fetchReaderPublicResource(
						normalizedSource, priority, 'media', abortWithCacheClear ? resourceCacheSignal : undefined
					);
					if (!resource) throw new Error('图片请求已暂停');
					response = resource.response;
				}
				if (!response.ok) throw new Error(`图片获取失败(${response.status})`);
				const blob = await response.blob();
				if (!blob.size) throw new Error('图片内容为空');
				if (persistentSource) {
					await persistLightboxBlob(persistentSource, blob, response.headers.get('content-type'), resourceCacheSignal);
				}
				return blob;
			} finally {
				if (resource) resource.release();
			}
		})();
		const tracked = persistentSource && !pendingRequest
			? trackSingleFlightRequest(LIGHTBOX_IMAGE_RESOURCE_REQUESTS, persistentSource, request)
			: request;
		try {
			return await tracked;
		} catch (error) {
			if (options.abortWithCacheClear !== true && error && error.name === 'AbortError' && RESOURCE_CACHE_CLEARING) {
				return fetchLightboxDownloadBlob(source, priority, options);
			}
			throw error;
		}
	}

	async function persistentLightboxImageSource(rawSource, options = {}) {
		const source = absoluteImageUrl(rawSource);
		if (!source || source.startsWith('data:') || source.startsWith('blob:') || RESOURCE_CACHE_CLEARING) return source;
		if (typeof caches === 'undefined') return options.cachedOnly ? '' : source;
		const resourceCacheSignal = RESOURCE_CACHE_ABORT_CONTROLLER.signal;
		const cachedObjectUrl = touchResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source);
		if (cachedObjectUrl) return cachedObjectUrl;
		try {
			const blob = options.cachedOnly
				? await cachedPersistentLightboxBlob(source)
				: await fetchLightboxDownloadBlob(source, POST_REQUEST_PRIORITY.auxiliary, { abortWithCacheClear: true });
			return blob && !resourceCacheSignal.aborted
				? cacheResourceObjectUrl(LIGHTBOX_IMAGE_OBJECT_URL_CACHE, source, URL.createObjectURL(blob), 32)
				: options.cachedOnly ? '' : source;
		} catch (error) {
			return error && error.message === '图片请求已暂停' ? '' : source;
		}
	}

	async function lightboxImageDownloadBlob(item, options = {}) {
		const cachedOriginal = await cachedLightboxOriginalBlob(item);
		if (cachedOriginal) return cachedOriginal;
		const originalSrc = item?.originalSrc;
		const previewSrc = item && (item.thumbSrc || originalSrc);
		if (options.original !== true && previewSrc && previewSrc !== originalSrc) {
			return fetchLightboxDownloadBlob(previewSrc);
		}
		return fetchLightboxDownloadBlob(originalSrc);
	}

	async function downloadLightboxItem(item, index) {
		try {
			const missingOriginals = await lightboxMissingOriginalCount([item]);
			const useOriginal = missingOriginals > 0 && globalThis.confirm(
				'当前原图尚未缓存。\n\n确定:按阅读器限速获取并下载原图\n取消:下载当前最高缓存/预览质量'
			);
			const blob = await lightboxImageDownloadBlob(item, { original: useOriginal });
			triggerBrowserDownload(blob, lightboxImageDownloadName(item, index, blob).replace(/^\d+-/, ''));
		} catch (error) {
			showSelectionToast(`图片下载失败:${error.message || '请重试'}`);
		}
	}

	async function lightboxMissingOriginalCount(items) {
		const candidates = (Array.isArray(items) ? items : []).filter((item) => (
			item?.originalSrc && item.thumbSrc && item.originalSrc !== item.thumbSrc
		));
		let missing = 0;
		for (const item of candidates) {
			if (!await cachedLightboxOriginalBlob(item)) missing++;
		}
		return missing;
	}

	function lightboxZipCrc32(bytes) {
		if (!lightboxZipCrc32.table) {
			lightboxZipCrc32.table = new Uint32Array(256);
			for (let index = 0; index < 256; index++) {
				let value = index;
				for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ value >>> 1 : value >>> 1;
				lightboxZipCrc32.table[index] = value >>> 0;
			}
		}
		let crc = 0xffffffff;
		for (let index = 0; index < bytes.length; index++) crc = lightboxZipCrc32.table[(crc ^ bytes[index]) & 0xff] ^ crc >>> 8;
		return (crc ^ 0xffffffff) >>> 0;
	}

	function createStoredZip(entries) {
		const encoder = new globalThis.TextEncoder();
		const localParts = [];
		const centralParts = [];
		let localOffset = 0;
		const modifiedAt = new Date();
		const dosTime = modifiedAt.getHours() << 11 | modifiedAt.getMinutes() << 5 | modifiedAt.getSeconds() >> 1;
		const dosDate = Math.max(0, modifiedAt.getFullYear() - 1980) << 9 | modifiedAt.getMonth() + 1 << 5 | modifiedAt.getDate();
		entries.forEach((entry) => {
			const name = encoder.encode(entry.name);
			const bytes = entry.bytes;
			const crc = lightboxZipCrc32(bytes);
			const local = new Uint8Array(30 + name.length);
			const localView = new DataView(local.buffer);
			localView.setUint32(0, 0x04034b50, true);
			localView.setUint16(4, 20, true);
			localView.setUint16(6, 0x0800, true);
			localView.setUint16(8, 0, true);
			localView.setUint16(10, dosTime, true);
			localView.setUint16(12, dosDate, true);
			localView.setUint32(14, crc, true);
			localView.setUint32(18, bytes.length, true);
			localView.setUint32(22, bytes.length, true);
			localView.setUint16(26, name.length, true);
			local.set(name, 30);
			const central = new Uint8Array(46 + name.length);
			const centralView = new DataView(central.buffer);
			centralView.setUint32(0, 0x02014b50, true);
			centralView.setUint16(4, 20, true);
			centralView.setUint16(6, 20, true);
			centralView.setUint16(8, 0x0800, true);
			centralView.setUint16(10, 0, true);
			centralView.setUint16(12, dosTime, true);
			centralView.setUint16(14, dosDate, true);
			centralView.setUint32(16, crc, true);
			centralView.setUint32(20, bytes.length, true);
			centralView.setUint32(24, bytes.length, true);
			centralView.setUint16(28, name.length, true);
			centralView.setUint32(42, localOffset, true);
			central.set(name, 46);
			localParts.push(local, bytes);
			centralParts.push(central);
			localOffset += local.length + bytes.length;
		});
		const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
		const end = new Uint8Array(22);
		const endView = new DataView(end.buffer);
		endView.setUint32(0, 0x06054b50, true);
		endView.setUint16(8, entries.length, true);
		endView.setUint16(10, entries.length, true);
		endView.setUint32(12, centralSize, true);
		endView.setUint32(16, localOffset, true);
		return new globalThis.Blob([...localParts, ...centralParts, end], { type: 'application/zip' });
	}

	function mergeLightboxPosts(target, posts) {
		(Array.isArray(posts) ? posts : []).forEach((post) => {
			const postNumber = +(post?.post_number || 0);
			if (!postNumber || !post.id) return;
			const previous = target.get(postNumber);
			target.set(postNumber, previous ? Object.assign(previous, post) : post);
		});
		return target;
	}

	function knownLightboxPosts(ctx) {
		const posts = new Map();
		mergeLightboxPosts(posts, ctx?.loader && ctx.loader.cachedPosts);
		mergeLightboxPosts(posts, postsFromPayload(ctx?.topicData));
		if (ctx && Array.isArray(ctx.streamItems)) {
			mergeLightboxPosts(posts, ctx.streamItems.map((item) => item?.node && item.node._ldpPostData));
		}
		if (ctx?.directRepliesByParent) {
			ctx.directRepliesByParent.forEach((replies) => mergeLightboxPosts(posts, [...replies.values()]));
		}
		if (ctx?.subReplyState) {
			ctx.subReplyState.forEach((state) => mergeLightboxPosts(posts, state?.all));
		}
		if (ctx?.lightboxImageCommentPosts) mergeLightboxPosts(posts, [...ctx.lightboxImageCommentPosts.values()]);
		return posts;
	}

	async function searchLightboxImagePosts(ctx) {
		if (!ctx || !ctx.topicId || !ctx.loader || !isFunction(ctx.loader.loadPostsByIds)) return [];
		if (ctx.lightboxImageSearchPostsPromise) return ctx.lightboxImageSearchPostsPromise;
		const request = (async () => {
			const posts = new Map();
			const seenPages = new Set();
			let page = 1;
			while (page <= 50) {
				const url = new URL(`${BASE}/search.json`);
				url.searchParams.set('q', `topic:${ctx.topicId} in:all-posts with:images`);
				url.searchParams.set('page', String(page));
				const payload = await fetchJSON(url.toString(), {
					scheduler: ctx.postRequestScheduler,
					fetchOptions: { credentials: 'include', cache: 'no-store' },
					cacheMode: 'refresh',
					key: `image-comments:search:${ctx.topicId}:${page}`,
					priority: POST_REQUEST_PRIORITY.background,
				});
				const grouped = payload?.grouped_search_result || {};
				const payloadPosts = postsFromPayload(payload);
				mergeLightboxPosts(posts, payloadPosts);
				const postIds = [...new Set([
					...(Array.isArray(grouped.post_ids) ? grouped.post_ids : []),
					...payloadPosts.map((post) => post?.id),
				].map(Number).filter(Boolean))];
				const signature = postIds.join(',');
				if (!postIds.length || seenPages.has(signature)) break;
				seenPages.add(signature);
				const loaded = await ctx.loader.loadPostsByIds(postIds, {
					priority: POST_REQUEST_PRIORITY.background,
					persistFetchedPosts: true,
				});
				mergeLightboxPosts(posts, loaded);
				const hasMore = grouped.more_posts === true || grouped.more_full_page_results === true || payload.more_posts === true;
				if (!hasMore) break;
				page++;
			}
			const result = [...posts.values()];
			ctx.loader.persistFetchedPosts(result);
			return result;
		})();
		ctx.lightboxImageSearchPostsPromise = request.catch((error) => {
			ctx.lightboxImageSearchPostsPromise = null;
			throw error;
		});
		return ctx.lightboxImageSearchPostsPromise;
	}

	function buildLightboxCommentTree(postsByNumber, directMatchNumbers) {
		const childrenByParent = new Map();
		postsByNumber.forEach((post) => {
			const parentNumber = +(post.reply_to_post_number || 0);
			if (!parentNumber || !postsByNumber.has(parentNumber)) return;
			if (!childrenByParent.has(parentNumber)) childrenByParent.set(parentNumber, []);
			childrenByParent.get(parentNumber).push(post);
		});
		childrenByParent.forEach((children) => children.sort((left, right) => +left.post_number - +right.post_number));
		const roots = [...directMatchNumbers]
			.map((postNumber) => postsByNumber.get(postNumber))
			.filter((post) => post && !postsByNumber.has(+(post.reply_to_post_number || 0)))
			.sort((left, right) => +left.post_number - +right.post_number);
		return { roots, childrenByParent };
	}

	function rememberLightboxSourcePost(item, sourcePost) {
		if (!sourcePost) return;
		item.sourcePost = sourcePost;
		item.postId = +(sourcePost.id || item.postId || 0);
		item.username = String(sourcePost.username || item.username || '');
	}

	function lightboxCommentTreeState(sourcePost, postsByNumber, directMatchNumbers, partial) {
		const { roots, childrenByParent } = buildLightboxCommentTree(postsByNumber, directMatchNumbers);
		return {
			sourcePost, postsByNumber, directMatchNumbers, roots, childrenByParent,
			partial: partial === true,
		};
	}

	function cachedLightboxCommentRelation(item, ctx, knownPosts) {
		const entry = ctx && TOPIC_DATA_CACHE.get(String(ctx.topicId));
		const topic = entry?.topic || ctx?.topicData;
		const relations = topic?._ldpLightboxCommentRelations;
		const relation = relations?.[item && item.key];
		if (!relation || Date.now() - Number(relation.cachedAt || 0) > LIGHTBOX_COMMENT_RELATION_CACHE_FRESH_FOR) return null;
		const postsByNumber = new Map();
		mergeLightboxPosts(postsByNumber, relation.posts);
		if (knownPosts) {
			for (const postNumber of postsByNumber.keys()) {
				const fresh = knownPosts.get(postNumber);
				if (fresh) postsByNumber.set(postNumber, fresh);
			}
		}
		const directMatchNumbers = new Set((relation.directMatchNumbers || []).map(Number).filter((value) => value > 0));
		if (directMatchNumbers.size && ![...directMatchNumbers].every((postNumber) => postsByNumber.has(postNumber))) return null;
		const sourcePostNumber = +(relation.sourcePostNumber || item?.postNumber || 0);
		const sourcePost = knownPosts?.get(sourcePostNumber) || relation.sourcePost || null;
		rememberLightboxSourcePost(item, sourcePost);
		return lightboxCommentTreeState(sourcePost, postsByNumber, directMatchNumbers, relation.partial);
	}

	function persistLightboxCommentRelation(item, ctx, state) {
		if (!item || !item.key || !ctx || !ctx.topicId || !state) return;
		const entry = TOPIC_DATA_CACHE.get(String(ctx.topicId));
		const topic = entry?.topic || ctx.topicData;
		if (!topic) return;
		const relations = topic._ldpLightboxCommentRelations && typeof topic._ldpLightboxCommentRelations === 'object'
			? topic._ldpLightboxCommentRelations
			: {};
		relations[item.key] = {
			cachedAt: Date.now(),
			sourcePostNumber: +(state.sourcePost?.post_number || item.postNumber || 0),
			sourcePost: state.sourcePost || item.sourcePost || null,
			directMatchNumbers: [...state.directMatchNumbers || []],
			posts: [...state.postsByNumber?.values() || []],
			partial: state.partial === true,
		};
		const keys = Object.keys(relations).sort((left, right) => (
			Number(relations[right]?.cachedAt || 0) - Number(relations[left]?.cachedAt || 0)
		));
		keys.slice(64).forEach((key) => delete relations[key]);
		topic._ldpLightboxCommentRelations = relations;
		setCachedTopicData(ctx.topicId, topic);
	}

	async function loadLightboxCommentTree(item, ctx) {
		const knownPosts = knownLightboxPosts(ctx);
		const cachedRelation = cachedLightboxCommentRelation(item, ctx, knownPosts);
		if (cachedRelation) return cachedRelation;
		let sourcePost = item?.sourcePost || knownPosts.get(+(item?.postNumber || 0)) || null;
		if (!sourcePost && ctx && ctx.loader && isFunction(ctx.loader.loadPost)) {
			const loaded = await ctx.loader.loadPost(item.postNumber, {
				advanceCursor: false,
				priority: POST_REQUEST_PRIORITY.visible,
			});
			sourcePost = loaded.find((post) => +post.post_number === +item.postNumber) || null;
			mergeLightboxPosts(knownPosts, loaded);
		}
		rememberLightboxSourcePost(item, sourcePost);
		let partial = false;
		const [sourceRepliesResult, searchResult] = await Promise.allSettled([
			sourcePost ? ctx.repliesIO.loadDirectReplies(sourcePost, { force: true }) : Promise.resolve([]),
			searchLightboxImagePosts(ctx),
		]);
		if (sourceRepliesResult.status === 'fulfilled') mergeLightboxPosts(knownPosts, sourceRepliesResult.value);
		else partial = true;
		if (searchResult.status === 'fulfilled') mergeLightboxPosts(knownPosts, searchResult.value);
		else partial = true;

		const directMatchNumbers = new Set();
		knownPosts.forEach((post, postNumber) => {
			if (postQuotesLightboxImage(post, item)) directMatchNumbers.add(postNumber);
		});
		const included = new Map();
		directMatchNumbers.forEach((postNumber) => {
			const post = knownPosts.get(postNumber);
			if (post) included.set(postNumber, post);
		});
		const queue = [...included.values()].sort((left, right) => +left.post_number - +right.post_number);
		const fetchedPostIds = new Set();
		const includeReplies = (replies) => {
			replies.forEach((reply) => {
				const replyNumber = +(reply?.post_number || 0);
				if (!replyNumber) return;
				if (postQuotesLightboxImage(reply, item)) directMatchNumbers.add(replyNumber);
				if (!included.has(replyNumber)) {
					included.set(replyNumber, reply);
					queue.push(reply);
				}
			});
		};
		while (queue.length) {
			const parent = queue.shift();
			const postId = +(parent?.id || 0);
			if (!postId || fetchedPostIds.has(postId)) continue;
			fetchedPostIds.add(postId);
			const parentNumber = +(parent.post_number || 0);
			const localChildren = [...knownPosts.values()].filter((post) => (
				+(post?.reply_to_post_number || 0) === parentNumber
			));
			includeReplies(localChildren);
			const knownChildren = ctx.directRepliesByParent?.get(parentNumber);
			if (!(+(parent.reply_count || 0) > localChildren.length || knownChildren && knownChildren.size > localChildren.length)) continue;
			try {
				const replies = await ctx.repliesIO.loadDirectReplies(parent);
				includeReplies(replies);
			} catch (error) {
				partial = true;
			}
		}
		const state = lightboxCommentTreeState(sourcePost, included, directMatchNumbers, partial);
		persistLightboxCommentRelation(item, ctx, state);
		return state;
	}

	function openLightbox(clickedImg, ctx, options = {}) {
		const providedItems = Array.isArray(options.items) ? options.items.filter(Boolean) : null;
		const imageSet = providedItems
			? {
					items: providedItems.slice(),
					initialIndex: Math.max(0, Math.min(providedItems.length - 1, +options.initialIndex || 0)),
				}
			: collectLightboxItems(clickedImg, ctx);
		const { items, initialIndex } = imageSet;
		if (!items.length) return;
		const siteSettings = lookupDiscourse('service:site-settings');
		const minimumCommentLength = Math.max(1, +(siteSettings?.min_post_length) || 16);
		const lb = makeElement('div');
		const lightboxScope = createLifecycleScope();
		lb.className = 'ldp-lightbox';
		lb.setAttribute('role', 'dialog');
		lb.setAttribute('aria-modal', 'true');
		setLabel(lb, '图片预览');
		lb.innerHTML = `
      <div class="ldp-lb-toolbar">
        <span class="ldp-lb-count"></span>
        <div class="ldp-lb-tools" role="toolbar" aria-label="图片工具">
          ${iconActionButtons('ldp-lb-btn', 'data-lb-action', [['zoom-out', '缩小(-)', 'minus']])}
          <button class="ldp-lb-btn ldp-lb-zoom-value" type="button" data-lb-action="reset" aria-label="适应窗口(0)">100%</button>
          ${iconActionButtons('ldp-lb-btn', 'data-lb-action', [
						['zoom-in', '放大(+)', 'plus'], ['reset', '适应窗口(0)', 'rotateCcw'],
						['view-original', '查看原图', 'maximize2'], ['download', '下载当前图片', 'download'],
						['batch-download', '批量下载图片', 'listChecks'], ['jump-to-post', '跳到楼层', 'arrowUp'],
					])}
        </div>
        <button class="ldp-lb-btn ldp-lb-comments-toggle" type="button" data-lb-action="toggle-comments" aria-label="展开图片评论" aria-expanded="false">${icon('messageSquare')}<span class="ldp-lb-comments-count">0</span></button>
        <button class="ldp-lb-btn ldp-lb-close" type="button" data-lb-action="close" aria-label="关闭图片预览(Esc)">${icon('x')}</button>
      </div>
      <div class="ldp-lb-main">
        <button class="ldp-lb-nav ldp-lb-prev" type="button" aria-label="上一张(←)">${icon('chevronRight')}</button>
        <div class="ldp-lb-stage">
          <div class="ldp-lb-canvas"><img class="ldp-lb-image" alt="" draggable="false" hidden></div>
          <div class="ldp-lb-status" role="status" aria-live="polite"><span>正在加载预览…</span><button class="ldp-lb-retry" type="button" hidden>重试</button></div>
        </div>
        <button class="ldp-lb-nav ldp-lb-next" type="button" aria-label="下一张(→)">${icon('chevronRight')}</button>
        <aside class="ldp-lb-comments" aria-label="图片评论">
          <button class="ldp-lb-comments-resizer" type="button" role="separator" aria-orientation="vertical" aria-label="调整图片评论区宽度"></button>
          <div class="ldp-lb-comments-inner">
            <div class="ldp-lb-comments-head"><strong>评论</strong><span>(0)</span><button class="ldp-lb-description-toggle" type="button" aria-label="展开图片描述" aria-expanded="false">${icon('chevronRight')}</button></div>
            <details class="ldp-lb-source"${PREFS.lightboxDescriptionExpanded === true ? ' open' : ''} hidden>
              <summary>图片描述</summary>
              <div class="ldp-lb-source-text"></div>
            </details>
            <div class="ldp-lb-source-reactions" hidden><div class="ldp-reactions"></div></div>
            <div class="ldp-lb-comments-body">
              <div class="ldp-lb-comments-status" role="status" aria-live="polite">正在查找这张图片的评论…</div>
              <div class="ldp-lb-comments-empty" hidden><span>还没有人评论这张图片</span><button class="ldp-lb-add" type="button">添加第一个评论</button></div>
              <div class="ldp-lb-comment-list"></div>
            </div>
            <form class="ldp-lb-comment-form" hidden>
              <div class="ldp-lb-comment-target"></div>
              <textarea class="ldp-lb-comment-input" maxlength="32000" placeholder="写下你的评论(至少 ${minimumCommentLength} 个字符)…" required></textarea>
              <label class="ldp-lb-comment-image-option"><input type="checkbox">同时引用当前图片</label>
              <div class="ldp-lb-comment-error" role="alert"></div>
              <div class="ldp-lb-comment-actions"><button class="ldp-lb-comment-cancel" type="button">取消</button><button class="ldp-lb-comment-submit" type="submit">发送</button></div>
            </form>
          </div>
        </aside>
      </div>
      <div class="ldp-lb-filmstrip"${items.length > 1 ? '' : ' hidden'}>
        <div class="ldp-lb-strip-progress" aria-hidden="true"><span></span></div>
        <div class="ldp-lb-thumbs" role="listbox" aria-label="图片缩略图">
          ${items.map((item, index) => `<button class="ldp-lb-thumb" type="button" role="option" data-lb-index="${index}" aria-label="${escAttr(item.alt || `查看第 ${index + 1} 张图片`)}"><img src="${escAttr(item.thumbSrc)}" alt="" decoding="async"></button>`).join('')}
        </div>
      </div>
      <div class="ldp-lb-batch-overlay" hidden>
        <section class="ldp-lb-batch-dialog" role="dialog" aria-modal="true" aria-label="批量下载图片">
          <div class="ldp-lb-batch-head"><strong>批量下载</strong><button class="ldp-lb-btn ldp-lb-batch-close" type="button" aria-label="关闭批量下载">${icon('x')}</button></div>
          <label class="ldp-lb-batch-name" hidden><span>名称</span><input type="text" maxlength="120" aria-label="ZIP 文件名称"></label>
          <div class="ldp-lb-batch-tools">
            <div class="ldp-lb-batch-scope" role="tablist" aria-label="批量下载范围">
              <button type="button" role="tab" data-lb-batch-scope="loaded" aria-pressed="true" aria-selected="true">当前加载的图片</button>
              <button type="button" role="tab" data-lb-batch-scope="all" aria-pressed="false" aria-selected="false" tabindex="-1">全部帖子的图片</button>
            </div>
            <button class="ldp-lb-batch-select-all" type="button" aria-pressed="false">${icon('square')}<span>全选</span></button>
            <span class="ldp-lb-batch-count">已选 0 / 0</span>
          </div>
          <div class="ldp-lb-batch-grid"></div>
          <div class="ldp-lb-batch-progress" hidden>
            <div class="ldp-lb-batch-progress-copy"><span>准备下载…</span><span>0%</span></div>
            <div class="ldp-lb-batch-progress-track" role="progressbar" aria-label="批量下载进度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><span class="ldp-lb-batch-progress-fill"></span></div>
          </div>
          <div class="ldp-lb-batch-actions"><span class="ldp-lb-batch-status"></span><button class="ldp-lb-batch-cancel" type="button">取消</button><button class="ldp-lb-batch-download" type="button" disabled>打包下载</button></div>
        </section>
      </div>`;
		const stage = lb.querySelector('.ldp-lb-stage');
		const mainImg = lb.querySelector('.ldp-lb-image');
		const status = lb.querySelector('.ldp-lb-status');
		const statusText = status.querySelector('span');
		const retryBtn = status.querySelector('.ldp-lb-retry');
		const countEl = lb.querySelector('.ldp-lb-count');
		const zoomValue = lb.querySelector('.ldp-lb-zoom-value');
		const viewOriginalBtn = lb.querySelector('[data-lb-action="view-original"]');
		const jumpToPostBtn = lb.querySelector('[data-lb-action="jump-to-post"]');
		const commentsToggle = lb.querySelector('.ldp-lb-comments-toggle');
		const commentsPanel = lb.querySelector('.ldp-lb-comments');
		const commentsResizer = lb.querySelector('.ldp-lb-comments-resizer');
		const commentsToggleCount = commentsToggle.querySelector('.ldp-lb-comments-count');
		const commentsHeadingCount = lb.querySelector('.ldp-lb-comments-head span');
		const commentsDescriptionToggle = lb.querySelector('.ldp-lb-description-toggle');
		const commentsSource = lb.querySelector('.ldp-lb-source');
		const commentsSourceText = commentsSource.querySelector('.ldp-lb-source-text');
		const commentsSourceReactions = lb.querySelector('.ldp-lb-source-reactions');
		const commentsStatus = lb.querySelector('.ldp-lb-comments-status');
		const commentsEmpty = lb.querySelector('.ldp-lb-comments-empty');
		const commentsList = lb.querySelector('.ldp-lb-comment-list');
		const commentForm = lb.querySelector('.ldp-lb-comment-form');
		const commentTargetLabel = commentForm.querySelector('.ldp-lb-comment-target');
		const commentInput = commentForm.querySelector('.ldp-lb-comment-input');
		const commentImageOption = commentForm.querySelector('.ldp-lb-comment-image-option');
		const commentImageCheckbox = commentImageOption.querySelector('input');
		const commentError = commentForm.querySelector('.ldp-lb-comment-error');
		const prevBtn = lb.querySelector('.ldp-lb-prev');
		const nextBtn = lb.querySelector('.ldp-lb-next');
		const filmstrip = lb.querySelector('.ldp-lb-filmstrip');
		const thumbsEl = lb.querySelector('.ldp-lb-thumbs');
		let thumbs = [...lb.querySelectorAll('.ldp-lb-thumb')];
		const progress = lb.querySelector('.ldp-lb-strip-progress > span');
		const batchOverlay = lb.querySelector('.ldp-lb-batch-overlay');
		const batchCloseButtons = lb.querySelectorAll('.ldp-lb-batch-close,.ldp-lb-batch-cancel');
		const batchNameInput = lb.querySelector('.ldp-lb-batch-name input');
		const batchScopeButtons = [...lb.querySelectorAll('[data-lb-batch-scope]')];
		const batchSelectAll = lb.querySelector('.ldp-lb-batch-select-all');
		const batchCount = lb.querySelector('.ldp-lb-batch-count');
		const batchGrid = lb.querySelector('.ldp-lb-batch-grid');
		const batchProgress = lb.querySelector('.ldp-lb-batch-progress');
		const batchProgressCopy = lb.querySelector('.ldp-lb-batch-progress-copy');
		const batchProgressTrack = lb.querySelector('.ldp-lb-batch-progress-track');
		const batchStatus = lb.querySelector('.ldp-lb-batch-status');
		const batchDownloadButton = lb.querySelector('.ldp-lb-batch-download');
		const batchSelection = new Set();
		const batchThumbnailUrls = new Map();
		const batchThumbnailQueue = [];
		let batchThumbnailActive = 0;
		let batchThumbnailObserver = null;
		let batchBusy = false;
		let batchScope = 'loaded';
		let batchLoadedScopeKeys = new Set();
		let batchLoadedForwardPostNumber = 0;
		let batchLoadedScopeExhausted = false;
		let batchAllImagesLoaded = false;
		const commentsEnabled = options.comments !== false;
		const fixedItems = options.fixedItems === true;
		commentsToggle.hidden = !commentsEnabled;
		commentsPanel.hidden = !commentsEnabled;
		const seenImageKeys = new Set(items.map((item) => item.key));
		const commentCache = new Map();
		let currentIndex = initialIndex;
		let mainImageRequestToken = 0;
		let commentRequestToken = 0;
		let commentTargetPost = null;
		let commentsManuallyToggled = false;
		const initialPostNumber = +(items[initialIndex]?.postNumber) || 0;
		let exploringPrevious = false;
		let exploringNext = false;
		let jumpingToPost = false;
		let backwardScanPostNumber = initialPostNumber;
		let forwardScanPostNumber = initialPostNumber;
		let backwardExhausted = fixedItems || !initialPostNumber ||
			!(ctx?.loader && isFunction(ctx.loader.loadBeforePost)) ||
			initialPostNumber <= 1;
		let forwardExhausted = fixedItems || !initialPostNumber ||
			!(ctx?.loader && isFunction(ctx.loader.loadAfterPost));
		let lightboxClosed = false;
		const commentsExpandedByDefault = commentsEnabled && PREFS.lightboxCommentsExpandedByDefault === true;
		lb.classList.toggle('ldp-lb-comments-collapsed', !commentsExpandedByDefault);
		setExpanded(commentsToggle, commentsExpandedByDefault);
		setLabel(commentsToggle, commentsExpandedByDefault ? '收起图片评论' : '展开图片评论');
		lb.style.setProperty('--ldp-lb-description-height', `${normalizeLightboxDescriptionHeight(PREFS.lightboxDescriptionHeight)}px`);
		let commentsWidthPercent = normalizeLightboxCommentsWidth(PREFS.lightboxCommentsWidthPercent);
		const setViewOriginalButtonState = (disabled, busy, label) => {
			viewOriginalBtn.disabled = disabled;
			if (busy) viewOriginalBtn.setAttribute('aria-busy', 'true');
			else viewOriginalBtn.removeAttribute('aria-busy');
			if (label) setLabel(viewOriginalBtn, label);
		};
		const imageTransform = createImageTransformController(stage, mainImg, {
			overflowPadding: 24,
			allowContainedPan: true,
			resetPanAtFit: false,
			zoomValue,
			zoomOutButton: lb.querySelector('[data-lb-action="zoom-out"]'),
			zoomInButton: lb.querySelector('[data-lb-action="zoom-in"]'),
			render: ({ scale, panX, panY }) => {
				mainImg.style.setProperty('--ldp-lb-scale', String(scale));
				mainImg.style.setProperty('--ldp-lb-pan-x', `${Math.round(panX)}px`);
				mainImg.style.setProperty('--ldp-lb-pan-y', `${Math.round(panY)}px`);
			},
		});
		const { render: renderTransform, setZoom, reset: resetView } = imageTransform;
		const applyCommentsWidth = (value, persist = false) => {
			commentsWidthPercent = normalizeLightboxCommentsWidth(value);
			lb.style.setProperty('--ldp-lb-comments-width-preferred', `${commentsWidthPercent}%`);
			commentsResizer.setAttribute('aria-valuemin', String(LIGHTBOX_COMMENTS_WIDTH_MIN));
			commentsResizer.setAttribute('aria-valuemax', String(LIGHTBOX_COMMENTS_WIDTH_MAX));
			commentsResizer.setAttribute('aria-valuenow', String(Math.round(commentsWidthPercent)));
			if (persist && commentsWidthPercent !== normalizeLightboxCommentsWidth(PREFS.lightboxCommentsWidthPercent)) {
				setPref('lightboxCommentsWidthPercent', commentsWidthPercent);
			}
			requestAnimationFrame(() => renderTransform());
		};
		applyCommentsWidth(commentsWidthPercent);
		const totalPostNumber = () => Math.max(
			0,
			+(ctx?.totalPosts || 0),
			+(ctx?.topicData && ctx.topicData.highest_post_number || 0)
		);
		if (initialPostNumber && totalPostNumber() && initialPostNumber >= totalPostNumber()) {
			forwardExhausted = true;
		}
		const scrollActiveThumbIntoView = () => {
			const thumb = thumbs[currentIndex];
			if (thumb) thumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
		};
		const updateNavigationState = () => {
			const exploring = exploringPrevious || exploringNext;
			[
				[prevBtn, exploringPrevious, currentIndex === 0, backwardExhausted,
					['正在加载更早图片', '已经到头了', '继续点击加载更早图片', '上一张']],
				[nextBtn, exploringNext, currentIndex === items.length - 1, forwardExhausted,
					['正在加载更多图片', '已经到底了', '继续点击加载更多图片', '下一张']],
			].forEach(([button, busy, atDiscoveredEdge, exhausted, labels]) => {
				const atPostEdge = atDiscoveredEdge && exhausted;
				button.disabled = exploring;
				button.setAttribute('aria-disabled', String(atPostEdge));
				button.setAttribute('aria-busy', String(busy));
				setLabel(button, labels[busy ? 0 : atPostEdge ? 1 : atDiscoveredEdge ? 2 : 3]);
			});
		};
		const updateJumpToPostState = () => {
			const postNumber = +(items[currentIndex]?.postNumber) || 0;
			jumpToPostBtn.disabled = jumpingToPost || !postNumber;
			jumpToPostBtn.setAttribute('aria-busy', String(jumpingToPost));
			setLabel(jumpToPostBtn, '跳到楼层');
		};
		const syncThumbs = () => {
			filmstrip.hidden = items.length < 2;
			thumbsEl.innerHTML = items.map((item, index) => {
				return `<button class="ldp-lb-thumb" type="button" role="option" data-lb-index="${index}" aria-label="${escAttr(item.alt || `查看第 ${index + 1} 张图片`)}"><img src="${escAttr(item.thumbSrc)}" alt="" decoding="async"></button>`;
			}).join('');
			thumbs = [...thumbsEl.querySelectorAll('.ldp-lb-thumb')];
			if (!batchOverlay.hidden) renderBatchSelection();
		};
		const batchScopeItems = () => batchScope === 'all'
			? items
			: items.filter((item) => batchLoadedScopeKeys.has(item.key));
		const batchCanLoadMore = () => batchScope === 'loaded' && !fixedItems && !batchLoadedScopeExhausted &&
			ctx && ctx.loader && isFunction(ctx.loader.loadAfterPost);
		const syncBatchScopeControls = () => {
			batchScopeButtons.forEach((button) => {
				const active = button.dataset.lbBatchScope === batchScope;
				setPressed(button, active);
				button.setAttribute('aria-selected', String(active));
				button.tabIndex = active ? 0 : -1;
				button.disabled = batchBusy || fixedItems && button.dataset.lbBatchScope === 'all';
			});
		};
		const syncBatchSelectionControls = () => {
			const scopedItems = batchScopeItems();
			const allSelected = scopedItems.length > 0 && scopedItems.every((item) => batchSelection.has(item.key));
			setPressed(batchSelectAll, allSelected);
			batchSelectAll.innerHTML = `${icon(allSelected ? 'squareCheck' : 'square')}<span>${allSelected ? '全不选' : '全选'}</span>`;
			batchCount.textContent = `已选 ${batchSelection.size} / ${scopedItems.length}`;
			batchSelectAll.disabled = batchBusy;
			batchDownloadButton.disabled = batchBusy || batchSelection.size === 0;
			syncBatchScopeControls();
		};
		const batchThumbnailPersistentKey = (item) => {
			const source = absoluteImageUrl(item && (item.thumbSrc || item.originalSrc));
			if (!/^https?:/i.test(source)) return '';
			try {
				const key = new URL(source);
				key.searchParams.set('__ldp_cached_thumbnail', '240-webp-v1');
				return key.href;
			} catch (error) {
				return '';
			}
		};
		const createBatchThumbnail = async (item) => {
			const persistentKey = batchThumbnailPersistentKey(item);
			const blob = await fetchLightboxDownloadBlob(item.thumbSrc || item.originalSrc, POST_REQUEST_PRIORITY.background);
			if (!isFunction(globalThis.createImageBitmap)) return URL.createObjectURL(blob);
			let bitmap;
			try {
				bitmap = await globalThis.createImageBitmap(blob, { resizeWidth: 240, resizeQuality: 'low' });
			} catch (error) {
				// Some browser-supported image formats cannot be decoded by createImageBitmap.
				// Keep the fetched image usable instead of leaving a permanently empty card.
				return URL.createObjectURL(blob);
			}
			const width = Math.max(1, Math.min(240, bitmap.width));
			const height = Math.max(1, Math.round(bitmap.height * width / bitmap.width));
			const canvas = makeElement('canvas');
			canvas.width = width;
			canvas.height = height;
			const context = canvas.getContext('2d', { alpha: true });
			if (!context) {
				bitmap.close();
				return URL.createObjectURL(blob);
			}
			context.drawImage(bitmap, 0, 0, width, height);
			bitmap.close();
			const thumbnailBlob = await new Promise((resolve) => {
				canvas.toBlob(resolve, 'image/webp', 0.78);
			});
			const result = thumbnailBlob?.size ? thumbnailBlob : blob;
			if (persistentKey && result === thumbnailBlob) await persistLightboxBlob(persistentKey, result, 'image/webp');
			return URL.createObjectURL(result);
		};
		const drainBatchThumbnailQueue = () => {
			while (!lightboxClosed && batchThumbnailActive < 4 && batchThumbnailQueue.length) {
				const task = batchThumbnailQueue.shift();
				batchThumbnailActive++;
				Promise.resolve().then(task).catch(() => {}).finally(() => {
					batchThumbnailActive--;
					drainBatchThumbnailQueue();
				});
			}
		};
		const enqueueBatchThumbnail = (task) => {
			batchThumbnailQueue.push(task);
			drainBatchThumbnailQueue();
		};
		const loadBatchThumbnail = (image, item) => {
			if (!image || !item || image.dataset.ldpBatchThumbLoading === '1') return;
			const cached = batchThumbnailUrls.get(item.key);
			if (cached) {
				image.dataset.ldpBatchThumbState = 'loading';
				image.src = cached;
				return;
			}
			image.dataset.ldpBatchThumbLoading = '1';
			const displayThumbnail = (source) => {
				if (lightboxClosed) {
					URL.revokeObjectURL(source);
					return;
				}
				const previous = batchThumbnailUrls.get(item.key);
				if (previous && previous !== source) URL.revokeObjectURL(source);
				else batchThumbnailUrls.set(item.key, source);
				if (image.isConnected && image.dataset.ldpBatchThumbKey === item.key) {
					image.dataset.ldpBatchThumbState = 'loading';
					image.src = batchThumbnailUrls.get(item.key) || source;
				}
			};
			const generateThumbnail = () => enqueueBatchThumbnail(async () => {
				if (lightboxClosed || batchThumbnailUrls.has(item.key)) return;
				try {
					displayThumbnail(await createBatchThumbnail(item));
				} catch (error) {
					if (image.isConnected && image.dataset.ldpBatchThumbKey === item.key) {
						image.alt = '缩略图加载失败';
						image.dataset.ldpBatchThumbState = 'failed';
					}
				}
			});
			const persistentKey = batchThumbnailPersistentKey(item);
			if (!persistentKey) {
				generateThumbnail();
				return;
			}
			void cachedPersistentLightboxBlob(persistentKey).then((cachedThumbnail) => {
				if (cachedThumbnail) {
					displayThumbnail(URL.createObjectURL(cachedThumbnail));
					return;
				}
				generateThumbnail();
			}).catch(generateThumbnail);
		};
		const observeBatchThumbnails = () => {
			if (batchThumbnailObserver) batchThumbnailObserver.disconnect();
			const images = batchGrid.querySelectorAll('[data-ldp-batch-thumb-key]');
			if (!isFunction(IntersectionObserver)) {
				images.forEach((image) => loadBatchThumbnail(image, items[+image.dataset.ldpBatchIndex]));
				return;
			}
			batchThumbnailObserver = new IntersectionObserver((entries) => {
				entries.forEach((entry) => {
					if (!entry.isIntersecting) return;
					batchThumbnailObserver.unobserve(entry.target);
					loadBatchThumbnail(entry.target, items[+entry.target.dataset.ldpBatchIndex]);
				});
			}, { root: batchGrid, rootMargin: '160px 0px' });
			images.forEach((image) => batchThumbnailObserver.observe(image));
		};
		function renderBatchSelection() {
			const scopedItems = batchScopeItems();
			const availableKeys = new Set(scopedItems.map((item) => item.key));
			batchSelection.forEach((key) => {
				if (!availableKeys.has(key)) batchSelection.delete(key);
			});
			const itemMarkup = scopedItems.map((item, index) => {
				const itemIndex = items.indexOf(item);
				const selected = batchSelection.has(item.key);
				const label = `#${item.postNumber || '?'} · 图片 ${index + 1}`;
				const cachedThumbnail = batchThumbnailUrls.get(item.key);
				const immediateThumbnail = cachedThumbnail || item.thumbSrc || item.originalSrc;
				return `<label class="ldp-lb-batch-item${selected ? ' selected' : ''}" data-lb-batch-index="${itemIndex}">
					<input type="checkbox" aria-label="选择${escAttr(label)}"${selected ? ' checked' : ''}>
					<img${immediateThumbnail ? ` src="${escAttr(immediateThumbnail)}" data-ldp-batch-thumb-fallback="${escAttr(immediateThumbnail)}"` : ''} data-ldp-batch-thumb-key="${escAttr(item.key)}" data-ldp-batch-index="${itemIndex}" data-ldp-batch-thumb-state="loading" alt="" decoding="async">
					<span>${esc(label)}</span>
				</label>`;
			}).join('');
			const moreMarkup = batchCanLoadMore()
				? `<button class="ldp-lb-batch-more" type="button"${batchBusy ? ' disabled' : ''}>${icon('plus')}<span>${batchBusy ? '正在加载…' : '加载更多图片'}</span></button>`
				: '';
			batchGrid.innerHTML = itemMarkup + moreMarkup;
			batchGrid.querySelectorAll('[data-ldp-batch-thumb-key]').forEach((image) => {
				const syncThumbnailState = () => {
					if (image.naturalWidth > 0) {
						image.dataset.ldpBatchThumbState = 'loaded';
						return;
					}
					const generatedSource = batchThumbnailUrls.get(image.dataset.ldpBatchThumbKey);
					const currentSource = image.currentSrc || image.src;
					const fallbackSource = image.dataset.ldpBatchThumbFallback || '';
					if (generatedSource && currentSource === generatedSource && fallbackSource && fallbackSource !== generatedSource) {
						batchThumbnailUrls.delete(image.dataset.ldpBatchThumbKey);
						URL.revokeObjectURL(generatedSource);
						image.dataset.ldpBatchThumbState = 'loading';
						image.src = fallbackSource;
						return;
					}
					image.dataset.ldpBatchThumbState = 'failed';
				};
				image.addEventListener('load', syncThumbnailState);
				image.addEventListener('error', syncThumbnailState);
				if (image.complete) syncThumbnailState();
			});
			if (!batchBusy) observeBatchThumbnails();
			syncBatchSelectionControls();
		}
		const setBatchProgress = (completed, total, label) => {
			const percent = total > 0 ? Math.max(0, Math.min(100, Math.round(completed / total * 100))) : 0;
			batchProgress.hidden = false;
			batchProgress.style.setProperty('--ldp-lb-batch-progress', `${percent}%`);
			batchProgressCopy.firstElementChild.textContent = label;
			batchProgressCopy.lastElementChild.textContent = `${percent}%`;
			batchProgressTrack.setAttribute('aria-valuenow', String(percent));
		};
		const openBatchDownload = () => {
			batchSelection.clear();
			batchScope = 'loaded';
			batchLoadedScopeKeys = new Set(items.map((item) => item.key));
			batchLoadedForwardPostNumber = Math.max(0,
				...items.map((item) => +item.postNumber || 0),
				...(ctx && Array.isArray(ctx.streamItems) ? ctx.streamItems.map((item) => +item.postNumber || 0) : [])
			);
			batchLoadedScopeExhausted = Boolean(totalPostNumber() && batchLoadedForwardPostNumber >= totalPostNumber());
			batchNameInput.value = '';
			const title = safeDownloadFilename(
				ctx?.topicData && ctx.topicData.title || document.title || '帖子图片',
				'帖子图片'
			);
			batchNameInput.placeholder = `${title}_${lightboxDownloadTimestamp()}.zip`;
			batchStatus.textContent = '请选择要打包的图片';
			batchProgress.hidden = true;
			batchOverlay.hidden = false;
			renderBatchSelection();
			requestAnimationFrame(() => {
				if (batchScopeButtons[0]) batchScopeButtons[0].focus({ preventScroll: true });
				const firstItem = batchScopeItems()[0];
				const firstIndex = firstItem ? items.indexOf(firstItem) : -1;
				const firstNode = firstIndex >= 0 ? batchGrid.querySelector(`[data-lb-batch-index="${firstIndex}"]`) : null;
				if (firstIndex >= 0 && firstNode) void openBatchItemPreview(firstIndex, firstNode);
			});
		};
		const closeBatchDownload = () => {
			if (batchBusy) return;
			const imageViewer = readerPortalQuery('.ldp-avatar-viewer.is-image');
			if (isFunction(imageViewer?._ldpClose)) imageViewer._ldpClose();
			batchOverlay.hidden = true;
			batchSelection.clear();
		};
		const setCommentsExpanded = (expanded, manual = false) => {
			if (manual) commentsManuallyToggled = true;
			lb.classList.toggle('ldp-lb-comments-collapsed', !expanded);
			setExpanded(commentsToggle, !!expanded);
			setLabel(commentsToggle, expanded ? '收起图片评论' : '展开图片评论');
			requestAnimationFrame(renderTransform);
		};
		const closeCommentForm = () => {
			commentTargetPost = null;
			commentForm.hidden = true;
			commentForm.removeAttribute('aria-busy');
			commentForm.dataset.rootComment = '0';
			commentInput.value = '';
			commentError.textContent = '';
		};
		const renderCommentSource = (item, sourcePost = null) => {
			const post = sourcePost || item?.sourcePost || null;
			const postNumber = +(item?.postNumber || post?.post_number || 0);
			commentsSource.hidden = !postNumber;
			commentsDescriptionToggle.hidden = !postNumber;
			commentsSourceText.textContent = lightboxSourceDescription(post, item);
			commentsSourceReactions.hidden = true;
			commentsSourceReactions._ldpPostData = post;
			commentsSourceReactions.dataset.postId = String(post?.id || '');
			commentsSourceReactions.dataset.postNumber = String(postNumber || '');
			if (post?.id) {
				renderPostReactions(commentsSourceReactions, ctx);
				commentsSourceReactions.hidden = !commentsSourceReactions.classList.contains('ldp-has-reactions');
			}
		};
		const quoteMatchesCurrentItem = (quote, item) => {
			if (!quote || +quote.dataset.post !== +item.postNumber) return false;
			const quotedTopicId = +(quote.dataset.topic || 0);
			if (quotedTopicId && item.topicId && quotedTopicId !== +item.topicId) return false;
			const expectedSource = comparableLightboxImageSource(item.originalSrc);
			return lightboxQuoteContainsImage(quote, item, expectedSource);
		};
		const renderLightboxCommentPost = (post, nested, state, item) => {
			const displayPost = Object.assign({}, post, { reply_count: 0 });
			const node = renderPost(displayPost, nested, ctx, {
				forceExpanded: true,
				showParentJump: nested,
			});
			node.classList.remove('ldp-reply-collapsible', 'ldp-nested-collapsed');
			node.querySelectorAll(':scope > .ldp-nested-toggle,:scope > .ldp-collapse-replies,:scope > .ldp-children,:scope > .ldp-sub-loading').forEach((element) => element.remove());
			const content = node.querySelector(':scope > .ldp-content');
			const directReference = state.directMatchNumbers.has(+post.post_number);
			if (content) {
				if (directReference) {
					content.querySelectorAll('aside.quote').forEach((quote) => {
						if (quoteMatchesCurrentItem(quote, item)) quote.remove();
					});
				}
				content.querySelectorAll('img:not(.emoji),video,audio,iframe,canvas,source,picture').forEach((media) => media.remove());
				content.querySelectorAll('.ldp-quote-controls').forEach((controls) => controls.remove());
				content.querySelectorAll('.lightbox-wrapper,figure,a.lightbox').forEach((wrapper) => {
					if (!String(wrapper.textContent || '').trim() && !wrapper.querySelector('img,video,audio,iframe')) wrapper.remove();
				});
			}
			node.querySelectorAll(':scope > .ldp-boost-list,:scope > .ldp-topic-footer-actions').forEach((element) => element.remove());
			node.querySelectorAll('.ldp-context-actions-slot,.ldp-boostbtn').forEach((element) => element.remove());
			node.querySelectorAll('.ldp-post-head .ldp-floor').forEach((floor) => {
				if (!floor.dataset.targetPostNumber) floor.dataset.targetPostNumber = String(post.post_number || '');
				floor.classList.add('ldp-lb-floor-jump');
				if (floor.tagName !== 'BUTTON') {
					floor.tabIndex = 0;
					floor.setAttribute('role', 'button');
				}
			});
			return node;
		};
		const descendantsForComment = (rootPostNumber, state) => {
			const descendants = [];
			const visited = new Set([+rootPostNumber]);
			const visit = (postNumber) => {
				const children = state.childrenByParent.get(+postNumber) || [];
				children.forEach((child) => {
					const childNumber = +(child?.post_number || 0);
					if (!childNumber || visited.has(childNumber)) return;
					visited.add(childNumber);
					descendants.push(child);
					visit(childNumber);
				});
			};
			visit(rootPostNumber);
			return descendants;
		};
		const renderCommentState = (state, item) => {
			const count = state?.postsByNumber ? state.postsByNumber.size : 0;
			commentsToggleCount.textContent = String(count);
			commentsHeadingCount.textContent = `(${count})`;
			renderCommentSource(item, state?.sourcePost);
			commentsList.replaceChildren();
			commentsStatus.hidden = true;
			commentsEmpty.hidden = count > 0;
			if (!count) {
				commentsEmpty.querySelector('span').textContent = state?.partial
					? '暂未找到评论,部分结果加载失败'
					: '还没有人评论这张图片';
			}
			(state?.roots || []).forEach((rootPost) => {
				const thread = makeElement('section');
				thread.className = 'ldp-lb-comment-thread';
				const rootWrap = makeElement('div');
				rootWrap.className = 'ldp-lb-comment-node';
				rootWrap.append(renderLightboxCommentPost(rootPost, false, state, item));
				thread.append(rootWrap);
				const descendants = descendantsForComment(rootPost.post_number, state);
				if (descendants.length) {
					const children = makeElement('div');
					children.className = 'ldp-lb-comment-children';
					descendants.forEach((reply) => {
						const replyWrap = makeElement('div');
						replyWrap.className = 'ldp-lb-comment-node';
						replyWrap.append(renderLightboxCommentPost(reply, true, state, item));
						children.append(replyWrap);
					});
					thread.append(children);
				}
				commentsList.append(thread);
			});
			if (state?.partial && count > 0) {
				commentsStatus.hidden = false;
				commentsStatus.textContent = '部分关联回复暂未加载';
			}
			if (!commentsManuallyToggled) setCommentsExpanded(commentsExpandedByDefault);
		};
		const openCommentForm = (targetPost = null) => {
			const item = items[currentIndex];
			const rootComment = !targetPost;
			commentTargetPost = targetPost || item.sourcePost || {
				id: item.postId,
				post_number: item.postNumber,
				username: item.username,
			};
			const username = cleanUsernameValue(commentTargetPost?.username);
			const targetNumber = +(commentTargetPost?.post_number || item.postNumber || 0);
			commentForm.dataset.rootComment = rootComment ? '1' : '0';
			commentTargetLabel.textContent = rootComment
				? `${username ? `评论 @${username}` : '评论'} 的图片(回复 #${targetNumber})`
				: `回复 ${username ? `@${username} · ` : ''}#${targetNumber}`;
			commentImageOption.hidden = rootComment;
			commentImageCheckbox.checked = rootComment;
			commentError.textContent = '';
			commentForm.hidden = false;
			setCommentsExpanded(true);
			requestAnimationFrame(() => {
				commentForm.scrollIntoView({ block: 'nearest' });
				commentInput.focus({ preventScroll: true });
			});
		};
		const loadCommentsForItem = async (item) => {
			const token = ++commentRequestToken;
			closeCommentForm();
			renderCommentSource(item);
			const cached = commentCache.get(item.key);
			if (cached) {
				renderCommentState(cached, item);
				return cached;
			}
			commentsToggleCount.textContent = '…';
			commentsHeadingCount.textContent = '(…)';
			commentsStatus.hidden = false;
			commentsStatus.textContent = '正在查找这张图片的评论…';
			commentsEmpty.hidden = true;
			commentsList.replaceChildren();
			if (!commentsManuallyToggled) setCommentsExpanded(commentsExpandedByDefault);
			try {
				const state = await loadLightboxCommentTree(item, ctx);
				if (lightboxClosed || token !== commentRequestToken || items[currentIndex].key !== item.key) return null;
				commentCache.set(item.key, state);
				renderCommentState(state, item);
				return state;
			} catch (error) {
				if (lightboxClosed || token !== commentRequestToken) return null;
				commentsToggleCount.textContent = '0';
				commentsHeadingCount.textContent = '(0)';
				commentsStatus.hidden = false;
				commentsStatus.textContent = `评论加载失败:${error.message || '请重试'}`;
				commentsEmpty.hidden = false;
				commentsEmpty.querySelector('span').textContent = '暂时无法加载图片评论';
				return null;
			}
		};
		const toggleLightboxCommentLike = async (button, postNode) => {
			const postData = postNode?._ldpPostData;
			const postId = +(postData?.id || postNode && postNode.dataset.postId || 0);
			if (!postId || !postData || button.disabled) return;
			await runReaderActionOnce(ctx, `post:${postId}:like`, async () => {
				await toggleReaderPostLike(postId, ctx, button, {
					setBusy: (busy) => setReaderControlBusy(button, busy),
					beforeSync: (acted, count) => syncReaderLikeButton(button, updatePostLikeAction(postData, acted, count)),
					postData,
					persist: true,
					onError: (error) => showSelectionToast(`操作失败:${error.message}`),
				});
			});
		};
		const positionLightboxReactionPicker = (picker) => {
			if (!picker || picker.hidden || !commentsPanel.isConnected) return;
			picker.style.left = '0px';
			const panelRect = commentsPanel.getBoundingClientRect();
			const safeInset = 10;
			picker.style.maxWidth = `${Math.max(1, Math.min(320, panelRect.width - safeInset * 2, window.innerWidth - 24))}px`;
			const pickerRect = picker.getBoundingClientRect();
			const safeLeft = Math.max(12, panelRect.left + safeInset);
			const safeRight = Math.min(window.innerWidth - 12, panelRect.right - safeInset);
			let shift = 0;
			if (pickerRect.right > safeRight) shift -= pickerRect.right - safeRight;
			if (pickerRect.left + shift < safeLeft) shift += safeLeft - (pickerRect.left + shift);
			picker.style.left = `${Math.round(shift)}px`;
		};
		const setLightboxReactionPickerExpanded = (postNode, expanded) => {
			const root = postNode?.querySelector(':scope > .ldp-reactions');
			const trigger = root?.querySelector('[data-reaction-picker]');
			const picker = root?.querySelector('.ldp-reaction-picker');
			if (!trigger || !picker) return;
			picker.hidden = !expanded;
			if (expanded) requestAnimationFrame(() => positionLightboxReactionPicker(picker));
			setExpanded(trigger, !!expanded);
			refreshTooltips(trigger);
		};
		const toggleLightboxReactionPicker = async (postNode) => {
			const currentPicker = postNode?.querySelector(':scope > .ldp-reactions .ldp-reaction-picker');
			const shouldOpen = !!(currentPicker?.hidden);
			if (shouldOpen && !reactionEmojiUrls) {
				await loadReactionEmojiRegistry();
				renderPostReactions(postNode, ctx);
			}
			setLightboxReactionPickerExpanded(postNode, shouldOpen);
		};
		const toggleLightboxReaction = async (button, postNode) => {
			const postData = postNode?._ldpPostData;
			const postId = +(postData?.id || postNode && postNode.dataset.postId || 0);
			const reaction = String(button && button.dataset.reaction || '');
			if (!postId || !reaction) return;
			const item = items[currentIndex];
			await toggleReaderPostReaction(postNode, ctx, reaction, (fresh) => {
				postNode._ldpPostData = Object.assign({}, postData, fresh);
				const state = commentCache.get(item.key);
				const cachedPost = state && state.postsByNumber.get(+(postData?.post_number || 0));
				if (cachedPost && +cachedPost.id === postId) Object.assign(cachedPost, fresh);
				if (item.sourcePost && +item.sourcePost.id === postId) Object.assign(item.sourcePost, fresh);
				renderPostReactions(postNode, ctx);
			}, (error) => showSelectionToast(`回应失败:${error.message}`));
		};
		const jumpToLightboxComment = async (post) => {
			const postNumber = +(post?.post_number || 0);
			if (!postNumber || lightboxClosed) return;
			try {
				const acceptsWork = () => !lightboxClosed;
				const jumped = +(post.reply_to_post_number || 0) > 0
					? await focusLiveNestedReply(post, ctx, { acceptsWork })
					: await ctx.timeline.jumpTo(postNumber, { acceptsWork });
				if (!jumped || lightboxClosed) return;
				close();
			} catch (error) {
				showSelectionToast('无法定位到该楼层');
			}
		};
		const onCommentFormSubmit = async (event) => {
			event.preventDefault();
			if (commentForm.getAttribute('aria-busy') === 'true') return;
			const item = items[currentIndex];
			const targetPost = commentTargetPost;
			const targetPostNumber = +(targetPost?.post_number || 0);
			const message = String(commentInput.value || '').trim();
			const messageLength = [...message].length;
			const includeImage = commentForm.dataset.rootComment === '1' || commentImageCheckbox.checked;
			const quoteRaw = includeImage ? lightboxImageQuoteRaw(item, ctx) : '';
			if (!message || !targetPostNumber || includeImage && !quoteRaw) {
				commentError.textContent = !message ? '请输入评论内容' : '无法确认图片引用来源';
				return;
			}
			if (messageLength < minimumCommentLength) {
				commentError.textContent = `评论至少需要 ${minimumCommentLength} 个字符(当前 ${messageLength} 个)`;
				return;
			}
			commentForm.setAttribute('aria-busy', 'true');
			commentError.textContent = '';
			try {
				const payload = await apiSend(`${BASE}/posts`, 'POST', {
					topic_id: +ctx.topicId,
					raw: `${quoteRaw}${message}`,
					reply_to_post_number: targetPostNumber,
				}, null, { priority: POST_REQUEST_PRIORITY.visible });
				const created = postsFromPayload(payload)[0] || payload?.post || payload;
				if (!created || !created.id || !created.post_number) throw new Error('服务器未返回新楼层');
				if (!created.reply_to_post_number) created.reply_to_post_number = targetPostNumber;
				if (!created.topic_id) created.topic_id = +ctx.topicId;
				if (includeImage) {
					created._ldpImageReferenceKeys = [...new Set([
						...(Array.isArray(created._ldpImageReferenceKeys) ? created._ldpImageReferenceKeys : []),
						item.key,
					])];
				}
				if (!ctx.lightboxImageCommentPosts) ctx.lightboxImageCommentPosts = new Map();
				ctx.lightboxImageCommentPosts.set(+created.post_number, created);
				if (targetPost?.id) {
					targetPost.reply_count = Math.max(0, +(targetPost.reply_count || 0)) + 1;
					persistReaderPostState(ctx, targetPost);
				}
				rememberPostData(ctx, created, targetPostNumber);
				ctx.loader.persistFetchedPosts([created]);
				ingestLivePosts([created], ctx, { refreshContent: true });
				ctx.totalPosts = Math.max(+(ctx.totalPosts || 0), +created.post_number);
				let state = commentCache.get(item.key);
				if (!state) state = await loadLightboxCommentTree(item, ctx);
				state.postsByNumber.set(+created.post_number, created);
				if (includeImage) state.directMatchNumbers.add(+created.post_number);
				const tree = buildLightboxCommentTree(state.postsByNumber, state.directMatchNumbers);
				state.roots = tree.roots;
				state.childrenByParent = tree.childrenByParent;
				commentCache.set(item.key, state);
				persistLightboxCommentRelation(item, ctx, state);
				commentRequestToken++;
				closeCommentForm();
				if (!lightboxClosed && items[currentIndex].key === item.key) renderCommentState(state, item);
			} catch (error) {
				commentError.textContent = `发送失败:${error.message || '请重试'}`;
			} finally {
				commentForm.removeAttribute('aria-busy');
			}
		};
		const onCommentsClick = (event) => {
			const addButton = event.target.closest('.ldp-lb-add');
			if (addButton) {
				openCommentForm();
				return;
			}
			const reactionPost = event.target.closest('.ldp-lb-comment-node > .ldp-post,.ldp-lb-source-reactions');
			const reactionPicker = event.target.closest('[data-reaction-picker]');
			if (reactionPost && reactionPicker) {
				event.preventDefault();
				void toggleLightboxReactionPicker(reactionPost);
				return;
			}
			const reactionButton = event.target.closest('[data-reaction]');
			if (reactionPost && reactionButton) {
				event.preventDefault();
				setLightboxReactionPickerExpanded(reactionPost, false);
				void toggleLightboxReaction(reactionButton, reactionPost);
				return;
			}
			const postNode = event.target.closest('.ldp-lb-comment-node > .ldp-post');
			if (!postNode) return;
			const postData = postNode._ldpPostData;
			const replyButton = event.target.closest('.ldp-replybtn');
			if (replyButton) {
				event.preventDefault();
				openCommentForm(postData);
				return;
			}
			const likeButton = event.target.closest('.ldp-like');
			if (likeButton) {
				event.preventDefault();
				void toggleLightboxCommentLike(likeButton, postNode);
				return;
			}
			const floor = event.target.closest('.ldp-lb-floor-jump');
			if (floor) {
				event.preventDefault();
				const targetNumber = +(floor.dataset.targetPostNumber || postData?.post_number || 0);
				const state = commentCache.get(items[currentIndex].key);
				const targetPost = state && state.postsByNumber.get(targetNumber) || knownLightboxPosts(ctx).get(targetNumber);
				if (targetPost) void jumpToLightboxComment(targetPost);
			}
		};
		const onCommentsKeydown = (event) => {
			if (event.key !== 'Enter' && event.key !== ' ') return;
			const floor = event.target.closest('.ldp-lb-floor-jump[role="button"]');
			if (!floor) return;
			event.preventDefault();
			floor.click();
		};
		const showItem = (index) => {
			currentIndex = Math.max(0, Math.min(items.length - 1, Number(index) || 0));
			const item = items[currentIndex];
			const previewSrc = item.thumbSrc || item.originalSrc;
			const hasSeparateOriginal = item.originalSrc !== previewSrc;
			mainImageRequestToken++;
			resetView();
			countEl.textContent = `${currentIndex + 1} / ${items.length}`;
			updateNavigationState();
			updateJumpToPostState();
			thumbs.forEach((thumb, thumbIndex) => {
				const active = thumbIndex === currentIndex;
				thumb.classList.toggle('active', active);
				thumb.setAttribute('aria-selected', String(active));
			});
			progress.style.setProperty('--ldp-lb-progress-size', `${100 / items.length}%`);
			progress.style.setProperty('--ldp-lb-progress-x', `${currentIndex * 100}%`);
			mainImg.hidden = true;
			mainImg.alt = item.alt;
			mainImg.dataset.ldpSourceMode = hasSeparateOriginal ? 'preview' : 'original';
			setViewOriginalButtonState(
				!hasSeparateOriginal, false, hasSeparateOriginal ? '查看原图' : '当前已是原图'
			);
			status.hidden = false;
			statusText.textContent = hasSeparateOriginal ? '正在加载预览…' : '正在加载图片…';
			retryBtn.hidden = true;
			mainImg.onload = () => {
				mainImg.hidden = false;
				status.hidden = true;
				void fetchLightboxDownloadBlob(previewSrc, POST_REQUEST_PRIORITY.background).catch(() => {});
				if (mainImg.dataset.ldpSourceMode === 'original') {
					setViewOriginalButtonState(true, false, '当前已是原图');
				}
				renderTransform();
			};
			mainImg.onerror = () => {
				mainImg.hidden = true;
				status.hidden = false;
				const originalFailed = mainImg.dataset.ldpSourceMode === 'original' && hasSeparateOriginal;
				statusText.textContent = originalFailed ? '原图加载失败' : '预览图加载失败';
				retryBtn.hidden = !originalFailed;
				setViewOriginalButtonState(!hasSeparateOriginal, false);
			};
			mainImg.removeAttribute('src');
			mainImg.src = previewSrc;
			if (hasSeparateOriginal) {
				loadCurrentOriginal(false, PREFS.lightboxOriginalByDefault !== true);
			}
			if (commentsEnabled) void loadCommentsForItem(item);
			requestAnimationFrame(scrollActiveThumbIntoView);
		};
		const loadCurrentOriginal = (refresh = false, cachedOnly = false) => {
			const item = items[currentIndex];
			const previewSrc = item && (item.thumbSrc || item.originalSrc);
			if (!item || !item.originalSrc || item.originalSrc === previewSrc) return;
			const itemKey = item.key;
			const requestToken = cachedOnly ? mainImageRequestToken : ++mainImageRequestToken;
			const requestIsCurrent = () => !lightboxClosed && requestToken === mainImageRequestToken &&
				lb.isConnected && items[currentIndex].key === itemKey;
			const allowOriginalRetry = (message) => {
				statusText.textContent = message;
				retryBtn.hidden = false;
				setViewOriginalButtonState(false, false, '查看原图');
			};
			if (!cachedOnly) {
				setViewOriginalButtonState(true, true, '正在加载原图');
				status.hidden = false;
				statusText.textContent = refresh ? '正在重新加载原图…' : '正在加载原图…';
				retryBtn.hidden = true;
			}
			const request = cachedOnly
				? persistentLightboxImageSource(item.originalSrc, { cachedOnly: true })
				: refresh
					? deletePersistentLightboxImage(item.originalSrc).then(() => persistentLightboxImageSource(item.originalSrc))
					: persistentLightboxImageSource(item.originalSrc);
			void request.then((source) => {
				if (!requestIsCurrent()) return;
				if (cachedOnly && !source) return;
				if (!source) {
					allowOriginalRetry('正文请求优先,原图稍后重试');
					return;
				}
				const originalImage = makeElement('img');
				originalImage.onload = () => {
					if (!requestIsCurrent()) return;
					mainImg.dataset.ldpSourceMode = 'original';
					mainImg.src = source;
				};
				originalImage.onerror = () => {
					if (!requestIsCurrent()) return;
					if (cachedOnly) return;
					allowOriginalRetry('原图加载失败');
				};
				originalImage.src = source;
			});
		};
		const adjacentItemIsScanned = (direction) => {
			const item = items[currentIndex + direction];
			const postNumber = +(item?.postNumber) || 0;
			if (!postNumber) return false;
			return direction < 0
				? postNumber >= backwardScanPostNumber
				: postNumber <= forwardScanPostNumber;
		};
		const mergeScannedLightboxItems = (posts) => {
			const currentKey = items[currentIndex]?.key;
			let added = false;
			posts.forEach((post) => {
				lightboxItemsFromPost(post, ctx?.topicId).forEach((item) => {
					if (seenImageKeys.has(item.key)) return;
					seenImageKeys.add(item.key);
					items.push(item);
					added = true;
				});
			});
			if (!added) return;
			items.sort(compareLightboxItems);
			currentIndex = Math.max(0, items.findIndex((item) => item.key === currentKey));
			syncThumbs();
		};
		const loadMoreBatchImages = async () => {
			if (batchBusy || !batchCanLoadMore() || lightboxClosed) return;
			batchBusy = true;
			batchStatus.textContent = `正在加载 #${batchLoadedForwardPostNumber} 之后的楼层…`;
			renderBatchSelection();
			try {
				const posts = await ctx.loader.loadAfterPost(batchLoadedForwardPostNumber, {
					priority: POST_REQUEST_PRIORITY.auxiliary,
					persistFetchedPosts: true,
				});
				if (lightboxClosed || batchScope !== 'loaded') return;
				if (!posts.length) {
					batchLoadedScopeExhausted = true;
					batchStatus.textContent = '已加载到帖子末尾';
					return;
				}
				batchLoadedForwardPostNumber = Math.max(
					batchLoadedForwardPostNumber,
					...posts.map((post) => +post.post_number || 0)
				);
				const discoveredKeys = new Set();
				posts.forEach((post) => {
					lightboxItemsFromPost(post, ctx?.topicId).forEach((item) => discoveredKeys.add(item.key));
				});
				mergeScannedLightboxItems(posts);
				discoveredKeys.forEach((key) => {
					if (seenImageKeys.has(key)) batchLoadedScopeKeys.add(key);
				});
				const highest = totalPostNumber();
				if (highest && batchLoadedForwardPostNumber >= highest) batchLoadedScopeExhausted = true;
				batchStatus.textContent = discoveredKeys.size
					? `本批新增 ${discoveredKeys.size} 张图片`
					: batchLoadedScopeExhausted ? '已加载到帖子末尾' : '本批没有图片,可继续加载';
			} catch (error) {
				batchStatus.textContent = `加载更多失败:${error.message || '请重试'}`;
			} finally {
				batchBusy = false;
				if (!lightboxClosed && !batchOverlay.hidden) renderBatchSelection();
			}
		};
		const scanAllTopicImages = async () => {
			if (batchBusy || batchAllImagesLoaded || fixedItems || lightboxClosed) return;
			const topic = ctx?.loader && ctx.loader.topic || ctx?.topicData;
			const stream = topic?.post_stream && Array.isArray(topic.post_stream.stream)
				? topic.post_stream.stream.filter(Boolean)
				: [];
			if (!stream.length || !ctx || !ctx.loader || !isFunction(ctx.loader.loadPostsByIds)) {
				batchStatus.textContent = '未找到完整楼层列表,暂时只能使用当前加载的图片';
				return;
			}
			batchBusy = true;
			batchSelection.clear();
			batchCloseButtons.forEach((button) => { button.disabled = true; });
			renderBatchSelection();
			const batchSize = 20;
			try {
				for (let offset = 0; offset < stream.length; offset += batchSize) {
					const end = Math.min(stream.length, offset + batchSize);
					setBatchProgress(offset, stream.length, `正在扫描全部帖子 ${offset} / ${stream.length}`);
					batchStatus.textContent = '按阅读器请求速率分批扫描,不请求图片原图';
					const posts = await ctx.loader.loadPostsByIds(stream.slice(offset, end), {
						priority: POST_REQUEST_PRIORITY.auxiliary,
						persistFetchedPosts: true,
					});
					if (lightboxClosed || batchScope !== 'all') return;
					mergeScannedLightboxItems(posts);
					setBatchProgress(end, stream.length, `正在扫描全部帖子 ${end} / ${stream.length}`);
				}
				batchAllImagesLoaded = true;
				batchStatus.textContent = `已扫描全部帖子,共找到 ${items.length} 张图片`;
				setBatchProgress(stream.length, stream.length, '全部帖子扫描完成');
			} catch (error) {
				batchStatus.textContent = `全帖扫描中断:${error.message || '请重试'}`;
			} finally {
				batchBusy = false;
				batchCloseButtons.forEach((button) => { button.disabled = false; });
				if (!lightboxClosed && !batchOverlay.hidden) renderBatchSelection();
			}
		};
		const exploreAdjacentItem = async (direction) => {
			const backward = direction < 0;
			if (!direction || exploringPrevious || exploringNext || lightboxClosed ||
					(backward ? backwardExhausted : forwardExhausted)) return;
			if (backward) exploringPrevious = true;
			else exploringNext = true;
			countEl.textContent = `${currentIndex + 1} / ${items.length} · 正在寻找${backward ? '上一张' : '下一张'}…`;
			updateNavigationState();
			let scanStalled = false;
			try {
				const adjacentIndex = currentIndex + direction;
				if (adjacentIndex >= 0 && adjacentIndex < items.length && adjacentItemIsScanned(direction)) {
					showItem(adjacentIndex);
					return;
				}
				const scanPostNumber = backward ? backwardScanPostNumber : forwardScanPostNumber;
				const posts = await (backward
					? ctx.loader.loadBeforePost(scanPostNumber, {
							priority: POST_REQUEST_PRIORITY.visible,
							persistFetchedPosts: true,
						})
					: ctx.loader.loadAfterPost(scanPostNumber, {
							priority: POST_REQUEST_PRIORITY.visible,
							persistFetchedPosts: true,
						}));
				const scannedPosts = (Array.isArray(posts) ? posts : [])
					.filter((post) => backward
						? +post.post_number < scanPostNumber
						: +post.post_number > scanPostNumber)
					.sort((left, right) => +left.post_number - +right.post_number);
				if (!scannedPosts.length) {
					if (backward) {
						if (backwardScanPostNumber <= 1) backwardExhausted = true;
						else scanStalled = true;
					} else {
						const highestPostNumber = totalPostNumber();
						if (!highestPostNumber || forwardScanPostNumber >= highestPostNumber) forwardExhausted = true;
						else scanStalled = true;
					}
				} else {
					const nextScanPostNumber = backward
						? Math.min(...scannedPosts.map((post) => +post.post_number || scanPostNumber))
						: Math.max(...scannedPosts.map((post) => +post.post_number || scanPostNumber));
					if ((backward && nextScanPostNumber >= backwardScanPostNumber) ||
							(!backward && nextScanPostNumber <= forwardScanPostNumber)) {
						scanStalled = true;
					} else {
						if (backward) backwardScanPostNumber = nextScanPostNumber;
						else forwardScanPostNumber = nextScanPostNumber;
						if (lightboxClosed || !lb.isConnected) return;
						const highestPostNumber = totalPostNumber();
						if (backwardScanPostNumber <= 1) backwardExhausted = true;
						if (highestPostNumber && forwardScanPostNumber >= highestPostNumber) forwardExhausted = true;
						mergeScannedLightboxItems(scannedPosts);
						const discoveredIndex = currentIndex + direction;
						if (discoveredIndex >= 0 && discoveredIndex < items.length && adjacentItemIsScanned(direction)) {
							showItem(discoveredIndex);
							return;
						}
					}
				}
				if (!lightboxClosed) {
					countEl.textContent = scanStalled
						? `${currentIndex + 1} / ${items.length} · ${backward ? '前面' : '后续'}楼层暂未加载,请重试`
						: `${currentIndex + 1} / ${items.length} · 本批没有图片,再翻一次继续查找`;
				}
			} catch (error) {
				if (!lightboxClosed) countEl.textContent = `查找${backward ? '上一张' : '下一张'}失败,请重试`;
			} finally {
				if (backward) exploringPrevious = false;
				else exploringNext = false;
				if (!lightboxClosed) updateNavigationState();
			}
		};
		const move = (direction) => {
			const nextIndex = currentIndex + direction;
			if (nextIndex >= 0 && nextIndex < items.length && adjacentItemIsScanned(direction)) showItem(nextIndex);
			else void exploreAdjacentItem(direction);
		};
		const downloadCurrent = async () => {
			const item = items[currentIndex];
			const button = lb.querySelector('[data-lb-action="download"]');
			if (!item || button.disabled) return;
			button.disabled = true;
			button.setAttribute('aria-busy', 'true');
			setLabel(button, '正在准备下载');
			try {
				await downloadLightboxItem(item, currentIndex);
			} finally {
				button.disabled = false;
				button.removeAttribute('aria-busy');
				setLabel(button, '下载当前图片');
			}
		};
		const downloadBatch = async () => {
			if (batchBusy || !batchSelection.size) return;
			const selectedItems = batchScopeItems().filter((item) => batchSelection.has(item.key));
			if (!selectedItems.length) return;
			batchBusy = true;
			const missingOriginals = await lightboxMissingOriginalCount(selectedItems);
			const useOriginal = missingOriginals > 0 && globalThis.confirm(
				`${missingOriginals} 张原图尚未缓存。\n\n确定:按阅读器限速逐张获取原图\n取消:使用每张图片当前最高缓存/预览质量`
			);
			batchStatus.textContent = useOriginal
				? '按阅读器请求速率逐张获取原图'
				: '使用每张图片当前最高缓存/预览质量';
			batchCloseButtons.forEach((button) => { button.disabled = true; });
			batchNameInput.disabled = true;
			batchSelectAll.disabled = true;
			batchGrid.querySelectorAll('input').forEach((input) => { input.disabled = true; });
			syncBatchSelectionControls();
			const entries = [];
			const failures = [];
			try {
				for (let index = 0; index < selectedItems.length; index++) {
					const item = selectedItems[index];
					setBatchProgress(index, selectedItems.length, `正在获取第 ${index + 1} / ${selectedItems.length} 张`);
					try {
						const blob = await lightboxImageDownloadBlob(item, { original: useOriginal });
						entries.push({
							name: lightboxImageDownloadName(item, index, blob),
							bytes: new Uint8Array(await blob.arrayBuffer()),
						});
					} catch (error) {
						failures.push({ item, error });
					}
					setBatchProgress(index + 1, selectedItems.length, `已处理 ${index + 1} / ${selectedItems.length} 张`);
				}
				if (!entries.length) throw new Error('所选图片均下载失败');
				setBatchProgress(selectedItems.length, selectedItems.length, '正在生成 ZIP 文件…');
				const zip = createStoredZip(entries);
				const archiveName = String(batchNameInput.value || batchNameInput.placeholder || '')
					.trim().replace(/\.zip$/i, '');
				triggerBrowserDownload(
					zip,
					`${safeDownloadFilename(archiveName, `帖子图片_${lightboxDownloadTimestamp()}`)}.zip`
				);
				batchStatus.textContent = failures.length
					? `已打包 ${entries.length} 张,${failures.length} 张获取失败`
					: `已打包 ${entries.length} 张图片`;
			} catch (error) {
				batchStatus.textContent = `打包失败:${error.message || '请重试'}`;
			} finally {
				batchBusy = false;
				batchCloseButtons.forEach((button) => { button.disabled = false; });
				batchNameInput.disabled = false;
				batchSelectAll.disabled = false;
				batchGrid.querySelectorAll('input').forEach((input) => { input.disabled = false; });
				syncBatchSelectionControls();
			}
		};
		let batchPreviewRequestToken = 0;
		const ensureBatchPreviewSource = async (item, itemIndex) => {
			const existing = batchThumbnailUrls.get(item.key);
			if (existing) return existing;
			const itemNode = batchGrid.querySelector(`[data-lb-batch-index="${itemIndex}"]`);
			const thumbnailImage = itemNode?.querySelector('img');
			const displayed = thumbnailImage && (thumbnailImage.currentSrc || thumbnailImage.src);
			if (displayed) return displayed;
			const persistentKey = batchThumbnailPersistentKey(item);
			const cached = persistentKey && await cachedPersistentLightboxBlob(persistentKey);
			if (cached) {
				const source = URL.createObjectURL(cached);
				batchThumbnailUrls.set(item.key, source);
				return source;
			}
			return new Promise((resolve, reject) => {
				enqueueBatchThumbnail(async () => {
					try {
						const source = await createBatchThumbnail(item);
						if (lightboxClosed) {
							URL.revokeObjectURL(source);
							reject(new Error('图片预览已关闭'));
							return;
						}
						const previous = batchThumbnailUrls.get(item.key);
						if (previous) URL.revokeObjectURL(source);
						else batchThumbnailUrls.set(item.key, source);
						resolve(previous || source);
					} catch (error) {
						reject(error);
					}
				});
			});
		};
		const setBatchItemSelected = (item, itemIndex, selected, itemNode = null) => {
			if (selected) batchSelection.add(item.key);
			else batchSelection.delete(item.key);
			const card = itemNode || batchGrid.querySelector(`[data-lb-batch-index="${itemIndex}"]`);
			const input = card?.querySelector('input[type="checkbox"]');
			if (input) input.checked = selected;
			if (card) card.classList.toggle('selected', selected);
			syncBatchSelectionControls();
		};
		const openBatchItemPreview = async (itemIndex, anchorCard) => {
			if (batchBusy || lightboxClosed || batchOverlay.hidden) return;
			const item = items[itemIndex];
			const scopedItems = batchScopeItems();
			const scopedIndex = item ? scopedItems.findIndex((candidate) => candidate.key === item.key) : -1;
			if (!item || scopedIndex < 0) return;
			const requestToken = ++batchPreviewRequestToken;
			try {
				const previewSource = await ensureBatchPreviewSource(item, itemIndex);
				if (requestToken !== batchPreviewRequestToken || lightboxClosed || batchOverlay.hidden) return;
				openUserAvatarViewer(item.key, '', anchorCard, previewSource, null, null, {
					kind: 'image',
					reuseImageViewer: true,
					source: item.originalSrc,
					loadOriginal: true,
					selectable: true,
					selected: batchSelection.has(item.key),
					selectionLabel: `${scopedIndex + 1} / ${scopedItems.length} · #${item.postNumber || '?'}`,
					previousDisabled: scopedIndex === 0,
					nextDisabled: scopedIndex === scopedItems.length - 1,
					onPrevious: () => {
						const previous = scopedItems[scopedIndex - 1];
						if (previous) void openBatchItemPreview(items.indexOf(previous), anchorCard);
					},
					onNext: () => {
						const next = scopedItems[scopedIndex + 1];
						if (next) void openBatchItemPreview(items.indexOf(next), anchorCard);
					},
					onSelectionChange: (selected) => setBatchItemSelected(item, itemIndex, selected),
					onDownload: () => downloadLightboxItem(item, itemIndex),
				});
			} catch (error) {
				if (requestToken === batchPreviewRequestToken) showSelectionToast('缩略图加载失败,请重试');
			}
		};
		const jumpToCurrentPost = async () => {
			const currentItem = items[currentIndex];
			const postNumber = +(currentItem?.postNumber) || 0;
			const topicId = +(currentItem?.topicId) || +(ctx?.topicId) || 0;
			if (!postNumber || jumpingToPost || lightboxClosed) return;
			if (topicId && ctx && topicId !== +ctx.topicId) {
				close();
				openModal(topicId, { targetPostNumber: postNumber });
				return;
			}
			jumpingToPost = true;
			updateJumpToPostState();
			try {
				const jumped = !!(ctx?.timeline) && await ctx.timeline.jumpTo(postNumber, {
					acceptsWork: () => !lightboxClosed,
				});
				if (lightboxClosed) return;
				if (!jumped) {
					countEl.textContent = `无法定位到第 #${postNumber} 楼,请重试`;
					return;
				}
				close();
			} catch (error) {
				if (!lightboxClosed) countEl.textContent = `无法定位到第 #${postNumber} 楼,请重试`;
			} finally {
				jumpingToPost = false;
				if (!lightboxClosed) updateJumpToPostState();
			}
		};
		const close = () => {
			lightboxClosed = true;
			mainImageRequestToken++;
			commentRequestToken++;
			clearTimeout(descriptionHeightSaveTimer);
			if (descriptionResizeObserver) descriptionResizeObserver.disconnect();
			if (batchThumbnailObserver) batchThumbnailObserver.disconnect();
			batchThumbnailQueue.length = 0;
			batchThumbnailUrls.forEach((source) => URL.revokeObjectURL(source));
			batchThumbnailUrls.clear();
			imageTransform.destroy();
			lb.remove();
			lightboxScope.destroy();
		};
		let descriptionHeightSaveTimer = 0;
		const descriptionResizeObserver = isFunction(ResizeObserver)
			? new ResizeObserver(() => {
					if (!commentsSource.open || commentsSource.hidden || lightboxClosed) return;
					clearTimeout(descriptionHeightSaveTimer);
					descriptionHeightSaveTimer = setTimeout(() => {
						const height = normalizeLightboxDescriptionHeight(commentsSourceText.getBoundingClientRect().height);
						if (height === normalizeLightboxDescriptionHeight(PREFS.lightboxDescriptionHeight)) return;
						setPref('lightboxDescriptionHeight', height);
						lb.style.setProperty('--ldp-lb-description-height', `${height}px`);
					}, 160);
				})
			: null;
		if (descriptionResizeObserver) descriptionResizeObserver.observe(commentsSourceText);
		commentsSource.addEventListener('toggle', () => {
			setExpanded(commentsDescriptionToggle, commentsSource.open);
			setLabel(commentsDescriptionToggle, commentsSource.open ? '收纳图片描述' : '展开图片描述');
			setPref('lightboxDescriptionExpanded', commentsSource.open);
		});
		setExpanded(commentsDescriptionToggle, commentsSource.open);
		setLabel(commentsDescriptionToggle, commentsSource.open ? '收纳图片描述' : '展开图片描述');
		commentsDescriptionToggle.addEventListener('click', () => {
			commentsSource.open = !commentsSource.open;
		});
		let commentsResizePointerId = null;
		commentsResizer.addEventListener('pointerdown', (event) => {
			if (event.button !== 0 || lb.classList.contains('ldp-lb-comments-collapsed')) return;
			commentsResizePointerId = event.pointerId;
			commentsResizer.setPointerCapture(event.pointerId);
			lb.classList.add('is-resizing-comments');
			event.preventDefault();
		});
		commentsResizer.addEventListener('pointermove', (event) => {
			if (commentsResizePointerId !== event.pointerId) return;
			const mainRect = lb.querySelector('.ldp-lb-main').getBoundingClientRect();
			if (!mainRect.width) return;
			const minimum = Math.min(
				LIGHTBOX_COMMENTS_WIDTH_MAX,
				Math.max(LIGHTBOX_COMMENTS_WIDTH_MIN, 240 / mainRect.width * 100)
			);
			applyCommentsWidth(Math.min(
				LIGHTBOX_COMMENTS_WIDTH_MAX,
				Math.max(minimum, (mainRect.right - event.clientX) / mainRect.width * 100)
			));
		});
		const finishCommentsResize = (event) => {
			if (commentsResizePointerId !== event.pointerId) return;
			commentsResizePointerId = null;
			lb.classList.remove('is-resizing-comments');
			applyCommentsWidth(commentsWidthPercent, true);
		};
		['pointerup', 'pointercancel'].forEach((type) =>
			commentsResizer.addEventListener(type, finishCommentsResize));
		commentsResizer.addEventListener('keydown', (event) => {
			if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
			event.preventDefault();
			applyCommentsWidth(commentsWidthPercent + (event.key === 'ArrowLeft' ? 2 : -2), true);
		});
		function onKey(e) {
			if (!lb.isConnected) return;
			const imageViewer = readerPortalQuery('.ldp-avatar-viewer');
			if (imageViewer?.isConnected) return;
			if (e.key === 'Escape') {
				if (!batchOverlay.hidden) {
					if (!batchBusy) closeBatchDownload();
					e.preventDefault();
					e.stopImmediatePropagation();
					return;
				}
				if (visibleNativeFloatingSurface()) return;
				e.preventDefault();
				e.stopImmediatePropagation();
				close();
				return;
			}
			if (!batchOverlay.hidden) return;
			if (e.target?.closest && e.target.closest('textarea,input,select,[contenteditable="true"]')) return;
			if (e.key === 'ArrowLeft') {
				e.preventDefault();
				move(-1);
			} else if (e.key === 'ArrowRight') {
				e.preventDefault();
				move(1);
			} else handleImageTransformShortcut(e, imageTransform, resetView);
		}
		lb.querySelector('.ldp-lb-toolbar').addEventListener('click', (e) => {
			const button = e.target.closest('[data-lb-action]');
			if (!button) return;
			const action = button.dataset.lbAction;
			if (action === 'close') close();
			else if (action === 'zoom-out') setZoom(imageTransform.scale / 1.2);
			else if (action === 'zoom-in') setZoom(imageTransform.scale * 1.2);
			else if (action === 'reset') resetView();
			else if (action === 'view-original') loadCurrentOriginal();
			else if (action === 'jump-to-post') void jumpToCurrentPost();
			else if (action === 'download') void downloadCurrent();
			else if (action === 'batch-download') openBatchDownload();
			else if (action === 'toggle-comments') {
				setCommentsExpanded(lb.classList.contains('ldp-lb-comments-collapsed'), true);
			}
		});
		batchOverlay.addEventListener('click', (event) => {
			const target = event.target instanceof Element ? event.target : null;
			if (target?.closest('.ldp-lb-batch-close,.ldp-lb-batch-cancel')) {
				closeBatchDownload();
				return;
			}
			const scopeButton = target?.closest('[data-lb-batch-scope]');
			if (scopeButton) {
				if (batchBusy || scopeButton.disabled) return;
				const nextScope = scopeButton.dataset.lbBatchScope === 'all' ? 'all' : 'loaded';
				batchScope = nextScope;
				batchSelection.clear();
				batchProgress.hidden = true;
				batchStatus.textContent = nextScope === 'all'
					? batchAllImagesLoaded ? `已扫描全部帖子,共找到 ${items.length} 张图片` : '准备扫描全部帖子…'
					: '请选择当前已加载的图片';
				if (nextScope === 'all' && !batchAllImagesLoaded) {
					void scanAllTopicImages();
					return;
				}
				renderBatchSelection();
				return;
			}
			if (target?.closest('.ldp-lb-batch-select-all')) {
				if (batchBusy) return;
				const scopedItems = batchScopeItems();
				const allSelected = scopedItems.length > 0 && scopedItems.every((item) => batchSelection.has(item.key));
				batchSelection.clear();
				if (!allSelected) scopedItems.forEach((item) => batchSelection.add(item.key));
				renderBatchSelection();
			} else if (target?.closest('.ldp-lb-batch-download')) {
				void downloadBatch();
			}
		});
		batchOverlay.addEventListener('pointerdown', (event) => {
			if (event.target === batchOverlay) closeBatchDownload();
		});
		batchGrid.addEventListener('wheel', (event) => {
			if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
			const delta = event.deltaMode === 1
				? event.deltaY * 40
				: event.deltaMode === 2 ? event.deltaY * batchGrid.clientHeight : event.deltaY;
			const precisionInput = event.deltaMode === 0 && Math.abs(event.deltaY) < 50;
			const maximum = Math.max(0, batchGrid.scrollHeight - batchGrid.clientHeight);
			const nextScrollTop = Math.max(0, Math.min(maximum,
				batchGrid.scrollTop + delta * (precisionInput ? 1.15 : 1.7)));
			if (nextScrollTop === batchGrid.scrollTop) return;
			consumeEvent(event);
			batchGrid.scrollTop = nextScrollTop;
		}, { passive: false });
		batchGrid.addEventListener('change', (event) => {
			const input = event.target.closest('input[type="checkbox"]');
			const itemNode = input?.closest('[data-lb-batch-index]');
			const itemIndex = itemNode && +itemNode.dataset.lbBatchIndex;
			const item = itemNode && items[itemIndex];
			if (!item || batchBusy) return;
			setBatchItemSelected(item, itemIndex, input.checked, itemNode);
		});
		batchGrid.addEventListener('click', (event) => {
			const loadMoreButton = event.target.closest('.ldp-lb-batch-more');
			if (loadMoreButton) {
				event.preventDefault();
				void loadMoreBatchImages();
				return;
			}
			const image = event.target.closest('.ldp-lb-batch-item img');
			const itemNode = image?.closest('[data-lb-batch-index]');
			const itemIndex = itemNode ? +itemNode.dataset.lbBatchIndex : -1;
			const item = itemIndex >= 0 ? items[itemIndex] : null;
			if (!item || batchBusy) return;
			consumeEvent(event);
			void openBatchItemPreview(itemIndex, itemNode);
		});
		lb.addEventListener('click', (event) => {
			const target = event.target instanceof Element ? event.target : null;
			const thumb = target?.closest('.ldp-lb-thumb');
			if (thumb) showItem(+thumb.dataset.lbIndex);
			else if (target?.closest('.ldp-lb-prev')) move(-1);
			else if (target?.closest('.ldp-lb-next')) move(1);
			else if (target?.closest('.ldp-lb-retry')) loadCurrentOriginal(true);
			else if (target?.closest('.ldp-lb-comment-cancel')) closeCommentForm();
		});
		stage.addEventListener('wheel', (e) => {
			e.preventDefault();
			const nextScale = imageTransform.scale * (e.deltaY < 0 ? 1.15 : 1 / 1.15);
			if (e.target === mainImg) setZoom(nextScale, e.clientX, e.clientY);
			else setZoom(nextScale);
		}, { passive: false });
		stage.addEventListener('dblclick', (e) => {
			if (e.target !== mainImg) return;
			const nativeScale = mainImg.clientWidth ? Math.min(8, mainImg.naturalWidth / mainImg.clientWidth) : 1;
			setZoom(imageTransform.scale > 1.05 ? 1 : Math.max(2, nativeScale));
		});
		lb.querySelector('.ldp-lb-comments').addEventListener('click', onCommentsClick);
		lb.querySelector('.ldp-lb-comments').addEventListener('keydown', onCommentsKeydown);
		commentForm.addEventListener('submit', onCommentFormSubmit);
		lightboxScope.listen(document, 'keydown', onKey);
		lightboxScope.listen(window, 'resize', renderTransform);
		syncThumbs();
		appendReaderPortalNode(lb);
		showItem(initialIndex);
	}

	function resolveOriginalSrc(imgEl) {
		const a = imgEl.closest('a.lightbox, a[href]');
		if (a?.getAttribute('href')) {
			const href = a.getAttribute('href');
			if (/\.(png|jpe?g|gif|webp|bmp|avif)(\?|#|$)/i.test(href) || a.classList.contains('lightbox')) {
				return href;
			}
		}
		return imgEl.getAttribute('data-large-src') || imgEl.currentSrc || imgEl.src;
	}

	/* ============ 4. 已读追踪器 ============ */
	function createReadTracker(topicId, scrollRoot, requestScheduler, onReadConfirmed) {
		const dwell = new Map();
		const reported = new Map();
		const observedNodes = new Set();
		const viewportNodes = new Set();
		const pendingVisibleCallbacks = new Map();
		let tickTimer = null, flushTimer = null, readFlushTimer = null;
		let started = false, flushRunning = false, initialReadFlushPending = false;
		let readFlushDueAt = 0;
		let initialReadRetryAt = 0;
		let readChallengeAttempted = false;

		const notifyReadConfirmed = (postNumbers) => {
			if (!isFunction(onReadConfirmed)) return;
			try {
				onReadConfirmed(postNumbers);
			} catch (error) {
				console.warn('[LDP] sync confirmed post read state failed', error);
			}
		};

		const io = new IntersectionObserver((entries) => {
			entries.forEach((en) => {
				const pn = +en.target.dataset.postNumber;
				if (!pn) return;
				const intersection = en.intersectionRect;
				const inViewport = en.isIntersecting &&
					intersection.width > 0 && intersection.height > 0;
				if (inViewport) viewportNodes.add(en.target);
				else viewportNodes.delete(en.target);
				const onVisible = inViewport && pendingVisibleCallbacks.get(en.target);
				if (onVisible) {
					pendingVisibleCallbacks.delete(en.target);
					onVisible();
					if (!observedNodes.has(en.target)) {
						viewportNodes.delete(en.target);
						io.unobserve(en.target);
					}
				}
			});
		}, { root: scrollRoot, threshold: 0 });

		const tick = () => {
			if (initialReadFlushPending) scheduleReadFlush();
		};

		const scheduleReadFlush = () => {
			if (readFlushTimer || !started || !initialReadFlushPending ||
					document.visibilityState !== 'visible') return;
			const now = Date.now();
			const dueAt = Math.max(readFlushDueAt || now + FLUSH_INTERVAL, initialReadRetryAt);
			readFlushTimer = setTimeout(() => {
				readFlushTimer = null;
				if (!started || !initialReadFlushPending || Date.now() < initialReadRetryAt) return;
				void flush({ priority: POST_REQUEST_PRIORITY.background });
			}, Math.max(0, dueAt - now));
		};

		const flush = async (options = {}) => {
			if (flushRunning) return false;
			if (initialReadRetryAt > Date.now()) return false;
			if (options.force !== true && readFlushDueAt > Date.now()) return false;
			const requestState = requestScheduler?.snapshot();
			if (options.force !== true && requestState && (
				requestState.active > 0 || requestState.queued > 0 || requestState.coolingDown
			)) return false;
			const params = { topic_id: topicId };
			let total = 0, any = false;
			const submittedPostNumbers = [];
			dwell.forEach((ms, pn) => {
				if (ms < READ_THRESHOLD) return;
				const inc = ms - (reported.get(pn) || 0);
				if (inc <= 0) return;
				params[`timings[${pn}]`] = inc;
				total += inc;
				reported.set(pn, ms);
				submittedPostNumbers.push(pn);
				any = true;
			});
			if (!any) {
				initialReadFlushPending = false;
				readFlushDueAt = 0;
				return false;
			}
			if (readFlushTimer) clearTimeout(readFlushTimer);
			readFlushTimer = null;
			flushRunning = true;
			const retryInitialReadFlush = initialReadFlushPending;
			initialReadFlushPending = false;
			readFlushDueAt = 0;
			params.topic_time = total;
			let challengeRecovery = null;
			try {
				await apiSend(
					`${BASE}/topics/timings`,
					'POST',
					params,
					{
						'Accept': '*/*',
						'Discourse-Background': 'true',
						'Discourse-Logged-In': 'true',
						'Discourse-Present': 'true',
						'X-SILENCE-LOGGER': 'true',
					},
					{
						priority: Number.isFinite(options.priority)
							? options.priority
							: POST_REQUEST_PRIORITY.background,
					}
				);
				initialReadRetryAt = 0;
				notifyReadConfirmed(submittedPostNumbers);
				return true;
			} catch (e) {
				initialReadFlushPending = initialReadFlushPending || retryInitialReadFlush;
				if (initialReadFlushPending) {
					initialReadRetryAt = Date.now() + (e?.cloudflareMitigated ? 5 * 60 * 1000 : FLUSH_INTERVAL);
				}
				Object.keys(params).forEach((k) => {
					const m = k.match(/^timings\[(\d+)\]$/);
					if (m) reported.set(+m[1], (reported.get(+m[1]) || 0) - params[k]);
				});
				if (e?.cloudflareMitigated && !readChallengeAttempted) {
					readChallengeAttempted = true;
					challengeRecovery = {
						params: { ...params },
						postNumbers: submittedPostNumbers.slice(),
					};
				}
			} finally {
				flushRunning = false;
				if (initialReadFlushPending && Date.now() >= initialReadRetryAt) scheduleReadFlush();
			}
			if (challengeRecovery) {
				const confirmedByFormPost = () => {
					challengeRecovery.postNumbers.forEach((postNumber) => {
						const increment = Number(challengeRecovery.params[`timings[${postNumber}]`]) || 0;
						reported.set(postNumber, Math.max(0, reported.get(postNumber) || 0) + increment);
					});
					initialReadRetryAt = 0;
					initialReadFlushPending = false;
					notifyReadConfirmed(challengeRecovery.postNumbers);
				};
				openCloudflareChallengePopup(`${BASE}/topics/timings`, {
					formParams: challengeRecovery.params,
					onFormSuccess: confirmedByFormPost,
					verify: async () => {
						initialReadRetryAt = 0;
						return flush({ force: true, priority: POST_REQUEST_PRIORITY.visible });
					},
				});
			}
			return false;
		};

		const pauseTimers = () => {
			clearInterval(tickTimer);
			clearInterval(flushTimer);
			clearTimeout(readFlushTimer);
			tickTimer = null;
			flushTimer = null;
			readFlushTimer = null;
		};

		const resumeTimers = () => {
			if (!started || document.visibilityState !== 'visible') return;
			if (!tickTimer) tickTimer = setInterval(tick, 1000);
			if (!flushTimer) flushTimer = setInterval(flush, FLUSH_INTERVAL);
			if (initialReadFlushPending) scheduleReadFlush();
		};

		const onVisibilityChange = () => {
			if (!started) return;
			if (document.visibilityState === 'visible') {
				resumeTimers();
				return;
			}
			tick();
			pauseTimers();
			flush({ force: true });
		};

		return {
			observe(node) {
				if (!node) return;
				observedNodes.add(node);
				io.observe(node);
				const postNumber = +(node.dataset?.postNumber || 0);
				if (postNumber && !dwell.has(postNumber)) {
					dwell.set(postNumber, READ_THRESHOLD);
					if (!initialReadFlushPending) readFlushDueAt = Date.now() + FLUSH_INTERVAL;
					initialReadFlushPending = true;
					if (started) scheduleReadFlush();
				}
			},
			unobserve(node) {
				if (!node) return;
				observedNodes.delete(node);
				viewportNodes.delete(node);
				io.unobserve(node);
			},
			runWhenVisible(node, callback) {
				if (!node || !isFunction(callback)) return false;
				if (viewportNodes.has(node) || readerPostNodeInViewport(node, scrollRoot)) {
					callback();
					return true;
				}
				pendingVisibleCallbacks.set(node, callback);
				io.observe(node);
				return false;
			},
			start() {
				if (started) return;
				started = true;
				document.addEventListener('visibilitychange', onVisibilityChange);
				resumeTimers();
				if (initialReadFlushPending) scheduleReadFlush();
			},
			stop() {
				if (!started) return;
				if (document.visibilityState === 'visible') tick();
				started = false;
				pauseTimers();
				document.removeEventListener('visibilitychange', onVisibilityChange);
				io.disconnect();
				observedNodes.clear();
				viewportNodes.clear();
				pendingVisibleCallbacks.clear();
				flush({ force: true });
			},
			retryPending() {
				initialReadRetryAt = 0;
				if (initialReadFlushPending) {
					return flush({ force: true, priority: POST_REQUEST_PRIORITY.visible });
				}
				return Promise.resolve(false);
			},
		};
	}

	/* ============ 5. 分块加载(init 带 Discourse 浏览追踪头 + 重试保护) ============ */
	function createLoader(topicId, requestScheduler, runtimeConfig) {
		const pageSize = runtimeConfig.pageSize;
		const onlyOpBatchFloor = Math.min(
			PERFORMANCE_LIMITS.pageSize.max,
			Math.max(pageSize, runtimeConfig.requestMaxConcurrent <= 1 ? 32 : 40)
		);
		let onlyOpPageSize = onlyOpBatchFloor;
		let stream = [];
		const cache = new Map();
		const postByNumber = new Map();
		const unavailablePostNumbers = new Set();
		const pendingPostRequests = new Map();
		let streamIndexById = new Map();
		const requestController = new AbortController();
		let cursor = 0;
		let onlyOpCursor = 0;
		let onlyOpSkippedCount = 0;
		let topic = null;
		let failStreak = 0;
		let onlyOpFailStreak = 0;
		let refreshRequest = null;
		let initializedFromCache = false;
		let stopTopicCacheSync = () => {};

		const growOnlyOpBatch = () => {
			let requestState = null;
			try { requestState = requestScheduler?.snapshot(); } catch (error) {}
			const shortPressure = requestState
				? Number(requestState.shortWindowCount || 0) / Math.max(1, Number(requestState.shortWindowBudget) || 1)
				: 0;
			const longPressure = requestState
				? Number(requestState.longWindowCount || 0) / Math.max(1, Number(requestState.longWindowBudget) || 1)
				: 0;
			const pressured = requestState && (
				requestState.coolingDown ||
				['10s', '60s'].includes(requestState.blockingReason) ||
				Number(requestState.globalQueued || 0) > 0 ||
				Math.max(shortPressure, longPressure) >= 0.7
			);
			onlyOpPageSize = Math.min(
				PERFORMANCE_LIMITS.pageSize.max,
				onlyOpPageSize + (pressured ? 12 : 8)
			);
		};

		const adjustOnlyOpBatchAfterError = (error) => {
			const status = Number(error?.status || 0);
			if (status === 408 || status === 429 || status >= 500) {
				onlyOpPageSize = PERFORMANCE_LIMITS.pageSize.max;
				return;
			}
			onlyOpPageSize = Math.max(onlyOpBatchFloor, onlyOpPageSize - 8);
		};

		const fetchLoaderJSON = (url, options = {}) => fetchJSON(url, {
			scheduler: requestScheduler,
			signal: requestController.signal,
			fetchOptions: options.fetchOptions,
			cacheMode: options.cacheMode || 'default',
			key: options.key || `post:${url}`,
			priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible,
		});

		const cachePost = (post) => {
			if (!post || !post.id) return;
			const previous = cache.get(post.id);
			const completePost = previous && previous !== post ? Object.assign(previous, post) : post;
			cache.set(post.id, completePost);
			const postNumber = +(completePost.post_number || 0);
			if (postNumber) postByNumber.set(postNumber, completePost);
		};

		const cachePosts = (posts) => (posts || []).forEach(cachePost);

		const appendMissingTopicPosts = (topicData, posts) => {
			if (!topicData || !Array.isArray(posts) || !posts.length) return false;
			if (!topicData.post_stream) topicData.post_stream = {};
			const current = Array.isArray(topicData.post_stream.posts) ? topicData.post_stream.posts : [];
			const indexesById = new Map();
			current.forEach((post, index) => {
				if (post?.id) indexesById.set(post.id, index);
			});
			let changed = false;
			posts.forEach((post) => {
				if (!post || !post.id) return;
				const index = indexesById.get(post.id);
				if (Number.isInteger(index)) {
					const previous = current[index];
					if (previous === post) return;
					current[index] = previous ? Object.assign(previous, post) : post;
					changed = true;
					return;
				}
				indexesById.set(post.id, current.length);
				current.push(post);
				changed = true;
			});
			if (changed) topicData.post_stream.posts = current;
			return changed;
		};

		const persistFetchedPosts = (posts) => {
			if (!Array.isArray(posts) || !posts.length) return;
			const entry = TOPIC_DATA_CACHE.get(String(topicId));
			const persistedTopic = (entry?.topic) || topic;
			if (!persistedTopic) return;
			const changed = appendMissingTopicPosts(persistedTopic, posts);
			if (topic && topic !== persistedTopic) appendMissingTopicPosts(topic, posts);
			if (changed) setCachedTopicData(topicId, persistedTopic);
		};

		const persistViewedPostNumbers = (postNumbers) => {
			const posts = [...new Set((postNumbers || []).map(Number).filter(Boolean))]
				.map((postNumber) => postByNumber.get(postNumber))
				.filter(Boolean);
			persistFetchedPosts(posts);
		};

		function normalizeTopicData(data) {
			const posts = Array.isArray(data.post_stream?.posts) ? data.post_stream.posts : [];
			const op = (data.details?.created_by && data.details.created_by.username)
					|| (posts.find((p) => p.post_number === 1) || {}).username
					|| null;
			data._opUsername = op;
			return {
				posts,
				stream: (data.post_stream?.stream) || posts.map((p) => p.id).filter(Boolean),
			};
		}

		function primeTopicData(data) {
			const normalized = normalizeTopicData(data);
			topic = data;
			stream = normalized.stream;
			streamIndexById = new Map(stream.map((id, index) => [id, index]));
			cachePosts(normalized.posts);
			return topic;
		}

		const syncTopicCacheData = (incomingTopic) => {
			if (!incomingTopic) return;
			const incoming = normalizeTopicData(incomingTopic);
			cachePosts(incoming.posts);
			if (incomingTopic === topic) return;
			if (!topic) return;
			appendMissingTopicPosts(topic, incoming.posts);
			Object.keys(incomingTopic).forEach((key) => {
				if (key !== 'post_stream') topic[key] = incomingTopic[key];
			});
			if (incoming.stream.length >= stream.length) {
				stream = incoming.stream.slice();
				streamIndexById = new Map(stream.map((id, index) => [id, index]));
			}
		};
		stopTopicCacheSync = subscribeTopicCache(topicId, syncTopicCacheData);

		async function refreshTopicData({ prime = true, priority } = {}) {
			syncCurrentUserFromHost();
			if (!refreshRequest) {
				refreshRequest = (async () => {
					const url = `${BASE}/t/${topicId}.json?track_visit=true&forceLoad=true`;
					const data = await fetchJSON(url, {
						scheduler: requestScheduler,
						signal: requestController.signal,
						cacheMode: 'refresh',
						fetchOptions: discourseJSONFetchOptions({
							'Discourse-Track-View': 'true',
							'Discourse-Track-View-Topic-Id': String(topicId),
						}),
						key: `topic:${topicId}:refresh`,
						priority: Number.isFinite(priority)
							? priority
							: topic
								? POST_REQUEST_PRIORITY.background
								: POST_REQUEST_PRIORITY.topic,
					});
					const entry = TOPIC_DATA_CACHE.get(String(topicId));
					const previousTopic = (entry?.topic) || topic;
					if (previousTopic?.post_stream) {
						appendMissingTopicPosts(data, previousTopic.post_stream.posts);
					}
					normalizeTopicData(data);
					setCachedTopicData(topicId, data, {
						fetchedAt: responseCacheValueStoredAt(data) || Date.now(),
						source: 'network',
					});
					return data;
				})().finally(() => { refreshRequest = null; });
			}
			const data = await refreshRequest;
			if (prime) return primeTopicData(data);
			return data;
		}

		async function init() {
			if (topic) return topic;
			syncCurrentUserFromHost();
			const previewTopic = getCachedTopicData(topicId);
			const storedTopic = await getStoredTopicSnapshot(topicId);
			const cachedTopic = storedTopic || previewTopic;
			const cacheEntry = TOPIC_DATA_CACHE.get(String(topicId));
			if (cachedTopic && cacheEntry) {
				initializedFromCache = true;
				primeTopicData(cachedTopic);
				const expectedPostCount = Math.max(0, Number(topic.posts_count) || 0);
				if (expectedPostCount > stream.length) {
					initializedFromCache = false;
					return refreshTopicData();
				}
				const fetchedAt = topicCacheEntryTimestamp(cacheEntry);
				if (!fetchedAt || Date.now() - fetchedAt > TOPIC_CACHE_TTL) {
					refreshTopicData({ prime: false, priority: POST_REQUEST_PRIORITY.background }).catch(() => {});
				}
				return topic;
			}
			initializedFromCache = false;
			return refreshTopicData();
		}

		async function next(options = {}) {
			const onlyOpScan = options.onlyOp === true;
			const position = onlyOpScan ? onlyOpCursor : cursor;
			if (position >= stream.length) return { posts: [], done: true };
			const batchSize = onlyOpScan ? onlyOpPageSize : pageSize;
			const slice = stream.slice(position, position + batchSize);
			const resetFailures = () => {
				if (onlyOpScan) onlyOpFailStreak = 0;
				else failStreak = 0;
			};
			const bumpFailures = () => onlyOpScan ? ++onlyOpFailStreak : ++failStreak;
			const advance = (count, skipped = 0) => {
				if (onlyOpScan) {
					onlyOpCursor += count;
					onlyOpSkippedCount += skipped;
					return onlyOpCursor >= stream.length;
				}
				cursor += count;
				return cursor >= stream.length;
			};
			const prepareOnlyOpPosts = (posts) => {
				if (!onlyOpScan) return posts;
				const opUsername = String(options.onlyOpUsername || '');
				if (!opUsername) return posts;
				const matches = [];
				posts.forEach((post) => {
					if (String(post?.username || '') === opUsername) {
						matches.push(post);
						return;
					}
					if (post?.id && cache.get(post.id) === post) cache.delete(post.id);
					const postNumber = +(post?.post_number || 0);
					if (postNumber && postByNumber.get(postNumber) === post) postByNumber.delete(postNumber);
				});
				if (matches.length) persistFetchedPosts(matches);
				return matches;
			};
			const finishBatch = (skipped = 0, growOnlyOp = false) => {
				const done = advance(slice.length, skipped);
				resetFailures();
				const posts = prepareOnlyOpPosts(slice.map((id) => cache.get(id)).filter(Boolean));
				if (growOnlyOp && onlyOpScan) growOnlyOpBatch();
				return { posts, done };
			};
			let missing = slice.filter((id) => !cache.has(id));
			if (isFunction(options.onSource)) {
				options.onSource(missing.length ? 'network' : 'cache', {
					cachedCount: slice.length - missing.length,
					missingCount: missing.length,
					totalCount: slice.length,
				});
			}
			let loadError = null;
			if (missing.length) {
				try {
					await loadPostsByIds(slice, {
						priority: POST_REQUEST_PRIORITY.scroll,
						persistFetchedPosts: !onlyOpScan && options.persistFetchedPosts === true,
					});
				} catch (error) {
					loadError = error;
				}
				missing = slice.filter((id) => !cache.has(id));
			}

			if (missing.length) {
				if (loadError) {
					if (onlyOpScan) adjustOnlyOpBatchAfterError(loadError);
					const status = Number(loadError.status || 0);
					if (status === 401 || status === 403) {
						return { posts: [], done: false, fatal: true, error: loadError };
					}
					if (!status || shouldStopReaderFallback(loadError)) {
						resetFailures();
						return { posts: [], done: false, retry: true, error: loadError };
					}
					if (bumpFailures() < 4) return { posts: [], done: false, retry: true, error: loadError };
					return finishBatch(missing.length);
				}
				if (onlyOpScan) onlyOpPageSize = Math.max(onlyOpBatchFloor, onlyOpPageSize - 8);
				if (bumpFailures() >= 4) {
					return finishBatch(missing.length);
				}
				return { posts: [], done: false, retry: true };
			}

			return finishBatch(0, true);
		}

		async function requestMissingPostIds(ids, priority, shouldPersist) {
			const missing = ids.filter((id) => !cache.has(id));
			const unclaimed = missing.filter((id) => !pendingPostRequests.has(id));
			if (unclaimed.length) {
				const requestIds = [...new Set(unclaimed)];
				const requestKey = requestIds.slice().sort((a, b) => String(a).localeCompare(String(b))).join(',');
				const qs = requestIds.map((id) => `post_ids[]=${id}`).join('&');
				const request = fetchLoaderJSON(`${BASE}/t/${topicId}/posts.json?${qs}`, {
					key: `posts:${topicId}:${requestKey}`,
					priority,
				}).then((part) => {
					cachePosts(part?.post_stream && part.post_stream.posts);
				}).finally(() => {
					requestIds.forEach((id) => {
						if (pendingPostRequests.get(id) === request) pendingPostRequests.delete(id);
					});
				});
				requestIds.forEach((id) => pendingPostRequests.set(id, request));
			}
			const requests = [...new Set(
				missing.map((id) => pendingPostRequests.get(id)).filter(Boolean)
			)];
			if (requests.length) await Promise.all(requests);
			if (shouldPersist) persistFetchedPosts(missing.map((id) => cache.get(id)).filter(Boolean));
		}

		async function loadPostsByIds(ids, options = {}) {
			const requestedIds = [...new Set((ids || []).filter(Boolean))];
			let missing = requestedIds.filter((id) => !cache.has(id));
			let lastError = null;
			const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.visible;
			const maxAttempts = Math.max(1, +(options.maxAttempts || 2));
			for (let attempt = 0; attempt < maxAttempts && missing.length; attempt++) {
				try {
					await requestMissingPostIds(missing, priority, options.persistFetchedPosts === true);
					lastError = null;
				} catch (error) {
					lastError = error;
					const status = Number(error?.status || 0);
					const splitDepth = Math.max(0, Number(options.clientSplitDepth) || 0);
					const canSplitClientError = [400, 404, 413, 414, 422].includes(status) &&
						missing.length > 1 && splitDepth < 1;
					if (canSplitClientError) {
						const middle = Math.ceil(missing.length / 2);
						const splitOptions = { ...options, maxAttempts: 1, clientSplitDepth: splitDepth + 1 };
						const results = await Promise.allSettled([
							loadPostsByIds(missing.slice(0, middle), splitOptions),
							loadPostsByIds(missing.slice(middle), splitOptions),
						]);
						const authFailure = results.find((result) => result.status === 'rejected' &&
							[401, 403].includes(Number(result.reason?.status || 0)));
						if (authFailure) throw authFailure.reason;
						const transientFailure = results.find((result) => result.status === 'rejected' && (
							!Number(result.reason?.status || 0) || shouldStopReaderFallback(result.reason)
						));
						if (transientFailure) throw transientFailure.reason;
						lastError = null;
						break;
					}
					if (shouldStopReaderFallback(error) || (status >= 400 && status < 500)) break;
				}
				missing = requestedIds.filter((id) => !cache.has(id));
				if (missing.length && attempt + 1 < maxAttempts) {
					await waitForDelay(240);
				}
			}
			if (missing.length && lastError) throw lastError;
			const posts = requestedIds.map((id) => cache.get(id)).filter(Boolean);
			return posts;
		}

		function streamIndexForPostNumber(postNumber) {
			const target = +postNumber;
			const cachedPost = postByNumber.get(target);
			const cachedIndex = cachedPost && streamIndexById.get(cachedPost.id);
			if (Number.isInteger(cachedIndex)) return cachedIndex;
			return Math.min(stream.length - 1, Math.max(0, target - 1));
		}

		function advanceCursorPast(posts, targetPostNumber) {
			if (!stream.length) return;
			let maxIndex = -1;
			posts.forEach((p) => {
				const byId = streamIndexById.get(p.id);
				if (Number.isInteger(byId)) {
					maxIndex = Math.max(maxIndex, byId);
				} else if (p.post_number) {
					maxIndex = Math.max(maxIndex, Math.min(stream.length - 1, Math.max(0, +p.post_number - 1)));
				}
			});
			if (maxIndex < 0 && targetPostNumber) {
				maxIndex = Math.min(stream.length - 1, Math.max(0, +targetPostNumber - 1));
			}
			if (maxIndex >= cursor) cursor = maxIndex + 1;
		}

		async function loadRelativePosts(postNumber, direction, options = {}) {
			if (!stream.length) return [];
			const last = direction === 'last';
			const targetIndex = last ? stream.length - 1 : streamIndexForPostNumber(postNumber);
			if (last && !stream[targetIndex]) return [];
			if (direction === 'before' && targetIndex <= 0) return [];
			const start = last ? targetIndex : direction === 'before'
				? Math.max(0, targetIndex - pageSize)
				: Math.min(stream.length, targetIndex + 1);
			const end = last ? stream.length : direction === 'before' ? targetIndex : start + pageSize;
			const posts = await loadPostsByIds(stream.slice(start, end), {
				priority: Number.isFinite(options.priority) ? options.priority :
					last ? POST_REQUEST_PRIORITY.target : POST_REQUEST_PRIORITY.scroll,
				persistFetchedPosts: !last && options.persistFetchedPosts === true,
			});
			if (last && posts.length) advanceCursorPast(posts);
			return posts;
		}

		async function loadPost(postNumber, options = {}) {
			const target = +postNumber;
			if (!target || unavailablePostNumbers.has(target)) return [];
			const around = options.scope === 'around';
			const shouldAdvance = around || options.advanceCursor !== false;
			const forceRefresh = !around && options.forceRefresh === true;
			const priority = Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.scroll;
			const cached = around || forceRefresh ? null : postByNumber.get(target);
			if (cached) {
				if (isFunction(options.onSource)) options.onSource('cache');
				if (shouldAdvance) advanceCursorPast([cached], target);
				return [cached];
			}
			if (isFunction(options.onSource)) options.onSource('network');
			if (!around) {
				const highestPostNumber = Math.max(0, Number(topic?.highest_post_number) || 0);
				const id = (!highestPostNumber || highestPostNumber === stream.length) ? stream[target - 1] : null;
				const posts = id && !forceRefresh ? await loadPostsByIds([id], { priority }) : [];
				const hit = posts.find((post) => +post.post_number === target);
				if (hit) {
					if (shouldAdvance) advanceCursorPast([hit], target);
					return [hit];
				}
			}
			const slug = encodeURIComponent((topic?.slug) || 'topic');
			const urls = around
				? [
						`${BASE}/t/${slug}/${topicId}/${target}.json`,
						`${BASE}/t/${slug}/${topicId}.json?post_number=${target}`,
						`${BASE}/t/${topicId}.json?post_number=${target}`,
						`${BASE}/posts/by_number/${topicId}/${target}.json`,
					]
				: [
						`${BASE}/posts/by_number/${topicId}/${target}.json`,
						`${BASE}/t/${slug}/${topicId}/${target}.json`,
						`${BASE}/t/${topicId}.json?post_number=${target}`,
					];
			let fallbackPosts = [];
			for (const url of urls) {
				try {
					const payload = await fetchLoaderJSON(url, {
						key: `${forceRefresh ? 'force-' : ''}${around ? 'around' : 'single'}:${topicId}:${target}:${url}`,
						priority,
						fetchOptions: forceRefresh ? { cache: 'no-store' } : {},
						cacheMode: forceRefresh ? 'refresh' : 'default',
					});
					const fetched = postsFromPayload(payload).filter((p) => p?.id && p.post_number);
					cachePosts(fetched);
					const found = fetched.find((p) => +p.post_number === target);
					if (found) {
						if (shouldAdvance) advanceCursorPast(around ? fetched : [found], target);
						return around ? fetched : [found];
					}
					if (around && fetched.length && !fallbackPosts.length) fallbackPosts = fetched;
				} catch (error) {
					const status = Number(error?.status || 0);
					if (!around && (status === 404 || status === 410) && url.includes('/posts/by_number/')) {
						unavailablePostNumbers.add(target);
						return [];
					}
					if (shouldStopReaderFallback(error)) throw error;
				}
			}
			if (around && stream.length) {
				const start = Math.max(0, Math.min(stream.length - pageSize, target - 1 - Math.floor(pageSize / 2)));
				const ids = stream.slice(start, start + pageSize);
				if (ids.length) {
					try {
						const posts = await loadPostsByIds(ids, { priority });
						if (posts.length) {
							advanceCursorPast(posts, target);
							return posts;
						}
					} catch (error) {
						if (shouldStopReaderFallback(error)) throw error;
					}
				}
			}
			if (around && fallbackPosts.length) advanceCursorPast(fallbackPosts, target);
			return around ? fallbackPosts : [];
		}

		return {
			init, next, loadPostsByIds, loadPost,
			loadBeforePost: (postNumber, options) => loadRelativePosts(postNumber, 'before', options),
			loadAfterPost: (postNumber, options) => loadRelativePosts(postNumber, 'after', options),
			loadLastPost: (options) => loadRelativePosts(0, 'last', options),
			refreshTopicData,
			persistViewedPostNumbers, persistFetchedPosts,
			destroy() {
				stopTopicCacheSync();
				stopTopicCacheSync = () => {};
				requestController.abort();
				stream.length = 0;
				cache.clear();
				postByNumber.clear();
				unavailablePostNumbers.clear();
				pendingPostRequests.clear();
				streamIndexById.clear();
				topic = null;
				refreshRequest = null;
			},
			get topic() { return topic; },
			get cachedPosts() { return [...cache.values()]; },
			get initializedFromCache() { return initializedFromCache; },
			get loadDone() { return cursor >= stream.length; },
			get scanDone() { return onlyOpCursor >= stream.length; },
			get scanCursor() { return onlyOpCursor; },
			get scanSkippedCount() { return onlyOpSkippedCount; },
			get scanBatchSize() { return onlyOpPageSize; },
			get streamLength() { return stream.length; },
		};
	}

	/* ============ 6. 楼层归位 ============ */
	function streamItemParticipatesInZebra(item) {
		return !!(item?.node) &&
			!item.zebraExcluded &&
			!item.node.classList.contains('ldp-nested-collapsed') &&
			!item.node.classList.contains('ldp-nested-preview');
	}

	function reindexStreamItems(ctx, start = 0) {
		const items = ctx?.streamItems;
		if (!items || !items.length) return;
		const from = Math.max(0, Math.min(items.length - 1, +start || 0));
		let zebraIndex = from > 0 ? +(items[from - 1].zebraCountAfter || 0) : 0;
		for (let index = from; index < items.length; index++) {
			const item = items[index];
			const participates = streamItemParticipatesInZebra(item);
			item.index = index;
			item.zebraIndex = participates ? zebraIndex++ : -1;
			item.zebraCountAfter = zebraIndex;
		}
	}

	function insertStreamPost(ctx, node, postNumber, options = {}) {
		const current = +postNumber;
		if (!current || ctx.streamItemMap.has(current)) return;
		let low = 0;
		let high = ctx.streamItems.length;
		const compare = (item) => {
			if (!ctx.isPostVoting) return item.postNumber - current;
			if (item.postNumber === 1 || current === 1) return item.postNumber === current ? 0 : (item.postNumber === 1 ? -1 : 1);
			const left = +(item.node?._ldpPostData && item.node._ldpPostData.post_voting_vote_count || 0);
			const right = +(node._ldpPostData?.post_voting_vote_count || 0);
			return right - left || item.postNumber - current;
		};
		while (low < high) {
			const mid = Math.floor((low + high) / 2);
			if (compare(ctx.streamItems[mid]) < 0) low = mid + 1;
			else high = mid;
		}
		const estimatedHeight = Math.max(
			STREAM_ESTIMATED_HEIGHT,
			postContentPlaceholderHeight(node._ldpPostData) + 72
		);
		if (ctx.streamMountedNodes.size && ctx.streamWindowEnd > ctx.streamWindowStart) {
			if (low < ctx.streamWindowStart) {
				ctx.streamWindowStart++;
				ctx.streamWindowEnd++;
			} else if (low < ctx.streamWindowEnd) {
				ctx.streamWindowEnd++;
			}
		}
		const item = {
			postNumber: current,
			username: String((node._ldpPostData?.username) || node.dataset.username || ''),
			node,
			zebraExcluded: options.zebraExcluded === true,
			height: estimatedHeight,
			index: low,
		};
		ctx.streamItems.splice(low, 0, item);
		ctx.streamItemMap.set(current, item);
		if (options.deferSync) {
			ctx.streamPendingReindexFrom = Number.isInteger(ctx.streamPendingReindexFrom)
				? Math.min(ctx.streamPendingReindexFrom, low)
				: low;
		} else {
			reindexStreamItems(ctx, low);
		}
		if (ctx.onlyOp) invalidateStreamLayout(ctx);
		else markStreamPrefixDirty(ctx, low);
		ctx.streamNeedsRepair = true;
		ensureStreamScaffold(ctx);
		if (!options.deferSync) scheduleStreamWindowSync(ctx);
	}

	function flushStreamPostBatch(ctx, syncNow = false, preserveViewportAnchor = false) {
		if (!ctx || !Number.isInteger(ctx.streamPendingReindexFrom)) return;
		const dirtyFrom = ctx.streamPendingReindexFrom;
		reindexStreamItems(ctx, dirtyFrom);
		ctx.streamPendingReindexFrom = null;
		if (ctx.onlyOp) invalidateStreamLayout(ctx);
		else markStreamPrefixDirty(ctx, dirtyFrom);
		if (syncNow) {
			const keepMountedWindow = preserveViewportAnchor && ctx.streamViewportAnchor &&
				ctx.streamMountedNodes.size > 0 && ctx.streamWindowEnd > ctx.streamWindowStart;
			if (keepMountedWindow) {
				if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame);
				ctx.streamWindowFrame = 0;
				// Prepending changes every item index before scrollTop can be corrected.
				// Keep the anchored DOM mounted, restore it, then calculate the new window.
				mountStreamRange(ctx, ctx.streamWindowStart, ctx.streamWindowEnd);
				syncStreamWindow(ctx);
			} else {
				flushStreamWindow(ctx);
			}
			if (ctx.timeline) ctx.timeline.update();
		} else {
			scheduleStreamWindowSync(ctx);
		}
	}

	function refreshStreamZebra(ctx, nodes = null) {
		if (!ctx || !ctx.commentsEl) return;
		if (ctx.onlyOp) streamLayoutItems(ctx);
		const targets = nodes || ctx.commentsEl.children;
		for (const node of targets) {
			if (!node.classList.contains('ldp-post')) continue;
			const item = ctx.streamItemMap?.get(streamPostNumber(node));
			const zebraIndex = item ? (ctx.onlyOp ? item.layoutZebraIndex : item.zebraIndex) : -1;
			node.classList.toggle('ldp-zebra-alt', zebraIndex >= 0 && zebraIndex % 2 === 1);
		}
	}

	function applyNestedReplyDisplayPreference(ctx) {
		if (!ctx || !Array.isArray(ctx.streamItems)) return;
		const collapseChildren = PREFS.expandNestedRepliesByDefault !== true;
		const expandLeaf = PREFS.expandLeafNestedReplies === true;
		let reindexFrom = null;
		ctx.streamItems.forEach((item, index) => {
			const node = item?.node;
			if (!node) return;
			const keepOnlyOpExpanded = !!(ctx.onlyOp && String(item.username || '') === String(ctx.op || ''));
			if (+(node.dataset.childReplyTotal || 0) > 0) {
				setChildRepliesCollapsed(node, keepOnlyOpExpanded ? false : collapseChildren);
			}
			const post = node._ldpPostData;
			const isLeafNestedReply = !!(post && node.classList.contains('ldp-reply-collapsible') &&
				!post.hidden && !(+post.reply_count > 0));
			if (!isLeafNestedReply) return;
			const repliesToTopicStarter = +(post.reply_to_post_number || node.dataset.replyToPostNumber || 0) === 1;
			const collapsed = !expandLeaf && !keepOnlyOpExpanded && !repliesToTopicStarter;
			setNestedReplyCollapsed(node, collapsed, { deferZebraSync: true });
			if (item.zebraExcluded === collapsed) return;
			item.zebraExcluded = collapsed;
			reindexFrom = reindexFrom == null ? index : Math.min(reindexFrom, index);
		});
		if (reindexFrom == null) return;
		reindexStreamItems(ctx, reindexFrom);
		if (ctx.onlyOp) invalidateStreamLayout(ctx);
		else markStreamPrefixDirty(ctx, reindexFrom);
		refreshStreamZebra(ctx);
		scheduleStreamWindowSync(ctx);
	}

	function findReplyListParent(postNumber, ctx) {
		const num = +postNumber;
		if (!num || !ctx) return null;
		const streamNode = getStreamPostNode(ctx, num);
		if (streamNode?.isConnected) return streamNode;
		return (ctx.commentsEl?.querySelector(`:scope > .ldp-post[data-post-number="${num}"]`)) ||
				null;
	}

	function rememberLatestReplyTime(ctx, post, options = {}) {
		const createdAt = String((post?.created_at) || '');
		const timestamp = Date.parse(createdAt);
		if (!ctx || !Number.isFinite(timestamp)) return;
		if (!options.force && timestamp <= +(ctx.latestReplyTimestamp || 0)) return;
		ctx.latestReplyAt = createdAt;
		ctx.latestReplyTimestamp = timestamp;
		if (ctx.timeline) ctx.timeline.update();
	}

	function rememberPostData(ctx, post, parentHint = 0) {
		const postNumber = +(post?.post_number || 0);
		if (!ctx || !postNumber) return 0;
		const knownParentNumber = +(ctx.replyParentByPost?.get(postNumber) || 0);
		const parentNumber = +(post.reply_to_post_number || parentHint || knownParentNumber || 0);
		if (!parentNumber) return 0;
		if (!post.reply_to_post_number) post.reply_to_post_number = parentNumber;
		if (ctx.replyParentByPost) ctx.replyParentByPost.set(postNumber, parentNumber);
		let replies = ctx.directRepliesByParent.get(parentNumber);
		if (!replies) {
			replies = new Map();
			ctx.directRepliesByParent.set(parentNumber, replies);
		}
		replies.set(postNumber, post);
		return parentNumber;
	}

	function attachPostBatch(posts, ctx, options = {}) {
		const batch = Array.isArray(posts) ? posts : [];
		if (!batch.length) return [];
		const batchOptions = { ...options, deferStreamSync: true };
		const nodes = batch.map((post) => attachPost(post, ctx, batchOptions)).filter(Boolean);
		syncKnownBoostIdentities(ctx, batch.map((post) => post?.username));
		flushStreamPostBatch(
			ctx,
			options.syncStreamNow === true,
			options.preserveViewportAnchor === true,
		);
		return nodes;
	}

	function attachPost(p, ctx, options = {}) {
		const rememberedParentNum = rememberPostData(ctx, p);
		rememberLatestReplyTime(ctx, p);
		const fromNestedList = !!options.nestedList;
		const parentNum = +(p.reply_to_post_number || rememberedParentNum || 0);
		const isNestedReply = !ctx.isPostVoting && !!parentNum;
		const renderAsReply = isNestedReply || (fromNestedList && !!parentNum);
		const preferredParentNode = options.preferredParentNode;
		const preferredParentMatches = preferredParentNode?.isConnected &&
			+preferredParentNode.dataset.postNumber === +parentNum;
		const logicalParentNode = fromNestedList && parentNum
			? (preferredParentMatches ? preferredParentNode : findReplyListParent(parentNum, ctx))
			: null;
		// A nested-list request always belongs to the requested post. Do not walk
		// that post's own parent chain: A -> B -> A conversations would otherwise
		// be reattached to an ancestor and create a visual reply cycle.
		const parentNode = fromNestedList ? logicalParentNode : null;
		if (fromNestedList) {
			const parentChildren = parentNode?.querySelector(':scope > .ldp-children');
			const existingNode = parentChildren?.querySelector(
				`:scope > .ldp-post[data-post-number="${+p.post_number}"]`
			);
			if (existingNode) return existingNode;
			const node = renderPost(p, renderAsReply, ctx, {
				forceExpanded: true,
				nestedPreview: true,
				showParentJump: false,
			});
			if (parentNode) {
				if (!options.deferNestedSync) parentChildren.appendChild(node);
				bindPostMediaLayoutSync(node, ctx);
				const streamNode = ctx.streamNodeMap.get(p.post_number);
				if (streamNode) {
					const streamIsNestedReply = +(p.reply_to_post_number || 0) > 0;
					const repliesToTopicStarter = +(p.reply_to_post_number || 0) === 1;
					const keepJumpTargetExpanded = streamNode.dataset.ldpJumpExpanded === '1';
					if (streamIsNestedReply) {
						ensureStreamNestedReplyPresentation(streamNode, p.reply_to_post_number);
					}
					if (streamNode._ldpPostData?.hidden) {
						setNestedReplyCollapsed(streamNode, true);
					} else if (repliesToTopicStarter || !streamIsNestedReply || p.reply_count > 0) {
						setNestedReplyCollapsed(streamNode, false);
						if (p.reply_count > 0) {
							ctx.repliesIO.observe(streamNode);
						}
					} else {
						setNestedReplyCollapsed(streamNode, !keepJumpTargetExpanded);
					}
				}
				if (!options.deferNestedSync) syncChildControls(parentNode);
			}
			if (parentNode) ctx.tracker.observe(node);
			return node;
		}

		if (ctx.streamNodeMap.has(+p.post_number)) return ctx.streamNodeMap.get(+p.post_number) || null;

		const hiddenStreamPost = !!p.hidden;
		const collapsedStreamReply = hiddenStreamPost || !!(isNestedReply && parentNum !== 1 && !(p.reply_count > 0) &&
			PREFS.expandLeafNestedReplies !== true);
		const node = renderPost(p, renderAsReply, ctx, {
			forceCollapsed: collapsedStreamReply,
			showParentJump: isNestedReply,
			deferContent: true,
		});
		insertStreamPost(ctx, node, p.post_number, {
			deferSync: !!options.deferStreamSync,
			zebraExcluded: hiddenStreamPost || collapsedStreamReply,
		});
		bindPostMediaLayoutSync(node, ctx);
		ctx.streamNodeMap.set(+p.post_number, node);
		ctx.tracker.observe(node);
		if (ctx.queueViewportTracker) ctx.queueViewportTracker.observe(node);
		if (+p.post_number === 1) syncCommentsHeaderPlacement(ctx);
		return node;
	}

	let threadGroupGradientSerial = 0;

	function findParentPreviewLine(post) {
		const line = post?.previousElementSibling;
		return line && line.classList.contains('ldp-floor-preview-link') ? line : null;
	}

	function clearCombinedParentPreviewLine(post) {
		const line = findParentPreviewLine(post);
		if (line) {
			line.classList.remove('ldp-floor-preview-link-grouped');
			line.querySelector(':scope > .ldp-thread-group-svg')?.remove();
		}
		if (post) {
			post.querySelector(':scope > .ldp-thread-group-svg')?.remove();
			post.classList.remove('ldp-parent-preview-linked');
		}
	}

	function syncGroupedParentPreviewLine(line, parent, posts) {
		const preview = line?.previousElementSibling;
		if (!line || !line.classList.contains('open') || !preview || !preview.classList.contains('ldp-floor-preview')) return false;
		const parentAvatar = parent?.querySelector(':scope > .ldp-post-head .ldp-avatar');
		const childAvatars = posts.map((post) => post.querySelector(':scope > .ldp-post-head .ldp-avatar'));
		const previewAvatar = preview.querySelector(':scope > .ldp-post-head .ldp-avatar');
		if (!parentAvatar || !previewAvatar || childAvatars.some((avatar) => !avatar)) return false;
		const layerRect = parent.getBoundingClientRect();
		const previewRect = preview.getBoundingClientRect();
		const previewAvatarRect = previewAvatar.getBoundingClientRect();
		const points = [{
			x: previewRect.left - layerRect.left,
			y: previewAvatarRect.top + previewAvatarRect.height / 2 - layerRect.top,
		}];
		[parentAvatar, ...childAvatars].forEach((avatar) => {
			const rect = avatar.getBoundingClientRect();
			points.push({
				x: rect.left - layerRect.left,
				y: rect.top + rect.height / 2 - layerRect.top,
			});
		});
		if (points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y))) return false;
		const lineStyle = getComputedStyle(line);
		const radius = normalizeReplyLineRadius(parseFloat(lineStyle.getPropertyValue('--ldp-reply-line-radius')));
		const lineWidth = normalizeLineWidth(parseFloat(lineStyle.getPropertyValue('--ldp-reply-line-width')), REPLY_LINE_WIDTH_DEFAULT);
		const linePadding = Math.ceil(lineWidth / 2) + 1;
		const branchGap = 5;
		const axisX = Math.min(...points.map((point) => point.x)) - radius;
		const minX = Math.floor(axisX - linePadding);
		const maxX = Math.ceil(Math.max(...points.map((point) => point.x)) + linePadding);
		const minY = Math.floor(points[0].y - linePadding);
		const maxY = Math.ceil(points[points.length - 1].y + linePadding);
		const width = Math.max(1, maxX - minX);
		const height = Math.max(1, maxY - minY);
		const localX = (value) => Math.round(value - minX);
		const localY = (value) => Math.round(value - minY);
		const trunkX = localX(axisX);
		const topY = localY(points[0].y);
		const bottomY = localY(points[points.length - 1].y - radius);
		const curveEndX = localX(axisX + radius);
		const topCurveStartY = localY(points[0].y + radius);
		const solidPathParts = [
			`M ${trunkX} ${topCurveStartY} V ${bottomY}`,
			`M ${trunkX} ${topCurveStartY} Q ${trunkX} ${topY} ${curveEndX} ${topY}`,
		];
		const topBranchEndX = localX(points[0].x - branchGap);
		const branchPathParts = [{
			d: `M ${curveEndX} ${topY} H ${topBranchEndX}`,
			reverse: topBranchEndX < curveEndX,
			startX: curveEndX,
			endX: topBranchEndX,
			y: topY,
		}];
		points.slice(1).forEach((point) => {
			const y = localY(point.y);
			solidPathParts.push(`M ${trunkX} ${localY(point.y - radius)} Q ${trunkX} ${y} ${curveEndX} ${y}`);
			const branchEndX = localX(point.x - branchGap);
			branchPathParts.push({
				d: `M ${curveEndX} ${y} H ${branchEndX}`,
				reverse: branchEndX < curveEndX,
				startX: curveEndX,
				endX: branchEndX,
				y,
			});
		});
		const pathParts = [{ d: solidPathParts.join(' '), branch: false, reverse: false },
			...branchPathParts.map((part) => ({ ...part, branch: true }))];
		let svg = parent.querySelector(':scope > .ldp-thread-group-svg');
		if (!svg) {
			svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
			svg.classList.add('ldp-thread-group-svg');
			svg.setAttribute('aria-hidden', 'true');
			parent.appendChild(svg);
		}
		svg.style.left = `${minX}px`;
		svg.style.top = `${minY}px`;
		svg.style.width = `${width}px`;
		svg.style.height = `${height}px`;
		svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
		let defs = svg.querySelector(':scope > .ldp-thread-group-defs');
		if (!defs) {
			defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
			defs.classList.add('ldp-thread-group-defs');
			svg.insertBefore(defs, svg.firstChild);
		}
		const gradients = [...defs.querySelectorAll(':scope > .ldp-thread-group-gradient')];
		while (gradients.length < branchPathParts.length) {
			const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient');
			gradient.classList.add('ldp-thread-group-gradient');
			gradient.id = `ldp-thread-group-gradient-${++threadGroupGradientSerial}`;
			gradient.setAttribute('gradientUnits', 'userSpaceOnUse');
			[0, 1, 1].forEach((offset, index) => {
				const stop = document.createElementNS('http://www.w3.org/2000/svg', 'stop');
				stop.setAttribute('offset', String(offset));
				stop.setAttribute('stop-color', 'currentColor');
				stop.setAttribute('stop-opacity', index === 2 ? '0' : '1');
				gradient.appendChild(stop);
			});
			defs.appendChild(gradient);
			gradients.push(gradient);
		}
		while (gradients.length > branchPathParts.length) gradients.pop().remove();
		const paths = [...svg.querySelectorAll(':scope > .ldp-thread-group-path')];
		while (paths.length < pathParts.length) {
			const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
			path.classList.add('ldp-thread-group-path');
			svg.appendChild(path);
			paths.push(path);
		}
		while (paths.length > pathParts.length) paths.pop().remove();
		let branchGradientIndex = 0;
		paths.forEach((path, index) => {
			const part = pathParts[index];
			path.classList.toggle('ldp-thread-group-branch', part.branch);
			path.classList.toggle('ldp-thread-group-branch-reverse', part.reverse);
			path.setAttribute('d', part.d);
			if (!part.branch) {
				path.style.removeProperty('stroke');
				return;
			}
			const gradient = gradients[branchGradientIndex++];
			const branchLength = Math.max(1, Math.abs(part.endX - part.startX));
			const fadeStart = Math.max(0, 1 - 12 / branchLength);
			gradient.setAttribute('x1', String(part.startX));
			gradient.setAttribute('x2', String(part.endX));
			gradient.setAttribute('y1', String(part.y));
			gradient.setAttribute('y2', String(part.y));
			gradient.children[1].setAttribute('offset', String(fadeStart));
			path.style.stroke = `url(#${gradient.id})`;
		});
		line.classList.add('ldp-floor-preview-link-grouped');
		parent.classList.add('ldp-parent-preview-linked');
		return true;
	}

	function syncNestedReplyBranches(children) {
		const posts = [...children.children].filter((node) => node.classList.contains('ldp-post'));
		const parent = children.closest('.ldp-post');
		const parentAvatar = parent?.querySelector(':scope > .ldp-post-head .ldp-avatar');
		const railToggle = children.querySelector(':scope > .ldp-nested-rail-toggle');
		const branchToggle = children.querySelector(':scope > .ldp-nested-branch-toggle');
		if (!parent || !parentAvatar || !posts.length || !children.isConnected) {
			if (parent) {
				parent.classList.remove('ldp-nested-branches-ready');
				clearCombinedParentPreviewLine(parent);
			}
			return;
		}
		const branchGap = 5;
		let branchAxisX = null;
		let branchMetrics = [];
		let railTop = 0;
		let branchCanvasWidth = 0;
		let parentBranchWidth = 0;
		if (parentAvatar && posts.length) {
			const childrenRect = children.getBoundingClientRect();
			const avatarRect = parentAvatar.getBoundingClientRect();
			if (!childrenRect.width || !avatarRect.width || !avatarRect.height) {
				parent.classList.remove('ldp-nested-branches-ready');
				clearCombinedParentPreviewLine(parent);
				return;
			}
			let axisX = Math.round(avatarRect.left - childrenRect.left - 15);
			let dropAnchorY = avatarRect.top + avatarRect.height / 2;
			const parentLine = findParentPreviewLine(parent);
			const parentAxisX = parentLine && parseFloat(parentLine.style.getPropertyValue('--ldp-parent-axis-left'));
			const parentPreviewLinked = !!parentLine && parentLine.classList.contains('open') && Number.isFinite(parentAxisX);
			if (parentPreviewLinked) {
				axisX = Math.round(parentLine.getBoundingClientRect().left + parentAxisX - childrenRect.left);
				dropAnchorY = avatarRect.top + avatarRect.height / 2;
			}
			const firstChildAvatar = posts[0]?.querySelector(':scope > .ldp-post-head .ldp-avatar');
			const firstChildX = firstChildAvatar ? Math.round(firstChildAvatar.getBoundingClientRect().left - childrenRect.left) : 18;
			const firstBranchWidth = Math.max(0, firstChildX - axisX - branchGap);
			const parentHeadWidth = Math.max(0, avatarRect.left - childrenRect.left - axisX - branchGap);
			branchCanvasWidth = Math.max(1, Math.ceil(childrenRect.width));
			parentBranchWidth = parentHeadWidth;
			const drop = Math.max(0, Math.round(childrenRect.top - dropAnchorY));
			railTop = -drop;
			branchAxisX = axisX;
			branchMetrics = posts.map((post) => {
				const childAvatar = post.querySelector(':scope > .ldp-post-head .ldp-avatar');
				if (!childAvatar) return null;
				const childAvatarRect = childAvatar.getBoundingClientRect();
				const postRect = post.getBoundingClientRect();
				return {
					branchY: Math.max(0, Math.round(childAvatarRect.top + childAvatarRect.height / 2 - postRect.top)),
					branchWidth: Math.max(0, Math.round(childAvatarRect.left - childrenRect.left) - branchAxisX - branchGap),
					railY: Math.max(0, Math.round(childAvatarRect.top + childAvatarRect.height / 2 - childrenRect.top)),
				};
			});
			const groupedReady = parentPreviewLinked && branchMetrics.length === posts.length && branchMetrics.every(Boolean) &&
				syncGroupedParentPreviewLine(parentLine, parent, posts);
			if (!groupedReady) clearCombinedParentPreviewLine(parent);
			children.style.setProperty('--ldp-nested-axis-left', `${axisX}px`);
			children.style.setProperty('--ldp-nested-branch-width', `${firstBranchWidth}px`);
			children.style.setProperty('--ldp-nested-parent-drop', `${drop}px`);
			children.style.setProperty('--ldp-nested-parent-head-width', `${parentHeadWidth}px`);
		} else {
			children.style.removeProperty('--ldp-nested-axis-left');
			children.style.removeProperty('--ldp-nested-branch-width');
			children.style.removeProperty('--ldp-nested-parent-drop');
			children.style.removeProperty('--ldp-nested-parent-head-width');
		}
		const firstBranchMetric = branchMetrics.find(Boolean);
		const lastBranchMetric = branchMetrics[branchMetrics.length - 1];
		if (railToggle && branchAxisX !== null && firstBranchMetric && lastBranchMetric) {
			railToggle.style.left = `${branchAxisX - 10}px`;
			railToggle.style.top = `${railTop}px`;
			railToggle.style.height = `${Math.max(24, lastBranchMetric.railY - 15 - railTop)}px`;
		} else if (railToggle) {
			railToggle.style.removeProperty('left');
			railToggle.style.removeProperty('top');
			railToggle.style.removeProperty('height');
		}
		if (branchToggle && branchAxisX !== null && lastBranchMetric && branchCanvasWidth) {
			const radius = normalizeReplyLineRadius(
				parseFloat(getComputedStyle(children).getPropertyValue('--ldp-reply-line-radius'))
			);
			const branchBottom = Math.max(railTop + 1, lastBranchMetric.railY + 10);
			const branchHeight = branchBottom - railTop;
			const pathParts = [
				`M ${branchAxisX} ${railTop} H ${branchAxisX + parentBranchWidth}`,
				`M ${branchAxisX} ${railTop} V ${Math.max(railTop, lastBranchMetric.railY - radius)}`,
			];
			branchMetrics.forEach((metric) => {
				if (!metric) return;
				pathParts.push(`M ${branchAxisX} ${metric.railY - radius} Q ${branchAxisX} ${metric.railY} ${branchAxisX + radius} ${metric.railY} H ${branchAxisX + metric.branchWidth}`);
			});
			branchToggle.style.left = '0px';
			branchToggle.style.top = `${railTop}px`;
			branchToggle.style.width = `${branchCanvasWidth}px`;
			branchToggle.style.height = `${branchHeight}px`;
			branchToggle.setAttribute('viewBox', `0 ${railTop} ${branchCanvasWidth} ${branchHeight}`);
			branchToggle.querySelector(':scope > .ldp-nested-branch-hit-path')?.setAttribute('d', pathParts.join(' '));
		} else if (branchToggle) {
			branchToggle.removeAttribute('viewBox');
			branchToggle.style.removeProperty('left');
			branchToggle.style.removeProperty('top');
			branchToggle.style.removeProperty('width');
			branchToggle.style.removeProperty('height');
			branchToggle.querySelector(':scope > .ldp-nested-branch-hit-path')?.removeAttribute('d');
		}
		posts.forEach((post, index) => {
			if (branchAxisX === null) {
				post.style.removeProperty('--ldp-nested-branch-y');
				post.style.removeProperty('--ldp-nested-branch-width');
			} else {
				const metric = branchMetrics[index];
				if (metric) {
					post.style.setProperty('--ldp-nested-branch-y', `${metric.branchY}px`);
					post.style.setProperty('--ldp-nested-branch-width', `${metric.branchWidth}px`);
				} else {
					post.style.removeProperty('--ldp-nested-branch-y');
					post.style.removeProperty('--ldp-nested-branch-width');
				}
			}
			post.classList.toggle('ldp-nested-branch-last', index === posts.length - 1);
			post.classList.toggle('ldp-nested-branch-single', posts.length === 1);
		});
		parent.classList.toggle('ldp-nested-branches-ready', branchMetrics.length === posts.length && branchMetrics.every(Boolean));
	}

	const nestedBranchSyncFrames = new WeakMap();

	function scheduleNestedBranchSync(post, ctx) {
		if (!post || !post.isConnected) return;
		const parentNum = +(post.dataset.replyToPostNumber || 0);
		const parent = parentNum && ctx && ctx.streamNodeMap && ctx.streamNodeMap.get(parentNum);
		if (nestedBranchSyncFrames.has(post)) return;
		const run = () => {
			if (!post.isConnected) {
				nestedBranchSyncFrames.delete(post);
				return;
			}
			if (streamScrollIsActive(ctx, 100)) {
				nestedBranchSyncFrames.set(post, setTimeout(run, 110));
				return;
			}
			nestedBranchSyncFrames.delete(post);
			syncChildControls(post);
			if (parent) syncChildControls(parent);
		};
		const handle = streamScrollIsActive(ctx, 100)
			? setTimeout(run, 110)
			: requestAnimationFrame(run);
		nestedBranchSyncFrames.set(post, handle);
	}

	function retryableReaderImageUrl(source) {
		try {
			const url = new URL(String(source || ''), location.href);
			url.searchParams.set('_ldp_retry', String(Date.now()));
			return url.href;
		} catch (error) {
			return String(source || '');
		}
	}

	function showReaderImageRetry(img, post, ctx) {
		if (!img || img.classList.contains('emoji')) return;
		let button = img._ldpImageRetryButton;
		if (!button || !button.isConnected) {
			button = makeElement('button');
			button.type = 'button';
			button.className = 'ldp-image-retry';
			button.innerHTML = `${icon('rotateCcw')}<span>重试图片</span>`;
			button.addEventListener('click', (event) => {
				consumeEvent(event);
				if (button.disabled) return;
				const source = String(img.dataset.ldpRetrySource || img.currentSrc || img.src || '');
				if (!source) return;
				button.disabled = true;
				button.setAttribute('aria-busy', 'true');
				button.querySelector('span').textContent = '正在重试…';
				const retryUrl = retryableReaderImageUrl(source);
				img.loading = 'eager';
				img.srcset = retryUrl;
				img.src = retryUrl;
				scheduleNestedBranchSync(post, ctx);
			});
			const link = img.closest('a');
			if (link && post.contains(link)) link.insertAdjacentElement('afterend', button);
			else img.insertAdjacentElement('afterend', button);
			img._ldpImageRetryButton = button;
		}
		button.disabled = false;
		button.setAttribute('aria-busy', 'false');
		button.querySelector('span').textContent = '重试图片';
	}

	function clearReaderImageRetry(img) {
		const button = img?._ldpImageRetryButton;
		if (button) button.remove();
		if (img) img._ldpImageRetryButton = null;
	}

	function bindPostMediaLayoutSync(post, ctx) {
		const content = post.querySelector(':scope > .ldp-content');
		if (!content) return;
		content.querySelectorAll('iframe').forEach((frame) => {
			frame.loading = 'lazy';
		});
		content.querySelectorAll('video').forEach((video) => {
			if (!video.hasAttribute('preload')) video.preload = 'metadata';
		});
		activateReaderMedia(content);
		content.querySelectorAll('img').forEach((img) => {
			if (img.dataset.ldpLayoutSyncBound === '1') return;
			img.dataset.ldpLayoutSyncBound = '1';
			if (!img.classList.contains('emoji')) {
				img.loading = 'lazy';
				img.decoding = 'async';
				if (!img.dataset.ldpRetrySource) {
					img.dataset.ldpRetrySource = absoluteImageUrl(img.currentSrc || img.src || img.getAttribute('src') || '');
				}
			}
			const onLoad = () => {
				clearReaderImageRetry(img);
				scheduleNestedBranchSync(post, ctx);
			};
			const onError = () => {
				showReaderImageRetry(img, post, ctx);
				scheduleNestedBranchSync(post, ctx);
			};
			img.addEventListener('load', onLoad);
			img.addEventListener('error', onError);
			if (img.complete) {
				if (img.naturalWidth > 0) onLoad();
				else onError();
			}
		});
	}

	function syncChildControls(post) {
		const children = post?.querySelector(':scope > .ldp-children');
		const btn = post?.querySelector(':scope > .ldp-collapse-replies');
		if (!children || !btn) return;
		const collapsed = post.classList.contains('ldp-children-collapsed');
		let renderedCount = 0;
		for (const child of children.children) {
			if (child.classList.contains('ldp-post')) renderedCount++;
		}
		const totalCount = Math.max(+(post.dataset.childReplyTotal || 0), renderedCount);
		const railToggle = children.querySelector(':scope > .ldp-nested-rail-toggle');
		const branchToggle = children.querySelector(':scope > .ldp-nested-branch-toggle');
		if (railToggle) {
			const railLabel = `点击收起 ${totalCount} 条回复`;
			setLabel(railToggle, railLabel);
			if (branchToggle) branchToggle.dataset.ldpTooltipLabel = railLabel;
		}
		if (renderedCount && post.classList.contains('ldp-reply') && !post._ldpPostData?.hidden) {
			setNestedReplyCollapsed(post, false);
		}
		if (!totalCount) {
			btn.classList.remove('show');
			post.classList.remove('ldp-has-child-branches');
			post.classList.remove('ldp-nested-branches-ready');
			clearCombinedParentPreviewLine(post);
			delete btn.dataset.replyControlKey;
			return;
		}
		const showBranches = !!renderedCount && !collapsed;
		post.classList.toggle('ldp-has-child-branches', showBranches);
		if (!showBranches) {
			post.classList.remove('ldp-nested-branches-ready');
			clearCombinedParentPreviewLine(post);
		}
		btn.classList.add('show');
		const controlKey = `${collapsed ? 'collapsed' : 'open'}:${totalCount}`;
		if (btn.dataset.replyControlKey !== controlKey) {
			btn.dataset.replyControlKey = controlKey;
			setExpanded(btn, collapsed ? 'false' : 'true');
			btn.innerHTML = `${icon(collapsed ? 'chevronRight' : 'chevronDown')}<span>${collapsed ? `展开 ${totalCount} 条回复` : `收起 ${totalCount} 条回复`}</span>`;
		}
		if (!collapsed) syncNestedReplyBranches(children);
	}

	function setChildRepliesCollapsed(post, collapsed) {
		if (!post) return;
		post.classList.toggle('ldp-children-collapsed', !!collapsed);
		syncChildControls(post);
	}

	/* ============ 7. 渲染单条 ============ */
	function childReplyShellHtml(total) {
		return `<button class="ldp-btn ldp-collapse-replies" type="button" aria-expanded="true"></button>
			<div class="ldp-children">
				<button class="ldp-nested-rail-toggle" type="button" aria-label="点击收起 ${total} 条回复"></button>
				<svg class="ldp-nested-branch-toggle" aria-hidden="true" focusable="false" data-ldp-tooltip-label="点击收起 ${total} 条回复"><path class="ldp-nested-branch-hit-path"></path></svg>
				<div class="ldp-sub-actions">
					<button class="ldp-btn ldp-sub-page-btn ldp-sub-page-prev" type="button">${icon('chevronRight')}<span>上一页</span></button>
					<span class="ldp-sub-page-info"></span>
					<button class="ldp-btn ldp-sub-page-btn ldp-sub-page-next" type="button">${icon('chevronRight')}<span>下一页</span></button>
				</div>
			</div>
			<div class="ldp-sub-loading">加载楼中楼中…</div>`;
	}

	function readerPostIsRead(post, ctx) {
		if (!post) return false;
		if (post.read === true) return true;
		const postNumber = +(post.post_number || 0);
		const lastReadPostNumber = +(ctx?.topicData && ctx.topicData.last_read_post_number || 0);
		return postNumber > 0 && lastReadPostNumber >= postNumber;
	}

	function postReadStateIconHtml(isRead) {
		return isRead
			? icon('check', 'ldp-post-read-state-icon')
			: '<svg class="ldp-icon ldp-post-read-state-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="5.5"/></svg>';
	}

	function readerPostNodeInViewport(postNode, scrollRoot) {
		if (!postNode || !postNode.isConnected || !postNode.getClientRects().length) return false;
		const rect = postNode.getBoundingClientRect();
		const viewport = scrollRoot?.getBoundingClientRect
			? scrollRoot.getBoundingClientRect()
			: { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
		return rect.bottom > viewport.top && rect.top < viewport.bottom &&
			rect.right > viewport.left && rect.left < viewport.right;
	}

	function syncPostReadState(postNode, isRead, options = {}) {
		if (!postNode) return;
		const marker = postNode.querySelector(':scope > .ldp-post-head > .ldp-post-read-state');
		if (!marker) return;
		const state = isRead ? 'read' : 'unread';
		if (postNode._ldpPostData) postNode._ldpPostData.read = !!isRead;
		if (marker.dataset.readState === state || marker.dataset.readTransitionState === state) return;
		const label = isRead ? '该楼层已读' : '该楼层未读';
		const cancelTransition = () => {
			if (marker._ldpReadStateSwapTimer) clearTimeout(marker._ldpReadStateSwapTimer);
			marker._ldpReadStateSwapTimer = 0;
			if (marker._ldpReadStateTimer) clearTimeout(marker._ldpReadStateTimer);
			marker._ldpReadStateTimer = 0;
			if (marker._ldpReadStateFinish) {
				marker.removeEventListener('animationend', marker._ldpReadStateFinish);
			}
			marker._ldpReadStateFinish = null;
			marker.classList.remove('is-confirming');
			delete marker.dataset.readTransitionState;
		};
		const applyVisual = () => {
			marker.dataset.readState = state;
			marker.classList.toggle('is-read', !!isRead);
			marker.classList.toggle('is-unread', !isRead);
			setLabel(marker, label);
			marker.dataset.ldpTooltipLabel = label;
			marker.removeAttribute('title');
			marker.innerHTML = postReadStateIconHtml(isRead);
			refreshTooltips(marker);
		};
		const applyState = () => {
			cancelTransition();
			if (marker.dataset.readState !== state) applyVisual();
		};
		const reduceMotion = prefersReducedMotion();
		if (options.animate === true && isRead && !reduceMotion) {
			marker.dataset.readTransitionState = state;
			marker.classList.add('is-confirming');
			const finish = () => {
				if (marker._ldpReadStateFinish !== finish) return;
				applyState();
			};
			marker._ldpReadStateFinish = finish;
			marker.addEventListener('animationend', finish, { once: true });
			marker._ldpReadStateSwapTimer = setTimeout(() => {
				if (marker._ldpReadStateFinish !== finish) return;
				marker._ldpReadStateSwapTimer = 0;
				applyVisual();
			}, 625);
			marker._ldpReadStateTimer = setTimeout(finish, 1450);
			return;
		}
		applyState();
	}

	function syncConfirmedPostReadStateWhenVisible(node, ctx) {
		if (!node) return;
		const syncConfirmedState = () => syncPostReadState(node, true, { animate: true });
		if (readerPostNodeInViewport(node, ctx?.scrollRoot)) {
			syncConfirmedState();
		} else if (ctx?.tracker && isFunction(ctx.tracker.runWhenVisible)) {
			ctx.tracker.runWhenVisible(node, syncConfirmedState);
		} else {
			syncPostReadState(node, true);
		}
	}

	function markReaderPostsRead(postNumbers, ctx) {
		const confirmed = new Set((postNumbers || []).map(Number).filter(Boolean));
		if (!confirmed.size || !ctx) return;
		if (!ctx.confirmedReadPostNumbers) ctx.confirmedReadPostNumbers = new Set();
		confirmed.forEach((postNumber) => ctx.confirmedReadPostNumbers.add(postNumber));
		const persistedPosts = new Map();
		const syncPostData = (post) => {
			if (!post || !confirmed.has(+(post.post_number || 0))) return;
			post.read = true;
			if (post.id) persistedPosts.set(+post.id, post);
		};
		const syncNode = (node) => {
			const postNumber = +(node?.dataset && node.dataset.postNumber || 0);
			if (!confirmed.has(postNumber)) return;
			const postData = node._ldpPostData;
			syncPostData(postData);
			syncConfirmedPostReadStateWhenVisible(node, ctx);
		};
		if (ctx.modal) ctx.modal.querySelectorAll('.ldp-post').forEach(syncNode);
		if (ctx.streamNodeMap) {
			ctx.streamNodeMap.forEach((node) => {
				syncNode(node);
				if (node?.querySelectorAll) node.querySelectorAll('.ldp-post').forEach(syncNode);
			});
		}
		if (ctx.subReplyState) {
			ctx.subReplyState.forEach((state) => {
				if (state?.nodeCache) state.nodeCache.forEach(syncNode);
				if (state && Array.isArray(state.all)) state.all.forEach(syncPostData);
			});
		}
		if (ctx.directRepliesByParent) {
			ctx.directRepliesByParent.forEach((replies) => {
				if (replies) replies.forEach(syncPostData);
			});
		}
		postsFromPayload(ctx.topicData).forEach(syncPostData);
		if (!persistedPosts.size) return;
		if (isFunction(ctx.loader?.persistFetchedPosts)) {
			ctx.loader.persistFetchedPosts([...persistedPosts.values()]);
		} else if (ctx.topicData) {
			setCachedTopicData(ctx.topicId, ctx.topicData);
		}
	}

	function renderPost(p, isReply, ctx, options = {}) {
		const avatar = avatarWithFlairHtml(
			persistentAvatarHtml(
				p.avatar_template,
				48,
				'ldp-avatar',
				p.name || p.username || '?'
			),
			p
		);
		const like = (p.actions_summary || []).find((action) => action.id === 2) || {};
		const count = like.count || 0;
		const acted = !!like.acted;
		const canAct = !!like.can_act;
		const isOP = ctx.op && p.username === ctx.op;
		const isME = ME_USERNAME && p.username === ME_USERNAME;
		const time = discourseRelativeTime(p.created_at);
		const exactTime = discourseExactTime(p.created_at);
		const profileUrl = userProfileUrl(p.username);
		const userCardAttr = `data-user-card="${escAttr(p.username)}"`;
		const isTopicStarter = !isReply && !options.nestedPreview && +p.post_number === 1;
		const hiddenCollapsible = !!p.hidden && !options.nestedPreview;
		const deferContent = options.deferContent === true;
		const boostListHtml = deferContent
			? (postHasBoostData(p.boosts)
				? '<div class="ldp-boost-list ldp-boost-pending" aria-hidden="true"></div>'
				: '<div class="ldp-boost-list" hidden></div>')
			: renderBoostList(p.boosts, { canQuickBoost: canShowBoostButton(p), ctx });
		const contentHtml = deferContent
			? `<div class="ldp-content cooked ldp-content-pending" aria-busy="true" style="--ldp-content-placeholder-height:${postContentPlaceholderHeight(p)}px"></div>`
			: '<div class="ldp-content cooked"></div>';
		const replyToPostNumber = +(p.reply_to_post_number || 0);
		const parentFloorHtml = options.showParentJump && isReply && replyToPostNumber
				? `<button class="ldp-floor ldp-jump-parent" type="button" data-target-post-number="${replyToPostNumber}" aria-label="跳到父楼层 #${replyToPostNumber}">@#${replyToPostNumber}</button>`
				: '';
		const currentFloorHtml = options.nestedPreview
				? `<button class="ldp-floor ldp-jump-self" type="button" data-target-post-number="${p.post_number}" data-parent-post-number="${replyToPostNumber || ''}" aria-label="跳到楼层 #${p.post_number}">#${p.post_number}</button>`
				: `<span class="ldp-floor ldp-body-floor${parentFloorHtml ? ' ldp-current-floor' : ''}">#${p.post_number}</span>`;
		const deferConfirmedReadAnimation = !!(ctx?.confirmedReadPostNumbers &&
			ctx.confirmedReadPostNumbers.has(+(p.post_number || 0)));
		const isRead = deferConfirmedReadAnimation ? false : readerPostIsRead(p, ctx);
		const readState = isRead ? 'read' : 'unread';
		const readStateLabel = isRead ? '该楼层已读' : '该楼层未读';
		const readStateHtml = `<span class="ldp-post-read-state is-${readState}" data-read-state="${readState}" data-ldp-tooltip-label="${readStateLabel}" role="img" aria-label="${readStateLabel}">${postReadStateIconHtml(isRead)}</span>`;
		const collapseNested = !options.forceExpanded &&
			(hiddenCollapsible || !!options.forceCollapsed);
		const allowChildReplies = !ctx.isPostVoting && !options.nestedPreview && p.reply_count > 0;
		const childRepliesHtml = allowChildReplies ? childReplyShellHtml(p.reply_count) : '';

		const node = makeElement('div');
		node.className = 'ldp-post' + (isReply ? ' ldp-reply ldp-reply-collapsible' : '') +
				(hiddenCollapsible ? ' ldp-hidden-collapsible' : '') +
				(options.nestedPreview ? ' ldp-nested-preview' : '') +
				(collapseNested ? ' ldp-nested-collapsed' : '') +
				(allowChildReplies && (isTopicStarter || PREFS.expandNestedRepliesByDefault !== true)
					? ' ldp-children-collapsed'
					: '');
		node.dataset.postId = p.id;
		node.dataset.postNumber = p.post_number;
		node.dataset.username = p.username || '';
		node.dataset.createdAt = p.created_at || '';
		node.dataset.ldpContentHydrated = deferContent ? '0' : '1';
		node._ldpPostData = p;
		node._ldpReaderContext = ctx;
		if (replyToPostNumber) node.dataset.replyToPostNumber = replyToPostNumber;
		if (allowChildReplies) node.dataset.childReplyTotal = String(p.reply_count);
		node.innerHTML = `
			${isReply || hiddenCollapsible ? '<button class="ldp-btn ldp-nested-toggle" type="button"></button>' : ''}
			<div class="ldp-post-head">
				${avatar ? `<button class="ldp-user-link ldp-avatar-link" type="button" data-user-avatar-preview data-tooltip ${userCardAttr} aria-label="查看 ${escAttr(p.name || p.username)} 的头像原图">${avatar}</button>` : ''}
				<a class="ldp-user-link ldp-author" href="${profileUrl}" target="_blank" rel="noopener" ${userCardAttr}>${esc(p.name || p.username)}</a>
				${isME ? '<span class="ldp-me">ME</span>' : ''}
				<a class="ldp-user-link ldp-user" href="${profileUrl}" target="_blank" rel="noopener" ${userCardAttr}>@${esc(p.username)}</a>
				${isOP ? '<span class="ldp-op">OP</span>' : ''}
				${time ? `<span class="ldp-time"${exactTime ? ` data-exact-time="${escAttr(exactTime)}"` : ''}><span class="ldp-time-relative">· ${esc(time)}</span></span>` : ''}
				${p.hidden ? '<span class="ldp-special-badge ldp-hidden-badge warn">已隐藏</span>' : ''}
				${parentFloorHtml}${currentFloorHtml}${readStateHtml}
			</div>
			${contentHtml}
			<div class="ldp-reactions">
				<div class="ldp-actions">
					<button class="ldp-btn ldp-like ${acted ? 'liked' : ''}"
						data-acted="${acted ? '1' : '0'}" aria-label="${acted ? '取消点赞' : '点赞'}" ${canAct || acted ? '' : 'disabled'}>
						${icon('heart')} <span class="ldp-like-count">${count}</span>
					</button>
					${compactPostActionsHtml(p, ctx)}
				</div>
			</div>
			${boostListHtml}
			${childRepliesHtml}
			${isTopicStarter ? topicFooterActionsHtml(p, ctx) : ''}
		`;
		if (deferConfirmedReadAnimation) syncConfirmedPostReadStateWhenVisible(node, ctx);
		if (!deferContent) {
			const content = node.querySelector(':scope > .ldp-content');
			if (content) setPostCookedContent(content, p.cooked, p.link_counts);
			renderPostPolls(node, ctx);
			renderPostSpecialContent(node, ctx);
		}
		prepareReaderMedia(node);
		hydratePersistentAvatarImages(node);
		if (isTopicStarter) syncTopicActionControls(ctx);
		syncPostActionControls(node, ctx);
		if (isReply || hiddenCollapsible) setNestedReplyCollapsed(node, collapseNested);
		return node;
	}

	/* ============ 8. 回复框 ============ */
	function lookupDiscourseModule(name) {
		if (DISCOURSE_MODULE_CACHE.has(name)) return DISCOURSE_MODULE_CACHE.get(name);
		try {
			const broker = HOST_PAGE_WINDOW.moduleBroker;
			if (!broker || !isFunction(broker.lookup)) return null;
			const module = broker.lookup(name, true);
			if (module && Object.keys(module).length) {
				DISCOURSE_MODULE_CACHE.set(name, module);
				return module;
			}
		} catch (error) {}
		return null;
	}

	function lookupDiscourse(name) {
		if (DISCOURSE_LOOKUP_CACHE.has(name)) return DISCOURSE_LOOKUP_CACHE.get(name);
		const discourseUrlModule = lookupDiscourseModule('discourse/lib/url');
		const discourseUrl = discourseUrlModule?.default;
		const container = discourseUrl?.container;
		if (!container || !isFunction(container.lookup)) return null;
		try {
			const item = container.lookup(name) || null;
			if (item) DISCOURSE_LOOKUP_CACHE.set(name, item);
			return item;
		} catch (error) {
			return null;
		}
	}

	function navigateDiscourseHostRoute(href) {
		let route;
		try {
			const url = new URL(String(href || ''), location.href);
			if (url.origin !== location.origin) return false;
			route = `${url.pathname}${url.search}${url.hash}`;
		} catch (e) {
			return false;
		}
		const discourseUrlModule = lookupDiscourseModule('discourse/lib/url');
		const discourseUrl = discourseUrlModule?.default;
		if (!discourseUrl || !isFunction(discourseUrl.routeTo)) return false;
		try {
			const transition = discourseUrl.routeTo(route);
			if (isFunction(transition?.catch)) transition.catch(() => {});
			return true;
		} catch (e) {
			return false;
		}
	}

	function openEmbeddedReaderLinkInHost(event, link, readerWorkspace) {
		if (!link || !readerWorkspace || !readerWorkspace.isEmbedded() || link.hasAttribute('download') || link.matches('a.lightbox') ||
			event.button && event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return false;
		let url;
		try {
			url = new URL(link.getAttribute('href') || link.href || '', location.href);
		} catch (error) {
			return false;
		}
		if (!/^https?:$/i.test(url.protocol) || url.origin !== location.origin) return false;
		url.searchParams.delete(NATIVE_BYPASS_PARAM);
		const topicRoute = extractTopicRouteFromUrl(url.href);
		if (topicRoute) return false;
		const previousSuppressedTopicId = AUTO_OPEN_SUPPRESSED_TOPIC_ID;
		consumeEvent(event);
		if (navigateDiscourseHostRoute(url.href)) return true;
		AUTO_OPEN_SUPPRESSED_TOPIC_ID = previousSuppressedTopicId;
		showSelectionToast('LINUX DO 站内导航暂不可用,请稍后重试');
		return true;
	}

	function refreshDiscourseHostRoute() {
		const router = lookupDiscourse('service:router');
		if (!router || !isFunction(router.refresh)) return false;
		try {
			const transition = router.refresh();
			if (isFunction(transition?.catch)) {
				transition.catch(() => {});
			}
			return true;
		} catch (e) {
			return false;
		}
	}

	function getUserCardUsername(link) {
		const username = link && (link.dataset.userCard || link.getAttribute('data-user-card'));
		return cleanUsernameValue(username);
	}

	async function openDirectPrivateMessage(username, ctx) {
		const href = `/new-message?username=${encodeURIComponent(username)}`;
		if (!navigateDiscourseHostRoute(href)) return false;
		if (!await waitForNativeComposerPopup(ctx)) return false;
		return !!(ctx?.nativeComposerWindow
			&& ctx.nativeComposerWindow.open(document.querySelector('#reply-control')));
	}

	async function openReaderEndorsementDialog(ctx, user) {
		const username = String(user?.username || '').trim();
		if (!ctx || !username) return false;
		const data = await fetchJSON(`${BASE}/category-experts/endorsable-categories/${encodeURIComponent(username)}.json`, {
			key: `user-endorsements:${username}`,
			priority: POST_REQUEST_PRIORITY.userCard,
		});
		const categories = Array.isArray(data?.categories) ? data.categories : [];
		const remaining = Number(data?.extras && data.extras.remaining_endorsements);
		const existingIds = new Set((Array.isArray(user.category_expert_endorsements)
			? user.category_expert_endorsements
			: []).map((item) => Number(item?.category_id)).filter(Number.isFinite));
		if (!categories.length) throw new Error('当前没有可认可的类别');
		const optionsHtml = categories.map((category) => {
			const categoryId = Number(category?.id);
			const existing = existingIds.has(categoryId);
			return `<label class="ldp-reader-action-option">
				<input type="checkbox" name="category_id" value="${categoryId}"${existing ? ' checked disabled' : ''}>
				<span class="ldp-reader-action-option-copy"><strong>${esc(category?.name || `类别 ${categoryId}`)}</strong>
					<small>${existing ? '已经认可' : '选择后将认可该用户为此类别的专家'}</small></span>
			</label>`;
		}).join('');
		return !!openReaderActionDialog(ctx, {
			title: `认可 @${username}`,
			intro: Number.isFinite(remaining) ? `今天还可新增 ${remaining} 次认可。` : '选择要认可的专家类别。',
			submitLabel: '确认认可',
			initialFocus: 'input[name="category_id"]:not(:disabled)',
			bodyHtml: `<div class="ldp-reader-action-options">${optionsHtml}</div>`,
			onSubmit: async (formData) => {
				if (Number.isFinite(remaining) && remaining < 1) throw new Error('今天的认可次数已用完');
				const addedIds = formData.getAll('category_id').map(Number)
					.filter((categoryId) => Number.isFinite(categoryId) && !existingIds.has(categoryId));
				if (!addedIds.length) throw new Error('请选择一个尚未认可的类别');
				const categoryIds = [...new Set([...existingIds, ...addedIds])];
				const response = await apiSend(
					`${BASE}/category-experts/endorse/${encodeURIComponent(username)}.json`,
					'PUT',
					{ categoryIds }
				);
				user.category_expert_endorsements = Array.isArray(response?.category_expert_endorsements)
					? response.category_expert_endorsements
					: user.category_expert_endorsements;
				setCachedUserCard(username, user, 0);
				return '认可已提交';
			},
		});
	}

	function userNotificationLevel(user) {
		if (user?.ignored) return 'ignore';
		if (user?.muted) return 'mute';
		return 'normal';
	}

	const USER_NOTIFICATION_LEVEL_LABELS = {
		normal: '常规',
		mute: '免打扰',
		ignore: '忽略',
	};

	async function saveReaderUserNotificationLevel(username, user, level, extraParams = {}) {
		if (!username || !Object.hasOwn(USER_NOTIFICATION_LEVEL_LABELS, level)) {
			throw new Error('无效的消息设置');
		}
		const store = lookupDiscourse('service:store');
		const currentUser = lookupDiscourse('service:current-user');
		if (!store || !isFunction(store.createRecord) || !currentUser) {
			throw new Error('Discourse 用户服务尚未就绪');
		}
		const userModel = store.createRecord('user', { username });
		if (!userModel || !isFunction(userModel.updateNotificationLevel)) {
			throw new Error('Discourse 用户消息设置接口尚未就绪');
		}
		await userModel.updateNotificationLevel({
			level,
			expiringAt: extraParams.expiring_at || null,
			actingUser: currentUser,
		});
		if (user) {
			user.muted = level === 'mute';
			user.ignored = level === 'ignore';
			setCachedUserCard(username, user, 0);
		}
		return USER_NOTIFICATION_LEVEL_LABELS[level];
	}

	function openReaderIgnoreDurationDialog(ctx, username, user) {
		const durations = [
			{ days: 1, label: '1 天' },
			{ days: 7, label: '1 周' },
			{ days: 30, label: '1 个月' },
			{ days: 120, label: '4 个月' },
			{ days: 365, label: '1 年' },
		];
		return openReaderActionDialog(ctx, {
			title: `忽略 @${username}`,
			intro: 'Discourse 要求为“忽略”设置截止时间;到期后会自动恢复为常规。',
			submitLabel: '确认忽略',
			initialFocus: 'select[name="ignore_days"]',
			bodyHtml: `<label class="ldp-reader-action-field"><span>忽略期限</span>
				<select class="ldp-reader-select" name="ignore_days">${durations.map((item) => `<option value="${item.days}"${item.days === 30 ? ' selected' : ''}>${item.label}</option>`).join('')}</select>
			</label>`,
			onSubmit: async (formData) => {
				const days = Number(formData.get('ignore_days'));
				if (!durations.some((item) => item.days === days)) throw new Error('请选择有效的忽略期限');
				const expiringAt = new Date(Date.now() + days * 86400000).toISOString();
				await saveReaderUserNotificationLevel(username, user, 'ignore', { expiring_at: expiringAt });
				return `已忽略 @${username}`;
			},
		});
	}

	async function runDirectUserCardAction(action, username, user, ctx) {
		if (!username || !['message', 'endorse', 'follow'].includes(action)) {
			return { ok: false, reason: 'invalid-action' };
		}
		try {
			if (action === 'message') {
				const opened = await openDirectPrivateMessage(username, ctx);
				return { ok: opened, reason: opened ? '' : 'route-unavailable' };
			}
			if (action === 'endorse') {
				const opened = await openReaderEndorsementDialog(ctx, user || { username });
				return { ok: opened, reason: opened ? '' : 'endorsement-unavailable' };
			}
			if (action === 'follow') {
				if (user && user.can_follow === false && !user.is_followed) {
					return { ok: false, reason: 'follow-not-allowed' };
				}
				const followed = !!(user?.is_followed);
				await apiSend(`${BASE}/follow/${encodeURIComponent(username)}.json`, followed ? 'DELETE' : 'PUT');
				if (user) {
					const totalFollowers = Number(user.total_followers);
					user.is_followed = !followed;
					if (Number.isFinite(totalFollowers)) user.total_followers = Math.max(0, totalFollowers + (followed ? -1 : 1));
					setCachedUserCard(username, user, 0);
				}
				invalidateUserFollowList(username, 'followers');
				invalidateUserFollowList(ME_USERNAME, 'following');
				return { ok: true, reason: '', keepOpen: true, followed: !followed };
			}
		} catch (error) {
			console.warn('[LINUX DO reader] direct user card action failed', { action, error });
			return { ok: false, reason: 'action-failed', error };
		}
	}

	function emitUserCardUpdate(entry, user, phase, skipListener = null) {
		if (!entry || !entry.listeners) return;
		entry.listeners.forEach((listener) => {
			if (listener === skipListener) return;
			try { listener(user, phase); } catch (e) {}
		});
	}

	function userTrustLevel(user) {
		const rawLevel = user?.trust_level;
		const direct = Number(rawLevel);
		if (rawLevel != null && rawLevel !== '' && Number.isFinite(direct)) return direct;
		const labels = [
			user?.title,
			user?.flair_name,
			user?.primary_group_name,
			...(Array.isArray(user?.groups)
				? user.groups.flatMap((group) => [group?.name, group?.full_name, group?.title])
				: []),
		];
		for (const label of labels) {
			const match = String(label || '').match(/(?:^|[,,\s])lv\s*([0-9]+)(?:\b|$)/i);
			if (match) return Number(match[1]);
		}
		return null;
	}

	function userDetailsChecked(user, detail) {
		const checkedAt = Number(user?.[`_ldp_${detail}_checked_at`] || 0);
		return checkedAt > 0 && Date.now() - checkedAt < USER_CARD_CACHE_FRESH_FOR;
	}

	function fetchUserJSON(path, key, options = null) {
		const requestOptions = { key, priority: POST_REQUEST_PRIORITY.userCard };
		if (options) Object.assign(requestOptions, {
			cacheMode: options.cacheMode,
			allowStaleOnError: options.allowStaleOnError,
		});
		return fetchJSON(`${BASE}${path}`, requestOptions);
	}

	async function fetchUserProfileDetails(username, options = {}) {
		const User = lookupDiscourseModule('discourse/models/user')?.default;
		if (!User || !isFunction(User.findByUsername)) {
			throw new Error('Discourse 用户模型尚未就绪');
		}
		try {
			return await User.findByUsername(username, {
				existingRequest: fetchUserJSON(
					`/u/${encodeURIComponent(username)}.json`,
					`user-profile:${username}`,
					options
				),
			});
		} catch (error) {
			if (shouldStopReaderFallback(error)) throw error;
			return User.findByUsername(username, {
				forCard: true,
				existingRequest: fetchUserJSON(
					`/u/${encodeURIComponent(username)}/card.json`,
					`user-card:${username}`,
					options
				),
			});
		}
	}

	function uniqueUserBadges(source) {
		const unique = new Map();
		(Array.isArray(source) ? source : []).forEach((badge) => {
			if (!badge) return;
			const badgeId = Number(badge.id || badge.badge_id) || 0;
			const name = String(badge.name || '').trim();
			const key = badgeId ? `id:${badgeId}` : `name:${name.toLocaleLowerCase()}`;
			if (!badgeId && !name) return;
			const previous = unique.get(key);
			const grantedAt = Date.parse(badge.granted_at || '') || 0;
			const previousGrantedAt = Date.parse(previous?.granted_at || '') || 0;
			if (!previous || grantedAt >= previousGrantedAt) unique.set(key, badge);
		});
		return [...unique.values()];
	}

	async function fetchUserBadges(username) {
		const data = await fetchUserJSON(`/user-badges/${encodeURIComponent(username)}.json`, `user-badges:${username}`);
		const badgesById = new Map((Array.isArray(data?.badges) ? data.badges : [])
			.map((badge) => [Number(badge?.id), badge]));
		return uniqueUserBadges((Array.isArray(data?.user_badges) ? data.user_badges : []).map((grant) => {
			const badge = badgesById.get(Number(grant?.badge_id)) || {};
			return Object.assign({}, badge, grant, {
				id: Number(grant?.badge_id || badge.id) || 0,
				name: String(badge.name || grant?.name || '').trim(),
			});
		}).filter((badge) => badge.id && badge.name && badge.enabled !== false));
	}

	function normalizeUserFollowList(data, type) {
		const source = Array.isArray(data)
			? data
			: Array.isArray(data?.users)
				? data.users
				: Array.isArray(data?.[type])
					? data[type]
					: [];
		return source.map((entry) => {
			const user = entry && (entry.user || entry);
			const username = cleanUsernameValue(user?.username);
			if (!username) return null;
			const name = String(user.name || username).trim();
			return {
				username,
				name,
				avatar_template: String(user.avatar_template || ''),
				flair_name: user.flair_name,
				flair_url: user.flair_url,
				flair_bg_color: user.flair_bg_color,
				flair_color: user.flair_color,
				searchText: pinyinSearchForms(`${username} ${name}`).join(' '),
			};
		}).filter(Boolean);
	}

	function cachedUserFollowList(key) {
		const cached = USER_FOLLOW_LIST_CACHE.get(key);
		if (!cached || Date.now() - cached.at > USER_FOLLOW_LIST_CACHE_FRESH_FOR) {
			USER_FOLLOW_LIST_CACHE.delete(key);
			return null;
		}
		USER_FOLLOW_LIST_CACHE.delete(key);
		USER_FOLLOW_LIST_CACHE.set(key, cached);
		return cached.users;
	}

	function invalidateUserFollowList(username, type) {
		username = cleanUsernameValue(username);
		if (!username || !['following', 'followers'].includes(type)) return;
		USER_FOLLOW_LIST_CACHE.delete(authScopedCacheKey(`${username}:${type}`));
		const policy = responseCachePolicy(`${BASE}/u/${encodeURIComponent(username)}/follow/${type}`);
		if (policy) void deleteStoredResponseCacheEntry(policy.id);
	}

	async function fetchUserFollowList(username, type) {
		username = cleanUsernameValue(username);
		if (!username || !['following', 'followers'].includes(type)) return [];
		const cacheKey = authScopedCacheKey(`${username}:${type}`);
		const cached = cachedUserFollowList(cacheKey);
		if (cached) return cached;
		const pendingRequest = USER_FOLLOW_LIST_REQUESTS.get(cacheKey);
		if (pendingRequest) return pendingRequest;
		const request = fetchUserJSON(
			`/u/${encodeURIComponent(username)}/follow/${type}`,
			`user-${type}:${username}`
		).then((data) => {
			const users = normalizeUserFollowList(data, type);
			USER_FOLLOW_LIST_CACHE.delete(cacheKey);
			USER_FOLLOW_LIST_CACHE.set(cacheKey, { users, at: Date.now() });
			while (USER_FOLLOW_LIST_CACHE.size > USER_FOLLOW_LIST_CACHE_MAX_ENTRIES) {
				USER_FOLLOW_LIST_CACHE.delete(USER_FOLLOW_LIST_CACHE.keys().next().value);
			}
			return users;
		}).finally(() => USER_FOLLOW_LIST_REQUESTS.delete(cacheKey));
		USER_FOLLOW_LIST_REQUESTS.set(cacheKey, request);
		return request;
	}

	async function fetchUserCard(username, onUpdate) {
		username = cleanUsernameValue(username);
		if (!username) return null;
		const existing = USER_CARD_REQUESTS.get(username);
		if (existing && !existing.cancelled) {
			if (isFunction(onUpdate)) {
				existing.listeners.add(onUpdate);
				if (existing.basic) onUpdate(existing.basic, 'stats');
			}
			return existing.promise;
		}
		if (existing) USER_CARD_REQUESTS.delete(username);
		const cached = getCachedUserCard(username);
		const profileChecked = cached && userDetailsChecked(cached, 'profile');
		const summaryChecked = cached && userDetailsChecked(cached, 'summary');
		const badgesChecked = cached && userDetailsChecked(cached, 'badges');
		if (cached && profileChecked && summaryChecked && badgesChecked) {
			if (isFunction(onUpdate)) onUpdate(cached, 'ready');
			return cached;
		}
		if (cached && isFunction(onUpdate)) onUpdate(cached, 'stats');
		const cachedStatsListener = cached && profileChecked ? onUpdate : null;
		const profilePromise = profileChecked
			? Promise.resolve(cached)
			: fetchUserProfileDetails(username);
		const summaryPromise = summaryChecked
			? Promise.resolve(cached.user_summary)
			: fetchUserSummary(username);
		const badgesPromise = badgesChecked
			? Promise.resolve(Array.isArray(cached.user_badges) ? cached.user_badges : [])
			: fetchUserBadges(username);
		const supplementalPromise = Promise.allSettled([summaryPromise, badgesPromise]);
		const entry = { listeners: new Set(), basic: null, promise: null, cancelled: false };
		if (isFunction(onUpdate)) entry.listeners.add(onUpdate);
		const request = (async () => {
			const profile = await profilePromise;
			const user = Object.assign({}, cached || {}, profile || {}, {
				_ldp_profile_checked_at: profileChecked
					? cached._ldp_profile_checked_at
					: Date.now(),
			});
			entry.basic = user;
			setCachedUserCard(username, user);
			emitUserCardUpdate(entry, user, 'stats', cachedStatsListener);
			if (entry.cancelled) return user;
			const [summaryResult, badgesResult] = await supplementalPromise;
			if (badgesResult.status === 'fulfilled') {
				user.user_badges = badgesResult.value;
			}
			if (summaryResult.status === 'rejected') throw summaryResult.reason;
			user.user_summary = summaryResult.value;
			user._ldp_summary_checked_at = Date.now();
			setCachedUserCard(username, user, 0);
			emitUserCardUpdate(entry, user, 'ready');
			return user;
		})().finally(() => {
			if (USER_CARD_REQUESTS.get(username) === entry) USER_CARD_REQUESTS.delete(username);
		});
		entry.promise = request;
		USER_CARD_REQUESTS.set(username, entry);
		return request;
	}

	function cancelQueuedUserCardRequests(username, scheduler = ACTIVE_READER_REQUEST_SCHEDULER) {
		username = cleanUsernameValue(username);
		if (!username) return;
		const entry = USER_CARD_REQUESTS.get(username);
		if (entry) entry.cancelled = true;
		if (!scheduler) return;
		[
			`user-profile:${username}`,
			`user-card:${username}`,
			`user-summary:${username}`,
			`user-directory:${username}`,
			`user-badges:${username}`,
			`user-following:${username}`,
			`user-followers:${username}`,
		].forEach((key) => scheduler.cancelQueued(key));
	}

	async function fetchUserSummary(username, options = {}) {
		username = cleanUsernameValue(username);
		if (!username) return null;
		let summary = null;
		try {
			const data = await fetchUserJSON(`/u/${encodeURIComponent(username)}/summary.json`, `user-summary:${username}`, options);
			summary = data && (data.user_summary || data.summary || data);
		} catch (e) {
			if (shouldStopReaderFallback(e)) throw e;
			summary = null;
		}
		const hasStats = summary && ['post_count', 'posts_count', 'likes_received', 'likes_given', 'topic_count', 'topics_entered']
			.some((key) => summary[key] != null && summary[key] !== '');
		const directoryStats = hasStats ? null : await fetchUserDirectoryStats(username, options);
		return Object.assign({}, directoryStats || {}, summary || {});
	}

	async function fetchUserDirectoryStats(username, options = {}) {
		username = cleanUsernameValue(username);
		if (!username) return null;
		try {
			const qs = new URLSearchParams();
			qs.set('period', 'all');
			qs.set('order', 'likes_received');
			qs.set('username', username);
			const data = await fetchUserJSON(`/directory_items.json?${qs}`, `user-directory:${username}`, options);
			const item = data && Array.isArray(data.directory_items) && data.directory_items[0];
			if (!item) return null;
			return {
				post_count: item.post_count,
				likes_received: item.likes_received,
				likes_given: item.likes_given,
				topic_count: item.topic_count,
			};
		} catch (e) {
			if (shouldStopReaderFallback(e)) throw e;
			return null;
		}
	}

	async function fetchSettingsUserInfo(username, force = false) {
		username = cleanUsernameValue(username);
		if (!username) return null;
		const cached = getCachedUserCard(username);
		if (!force && cached && userDetailsChecked(cached, 'profile') && userDetailsChecked(cached, 'summary')) return cached;
		const requestOptions = force
			? { cacheMode: 'refresh', allowStaleOnError: false }
			: { cacheMode: 'default' };
		const [profile, summary] = await Promise.all([
			fetchUserProfileDetails(username, requestOptions),
			fetchUserSummary(username, requestOptions),
		]);
		const checkedAt = Date.now();
		const user = Object.assign({}, cached || {}, profile || {}, {
			username,
			user_summary: summary,
			_ldp_profile_checked_at: checkedAt,
			_ldp_summary_checked_at: checkedAt,
			_ldp_user_info_checked_at: checkedAt,
		});
		setCachedUserCard(username, user, 0);
		return user;
	}

	function getUserCardSurface(preview = false) {
		const previewClass = 'ldp-user-card-follow-preview';
		let card = readerPortalQuery(preview
			? `.${previewClass}`
			: `.ldp-user-card-fallback:not(.${previewClass})`);
		if (!card) {
			card = makeElement('div');
			card.className = `ldp-user-card-fallback${preview ? ` ${previewClass}` : ''}`;
			appendReaderPortalNode(card);
		}
		return card;
	}

	function getUserCardFollowPanel() {
		let panel = readerPortalQuery('.ldp-user-card-follow-panel');
		if (panel) return panel;
		panel = makeElement('section');
		panel.id = 'ldp-user-card-follow-panel';
		panel.className = 'ldp-user-card-follow-panel';
		setLabel(panel, '关注人员列表');
		panel.hidden = true;
		panel.innerHTML = `
			<header><strong class="ldp-user-card-follow-title"></strong>
				<button type="button" data-user-card-follow-close aria-label="关闭关注人员列表">×</button></header>
			<nav class="ldp-user-card-breadcrumbs" aria-label="用户卡层级" hidden></nav>
			<label class="ldp-user-card-follow-search"><span>检索</span>
				<input type="search" autocomplete="off" spellcheck="false" placeholder="昵称、用户名或拼音">
			</label>
			<div class="ldp-user-card-follow-summary" aria-live="polite"></div>
			<div class="ldp-user-card-follow-list" role="list"></div>
			<nav class="ldp-user-card-follow-pagination" aria-label="人员列表分页" hidden>
				<button type="button" data-user-card-follow-page="previous">上一页</button>
				<span aria-live="polite"></span>
				<button type="button" data-user-card-follow-page="next">下一页</button>
			</nav>`;
		appendReaderPortalNode(panel);
		return panel;
	}

	function hideFallbackUserCard() {
		const card = readerPortalQuery('.ldp-user-card-fallback:not(.ldp-user-card-follow-preview)');
		if (card) card.classList.remove('open');
	}

	function fallbackUserCardOpen() {
		return !!readerPortalQuery('.ldp-user-card-fallback.open:not(.ldp-user-card-follow-preview)');
	}

	function positionFallbackUserCardNotificationMenu(card) {
		const menu = card?.querySelector('.ldp-user-card-notification-menu:not([hidden])');
		const actions = card?.querySelector('.ldp-user-card-actions');
		if (!menu || !actions) return;
		const margin = 10;
		const gap = 6;
		const rect = actions.getBoundingClientRect();
		const width = Math.min(rect.width, window.innerWidth - margin * 2);
		menu.style.width = `${Math.round(width)}px`;
		const height = menu.offsetHeight;
		const spaceAbove = rect.top - margin - gap;
		const spaceBelow = window.innerHeight - rect.bottom - margin - gap;
		const opensAbove = spaceBelow < height && spaceAbove > spaceBelow;
		const preferredTop = opensAbove ? rect.top - height - gap : rect.bottom + gap;
		const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
		const top = Math.max(margin, Math.min(preferredTop, window.innerHeight - height - margin));
		menu.style.left = `${Math.round(left)}px`;
		menu.style.top = `${Math.round(top)}px`;
	}

	function positionFallbackUserCard(card, anchor) {
		card.style.removeProperty('max-height');
		const rect = anchor.getBoundingClientRect();
		const margin = 10;
		const width = card.offsetWidth || 300;
		const height = Math.min(card.offsetHeight || 220, window.innerHeight - margin * 2);
		const left = Math.max(margin, Math.min(rect.left, window.innerWidth - width - margin));
		let top = rect.bottom + 8;
		if (top + height > window.innerHeight - margin) top = Math.max(margin, rect.top - height - 8);
		card.style.left = `${Math.round(left)}px`;
		card.style.top = `${Math.round(top)}px`;
		positionFallbackUserCardNotificationMenu(card);
	}

	function positionUserCardFollowPanel(panel, card) {
		if (!panel || panel.hidden || !card) return;
		const margin = 10;
		const gap = 8;
		panel.style.removeProperty('max-height');
		card.style.removeProperty('max-height');
		const cardRect = card.getBoundingClientRect();
		const width = panel.offsetWidth || 320;
		const height = Math.min(panel.offsetHeight || 220, window.innerHeight - margin * 2);
		const right = cardRect.right + gap;
		const left = cardRect.left - width - gap;
		const preferredLeft = right + width <= window.innerWidth - margin ? right : left;
		const fallbackLeft = Math.max(margin, Math.min(preferredLeft, window.innerWidth - width - margin));
		const fallbackTop = Math.max(margin, Math.min(cardRect.top, window.innerHeight - height - margin));
		const avatarViewer = readerPortalQuery('.ldp-avatar-viewer');
		const viewerRect = avatarViewer?.isConnected && avatarViewer._ldpAnchorCard === card
			? avatarViewer.getBoundingClientRect()
			: null;
		const overlaps = (first, second) => first.left < second.right + gap && first.right + gap > second.left &&
			first.top < second.bottom + gap && first.bottom + gap > second.top;
		const fallbackRect = {
			left: fallbackLeft,
			top: fallbackTop,
			right: fallbackLeft + width,
			bottom: fallbackTop + height,
		};
		if (!viewerRect || !overlaps(fallbackRect, viewerRect)) {
			panel.style.left = `${Math.round(fallbackLeft)}px`;
			panel.style.top = `${Math.round(fallbackTop)}px`;
			return;
		}

		const minimumPanelHeight = Math.min(height, 150);
		const freeRegions = [
			{ left: margin, top: margin, right: viewerRect.left - gap, bottom: window.innerHeight - margin },
			{ left: viewerRect.right + gap, top: margin, right: window.innerWidth - margin, bottom: window.innerHeight - margin },
			{ left: margin, top: margin, right: window.innerWidth - margin, bottom: viewerRect.top - gap },
			{ left: margin, top: viewerRect.bottom + gap, right: window.innerWidth - margin, bottom: window.innerHeight - margin },
		].filter((region) => region.right - region.left >= width && region.bottom - region.top >= minimumPanelHeight);
		const placements = freeRegions.flatMap((region) => {
			const availableHeight = region.bottom - region.top;
			const placedHeight = Math.min(height, availableHeight);
			const candidateLefts = new Set([
				Math.max(region.left, Math.min(cardRect.left, region.right - width)),
				region.left,
				region.right - width,
			]);
			const candidateTops = new Set([
				Math.max(region.top, Math.min(cardRect.top, region.bottom - placedHeight)),
				region.top,
				region.bottom - placedHeight,
			]);
			return [...candidateLefts].flatMap((placedLeft) => [...candidateTops].map((placedTop) => {
				const rect = {
					left: placedLeft,
					top: placedTop,
					right: placedLeft + width,
					bottom: placedTop + placedHeight,
				};
				return {
					left: placedLeft,
					top: placedTop,
					height: placedHeight,
					rect,
					score: Math.abs(placedLeft - fallbackLeft) + Math.abs(placedTop - fallbackTop) + (height - placedHeight) * 3,
				};
			}));
		}).filter((placement) => !overlaps(placement.rect, cardRect))
			.sort((first, second) => first.score - second.score);
		if (placements.length) {
			const placement = placements[0];
			if (placement.height < height) panel.style.maxHeight = `${Math.floor(placement.height)}px`;
			panel.style.left = `${Math.round(placement.left)}px`;
			panel.style.top = `${Math.round(placement.top)}px`;
			return;
		}

		const cardWidth = cardRect.width;
		const cardHeight = Math.min(cardRect.height, window.innerHeight - margin * 2);
		const minimumCardHeight = Math.min(cardHeight, 180);
		const stackedRegions = freeRegions.filter((region) =>
			region.right - region.left >= Math.max(cardWidth, width) &&
			region.bottom - region.top >= minimumCardHeight + gap + minimumPanelHeight
		).map((region) => {
			const availableHeight = region.bottom - region.top - gap;
			const desiredTotal = Math.max(1, cardHeight + height);
			const placedCardHeight = Math.max(minimumCardHeight, Math.min(
				availableHeight - minimumPanelHeight,
				Math.round(availableHeight * cardHeight / desiredTotal)
			));
			const placedPanelHeight = Math.min(height, availableHeight - placedCardHeight);
			const groupHeight = placedCardHeight + gap + placedPanelHeight;
			const groupTop = Math.max(region.top, Math.min(cardRect.top, region.bottom - groupHeight));
			const placedCardLeft = Math.max(region.left, Math.min(cardRect.left, region.right - cardWidth));
			const placedPanelLeft = Math.max(region.left, Math.min(placedCardLeft, region.right - width));
			return {
				cardLeft: placedCardLeft,
				cardTop: groupTop,
				cardHeight: placedCardHeight,
				panelLeft: placedPanelLeft,
				panelTop: groupTop + placedCardHeight + gap,
				panelHeight: placedPanelHeight,
				score: Math.abs(placedCardLeft - cardRect.left) + Math.abs(groupTop - cardRect.top) +
					(cardHeight - placedCardHeight) * 2 + (height - placedPanelHeight) * 2,
			};
		}).sort((first, second) => first.score - second.score);
		if (stackedRegions.length) {
			const placement = stackedRegions[0];
			card.style.left = `${Math.round(placement.cardLeft)}px`;
			card.style.top = `${Math.round(placement.cardTop)}px`;
			if (placement.cardHeight < cardHeight) card.style.maxHeight = `${Math.floor(placement.cardHeight)}px`;
			panel.style.left = `${Math.round(placement.panelLeft)}px`;
			panel.style.top = `${Math.round(placement.panelTop)}px`;
			if (placement.panelHeight < height) panel.style.maxHeight = `${Math.floor(placement.panelHeight)}px`;
			positionFallbackUserCardNotificationMenu(card);
			return;
		}

		panel.style.left = `${Math.round(fallbackLeft)}px`;
		panel.style.top = `${Math.round(fallbackTop)}px`;
	}

	function positionUserCardFollowPreview(preview, panel, card) {
		if (!preview || !panel || panel.hidden || !card) return;
		const margin = 10;
		const gap = 8;
		const panelRect = panel.getBoundingClientRect();
		const cardRect = card.getBoundingClientRect();
		const width = preview.offsetWidth || 320;
		const height = Math.min(preview.offsetHeight || 220, window.innerHeight - margin * 2);
		const panelIsRightOfCard = panelRect.left >= cardRect.right;
		const preferredLeft = panelIsRightOfCard ? panelRect.right + gap : panelRect.left - width - gap;
		const alternateLeft = panelIsRightOfCard ? panelRect.left - width - gap : panelRect.right + gap;
		const preferredFits = preferredLeft >= margin && preferredLeft + width <= window.innerWidth - margin;
		const left = preferredFits ? preferredLeft : alternateLeft;
		preview.style.left = `${Math.round(Math.max(margin, Math.min(left, window.innerWidth - width - margin)))}px`;
		preview.style.top = `${Math.round(Math.max(margin, Math.min(panelRect.top, window.innerHeight - height - margin)))}px`;
	}

	function userStatValue(user, keys) {
		const sources = [user?.user_summary, user, user?.summary, user?.user_stat, user?.stats].filter(Boolean);
		for (const key of keys) {
			for (const source of sources) {
				const value = source[key];
				if (value != null && value !== '') return value;
			}
		}
		return null;
	}

	function userCardStat(value, label, href) {
		const num = Number(value);
		const inner = `<strong>${Number.isFinite(num) ? esc(num) : '-'}</strong><span>${esc(label)}</span>`;
		if (!href) return `<div class="ldp-user-card-stat">${inner}</div>`;
		return `<a class="ldp-user-card-stat" href="${escAttr(href)}" aria-label="查看${escAttr(label)}">${inner}</a>`;
	}

	function isSafeUserCardUrl(value) {
		const url = String(value || '').trim();
		return /^(https?:)?\/\//i.test(url) || url.startsWith('/') || url.startsWith('data:image/');
	}

	function sanitizeUserCardBioHtml(value) {
		const template = makeElement('template');
		template.innerHTML = String(value || '');
		const allowedTags = new Set(['A', 'B', 'BR', 'EM', 'I', 'IMG', 'P', 'SPAN', 'STRONG']);
		const allowedAttrs = {
			A: new Set(['href']),
			IMG: new Set(['src', 'alt', 'class', 'width', 'height']),
			SPAN: new Set(['class']),
		};
		const cleanNode = (node) => {
			if (node.nodeType === Node.TEXT_NODE) return esc(node.textContent || '');
			if (node.nodeType !== Node.ELEMENT_NODE) return '';
			const tag = node.tagName;
			const children = [...node.childNodes].map(cleanNode).join('');
			if (!allowedTags.has(tag)) return children;
			const attrs = [...node.attributes]
					.filter((attr) => {
						const name = attr.name.toLowerCase();
						if (name.startsWith('on') || name === 'style') return false;
						if (!(allowedAttrs[tag]?.has(name))) return false;
						if ((name === 'href' || name === 'src') && !isSafeUserCardUrl(attr.value)) return false;
						return true;
					})
					.map((attr) => ` ${attr.name.toLowerCase()}="${escAttr(attr.value)}"`).join('');
			if (tag === 'A') return `<a${attrs} target="_blank" rel="noopener">${children}</a>`;
			if (tag === 'IMG') return `<img${attrs} loading="lazy" decoding="async">`;
			return `<${tag.toLowerCase()}${attrs}>${children}</${tag.toLowerCase()}>`;
		};
		return [...template.content.childNodes].map(cleanNode).join('');
	}

	function userCardProgressHtml(phase) {
		if (phase === 'ready') return '';
		const loadingStats = phase === 'stats';
		const label = loadingStats ? '资料已获取,正在获取统计…' : '正在获取用户资料…';
		return `<div class="ldp-user-card-progress${loadingStats ? ' is-stats' : ''}" role="status" aria-live="polite">
			<span>${label}</span><span class="ldp-user-card-progress-track"><span class="ldp-user-card-progress-fill"></span></span>
		</div>`;
	}

	function userRegistrationDate(value) {
		const date = new Date(value || 0);
		if (!Number.isFinite(date.getTime())) return '';
		return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日`;
	}

	function userRecentDate(value) {
		const date = new Date(value || 0);
		if (!Number.isFinite(date.getTime())) return '';
		const elapsed = Date.now() - date.getTime();
		if (elapsed >= 0 && elapsed < 60 * 1000) return '刚刚';
		if (elapsed >= 0 && elapsed < 60 * 60 * 1000) return `${Math.max(1, Math.floor(elapsed / 60000))} 分钟前`;
		if (elapsed >= 0 && elapsed < 24 * 60 * 60 * 1000) return `${Math.floor(elapsed / 3600000)} 小时前`;
		if (date.getFullYear() === new Date().getFullYear()) return `${date.getMonth() + 1} 月 ${date.getDate()} 日`;
		return userRegistrationDate(value);
	}

	function userVisibleGroups(user, includePrimaryGroup = false) {
		const visible = new Map();
		const groups = Array.isArray(user?.groups) ? user.groups : [];
		[...groups, includePrimaryGroup && user && user.primary_group_name
			? { name: user.primary_group_name }
			: null].forEach((group) => {
			const name = String(group?.name || '').trim();
			if (!name || /^trust_level_[0-9]+$/i.test(name)) return;
			const key = name.toLocaleLowerCase();
			if (visible.has(key)) return;
			visible.set(key, {
				name,
				label: String(group.full_name || group.display_name || group.title || name).trim() || name,
			});
		});
		return [...visible.values()];
	}

	function userCardGroupsHtml(user) {
		const groups = userVisibleGroups(user, true);
		if (!groups.length) return '';
		const discourseUrl = lookupDiscourseModule('discourse/lib/url');
		return `<div class="ldp-user-card-groups" aria-label="用户分组">
			<span>用户分组</span><div>${groups.map((group) => {
				const href = isFunction(discourseUrl?.groupPath)
					? discourseUrl.groupPath(encodeURIComponent(group.name))
					: '';
				return href
					? `<a href="${escAttr(href)}" target="_blank" rel="noopener" data-ldp-tooltip-label="进入 ${escAttr(group.label)} 分组">${esc(group.label)}</a>`
					: `<span>${esc(group.label)}</span>`;
			}).join('')}</div>
		</div>`;
	}

	function userProfileFactsHtml(user, variant, options = {}) {
		const fact = (label, value, factOptions = {}) => {
			if (value == null || value === '') return '';
			const classes = ['ldp-user-profile-fact'];
			if (factOptions.accent) classes.push('is-accent');
			if (factOptions.wide) classes.push('is-wide');
			const tooltip = factOptions.title ? ` data-ldp-tooltip-label="${escAttr(factOptions.title)}"` : '';
			return `<span class="${classes.join(' ')}"${tooltip}><span class="ldp-user-profile-fact-label">${esc(label)}</span><span class="ldp-user-profile-fact-value">${esc(value)}</span></span>`;
		};
		const numberValue = (keys) => {
			const value = userStatValue(user, keys);
			if (!options.emptyNumberAsZero && (value == null || value === '')) return '';
			const numeric = Number(value);
			return Number.isFinite(numeric) ? numeric.toLocaleString('zh-CN') : '';
		};
		const trustLabels = ['新用户', '基本用户', '成员', '活跃用户', '领导者'];
		const trustLevel = Object.hasOwn(options, 'trustLevel') ? options.trustLevel : userTrustLevel(user);
		const groupText = variant === 'card'
			? ''
			: userVisibleGroups(user, options.includePrimaryGroup).map((group) => group.label).join(', ');
		const facts = [
			fact('加入日期:', userRegistrationDate(user?.created_at)),
			fact('最后一个帖子', userRecentDate(user && (user.last_posted_at || user.last_post_at))),
			fact('最后活动', userRecentDate(user && (user.last_seen_at || user.last_active_at))),
			fact('浏览量', numberValue(['profile_view_count', 'profile_views', 'views'])),
			fact('信任级别', Number.isFinite(trustLevel) ? (trustLabels[trustLevel] || `Lv${trustLevel}`) : ''),
			fact('电子邮件', user?.email, { wide: true }),
			...(variant === 'card' ? [] : [fact('群组', groupText, { accent: true, wide: true, title: options.groupTooltip ? groupText : '' })]),
			...(variant === 'card' ? [] : [
				fact('正在关注', numberValue(['total_following', 'following_count', 'followings_count'])),
				fact('关注者', numberValue(['total_followers', 'follower_count', 'followers_count'])),
			]),
			fact('点数', numberValue(['gamification_score', 'points']), { accent: true }),
		].filter(Boolean);
		return facts.length ? `<div class="ldp-user-profile-facts is-${variant}">${facts.join('')}</div>` : '';
	}

	function userCardFollowStatsHtml(user, username, options = {}) {
		const interactive = options.interactive !== false;
		const configs = [
			{ type: 'following', label: '关注', total: userStatValue(user, ['total_following', 'following_count', 'followings_count']), visible: user && user.can_see_following !== false },
			{ type: 'followers', label: '被关注', total: userStatValue(user, ['total_followers', 'follower_count', 'followers_count']), visible: user && user.can_see_followers !== false },
		];
		return `<div class="ldp-user-card-follow-stats" aria-label="用户关注关系">
			${configs.map((item) => {
				const total = Number(item.total);
				const count = Number.isFinite(total) ? total.toLocaleString('zh-CN') : '-';
				if (!interactive) {
					return `<span class="ldp-user-card-follow-stat is-readonly" aria-label="${escAttr(`${item.label} ${count}`)}">
						<strong>${esc(count)}</strong><span>${esc(item.label)}</span>
					</span>`;
				}
				const label = item.visible ? `查看${item.label}人员` : `${item.label}列表不可见`;
				return `<button class="ldp-user-card-follow-stat" type="button" data-user-card-follow-type="${item.type}"
					data-username="${escAttr(username)}" aria-controls="ldp-user-card-follow-panel" aria-expanded="false"
					aria-label="${escAttr(label)}"${item.visible ? '' : ' disabled'}>
					<strong>${esc(count)}</strong><span>${esc(item.label)}</span>
				</button>`;
			}).join('')}
		</div>`;
	}

	function userCardFollowItemHtml(user) {
		const username = String(user?.username || '');
		const name = String(user?.name || username);
		const avatar = avatarWithFlairHtml(
			persistentAvatarHtml(user?.avatar_template, 48, 'ldp-user-card-follow-avatar', name),
			user
		);
		return `<a class="ldp-user-card-follow-item ldp-user-link" href="${userProfileUrl(username)}" target="_blank" rel="noopener"
			role="listitem" data-user-card="${escAttr(username)}">
			${avatar}<span><strong>${esc(name)}</strong><small>@${esc(username)}</small></span>
		</a>`;
	}

	function renderUserCardFollowList(panel) {
		if (panel?.hidden) return;
		const list = panel?.querySelector('.ldp-user-card-follow-list');
		const summary = panel?.querySelector('.ldp-user-card-follow-summary');
		const input = panel?.querySelector('input[type="search"]');
		const pagination = panel?.querySelector('.ldp-user-card-follow-pagination');
		if (!panel || !list || !summary || !input || !pagination) return;
		const users = Array.isArray(panel._ldpFollowUsers) ? panel._ldpFollowUsers : [];
		const query = normalizeSearchText(input.value);
		const matches = query ? users.filter((user) => user.searchText.includes(query)) : users;
		const pageCount = Math.max(1, Math.ceil(matches.length / USER_FOLLOW_LIST_PAGE_SIZE));
		const requestedPage = Math.max(0, Number(panel.dataset.followPage) || 0);
		const page = Math.min(requestedPage, pageCount - 1);
		const offset = page * USER_FOLLOW_LIST_PAGE_SIZE;
		const visible = matches.slice(offset, offset + USER_FOLLOW_LIST_PAGE_SIZE);
		panel.dataset.followPage = String(page);
		releasePersistentAvatarImages(list);
		list.innerHTML = visible.length
			? visible.map(userCardFollowItemHtml).join('')
			: `<div class="ldp-user-card-follow-empty">${query ? '没有匹配的人员' : '暂无人员'}</div>`;
		summary.textContent = query ? `找到 ${matches.length} 人` : `${matches.length} 人`;
		pagination.hidden = matches.length <= USER_FOLLOW_LIST_PAGE_SIZE;
		pagination.querySelector('span').textContent = `${page + 1} / ${pageCount}`;
		pagination.querySelector('[data-user-card-follow-page="previous"]').disabled = page === 0;
		pagination.querySelector('[data-user-card-follow-page="next"]').disabled = page >= pageCount - 1;
		list.scrollTop = 0;
		hydratePersistentAvatarImages(list, POST_REQUEST_PRIORITY.userCard);
	}

	function connectNumber(value) {
		const match = String(value || '').replace(/,/g, '').match(/-?\d+/);
		return match ? Number(match[0]) : 0;
	}

	function connectCurrentTarget(value) {
		const parts = String(value || '').replace(/,/g, '').split('/');
		if (parts.length >= 2) return { current: connectNumber(parts[0]), target: connectNumber(parts[1]) };
		const numbers = String(value || '').replace(/,/g, '').match(/-?\d+/g) || [];
		return { current: Number(numbers[0] || 0), target: Number(numbers[1] || 0) };
	}

	function parseConnectTrustHtml(html) {
		const doc = new globalThis.DOMParser().parseFromString(String(html || ''), 'text/html');
		const card = [...doc.querySelectorAll('.card')].find((candidate) => {
			const heading = candidate.querySelector('.card-title,h2');
			return heading && /信任级别\s*\d+\s*的要求/.test(heading.textContent || '');
		});
		if (!card) throw new Error('Connect 未返回升级要求,请先登录 Connect');
		const heading = String(card.querySelector('.card-title,h2')?.textContent || '').trim();
		const targetLevel = Number((heading.match(/信任级别\s*(\d+)\s*的要求/) || [])[1]);
		const subtitle = String(card.querySelector('.card-subtitle')?.textContent || '').trim();
		const username = (subtitle.match(/@([^\s·]+)/) || [])[1] || '';
		const timePeriod = Number((subtitle.match(/过去\s*([\d,]+)\s*天/) || [])[1]?.replace(/,/g, '')) || 100;
		const rings = [...card.querySelectorAll('.tl3-ring')].map((item) => ({
			label: String(item.querySelector('.tl3-ring-label')?.textContent || '').trim(),
			current: connectNumber(item.querySelector('.tl3-ring-current')?.textContent),
			target: connectNumber(item.querySelector('.tl3-ring-target')?.textContent),
			met: item.querySelector('.tl3-ring-circle')?.classList.contains('met') || false,
		})).filter((item) => item.label);
		const requirementGroups = [
			['bars', '.tl3-bar-item', '.tl3-bar-label', '.tl3-bar-nums'],
			['quotas', '.tl3-quota-card', '.tl3-quota-label', '.tl3-quota-nums'],
			['vetoes', '.tl3-veto-item', '.tl3-veto-label', '.tl3-veto-value'],
		];
		const { bars, quotas, vetoes } = Object.fromEntries(requirementGroups.map(([
			group, itemSelector, labelSelector, valueSelector,
		]) => [group, [...card.querySelectorAll(itemSelector)].map((item) => {
			const valueNode = item.querySelector(valueSelector);
			const values = group === 'vetoes'
				? { current: connectNumber(valueNode?.textContent), target: 0 }
				: connectCurrentTarget(valueNode?.textContent);
			const met = group === 'bars'
				? valueNode?.classList.contains('met') || item.querySelector('.tl3-bar-fill')?.classList.contains('met') || false
				: item.classList.contains('met') || (group === 'quotas' ? values.current <= values.target : values.current === 0);
			return {
				label: String(item.querySelector(labelSelector)?.textContent || '').trim(),
				...values, met, ...(group === 'bars' ? {} : { reverse: true }),
			};
		}).filter((item) => item.label)]));
		const statusNode = card.querySelector('.status-met,.status-unmet');
		const badgeNode = card.querySelector('.badge');
		const allRequirements = [...rings, ...bars, ...quotas, ...vetoes];
		const met = statusNode
			? statusNode.classList.contains('status-met')
			: badgeNode ? !/未达到|未达/.test(badgeNode.textContent || '') : allRequirements.every((item) => item.met);
		return {
			targetLevel: Number.isFinite(targetLevel) ? targetLevel : null,
			username,
			timePeriod,
			rings,
			bars,
			quotas,
			vetoes,
			met,
		};
	}

	function requestConnectTrustHtml() {
		const request = globalThis.GM_xmlhttpRequest;
		if (!isFunction(request)) return Promise.reject(new Error('当前脚本未获得 Connect 跨域读取权限'));
		return new Promise((resolve, reject) => {
			request({
				method: 'GET',
				url: 'https://connect.linux.do/',
				headers: { 'Accept': 'text/html' },
				timeout: 20000,
				onload: (response) => {
					if (response.status >= 200 && response.status < 300) resolve(response.responseText || '');
					else reject(new Error(`Connect 请求失败(${response.status || '未知状态'})`));
				},
				ontimeout: () => reject(new Error('Connect 请求超时')),
				onerror: () => reject(new Error('Connect 请求失败')),
			});
		});
	}

	function fetchConnectTrustData(force = false, persistedCache = null) {
		const freshFor = 5 * MINUTE;
		if (!CONNECT_TRUST_CACHE && persistedCache && persistedCache.data) {
			CONNECT_TRUST_CACHE = {
				data: persistedCache.data,
				cachedAt: Number(persistedCache.cachedAt || 0),
			};
		}
		if (CONNECT_TRUST_REQUEST) return CONNECT_TRUST_REQUEST;
		if (!force && CONNECT_TRUST_CACHE && Date.now() - CONNECT_TRUST_CACHE.cachedAt < freshFor) {
			return Promise.resolve(CONNECT_TRUST_CACHE.data);
		}
		CONNECT_TRUST_REQUEST = requestConnectTrustHtml().then(parseConnectTrustHtml).then((data) => {
			CONNECT_TRUST_CACHE = { data, cachedAt: Date.now() };
			return data;
		}).finally(() => {
			CONNECT_TRUST_REQUEST = null;
		});
		return CONNECT_TRUST_REQUEST;
	}

	function settingsUserProfileHtml(user) {
		const username = String(user?.username || ME_USERNAME || '');
		const name = String(user?.name || username);
		const trustLevel = userTrustLevel(user);
		const background = String(user && (
			user.card_background_upload_url || user.profile_background_upload_url
		) || '').trim();
		const avatar = avatarWithFlairHtml(
			persistentAvatarHtml(user?.avatar_template, 144, 'ldp-user-info-avatar-image', name),
			user
		);
		const title = String(user && (user.title || user.flair_name) || '').trim();
		const titleFlair = userFlairHtml(user);
		const bio = String(user && (user.bio_excerpt || user.bio_raw) || '').trim();
		const website = String(user && (user.website_name || user.website) || '').trim();
		const websiteUrl = String(user && (user.website || user.website_name) || '').trim();
		const safeWebsite = websiteUrl && isSafeUserCardUrl(websiteUrl)
			? (websiteUrl.startsWith('/') ? `${BASE}${websiteUrl}` : websiteUrl)
			: '';
		return `<section class="ldp-user-info-profile" aria-label="当前用户资料">
			<div class="ldp-user-info-cover">${background && isSafeUserCardUrl(background) ? `<img src="${escAttr(absoluteImageUrl(background))}" alt="" loading="lazy" decoding="async">` : ''}</div>
			<div class="ldp-user-info-profile-body">
				<div class="ldp-user-info-avatar">${avatar || esc(name.slice(0, 1).toUpperCase())}</div>
				<div class="ldp-user-info-identity">
					<div class="ldp-user-info-name-row"><strong class="ldp-user-info-name">${esc(name)}</strong>${Number.isFinite(trustLevel) ? `<span class="ldp-user-info-level">Lv${trustLevel}</span>` : ''}</div>
					<div class="ldp-user-info-username">@${esc(username)}</div>
					${title ? `<div class="ldp-user-info-title">${titleFlair}${esc(title)}</div>` : ''}
					${safeWebsite ? `<a class="ldp-user-info-site" href="${escAttr(safeWebsite)}" target="_blank" rel="noopener">${icon('externalLink')}<span>${esc(website || websiteUrl)}</span></a>` : ''}
				</div>
				${bio ? `<div class="ldp-user-info-bio">${sanitizeUserCardBioHtml(bio)}</div>` : ''}
				${userProfileFactsHtml(user, 'settings', { emptyNumberAsZero: true, trustLevel })}
			</div>
		</section>`;
	}

	function connectProgress(item) {
		if (item.reverse) {
			if (item.target === 0) return 100;
			return Math.max(0, Math.min(100, item.current / item.target * 100));
		}
		return Math.max(0, Math.min(100, item.target > 0 ? item.current / item.target * 100 : item.met ? 100 : 0));
	}

	function connectAchievementClass(item) {
		if (item.reverse || !(item.target > 0) || item.current < item.target) return '';
		const ratio = item.current / item.target;
		if (ratio >= 10) return ' is-goal is-over is-over-high is-over-ultra is-over-epic';
		if (ratio >= 5) return ' is-goal is-over is-over-high is-over-ultra';
		if (ratio >= 2) return ' is-goal is-over is-over-high';
		if (ratio > 1) return ' is-goal is-over';
		return ' is-goal';
	}

	function connectMetricBelowTarget(item) {
		return !item.reverse && Number(item.target) > 0 && Number(item.current) < Number(item.target);
	}

	const CONNECT_REQUIREMENT_HELP = [
		[/访问天数|days?visited/, (period) => `访问天数:过去 ${period} 天内,访问站点并至少阅读过 1 个帖子的不同自然日数量。`],
		[/浏览话题|topics?viewed/, (period) => `浏览话题:过去 ${period} 天内浏览过的公开话题数量;目标按同期公开话题总量的一定比例计算,并受站点上限限制。`],
		[/浏览帖子|posts?read/, (period) => `浏览帖子:过去 ${period} 天内实际读过的公开帖子数量;目标按同期公开帖子总量的一定比例计算,并受站点上限限制。`],
		[/回复话题|topics?replied/, (period) => `回复话题:过去 ${period} 天内回复过的不同公开话题数量;同一话题回复多次仍只计 1 个。`],
		[/获赞天数|likes?received.*days/, (period) => `获赞天数:过去 ${period} 天内至少收到过 1 个赞的不同自然日数量;同一天收到多个赞仍只计 1 天。`],
		[/获赞用户|likes?received.*users/, (period) => `获赞用户:过去 ${period} 天内给你点过赞的不同用户数量;同一用户多次点赞仍只计 1 人。`],
		[/^获赞$|likes?received/, (period) => `获赞:过去 ${period} 天内,你在公开话题中的帖子收到的点赞总数。`],
		[/^点赞$|likes?given/, (period) => `点赞:过去 ${period} 天内,你在公开话题中送出的点赞总数。`],
		[/被举报帖子|flaggedposts/, (period) => `被举报帖子:过去 ${period} 天内,被其他用户举报且经管理确认的不同帖子数量。这是上限项,数值越少越好。`],
		[/举报用户|userswhoflagged|flaggedbyusers/, (period) => `举报用户:过去 ${period} 天内,对你的帖子发起且经管理确认举报的不同用户数量;不是你举报了多少人。这是上限项,数值越少越好。`],
		[/被禁言|silenced/, () => '被禁言:过去 6 个月内的禁言处罚记录,当前仍在禁言也会计入;此项必须为 0。'],
		[/被封禁|suspended/, () => '被封禁:过去 6 个月内的封禁处罚记录,当前仍在封禁也会计入;此项必须为 0。'],
	];

	function connectRequirementHelp(label, timePeriod = 100) {
		const normalized = String(label || '').replace(/\s+/g, '').toLowerCase();
		const period = Number.isFinite(Number(timePeriod)) && Number(timePeriod) > 0 ? Number(timePeriod) : 100;
		const match = CONNECT_REQUIREMENT_HELP.find(([pattern]) => pattern.test(normalized));
		return match ? match[1](period) : '';
	}

	function settingsConnectHtml(data, error) {
		if (!data) return `<section class="ldp-connect-card"><div class="ldp-connect-head"><div class="ldp-connect-heading"><strong>Connect 升级进度</strong><small>升级要求来自 connect.linux.do</small></div></div><div class="ldp-connect-error"><span>${esc(error?.message || '暂时无法读取 Connect 数据')}</span></div></section>`;
		const timePeriod = Number(data.timePeriod) > 0 ? Number(data.timePeriod) : 100;
		const valueLabel = (item) => `${Number(item.current).toLocaleString('zh-CN')} / ${Number(item.target).toLocaleString('zh-CN')}`;
		const style = (item, forceDanger = false) => `--ldp-connect-progress:${connectProgress(item).toFixed(1)}%;--ldp-connect-color:${forceDanger || connectMetricBelowTarget(item) || !item.met ? 'var(--danger,#c84545)' : 'var(--tertiary,#47855f)'}`;
		const metricHelp = (item) => {
			const help = connectRequirementHelp(item.label, timePeriod);
			return help ? ` data-ldp-tooltip-label="${escAttr(help)}"` : '';
		};
		const barGroup = (title, items) => items.length ? `<div class="ldp-connect-group"><div class="ldp-connect-group-title">${title}</div><div class="ldp-connect-bars">${items.map((item) => `<div class="ldp-connect-bar ldp-connect-metric${connectMetricBelowTarget(item) ? ' is-short' : ''}${connectAchievementClass(item)}" style="${style(item)}"${metricHelp(item)}><div class="ldp-connect-bar-copy"><span>${esc(item.label)}</span><strong>${esc(valueLabel(item))}</strong></div><div class="ldp-connect-bar-track"><span class="ldp-connect-bar-fill"></span></div></div>`).join('')}</div></div>` : '';
		const quotaItems = [...data.quotas, ...data.vetoes];
		const participationGroup = barGroup('参与互动', data.bars);
		const complianceGroup = quotaItems.length ? `<div class="ldp-connect-group"><div class="ldp-connect-group-title">合规记录</div><div class="ldp-connect-quotas">${quotaItems.map((item) => `<div class="ldp-connect-quota ldp-connect-metric" style="${style(item, Number(item.current) > 0)}"${metricHelp(item)}><div class="ldp-connect-quota-copy"><span>${esc(item.label)}</span><strong>${esc(valueLabel(item))}</strong></div><div class="ldp-connect-bar-track"><span class="ldp-connect-bar-fill"></span></div></div>`).join('')}</div></div>` : '';
		return `<section class="ldp-connect-card" aria-label="Connect 升级进度">
			<div class="ldp-connect-head"><div class="ldp-connect-heading"><strong>信任级别 ${esc(data.targetLevel == null ? '' : data.targetLevel)} 的要求</strong><small>${data.username ? `@${esc(data.username)} · ` : ''}过去 ${esc(timePeriod)} 天的数据</small></div><span class="ldp-connect-status ldp-connect-metric${data.met ? '' : ' is-unmet'}" data-ldp-tooltip-label="所有项目需要同时达标。互动与活跃项须达到下限;合规项不得超过上限,并在存在记录时以红色提醒。">${data.met ? '已达到' : '未达到'}</span></div>
			${data.rings.length ? `<div class="ldp-connect-rings">${data.rings.map((item) => `<div class="ldp-connect-ring ldp-connect-metric" style="${style(item)}"${metricHelp(item)}><div class="ldp-connect-ring-visual"><span class="ldp-connect-ring-value">${esc(Number(item.current).toLocaleString('zh-CN'))}<small>/ ${esc(Number(item.target).toLocaleString('zh-CN'))}</small></span></div><span class="ldp-connect-ring-label">${esc(item.label)}</span></div>`).join('')}</div>` : ''}
			${participationGroup || complianceGroup ? `<div class="ldp-connect-detail-groups">${participationGroup}${complianceGroup}</div>` : ''}
			${error ? `<div class="ldp-connect-error"><span>当前显示缓存数据;${esc(error.message || '联网更新失败')}</span></div>` : ''}
		</section>`;
	}

	function renderSettingsUserInfo(container, user, connectData, connectError) {
		if (!container) return;
		const selectedView = ['connect', 'profile'].includes(container.dataset.userInfoView)
			? container.dataset.userInfoView
			: 'connect';
		container.dataset.userInfoView = selectedView;
		container.innerHTML = `<div class="ldp-user-info-tabs" role="tablist" aria-label="用户信息分类">
				<button class="ldp-user-info-tab${selectedView === 'connect' ? ' active' : ''}" type="button" role="tab" data-user-info-view="connect" aria-selected="${selectedView === 'connect'}">${icon('activity')}<span>Connect</span></button>
				<button class="ldp-user-info-tab${selectedView === 'profile' ? ' active' : ''}" type="button" role="tab" data-user-info-view="profile" aria-selected="${selectedView === 'profile'}">${icon('userRound')}<span>基本信息</span></button>
			</div>
			<div class="ldp-user-info-view" role="tabpanel" data-user-info-panel="connect"${selectedView === 'connect' ? '' : ' hidden'}>${settingsConnectHtml(connectData, connectError)}</div>
			<div class="ldp-user-info-view" role="tabpanel" data-user-info-panel="profile"${selectedView === 'profile' ? '' : ' hidden'}>${settingsUserProfileHtml(user)}</div>`;
		hydratePersistentAvatarImages(container, POST_REQUEST_PRIORITY.userCard);
	}

	function userCardActionsHtml(user, username) {
		const privateMessageUnavailable = user && (
			user.can_send_private_message_to_user === false || user.can_send_private_messages === false
		);
		const followed = !!(user?.is_followed);
		const followUnavailable = !!(user && user.can_follow === false && !followed);
		const endorsementUnavailable = !!(user && user.category_expert_endorsements === null);
		const notificationLevel = userNotificationLevel(user);
		const notificationUnavailable = !!(user && user.can_mute_user === false
			&& user.can_ignore_user === false && notificationLevel === 'normal');
		const actions = [
			{ action: 'message', icon: 'mail', label: privateMessageUnavailable ? '私信(当前不可用)' : '私信', disabled: privateMessageUnavailable },
			{ action: 'notifications', icon: 'bell', label: notificationUnavailable ? '消息设置(当前不可用)' : `消息设置:${USER_NOTIFICATION_LEVEL_LABELS[notificationLevel]}`, active: notificationLevel !== 'normal', disabled: notificationUnavailable, expanded: true },
			{ action: 'endorse', icon: 'award', label: endorsementUnavailable ? '认可(当前不可用)' : '认可', disabled: endorsementUnavailable },
			{ action: 'follow', icon: 'userPlus', label: followUnavailable ? '关注(当前不可用)' : (followed ? '取消关注' : '关注'), active: followed, disabled: followUnavailable },
		];
		const notificationOptions = [
			{ level: 'normal', icon: 'bell', label: '常规', description: '回复、引用或提到您时正常通知。' },
			...(user && user.can_mute_user === false ? [] : [
				{ level: 'mute', icon: 'bellOff', label: '免打扰', description: '不接收此用户的通知、私信和直接聊天。' },
			]),
			...(user && user.can_ignore_user === false ? [] : [
				{ level: 'ignore', icon: 'eyeOff', label: '忽略', description: '隐藏此用户的内容,并停止相关通知。' },
			]),
		];
		return `<div class="ldp-user-card-actions-wrap">
			<div class="ldp-user-card-actions" role="toolbar" aria-label="用户操作">
				${actions.map((item) => `<button type="button" class="ldp-user-card-action${item.active ? ' is-active' : ''}"
					data-user-card-action="${item.action}" data-username="${escAttr(username)}"
					data-tooltip="${escAttr(item.label)}" aria-label="${escAttr(item.label)}"${item.expanded ? ' aria-expanded="false"' : ''}${item.disabled ? ' disabled' : ''}>
					${icon(item.icon)}
				</button>`).join('')}
			</div>
			<div class="ldp-user-card-notification-menu" role="menu" aria-label="消息设置" hidden>
				${notificationOptions.map((item) => `<button type="button" role="menuitemradio"
					class="ldp-user-card-notification-option${item.level === notificationLevel ? ' is-active' : ''}"
					data-user-notification-level="${item.level}" aria-checked="${item.level === notificationLevel}">
					${icon(item.icon)}<span class="ldp-user-card-notification-option-copy">
						<strong>${item.label}</strong><small>${item.description}</small>
					</span>
				</button>`).join('')}
			</div>
		</div>`;
	}

	function userCardHomeHtml(username) {
		return `<a class="ldp-user-card-home" href="${userProfileUrl(username)}" target="_blank" rel="noopener"
			data-tooltip="进入用户空间" aria-label="进入用户空间">${icon('externalLink')}</a>`;
	}

	function userCardBackgroundHtml(user) {
		const backgroundUrl = String(user && (
			user.card_background_upload_url || user.cardBackgroundUploadUrl ||
			user.profile_background_upload_url || user.profileBackgroundUploadUrl
		) || '').trim();
		if (!backgroundUrl || !isSafeUserCardUrl(backgroundUrl)) return '';
		const source = absoluteImageUrl(backgroundUrl);
		return `<button class="ldp-user-card-background" type="button" data-user-card-background
			data-background-url="${escAttr(source)}" aria-label="预览用户背景图">
			<img src="${escAttr(source)}" alt="" loading="lazy" decoding="async">
		</button>`;
	}

	function userCardBadgeHash(badge) {
		const identity = `${badge?.id || ''}|${badge?.name || ''}|${badge?.icon || ''}`;
		let hash = 2166136261;
		for (let index = 0; index < identity.length; index++) {
			hash ^= identity.charCodeAt(index);
			hash = Math.imul(hash, 16777619);
		}
		return hash >>> 0;
	}

	const USER_CARD_BADGE_EXACT_KINDS = Object.freeze({
			'种子用户': 'seed', '龙行龘龘': 'dragon', '大预言家': 'crystal', '圆圆满满': 'moon',
			'浴火重生': 'fire', '海纳百川': 'waves', '一元复始': 'clock', '蛇来运转': 'snake',
			'破界者': 'hammer', '不二之选': 'star', '骐骥驰骋': 'horse', '幸运佬': 'clover',
			'金笔杆': 'pencil', '银笔杆': 'pencil', '铜笔杆': 'pencil', '文化宣导员': 'megaphone',
			'元气满满': 'gift', '基本用户': 'user', '成员': 'user', '活跃用户': 'fire', '领导者': 'crown',
			'阅读准则': 'book', '已认证': 'certificate', '已授权': 'certificate', '当月最佳新用户': 'star',
			'爱好者': 'calendar', '百尺竿头': 'calendar', '全年不落': 'calendar', '周年纪念日': 'calendar',
			'推广者': 'userplus', '活动家': 'userplus', '拥护者': 'userplus',
			'指导顾问': 'check', '无所不知': 'check', '解决方案机构': 'check',
	});
	const USER_CARD_BADGE_KIND_RULES = Object.freeze([
			['mail', /mail|envelope|电子邮件|邮箱/],
			['flag', /\bflag\b|report|举报/],
			['at', /at-sign|mention|提及/],
			['quote', /quote|引用/],
			['box', /onebox|cube|box/],
			['certificate', /certificate|认证|授权/],
			['code', /code|github|commit|contributor|开源|贡献/],
			['seed', /seed|sprout|幼苗|种子/],
			['hammer', /hammer|gavel|破界/],
			['calendar', /calendar|streak|连续|全年|纪念日/],
			['userplus', /user-plus|invite|邀请|推广者|活动家|拥护者/],
			['megaphone', /bullhorn|megaphone|announcement|公告|推广|广播/],
			['heart', /heart|like|love|赞|爱心|喜爱|谢谢|回馈|善解人意/],
			['eye', /\beye\b|view|reader|阅读|浏览|围观/],
			['pencil', /pencil|edit|write|author|编辑|创作|作者|书写|笔杆|wiki/],
			['document', /file|document|post|topic|article|文件|文档|帖子|主题|文章/],
			['smile', /smile|laugh|emoji|表情|微笑|笑/],
			['crown', /chess|crown|leader|king|领袖|领导|王者/],
			['link', /link|share|链接|分享/],
			['chat', /comment|chat|reply|conversation|回复|讨论|聊天|对话/],
			['check', /check|solution|accepted|认可|解决|采纳|完成|顾问|无所不知/],
			['star', /star|award|medal|荣誉|勋章|明星|精选|尊敬|敬仰|最佳/],
			['shield', /shield|moderator|admin|管理|守护|安全/],
			['clock', /clock|time|anniversary|year|周年|时间|资历/],
			['fire', /fire|hot|active|热门|活跃|热心/],
			['book', /book|learn|guide|tutorial|知识|教程|学习|指南/],
			['gift', /gift|赠送|礼物/],
			['user', /user|person|profile|member|用户|新人|成员|欢迎/],
	]);

	function userCardBadgeGlyphKind(badge) {
		const name = String(badge?.name || '').trim();
		if (USER_CARD_BADGE_EXACT_KINDS[name]) return USER_CARD_BADGE_EXACT_KINDS[name];
		const source = `${badge?.icon || ''} ${name}`.toLowerCase();
		return (USER_CARD_BADGE_KIND_RULES.find(([, pattern]) => pattern.test(source)) || ['sigil'])[0];
	}

	const USER_CARD_BADGE_GLYPHS = Object.freeze({
			mail: '<path d="M2 5h20v14H2V5zm3 2 7 5 7-5H5zm15 2.3-8 5.5-8-5.5V17h16V9.3z" fill-rule="evenodd"/>',
			flag: '<path d="M4 2h2v20H4V2zm3 2h13l-3 5 3 5H7V4z"/>',
			at: '<path d="M12 2a10 10 0 1 0 5.8 18.2l-1.3-1.7A7.8 7.8 0 1 1 19.8 12v1.2c0 1.2-.5 1.8-1.4 1.8-.8 0-1.3-.5-1.3-1.5V8h-2v1A5 5 0 1 0 16 16c.7.8 1.6 1.2 2.7 1.2 2.1 0 3.3-1.5 3.3-4V12c0-5.5-4.5-10-10-10zm0 12.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5z" fill-rule="evenodd"/>',
			quote: '<path d="M3 5h8v8H7c0 3 1.3 4.8 4 5.5V21c-5.3-.8-8-4.2-8-10V5zm10 0h8v8h-4c0 3 1.3 4.8 4 5.5V21c-5.3-.8-8-4.2-8-10V5z"/>',
			box: '<path d="m12 2 9 5v10l-9 5-9-5V7l9-5zm0 2.8L6.1 8 12 11.2 17.9 8 12 4.8zM5 9.7v6.1l6 3.3V13L5 9.7zm8 9.4 6-3.3V9.7L13 13v6.1z" fill-rule="evenodd"/>',
			certificate: '<path d="M12 2a7 7 0 1 0 0 14 7 7 0 0 0 0-14zm-3 7.2 2 2 4-4 1.5 1.5-5.5 5.5-3.5-3.5L9 9.2zM8 16l-2 6 6-2 6 2-2-6a9 9 0 0 1-8 0z" fill-rule="evenodd"/>',
			code: '<path d="m8.5 6-6 6 6 6 1.7-1.7L5.9 12l4.3-4.3L8.5 6zm7 0-1.7 1.7 4.3 4.3-4.3 4.3 1.7 1.7 6-6-6-6zM13 3 9 21h2l4-18h-2z"/>',
			seed: '<path d="M12 22v-8c-5-.3-8-3.2-8-8 4.8 0 7.2 1.6 8 4.7C12.8 7.6 15.2 6 20 6c0 4.8-3 7.7-8 8v8h-2z"/>',
			hammer: '<path d="m4 3 7 7-3 3-7-7 3-3zm8 5 3-3 4 4-3 3 6 6-4 4-6-6-3 3-4-4 7-7z"/>',
			calendar: '<path d="M3 4h3V2h2v2h8V2h2v2h3v18H3V4zm2 6v10h14V10H5zm0-4v2h14V6H5zm3 7h3v3H8v-3zm5 0h3v3h-3v-3z" fill-rule="evenodd"/>',
			userplus: '<path d="M9 2a5 5 0 1 1 0 10A5 5 0 0 1 9 2zM1 22c0-5 2.8-8 8-8 3.4 0 5.8 1.3 7 3.6V15h2v3h3v2h-3v3h-2v-3.4c-.7-.2-1.5-.3-2.4-.3-1.5 0-2.7.9-3.1 2.7H1z"/>',
			megaphone: '<path d="M3 10v4h3l3 3V7l-3 3H3zm7-3 9-3v16l-9-3V7zm-5 8h2l1.5 5H6.2L5 15z"/>',
			heart: '<path d="M12 20.5 4.2 13C-.5 8.2 6.1 2.1 12 7.2 17.9 2.1 24.5 8.2 19.8 13L12 20.5z"/>',
			eye: '<path d="M1.5 12s3.7-6 10.5-6 10.5 6 10.5 6-3.7 6-10.5 6S1.5 12 1.5 12zm10.5 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z" fill-rule="evenodd"/>',
			pencil: '<path d="m4 16.5-.8 4.3 4.3-.8L19.8 7.7l-3.5-3.5L4 16.5zm13.4-13.4 1.4-1.4a1.6 1.6 0 0 1 2.2 0l1.3 1.3a1.6 1.6 0 0 1 0 2.2l-1.4 1.4-3.5-3.5z"/>',
			document: '<path d="M5 2h9l5 5v15H5V2zm9 1.8V8h4.2L14 3.8zM8 12h8v-1.5H8V12zm0 4h8v-1.5H8V16zm0 4h6v-1.5H8V20z" fill-rule="evenodd"/>',
			smile: '<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-4 7.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm8 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm-8.2 5h8.4c-.7 2.3-2.1 3.5-4.2 3.5s-3.5-1.2-4.2-3.5z" fill-rule="evenodd"/>',
			crown: '<path d="m3 7 4.5 3L12 4l4.5 6L21 7l-2 12H5L3 7zm3.4 10h11.2l.5-3H5.9l.5 3z" fill-rule="evenodd"/>',
			link: '<path d="M9.5 15.9 7.4 18a3 3 0 0 1-4.2-4.2l4-4a3 3 0 0 1 4.2 0l1 1-1.6 1.6-1-1a.8.8 0 0 0-1.1 0l-4 4a.8.8 0 0 0 1.1 1.1l2.1-2.1 1.6 1.5zm5-7.8L16.6 6a3 3 0 0 1 4.2 4.2l-4 4a3 3 0 0 1-4.2 0l-1-1 1.6-1.6 1 1a.8.8 0 0 0 1.1 0l4-4a.8.8 0 0 0-1.1-1.1l-2.1 2.1-1.6-1.5zM8.8 13.6l4.8-4.8 1.6 1.6-4.8 4.8-1.6-1.6z"/>',
			chat: '<path d="M3 4h18v13H9l-5.5 4v-4H3V4zm4 5h10V7.5H7V9zm0 4h7v-1.5H7V13z" fill-rule="evenodd"/>',
			check: '<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-2 14.5-4-4 1.8-1.8 2.2 2.2 6.2-6.2L18 8.5l-8 8z" fill-rule="evenodd"/>',
			star: '<path d="m12 2.5 3 6.1 6.7 1-4.9 4.7 1.2 6.7-6-3.2-6 3.2 1.2-6.7-4.9-4.7 6.7-1 3-6.1z"/>',
			shield: '<path d="M12 2 21 5v6c0 5.7-3.7 9.4-9 11-5.3-1.6-9-5.3-9-11V5l9-3zm0 3L6 7v4c0 3.9 2.2 6.5 6 8 3.8-1.5 6-4.1 6-8V7l-6-2z" fill-rule="evenodd"/>',
			clock: '<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm1 5h-2v6l5 3 1-1.7-4-2.3V7z" fill-rule="evenodd"/>',
			fire: '<path d="M13.5 2c1 4-1.5 5.2-1.5 8 0 1.1.7 2 1.7 2 1.8 0 2.5-2.1 2-4.2 3 2.4 4.3 5 3.7 8A7.5 7.5 0 0 1 5 15c-.3-3.4 1.5-6.5 5.1-9.4-.2 3.6 1.3 4.2 2 2.4.6-1.8.1-3.8 1.4-6z"/>',
			book: '<path d="M3 4h7c1.2 0 2.2.4 3 1.2A4.1 4.1 0 0 1 16 4h5v15h-5c-1.2 0-2.2.5-3 1.5A3.8 3.8 0 0 0 10 19H3V4zm9 3.5A2.8 2.8 0 0 0 10 6H5v11h5c.7 0 1.4.2 2 .5v-10zm2 10c.6-.3 1.3-.5 2-.5h3V6h-3c-.8 0-1.5.5-2 1.5v10z" fill-rule="evenodd"/>',
			gift: '<path d="M2 9h20v4h-1v9H3v-9H2V9zm3 4v7h6v-7H5zm8 0v7h6v-7h-6zM7.5 8C4 8 4 3 7 3c2 0 3.5 2.4 5 5H7.5zm9 0H12c1.5-2.6 3-5 5-5 3 0 3 5-.5 5z" fill-rule="evenodd"/>',
			user: '<path d="M12 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10zM3 22c0-5 3.2-8 9-8s9 3 9 8H3z"/>',
			dragon: '<path d="M3 17c2-6 5-10 10-12l-1 4c4-2 7-1 9 2l-4 1 3 3-5 1c-2 4-6 6-12 4l4-2-4-1zm8-4 2 2 2-3-4 1z" fill-rule="evenodd"/>',
			crystal: '<path d="M12 2a8 8 0 0 1 5 14.2L20 22H4l3-5.8A8 8 0 0 1 12 2zm0 3a5 5 0 1 0 0 10 5 5 0 0 0 0-10zm-4 14-1 2h10l-1-2H8z" fill-rule="evenodd"/>',
			moon: '<path d="M16.5 2.5A10 10 0 1 0 21.5 17 8 8 0 0 1 16.5 2.5z"/>',
			waves: '<path d="M2 7c3 0 3 2 6 2s3-2 6-2 3 2 6 2h2v3h-2c-3 0-3-2-6-2s-3 2-6 2-3-2-6-2V7zm0 7c3 0 3 2 6 2s3-2 6-2 3 2 6 2h2v3h-2c-3 0-3-2-6-2s-3 2-6 2-3-2-6-2v-3z"/>',
			snake: '<path d="M18 3c3 0 4 2 4 4 0 3-2 4-5 4h-6c-1.5 0-2 .7-2 1.5S9.5 14 11 14h3c4 0 6 1.8 6 4.5S18 23 14 23H5v-3h9c1.8 0 3-.5 3-1.5S15.8 17 14 17h-3c-3.5 0-5-1.8-5-4.5S7.5 8 11 8h6c1.3 0 2-.4 2-1s-.7-1-2-1h-2V3h3zM4 17l-3-3 3-3v6z"/>',
			horse: '<path d="M6 22v-7l3-4-1-5 5-4 1 4 5 2-1 6-4 2v6h-3v-7l3-2 1-3-4-1-4 7v6H6z"/>',
			clover: '<path d="M12 11C8-1 0 3 5 10-2 8-1 18 8 15c-4 7 6 10 7 2 7 5 11-5 3-7 6-6-3-12-6 1zm0 3 2 8h-4l2-8z" fill-rule="evenodd"/>',
	});

	function userCardBadgeGlyphHtml(kind, badge) {
		if (USER_CARD_BADGE_GLYPHS[kind]) return USER_CARD_BADGE_GLYPHS[kind];
		const hash = userCardBadgeHash(badge);
		const points = Array.from({ length: 8 }, (_, index) => {
			const angle = -Math.PI / 2 + index * Math.PI / 4;
			const radius = 7 + ((hash >>> (index * 4)) & 3);
			return `${12 + Math.cos(angle) * radius},${12 + Math.sin(angle) * radius}`;
		}).join(' ');
		const core = 2.5 + ((hash >>> 29) & 3) * .65;
		return `<polygon points="${points}"/><circle cx="12" cy="12" r="${core}" fill="var(--secondary,#fff)"/><circle cx="12" cy="12" r="${Math.max(1, core - 1.5)}"/>`;
	}

	function userCardBadgeIconHtml(badge) {
		return `<svg class="ldp-user-card-badge-icon" viewBox="0 0 24 24" aria-hidden="true">
			<g transform="translate(3 1) scale(.75)">${userCardBadgeGlyphHtml(userCardBadgeGlyphKind(badge), badge)}</g>
		</svg>`;
	}

	function userCardBadgesHtml(user) {
		const badges = uniqueUserBadges(user?.user_badges);
		if (!badges.length) return '';
		const featuredIds = new Set((Array.isArray(user?.featured_user_badge_ids)
			? user.featured_user_badge_ids : []).map(Number));
		const badgeSortNumber = (badge, field, minimum = 0) => {
			const value = Number(badge?.[field]);
			return Number.isFinite(value) && value >= minimum ? value : Number.MAX_SAFE_INTEGER;
		};
		const ordered = badges.slice().sort((a, b) =>
			badgeSortNumber(a, 'badge_type_id', 1) - badgeSortNumber(b, 'badge_type_id', 1) ||
			badgeSortNumber(a, 'grant_count') - badgeSortNumber(b, 'grant_count') ||
			Number(featuredIds.has(Number(b?.id))) - Number(featuredIds.has(Number(a?.id))) ||
			String(b?.granted_at || '').localeCompare(String(a?.granted_at || ''))
		);
		return `<div class="ldp-user-card-badges" aria-label="用户徽章,共 ${ordered.length} 枚">
			<span class="ldp-user-card-badges-label">徽章(${ordered.length})</span>
			<div class="ldp-user-card-badge-strip">
				<button class="ldp-user-card-badge-scroll is-prev" type="button" data-user-card-badge-scroll="-1"
					aria-label="向左查看更多徽章" hidden>${icon('chevronRight')}</button>
				<div class="ldp-user-card-badge-list" tabindex="0" aria-label="全部 ${ordered.length} 枚徽章,可左右滚动查看">
					${ordered.map((badge) => {
						const name = String(badge.name || '').trim();
						const tier = Number(badge?.badge_type_id);
						return `<button class="ldp-user-card-badge" type="button" data-badge-tier="${Number.isFinite(tier) ? tier : ''}"
							data-ldp-tooltip-label="${escAttr(name)}" aria-label="${escAttr(name)}">${userCardBadgeIconHtml(badge)}</button>`;
					}).join('')}
				</div>
				<button class="ldp-user-card-badge-scroll is-next" type="button" data-user-card-badge-scroll="1"
					aria-label="向右查看更多徽章" hidden>${icon('chevronRight')}</button>
			</div>
		</div>`;
	}

	function syncUserCardBadgeScrollControls(root) {
		if (!root) return;
		root.querySelectorAll('.ldp-user-card-badge-strip').forEach((strip) => {
			const list = strip.querySelector('.ldp-user-card-badge-list');
			const previous = strip.querySelector('[data-user-card-badge-scroll="-1"]');
			const next = strip.querySelector('[data-user-card-badge-scroll="1"]');
			if (!list || !previous || !next) return;
			const maxScrollLeft = Math.max(0, list.scrollWidth - list.clientWidth);
			const overflow = maxScrollLeft > 1;
			strip.classList.toggle('is-scrollable', overflow);
			previous.hidden = !overflow;
			next.hidden = !overflow;
			previous.disabled = !overflow || list.scrollLeft <= 1;
			next.disabled = !overflow || list.scrollLeft >= maxScrollLeft - 1;
		});
	}

	function userCardAvatarImageHtml(template, name, previewSource = '') {
		const source = avatarUrl(template, 96);
		const hasCachedSource = AVATAR_OBJECT_URL_CACHE.has(source);
		const preview = String(previewSource || '').trim();
		if (!preview || hasCachedSource || source.startsWith('data:') || source.startsWith('blob:')) {
			return persistentAvatarHtml(template, 96, 'ldp-user-card-avatar', name);
		}
		return `<img class="ldp-user-card-avatar" src="${escAttr(preview)}" alt="" loading="eager" decoding="async"
			data-ldp-avatar-template="${escAttr(template)}" data-ldp-avatar-size="96" data-ldp-avatar-class="ldp-user-card-avatar">`;
	}

	function renderFallbackUserCard(user, username, phase = 'ready', avatarPreviewSource = '', options = {}) {
		user = user || {};
		username = user.username || username || '';
		const name = user.name || username;
		const avatar = avatarWithFlairHtml(
			userCardAvatarImageHtml(user.avatar_template, name, avatarPreviewSource),
			user
		);
		const title = user.title || user.flair_name || '';
		const bio = user.bio_excerpt || user.bio_raw || '';
		const trustLevel = userTrustLevel(user);
		const avatarControl = !avatar
			? ''
			: options.mediaInteractive === false
				? `<span class="ldp-user-card-avatar-trigger">${avatar}</span>`
				: `<button class="ldp-user-card-avatar-trigger" type="button" data-user-card-avatar data-avatar-template="${escAttr(user.avatar_template)}" data-avatar-username="${escAttr(username)}" aria-label="查看 ${escAttr(name)} 的头像原图">${avatar}</button>`;
		return `
			${options.mediaInteractive === false ? '' : userCardBackgroundHtml(user)}
			${userCardHomeHtml(username)}
			<div class="ldp-user-card-head">
				${avatarControl}
				<div class="ldp-user-card-identity">
					<div class="ldp-user-card-name-row">
						<div class="ldp-user-card-name">${esc(name)}</div>
						${Number.isFinite(trustLevel) ? `<span class="ldp-user-card-level">Lv${esc(trustLevel)}</span>` : ''}
					</div>
					<div class="ldp-user-card-username">@${esc(username)}</div>
					${title ? `<div class="ldp-user-card-title">${esc(title)}</div>` : ''}
				</div>
			</div>
			${userProfileFactsHtml(user, 'card', { includePrimaryGroup: true, groupTooltip: true, trustLevel })}
			${userCardGroupsHtml(user)}
			${userCardFollowStatsHtml(user, username, { interactive: options.followStatsInteractive !== false })}
			${bio ? `<div class="ldp-user-card-bio">${sanitizeUserCardBioHtml(bio)}</div>` : ''}
			${userCardBadgesHtml(user)}
			<div class="ldp-user-card-stats">
				${userCardStat(userStatValue(user, ['post_count', 'posts_count']), '帖子', userActivityUrl(username, 'activity/replies'))}
				${userCardStat(userStatValue(user, ['likes_received', 'likes_given']), '获赞', userActivityUrl(username, 'activity/likes-received'))}
				${userCardStat(userStatValue(user, ['topic_count', 'topics_entered']), '主题', userActivityUrl(username, 'activity/topics'))}
			</div>
			${userCardProgressHtml(phase)}
			${options.actions === false ? '' : `${userCardActionsHtml(user, username)}
			<div class="ldp-user-card-action-status" role="status" aria-live="polite"></div>`}`;
	}

	function userCardSeedFromLink(link, username) {
		const head = link?.closest('.ldp-post-head');
		const post = head?.closest('.ldp-post');
		const postData = post?._ldpPostData || {};
		const avatar = head?.querySelector('.ldp-avatar');
		const author = head?.querySelector('.ldp-author');
		return {
			username,
			name: (author?.textContent || '').trim() || username,
			_ldp_avatar_preview_url: avatar && (avatar.currentSrc || avatar.src) || '',
			avatar_template: avatar && (
				avatar.dataset.ldpAvatarTemplate ||
				avatar.dataset.ldpAvatarOriginalTemplate ||
				avatar.currentSrc ||
				avatar.src
			) || '',
			flair_name: postData.flair_name,
			flair_url: postData.flair_url,
			flair_bg_color: postData.flair_bg_color,
			flair_color: postData.flair_color,
		};
	}

	async function showFallbackUserCard(link, isCurrent, onOpen = null, options = {}) {
		const username = getUserCardUsername(link);
		if (!username || !isCurrent()) return;
		const card = options.card || getUserCardSurface();
		const positionCard = isFunction(options.positionCard)
			? options.positionCard
			: (target) => positionFallbackUserCard(target, link);
		const seedUser = userCardSeedFromLink(link, username);
		const avatarPreviewSource = seedUser._ldp_avatar_preview_url || '';
		const renderUser = (user, phase = 'ready') => {
			if (!isCurrent()) return;
			releasePersistentAvatarImages(card);
			card._ldpUserData = user || { username };
			card.innerHTML = renderFallbackUserCard(user, username, phase, avatarPreviewSource, options);
			hydratePersistentAvatarImages(card, POST_REQUEST_PRIORITY.userCard);
			syncUserCardBadgeScrollControls(card);
			requestAnimationFrame(() => card.isConnected && syncUserCardBadgeScrollControls(card));
			card.classList.add('open');
			positionCard(card, link);
		};
		renderUser(seedUser, 'profile');
		if (isFunction(onOpen)) onOpen(card._ldpUserData);
		try {
			await fetchUserCard(username, renderUser);
		} catch (e) {
			if (!isCurrent()) return;
			card._ldpUserData = { username };
			card.innerHTML = `${userCardHomeHtml(username)}<div class="ldp-user-card-bio">用户信息加载失败</div>${options.actions === false ? '' : `${userCardActionsHtml({}, username)}<div class="ldp-user-card-action-status" role="status" aria-live="polite"></div>`}`;
			positionCard(card, link);
		}
	}

	function composerElementAvailable(el) {
		if (!el || el.hidden || el.disabled || el.getAttribute('aria-hidden') === 'true') return false;
		if (el.classList.contains('hidden') || el.classList.contains('d-none')) return false;
		return !el.closest('[hidden],[aria-hidden="true"]');
	}

	function nativeComposerVisible(el) {
		if (!composerElementAvailable(el) || el.classList.contains('closed')) return false;
		const style = getComputedStyle(el);
		if (style.display === 'none' || style.visibility === 'hidden' || style.contentVisibility === 'hidden') return false;
		const rect = el.getBoundingClientRect();
		return rect.width > 0 && rect.height > 0 &&
			rect.right > 0 && rect.bottom > 0 && rect.left < window.innerWidth && rect.top < window.innerHeight;
	}

	function visibleNativeFloatingSurface() {
		return [...document.querySelectorAll('.fk-d-menu,.emoji-picker')].find(nativeComposerVisible) || null;
	}

	function createReaderWindowController(overlay, modal) {
		const capsule = overlay.querySelector('.ldp-reader-window-capsule');
		const dragHandle = capsule.querySelector('.ldp-reader-drag-handle');
		const lockButton = capsule.querySelector('.ldp-reader-lock-button');
		const pinButton = capsule.querySelector('.ldp-reader-pin-button');
		let state = null;
		let interaction = null;
		let frame = 0;
		let destroyed = false;
		const readerWindowPrefKey = (suffix) => `readerWindow${suffix}`;
		const readerWindowPref = (suffix) => PREFS[readerWindowPrefKey(suffix)];
		const setReaderWindowPref = (suffix, value) => setPref(readerWindowPrefKey(suffix), value);
		const setReaderWindowPrefs = (values) => setPrefs(Object.entries(values).reduce((next, [suffix, value]) => {
			next[readerWindowPrefKey(suffix)] = value;
			return next;
		}, {}));
		const isLocked = () => !!readerWindowPref('Locked');
		const isPinned = () => !!readerWindowPref('Pinned');

		const viewportBounds = () => ({
			maxWidth: Math.max(1, window.innerWidth - READER_WINDOW_MARGIN * 2),
			maxHeight: Math.max(1, window.innerHeight - READER_WINDOW_MARGIN * 2),
		});
		const clampSize = (width, height) => {
			const bounds = viewportBounds();
			return {
				width: Math.min(bounds.maxWidth, Math.max(Math.min(READER_WINDOW_MIN_WIDTH, bounds.maxWidth), Math.round(width))),
				height: Math.min(bounds.maxHeight, Math.max(Math.min(READER_WINDOW_MIN_HEIGHT, bounds.maxHeight), Math.round(height))),
			};
		};
		const preferredSize = () => clampSize(
			readerWindowPref('Width') || Math.min(READER_WINDOW_DEFAULT_WIDTH, window.innerWidth * READER_WINDOW_DEFAULT_VIEWPORT_WIDTH),
			readerWindowPref('Height') || window.innerHeight * READER_WINDOW_DEFAULT_VIEWPORT_HEIGHT
		);
		const centeredState = () => {
			const size = preferredSize();
			return clampState({
				...size,
				left: readerWindowPref('X') || Math.round((window.innerWidth - size.width) / 2),
				top: readerWindowPref('Y') || Math.round((window.innerHeight - size.height) / 2),
			});
		};
		const clampState = (next) => {
			const size = clampSize(next.width, next.height);
			return {
				...size,
				left: Math.min(window.innerWidth - READER_WINDOW_MARGIN - size.width,
					Math.max(READER_WINDOW_MARGIN, Math.round(next.left))),
				top: Math.min(window.innerHeight - READER_WINDOW_MARGIN - size.height,
					Math.max(READER_WINDOW_MARGIN, Math.round(next.top))),
			};
		};
		const managed = () => readerPresentation(overlay.dataset.readerWorkspaceMode).floating &&
			window.innerWidth > READER_WINDOW_COMPACT_WIDTH;
		const updateCapsulePlacement = (next) => {
			const docked = managed() && next && next.top < READER_WINDOW_HANDLE_DOCK_THRESHOLD;
			overlay.classList.toggle('ldp-window-handle-docked', !!docked);
			if (!next) return;
			overlay.style.setProperty('--ldp-reader-window-center-x', `${next.left + next.width / 2}px`);
			overlay.style.setProperty('--ldp-reader-window-top', `${next.top}px`);
		};
		const syncLockButton = () => {
			const locked = isLocked();
			const label = locked ? '解锁浮窗' : '锁定浮窗';
			lockButton.classList.toggle('active', locked);
			setPressed(lockButton, locked);
			setLabel(lockButton, label);
			if (lockButton.dataset.locked !== String(locked)) {
				lockButton.dataset.locked = String(locked);
				lockButton.innerHTML = icon(locked ? 'lock' : 'unlock');
			}
		};
		const animateLockButton = () => {
			if (prefersReducedMotion()) return;
			lockButton.classList.remove('ldp-lock-state-changing');
			void lockButton.offsetWidth;
			lockButton.classList.add('ldp-lock-state-changing');
		};
		const apply = () => {
			if (destroyed) return;
			const displayState = managed() ? clampState(state || centeredState()) : null;
			overlay.classList.toggle('ldp-window-locked', isLocked());
			overlay.classList.toggle('ldp-window-pinned', isPinned());
			overlay.classList.toggle('ldp-window-managed', managed());
			syncLockButton();
			pinButton.classList.toggle('active', isPinned());
			setPressed(pinButton, isPinned());
			setLabel(pinButton, isPinned() ? '取消置顶浮窗' : '置顶浮窗');
			if (!managed()) {
				updateCapsulePlacement(null);
				modal.style.removeProperty('left');
				modal.style.removeProperty('top');
				modal.style.removeProperty('width');
				modal.style.removeProperty('height');
				return;
			}
			if (!state) state = { ...displayState };
			modal.style.left = `${displayState.left}px`;
			modal.style.top = `${displayState.top}px`;
			modal.style.width = `${displayState.width}px`;
			modal.style.height = `${displayState.height}px`;
			updateCapsulePlacement(displayState);
		};
		const stopInteraction = () => {
			if (!interaction) return;
			const { target, pointerId } = interaction;
			interaction = null;
			if (frame) cancelAnimationFrame(frame);
			frame = 0;
			overlay.classList.remove('ldp-window-interacting');
			modal.style.removeProperty('transform');
			capsule.style.removeProperty('transform');
			try {
				if (target.hasPointerCapture(pointerId)) target.releasePointerCapture(pointerId);
			} catch (e) {}
		};
		const renderInteraction = () => {
			frame = 0;
			if (!interaction || !managed()) return;
			const dx = interaction.x - interaction.startX;
			const dy = interaction.y - interaction.startY;
			const start = interaction.start;
			if (interaction.type === 'drag') {
				interaction.preview = clampState({ ...start, left: start.left + dx, top: start.top + dy });
				modal.style.transform = `translate3d(${interaction.preview.left - start.left}px,${interaction.preview.top - start.top}px,0)`;
				updateCapsulePlacement(interaction.preview);
				return;
			}
			const direction = interaction.type;
			let left = start.left;
			let top = start.top;
			let right = start.left + start.width;
			let bottom = start.top + start.height;
			if (direction.includes('w')) left = Math.min(right - READER_WINDOW_MIN_WIDTH, Math.max(READER_WINDOW_MARGIN, start.left + dx));
			if (direction.includes('e')) right = Math.max(left + READER_WINDOW_MIN_WIDTH, Math.min(window.innerWidth - READER_WINDOW_MARGIN, right + dx));
			if (direction.includes('n')) top = Math.min(bottom - READER_WINDOW_MIN_HEIGHT, Math.max(READER_WINDOW_MARGIN, start.top + dy));
			if (direction.includes('s')) bottom = Math.max(top + READER_WINDOW_MIN_HEIGHT, Math.min(window.innerHeight - READER_WINDOW_MARGIN, bottom + dy));
			state = clampState({ left, top, width: right - left, height: bottom - top });
			apply();
		};
		const onPointerMove = (event) => {
			if (!interaction || event.pointerId !== interaction.pointerId) return;
			interaction.x = event.clientX;
			interaction.y = event.clientY;
			if (!frame) frame = requestAnimationFrame(renderInteraction);
		};
		const persistGeometry = () => {
			setReaderWindowPrefs({
				Width: state.width,
				Height: state.height,
				X: state.left,
				Y: state.top,
			});
		};
		const onPointerEnd = (event) => {
			if (!interaction || event.pointerId !== interaction.pointerId) return;
			if (frame) {
				cancelAnimationFrame(frame);
				frame = 0;
				renderInteraction();
			}
			const resized = interaction.type !== 'drag';
			if (!resized && interaction.preview) state = interaction.preview;
			stopInteraction();
			if (state) {
				apply();
				persistGeometry();
				overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
			}
		};
		const beginInteraction = (event, type, target) => {
			if (event.button !== 0 || isLocked() || !managed()) return;
			consumeEvent(event);
			state = clampState(state || centeredState());
			interaction = {
				type,
				target,
				pointerId: event.pointerId,
				startX: event.clientX,
				startY: event.clientY,
				x: event.clientX,
				y: event.clientY,
				start: { ...state },
			};
			overlay.classList.add('ldp-window-interacting');
			target.setPointerCapture(event.pointerId);
		};
		const windowScope = createLifecycleScope();
		const readerWindowHandle = (target) => {
			const handle = target instanceof Element && target.closest('.ldp-reader-drag-handle,[data-reader-resize]');
			return handle && overlay.contains(handle) ? handle : null;
		};
		const onHandlePointerDown = (event) => {
			const handle = readerWindowHandle(event.target);
			if (!handle) return;
			const type = handle === dragHandle ? 'drag' : handle.dataset.readerResize;
			if (!type) return;
			beginInteraction(event, type, handle);
		};
		windowScope.listen(overlay, 'pointerdown', onHandlePointerDown);
		windowScope.listen(overlay, 'pointermove', onPointerMove);
		windowScope.listen(overlay, 'pointerup', onPointerEnd);
		windowScope.listen(overlay, 'pointercancel', onPointerEnd);
		const onLockAnimationEnd = () => lockButton.classList.remove('ldp-lock-state-changing');
		const onLockClick = (event) => {
			consumeEvent(event);
			stopInteraction();
			setReaderWindowPref('Locked', !isLocked());
			apply();
			animateLockButton();
			overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
		};
		windowScope.listen(lockButton, 'click', onLockClick);
		windowScope.listen(lockButton, 'animationend', onLockAnimationEnd);
		const onPinClick = (event) => {
			consumeEvent(event);
			setReaderWindowPref('Pinned', !isPinned());
			apply();
			overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
		};
		windowScope.listen(pinButton, 'click', onPinClick);
		const onResize = () => {
			stopInteraction();
			apply();
		};
		windowScope.listen(window, 'resize', onResize);
		apply();

		return {
			syncMode() {
				stopInteraction();
				apply();
			},
			getState() {
				return { ...(state ? (managed() ? clampState(state) : state) : centeredState()) };
			},
			setGeometry(width, height, left, top) {
				if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
				const current = state || centeredState();
				const size = clampSize(Number(width) || current.width, Number(height) || current.height);
				state = clampState({
					...size,
					left: Number.isFinite(Number(left)) ? Number(left) : current.left,
					top: Number.isFinite(Number(top)) ? Number(top) : current.top,
				});
				persistGeometry();
				apply();
				overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
			},
			setLocked(locked) {
				if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
				stopInteraction();
				setReaderWindowPref('Locked', !!locked);
				apply();
			},
			setPinned(pinned) {
				if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
				setReaderWindowPref('Pinned', !!pinned);
				apply();
			},
			reset() {
				if (window.innerWidth <= READER_WINDOW_COMPACT_WIDTH) return;
				stopInteraction();
				setReaderWindowPrefs({
					Width: 0,
					Height: 0,
					X: 0,
					Y: 0,
					Locked: false,
					Pinned: false,
				});
				state = centeredState();
				apply();
				overlay.dispatchEvent(new CustomEvent('ldp-reader-window-change'));
			},
			isDefault() {
				return !readerWindowPref('Width') && !readerWindowPref('Height') &&
					!readerWindowPref('X') && !readerWindowPref('Y') &&
					!isLocked() && !isPinned();
			},
			isLocked,
			isPinned,
			destroy() {
				if (destroyed) return;
				stopInteraction();
				destroyed = true;
				windowScope.destroy();
			},
		};
	}

	function syncNativeTopicStatsComponent(card, reactionCountsByTopicId) {
		const posts = card?.querySelector(':scope > td.posts');
		const views = card?.querySelector(':scope > td.views');
		const activity = card?.querySelector(':scope > td.activity');
		if (!posts || !views || !activity) return;
		let component = posts.querySelector(':scope > .ldp-topic-stats-component');
		const sourceMarkup = (cell) => [...cell.childNodes]
			.filter((node) => node !== component)
			.map((node) => node.nodeType === 1 ? node.outerHTML : String(node.textContent || ''))
			.join('');
		const isOldTopic = views.dataset.ldpNativeOldTopic === 'true';
		const directId = card.dataset.topicId || card.getAttribute('data-topic-id');
		const topicLink = card.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]');
		const topicId = String(directId || extractTopicRouteFromUrl(topicLink?.href)?.topicId || '');
		const responseCount = topicId && reactionCountsByTopicId.has(topicId)
			? reactionCountsByTopicId.get(topicId)
			: null;
		const sourceSignature = `${sourceMarkup(posts)}\u0001${sourceMarkup(views)}\u0001${sourceMarkup(activity)}\u0001${isOldTopic ? 'old' : 'recent'}\u0001${responseCount == null ? '' : responseCount}`;
		if (component && component.dataset.ldpSourceSignature === sourceSignature && component.querySelectorAll(':scope > .ldp-topic-stats-row').length === 2) return;
		const cloneSourceNodes = (cell) => [...cell.childNodes]
			.filter((node) => node !== component)
			.map((node) => node.cloneNode(true));
		const postNodes = cloneSourceNodes(posts);
		const viewNodes = cloneSourceNodes(views);
		const activityNodes = cloneSourceNodes(activity);
		if (!component) {
			component = makeElement('div');
			component.className = 'ldp-topic-stats-component';
			posts.append(component);
		}
		const makeStat = (kind, label, nodes) => {
			const item = makeElement('span');
			item.className = `ldp-topic-stat ldp-topic-stat--${kind}`;
			const caption = makeElement('span');
			caption.className = 'ldp-topic-stat-label';
			caption.textContent = label;
			const value = makeElement('span');
			value.className = 'ldp-topic-stat-value';
			nodes.forEach((node) => value.append(node));
			item.append(caption, value);
			return item;
		};
		const makeRow = (...items) => {
			const row = makeElement('span');
			row.className = 'ldp-topic-stats-row';
			row.append(...items);
			return row;
		};
		component.replaceChildren(
			makeRow(
				makeStat('reply', '回复', postNodes),
				makeStat(isOldTopic ? 'old' : 'date', isOldTopic ? '旧帖' : '最近回复', viewNodes)
			),
			makeRow(
				makeStat('activity', '活跃', activityNodes),
				makeStat('response', '回应', [document.createTextNode(responseCount == null ? '—' : String(responseCount))])
			)
		);
		component.dataset.ldpSourceSignature = sourceSignature;
	}

	function syncNativeTopicActivityStat(card) {
		const activity = card?.querySelector(':scope > td.activity');
		const component = card?.querySelector(':scope > td.posts > .ldp-topic-stats-component');
		const source = activity?.querySelector('.relative-date');
		const target = component?.querySelector('.ldp-topic-stat--activity .relative-date');
		if (!activity || !component || !source || !target) return false;
		if (target.textContent !== source.textContent) target.textContent = source.textContent;
		const signatureParts = String(component.dataset.ldpSourceSignature || '').split('\u0001');
		if (signatureParts.length === 5) {
			signatureParts[2] = [...activity.childNodes]
				.map((node) => node.nodeType === 1 ? node.outerHTML : String(node.textContent || ''))
				.join('');
			component.dataset.ldpSourceSignature = signatureParts.join('\u0001');
		}
		return true;
	}

	function nativeTopicOpReactionCounts() {
		const counts = new Map();
		const router = lookupDiscourse('service:router');
		const routeName = discourseModelValue(router, 'currentRouteName');
		const controllerNames = [
			routeName && `controller:${routeName}`,
			routeName && `controller:${String(routeName).replace(/\./g, '/')}`,
			'controller:discovery/list',
			'controller:tag/show',
			'controller:tags/intersection',
		].filter(Boolean);
		const seenControllers = new Set();
		for (const name of controllerNames) {
			const controller = lookupDiscourse(name);
			if (!controller || seenControllers.has(controller)) continue;
			seenControllers.add(controller);
			const model = discourseModelValue(controller, 'model');
			const list = discourseModelValue(model, 'list') || discourseModelValue(controller, 'list') || model;
			const topicsValue = discourseModelValue(list, 'topics') || discourseModelValue(list, 'content');
			const topics = Array.isArray(topicsValue)
				? topicsValue
				: isFunction(topicsValue?.toArray)
					? topicsValue.toArray()
					: [];
			topics.forEach((topic) => {
				const topicId = String(discourseModelValue(topic, 'id') || '');
				if (!topicId || counts.has(topicId)) return;
				const reactionsData = discourseModelValue(topic, 'op_reactions_data') || discourseModelValue(topic, 'opReactionsData');
				const rawCount = reactionsData
					? discourseModelValue(reactionsData, 'reaction_users_count') ?? discourseModelValue(reactionsData, 'reactionUsersCount')
					: discourseModelValue(topic, 'op_like_count') ?? discourseModelValue(topic, 'opLikeCount');
				const count = Number(rawCount);
				counts.set(topicId, Number.isFinite(count) && count >= 0 ? Math.trunc(count) : null);
			});
		}
		return counts;
	}

	function syncNativeTopicQueueButton(card) {
		if (!card || card.closest('.ldp-overlay')) return;
		const topicLink = card.querySelector(
			'a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]'
		);
		const route = extractTopicRouteFromUrl(topicLink && (topicLink.getAttribute('href') || topicLink.href));
		const topicId = Number(card.dataset.topicId || card.getAttribute('data-topic-id') || route?.topicId);
		const line = topicLink && (topicLink.closest('.link-top-line') || topicLink.parentElement);
		if (!(topicId > 0) || !line) return;
		let button = line.querySelector(':scope > .ldp-reader-queue-add');
		if (!button) {
			button = makeElement('button');
			button.className = 'ldp-reader-queue-add';
			button.type = 'button';
			line.appendChild(button);
		}
		button.dataset.readerQueueTopicId = String(topicId);
		const added = !!readerQueueEntry(topicId);
		if (button.dataset.readerQueueAdded === String(added) && button.firstElementChild) return;
		syncReaderQueueAddButton(button, added);
	}

	function installNativeTopicQueueButtons() {
		if (!document.body || document.body._ldpReaderQueueButtonsObserver) return;
		const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item,.search-result-topic,.fps-result';
		const pendingCards = new Set();
		let frame = 0;
		const collect = (node) => {
			const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
			if (!element || element.closest('.ldp-overlay')) return;
			if (element.matches(cardSelector)) pendingCards.add(element);
			element.querySelectorAll && element.querySelectorAll(cardSelector).forEach((card) => pendingCards.add(card));
			const parentCard = element.closest(cardSelector);
			if (parentCard) pendingCards.add(parentCard);
		};
		const flush = () => {
			frame = 0;
			const cards = [...pendingCards].filter((card) => card.isConnected);
			pendingCards.clear();
			cards.forEach(syncNativeTopicQueueButton);
		};
		const schedule = () => {
			if (!frame && pendingCards.size) frame = requestAnimationFrame(flush);
		};
		document.querySelectorAll(cardSelector).forEach((card) => pendingCards.add(card));
		schedule();
		const observer = new MutationObserver((records) => {
			records.forEach((record) => {
				collect(record.target);
				record.addedNodes.forEach(collect);
			});
			schedule();
		});
		observer.observe(document.body, { childList: true, subtree: true });
		document.body._ldpReaderQueueButtonsObserver = observer;
	}

	function syncNativeTopicTitleTools(card, dndControl) {
		const line = dndControl?.closest('.link-top-line');
		if (!card || !line || !card.contains(line)) return;
		const normalizeText = (node) => String(node?.textContent || '').replace(/\s+/g, ' ').trim();
		const expertLabel = [...line.querySelectorAll('*')].find((node) => normalizeText(node) === '专家回应');
		if (!expertLabel) return;
		const directChild = (node) => {
			let current = node;
			while (current && current.parentElement !== line) current = current.parentElement;
			return current && current.parentElement === line ? current : null;
		};
		const expertItem = directChild(expertLabel);
		const dndItem = directChild(dndControl);
		if (!expertItem || !dndItem) return;
		if (expertItem === dndItem && expertItem.classList.contains('ldp-native-topic-title-tools')) return;
		const children = [...line.children];
		const start = children.indexOf(expertItem);
		const end = children.indexOf(dndItem);
		if (start < 0 || end < start) return;
		const tools = children.slice(start, end + 1);
		const group = makeElement('span');
		group.className = 'ldp-native-topic-title-tools';
		expertItem.before(group);
		group.append(...tools);
	}

	function markNativeDoNotDisturbBadges(scope = document, reactionCountsByTopicId = null) {
		const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item';
		const cards = [];
		if (isFunction(scope?.matches) && scope.matches(cardSelector)) cards.push(scope);
		if (isFunction(scope?.querySelectorAll)) cards.push(...scope.querySelectorAll(cardSelector));
		if (!cards.length) return;
		const reactionCounts = reactionCountsByTopicId || nativeTopicOpReactionCounts();
		cards.forEach((card) => {
			const cells = [...card.querySelectorAll(':scope > td')];
			const dateCell = card.querySelector(':scope > td.views') || cells[3] || null;
			cells.forEach((cell) => {
				const text = cell === dateCell ? String(cell.textContent || '').replace(/\s+/g, ' ').trim() : '';
				const hasTopicDate = /\b(?:\d{4}[/-])?\d{1,2}[/-]\d{1,2}\s+\d{1,2}:\d{2}\b/.test(text);
				const hasOldTopicDate = /\b\d{4}[/-]\d{1,2}[/-]\d{1,2}\s+\d{1,2}:\d{2}\b/.test(text);
				if (hasTopicDate && cell.dataset.ldpNativeTopicDate !== 'true') cell.dataset.ldpNativeTopicDate = 'true';
				else if (!hasTopicDate && 'ldpNativeTopicDate' in cell.dataset) delete cell.dataset.ldpNativeTopicDate;
				if (hasOldTopicDate && cell.dataset.ldpNativeOldTopic !== 'true') cell.dataset.ldpNativeOldTopic = 'true';
				else if (!hasOldTopicDate && 'ldpNativeOldTopic' in cell.dataset) delete cell.dataset.ldpNativeOldTopic;
			});
			const controls = [...card.querySelectorAll('a,button,[role="button"]')];
			const matchingControl = controls.find((node) => node.textContent.replace(/\s+/g, ' ').trim() === '免打扰');
			const fallback = matchingControl ? null : [...card.querySelectorAll('span')].find((node) => node.textContent.replace(/\s+/g, ' ').trim() === '免打扰');
			const dndControl = matchingControl || (fallback && (fallback.closest('a,button,[role="button"]') || fallback));
			if (dndControl && dndControl.dataset.ldpNativeDnd !== 'true') dndControl.dataset.ldpNativeDnd = 'true';
			syncNativeTopicTitleTools(card, dndControl);
			syncNativeTopicQueueButton(card);
			syncNativeTopicStatsComponent(card, reactionCounts);
		});
	}

	function clearNativeEmbeddedTopicEnhancements(scope = document) {
		if (!scope || !isFunction(scope.querySelectorAll)) return;
		scope.querySelectorAll('.ldp-topic-stats-component').forEach((component) => component.remove());
		scope.querySelectorAll('.ldp-native-topic-title-tools').forEach((group) => {
			group.replaceWith(...[...group.childNodes]);
		});
		scope.querySelectorAll('[data-ldp-native-dnd]').forEach((node) => node.removeAttribute('data-ldp-native-dnd'));
		scope.querySelectorAll('[data-ldp-native-topic-date]').forEach((node) => node.removeAttribute('data-ldp-native-topic-date'));
		scope.querySelectorAll('[data-ldp-native-old-topic]').forEach((node) => node.removeAttribute('data-ldp-native-old-topic'));
	}

	function createReaderWorkspaceController(overlay, modal, isDirectTopicRoute) {
		const resizeHandle = modal.querySelector('.ldp-reader-embed-resize');
		const hostScrollbar = overlay.querySelector('.ldp-reader-host-scrollbar');
		const hostScrollbarThumb = hostScrollbar?.querySelector('.ldp-reader-host-scrollbar-thumb');
		const hostTopButton = overlay.querySelector('.ldp-reader-host-top');
		const root = PAGE_ROOT;
		const availableModes = isDirectTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES;
		const workspaceStyleNodes = [root, overlay];
		const workspaceScope = createLifecycleScope();
		let mode = 'floating';
		let pointer = null;
		let resizeFrame = 0;
		let embedWidth = currentListReaderEmbedWidth();
		let destroyed = false;
		let embeddedHostRoots = new Map();
		let embeddedHostObserver = null;
		let embeddedHostFrame = 0;
		let embeddedHostCardFrame = 0;
		const embeddedHostChangedCards = new Set();
		const embeddedHostActivityCards = new Set();
		let hostScrollbarFrame = 0;
		let hostScrollbarResizeObserver = null;
		let hostScrollbarPointer = null;
		let hostScrollbarState = { maxScroll: 0, maxThumbTop: 0, thumbHeight: 0 };
		let hostTopButtonTimer = 0;
		let hostTopCountdownTimer = 0;
		let hostScrollTop = 0;
		let hostUpwardDistance = 0;
		let hostUpwardStartedAt = 0;
		let hostLastScrollAt = 0;
		let hostPointerClientX = NaN;
		let hostPointerClientY = NaN;
		let hostTopJumping = false;
		let nativeListRowHeight = 0;
		const embeddedMode = () => readerPresentation(mode).embedded;

		const setWorkspaceStyle = (name, value) => {
			workspaceStyleNodes.forEach((node) => node.style.setProperty(name, value));
		};
		const clearWorkspaceStyle = (name) => {
			workspaceStyleNodes.forEach((node) => node.style.removeProperty(name));
		};
		const measureNativeListRowHeight = () => {
			const heights = [...document.querySelectorAll('tr.topic-list-item,.topic-list-item,.latest-topic-list-item')]
				.filter((node) => !node.closest('.ldp-overlay'))
				.map((node) => {
					const rect = node.getBoundingClientRect();
					return rect.width > 0 && rect.height >= 48 && rect.height <= 320 ? Math.round(rect.height) : 0;
				})
				.filter(Boolean)
				.sort((left, right) => left - right);
			return heights.length ? heights[Math.floor((heights.length - 1) / 2)] : 0;
		};
		const setEmbedResizing = (active) => {
			root.classList.toggle('ldp-reader-embed-resizing', !!active);
			overlay.classList.toggle('ldp-reader-embed-resizing', !!active);
		};
		const pageScrollMetrics = () => {
			const scrollingElement = document.scrollingElement || root;
			const viewportHeight = Math.max(1, Number(window.innerHeight || root.clientHeight || 0));
			const scrollHeight = Math.max(
				viewportHeight,
				Number(scrollingElement?.scrollHeight || 0),
				Number(document.body?.scrollHeight || 0),
			);
			const maxScroll = Math.max(0, scrollHeight - viewportHeight);
			const scrollTop = Math.min(maxScroll, Math.max(0, Number(
				window.scrollY || scrollingElement?.scrollTop || 0,
			)));
			return { viewportHeight, scrollHeight, maxScroll, scrollTop };
		};
		const syncHostScrollbar = () => {
			hostScrollbarFrame = 0;
			if (destroyed || !embeddedMode() || !hostScrollbar || !hostScrollbarThumb) return;
			const metrics = pageScrollMetrics();
			const trackHeight = Math.max(1, hostScrollbar.clientHeight);
			const thumbHeight = metrics.maxScroll > 0
				? Math.min(trackHeight, Math.max(24, trackHeight * metrics.viewportHeight / metrics.scrollHeight))
				: trackHeight;
			const maxThumbTop = Math.max(0, trackHeight - thumbHeight);
			const thumbTop = metrics.maxScroll > 0
				? maxThumbTop * metrics.scrollTop / metrics.maxScroll
				: 0;
			hostScrollbarState = { maxScroll: metrics.maxScroll, maxThumbTop, thumbHeight };
			hostScrollbar.classList.toggle('ldp-reader-host-scrollbar-inactive', metrics.maxScroll <= 0);
			hostScrollbar.setAttribute('aria-disabled', String(metrics.maxScroll <= 0));
			hostScrollbar.setAttribute('aria-valuemax', String(Math.round(metrics.maxScroll)));
			hostScrollbar.setAttribute('aria-valuenow', String(Math.round(metrics.scrollTop)));
			hostScrollbarThumb.style.height = `${Math.round(thumbHeight * 100) / 100}px`;
			hostScrollbarThumb.style.transform = `translateY(${Math.round(thumbTop * 100) / 100}px)`;
		};
		const scheduleHostScrollbarSync = () => {
			if (hostScrollbarFrame || destroyed || !embeddedMode()) return;
			hostScrollbarFrame = requestAnimationFrame(syncHostScrollbar);
		};
		const hideHostTopButton = () => {
			if (hostTopButtonTimer) clearTimeout(hostTopButtonTimer);
			if (hostTopCountdownTimer) clearInterval(hostTopCountdownTimer);
			hostTopButtonTimer = 0;
			hostTopCountdownTimer = 0;
			if (hostTopButton) hostTopButton.hidden = true;
		};
		const resetHostUpwardScroll = (scrollTop = pageScrollMetrics().scrollTop) => {
			hostScrollTop = scrollTop;
			hostUpwardDistance = 0;
			hostUpwardStartedAt = 0;
			hostLastScrollAt = 0;
		};
		const hostHorizontalBounds = () => mode === 'embed-left'
			? { left: embedWidth, right: window.innerWidth }
			: { left: 0, right: window.innerWidth - embedWidth };
		const rememberHostPointer = (event) => {
			if (!embeddedMode() || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return;
			const bounds = hostHorizontalBounds();
			if (event.clientX < bounds.left || event.clientX > bounds.right) return;
			hostPointerClientX = event.clientX;
			hostPointerClientY = event.clientY;
		};
		const showHostTopButton = () => {
			if (!hostTopButton || !Number.isFinite(hostPointerClientX) || !Number.isFinite(hostPointerClientY)) return;
			const metrics = pageScrollMetrics();
			if (metrics.scrollTop <= 8) {
				hideHostTopButton();
				return;
			}
			if (hostTopButton.hidden) {
				const bounds = hostHorizontalBounds();
				const buttonSize = 30;
				const edgeGap = 8;
				const pointerGap = 12;
				const left = Math.max(bounds.left + edgeGap, Math.min(
					bounds.right - buttonSize - edgeGap,
					hostPointerClientX + pointerGap,
				));
				const top = Math.max(edgeGap, Math.min(
					window.innerHeight - buttonSize - edgeGap,
					hostPointerClientY - buttonSize / 2,
				));
				hostTopButton.style.left = `${Math.round(left)}px`;
				hostTopButton.style.top = `${Math.round(top)}px`;
				hostTopButton.hidden = false;
			}
			if (hostTopButtonTimer) clearTimeout(hostTopButtonTimer);
			if (hostTopCountdownTimer) clearInterval(hostTopCountdownTimer);
			const countdown = hostTopButton.querySelector('.ldp-reader-host-top-countdown');
			const hideAt = performance.now() + 3000;
			const updateCountdown = () => {
				if (countdown) countdown.textContent = String(Math.max(1, Math.ceil((hideAt - performance.now()) / 1000)));
			};
			updateCountdown();
			hostTopCountdownTimer = setInterval(updateCountdown, 200);
			hostTopButtonTimer = setTimeout(hideHostTopButton, 3000);
		};
		const onHostScroll = () => {
			scheduleHostScrollbarSync();
			if (!embeddedMode()) return;
			const scrollTop = pageScrollMetrics().scrollTop;
			if (hostTopJumping) {
				resetHostUpwardScroll(scrollTop);
				return;
			}
			const now = performance.now();
			const upwardDelta = hostScrollTop - scrollTop;
			hostScrollTop = scrollTop;
			if (scrollTop <= 8) hideHostTopButton();
			if (upwardDelta <= 0) {
				hostUpwardDistance = 0;
				hostUpwardStartedAt = 0;
				hostLastScrollAt = now;
				return;
			}
			if (!hostUpwardStartedAt || now - hostLastScrollAt > 240) {
				hostUpwardStartedAt = now;
				hostUpwardDistance = 0;
			}
			hostLastScrollAt = now;
			hostUpwardDistance += upwardDelta;
			if (hostUpwardDistance >= 240 && now - hostUpwardStartedAt <= 450) {
				showHostTopButton();
				hostUpwardStartedAt = now;
				hostUpwardDistance = 0;
			}
		};
		const onHostTopClick = () => {
			if (!embeddedMode()) return;
			hideHostTopButton();
			hostTopJumping = true;
			window.scrollTo({ top: 0, behavior: 'auto' });
			requestAnimationFrame(() => {
				hostTopJumping = false;
				resetHostUpwardScroll(0);
			});
		};
		const startHostScrollbarSync = () => {
			if (!hostScrollbar || !hostScrollbarThumb) return;
			if (!hostScrollbarResizeObserver && isFunction(ResizeObserver)) {
				hostScrollbarResizeObserver = new ResizeObserver(scheduleHostScrollbarSync);
				hostScrollbarResizeObserver.observe(root);
				if (document.body) hostScrollbarResizeObserver.observe(document.body);
			}
			resetHostUpwardScroll();
			scheduleHostScrollbarSync();
		};
		const stopHostScrollbarSync = () => {
			if (hostScrollbarFrame) cancelAnimationFrame(hostScrollbarFrame);
			hostScrollbarFrame = 0;
			if (hostScrollbarResizeObserver) hostScrollbarResizeObserver.disconnect();
			hostScrollbarResizeObserver = null;
			hostScrollbarPointer = null;
			hostScrollbarState = { maxScroll: 0, maxThumbTop: 0, thumbHeight: 0 };
			hideHostTopButton();
			resetHostUpwardScroll(0);
			if (!hostScrollbar || !hostScrollbarThumb) return;
			hostScrollbar.classList.remove('ldp-reader-host-scrollbar-dragging');
			hostScrollbar.classList.add('ldp-reader-host-scrollbar-inactive');
			hostScrollbar.setAttribute('aria-disabled', 'true');
			hostScrollbar.setAttribute('aria-valuemax', '0');
			hostScrollbar.setAttribute('aria-valuenow', '0');
			hostScrollbarThumb.style.removeProperty('height');
			hostScrollbarThumb.style.removeProperty('transform');
		};
		const scrollHostFromPointer = (clientY) => {
			if (!hostScrollbar || !hostScrollbarPointer || hostScrollbarState.maxScroll <= 0) return;
			const rect = hostScrollbar.getBoundingClientRect();
			const thumbTop = Math.max(0, Math.min(
				hostScrollbarState.maxThumbTop,
				clientY - rect.top - hostScrollbarPointer.offsetY,
			));
			const ratio = hostScrollbarState.maxThumbTop > 0 ? thumbTop / hostScrollbarState.maxThumbTop : 0;
			window.scrollTo({ top: ratio * hostScrollbarState.maxScroll, behavior: 'auto' });
		};
		const onHostScrollbarPointerDown = (event) => {
			if (event.button !== 0 || !embeddedMode() || !hostScrollbar || !hostScrollbarThumb) return;
			if (hostScrollbarFrame) cancelAnimationFrame(hostScrollbarFrame);
			hostScrollbarFrame = 0;
			syncHostScrollbar();
			if (hostScrollbarState.maxScroll <= 0) return;
			consumeEvent(event);
			const thumbRect = hostScrollbarThumb.getBoundingClientRect();
			const onThumb = event.target === hostScrollbarThumb;
			hostScrollbarPointer = {
				pointerId: event.pointerId,
				offsetY: onThumb ? event.clientY - thumbRect.top : hostScrollbarState.thumbHeight / 2,
			};
			hostScrollbar.classList.add('ldp-reader-host-scrollbar-dragging');
			hostScrollbar.setPointerCapture(event.pointerId);
			try { hostScrollbar.focus({ preventScroll: true }); } catch (e) { hostScrollbar.focus(); }
			scrollHostFromPointer(event.clientY);
		};
		const onHostScrollbarPointerMove = (event) => {
			if (!hostScrollbarPointer || event.pointerId !== hostScrollbarPointer.pointerId) return;
			event.preventDefault();
			scrollHostFromPointer(event.clientY);
		};
		const stopHostScrollbarPointer = (event) => {
			if (!hostScrollbarPointer || event && event.pointerId !== hostScrollbarPointer.pointerId) return;
			const pointerId = hostScrollbarPointer.pointerId;
			hostScrollbarPointer = null;
			if (hostScrollbar) {
				hostScrollbar.classList.remove('ldp-reader-host-scrollbar-dragging');
				try {
					if (hostScrollbar.hasPointerCapture(pointerId)) hostScrollbar.releasePointerCapture(pointerId);
				} catch (e) {}
			}
		};
		const onHostScrollbarKeyDown = (event) => {
			if (!embeddedMode()) return;
			const metrics = pageScrollMetrics();
			let nextTop = metrics.scrollTop;
			if (event.key === 'ArrowUp') nextTop -= 40;
			else if (event.key === 'ArrowDown') nextTop += 40;
			else if (event.key === 'PageUp') nextTop -= metrics.viewportHeight * .9;
			else if (event.key === 'PageDown') nextTop += metrics.viewportHeight * .9;
			else if (event.key === 'Home') nextTop = 0;
			else if (event.key === 'End') nextTop = metrics.maxScroll;
			else return;
			consumeEvent(event);
			window.scrollTo({ top: Math.max(0, Math.min(metrics.maxScroll, nextTop)), behavior: 'auto' });
		};
		const topLevelBodyChild = (node) => {
			let current = node && node.nodeType === 1 ? node : null;
			while (current?.parentElement && current.parentElement !== document.body) current = current.parentElement;
			return current && current.parentElement === document.body ? current : null;
		};
		const syncEmbeddedHostRoots = () => {
			embeddedHostFrame = 0;
			if (destroyed || !document.body || !embeddedMode()) return;
			const nextRoots = new Map();
			const rank = { sidebar: 1, header: 2, shell: 3 };
			const mark = (node, role) => {
				const topLevel = topLevelBodyChild(node);
				if (!topLevel || topLevel === overlay || topLevel.contains(overlay)) return;
				const previousRole = nextRoots.get(topLevel);
				if (!previousRole || rank[role] > rank[previousRole]) nextRoots.set(topLevel, role);
			};
			document.querySelectorAll('#ember-app,#main-outlet-wrapper,#main-outlet,.list-container,.topic-list,.navigation-container').forEach((node) => mark(node, 'shell'));
			document.querySelectorAll('.d-header,body > header').forEach((node) => mark(node, 'header'));
			document.querySelectorAll('#d-sidebar,.sidebar-wrapper,.sidebar-container').forEach((node) => mark(node, 'sidebar'));
			for (const node of embeddedHostRoots.keys()) {
				if (!nextRoots.has(node)) node.removeAttribute('data-ldp-reader-host-root');
			}
			nextRoots.forEach((role, node) => {
				node.setAttribute('data-ldp-reader-host-root', role);
				if (role === 'shell') markNativeDoNotDisturbBadges(node);
			});
			embeddedHostRoots = nextRoots;
		};
		const scheduleEmbeddedHostRootSync = () => {
			if (embeddedHostFrame || destroyed || !embeddedMode()) return;
			embeddedHostFrame = requestAnimationFrame(syncEmbeddedHostRoots);
		};
		const flushEmbeddedHostCards = () => {
			embeddedHostCardFrame = 0;
			if (destroyed || !embeddedMode()) {
				embeddedHostChangedCards.clear();
				embeddedHostActivityCards.clear();
				return;
			}
			const activityCards = [...embeddedHostActivityCards]
				.filter((card) => card.isConnected && !embeddedHostChangedCards.has(card));
			embeddedHostActivityCards.clear();
			activityCards.forEach((card) => {
				if (!syncNativeTopicActivityStat(card)) embeddedHostChangedCards.add(card);
			});
			const cards = [...embeddedHostChangedCards].filter((card) => card.isConnected);
			embeddedHostChangedCards.clear();
			if (!cards.length) return;
			const reactionCountsByTopicId = nativeTopicOpReactionCounts();
			cards.forEach((card) => markNativeDoNotDisturbBadges(card, reactionCountsByTopicId));
		};
		const scheduleEmbeddedHostCardSet = (cards, pendingCards) => {
			cards.forEach((card) => pendingCards.add(card));
			if (!pendingCards.size || embeddedHostCardFrame || destroyed || !embeddedMode()) return;
			embeddedHostCardFrame = requestAnimationFrame(flushEmbeddedHostCards);
		};
		const startEmbeddedHostRootObserver = () => {
			if (!embeddedHostObserver && document.body) {
				embeddedHostObserver = new MutationObserver((records) => {
					const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item';
					const changedCards = new Set();
					const collectNearestCard = (node) => {
						const element = node && (node.nodeType === 1 ? node : node.parentElement);
						if (!element || element.closest('.ldp-overlay,.ldp-topic-stats-component')) return;
						const card = element.matches(cardSelector) ? element : element.closest(cardSelector);
						if (card) changedCards.add(card);
					};
					const collectAddedCards = (node) => {
						const element = node && (node.nodeType === 1 ? node : node.parentElement);
						if (!element || element.closest('.ldp-overlay,.ldp-topic-stats-component')) return;
						const card = element.matches(cardSelector) ? element : element.closest(cardSelector);
						if (card) {
							changedCards.add(card);
							return;
						}
						if (element.querySelectorAll) element.querySelectorAll(cardSelector).forEach((item) => changedCards.add(item));
					};
					records.forEach((record) => {
						const activityTarget = record.type === 'childList' && record.target.nodeType === 1 &&
							record.target.matches('.relative-date') && record.target.closest('td.activity');
						const onlyActivityTextChanged = activityTarget && record.addedNodes.length > 0 &&
							record.removedNodes.length > 0 &&
							[...record.addedNodes, ...record.removedNodes].every((node) => node.nodeType === 3);
						if (onlyActivityTextChanged) {
							const card = activityTarget.closest(cardSelector);
							if (card) scheduleEmbeddedHostCardSet([card], embeddedHostActivityCards);
							return;
						}
						if (record.type === 'childList' && record.target === document.body) scheduleEmbeddedHostRootSync();
						const changedNodes = record.type === 'childList'
							? [...record.addedNodes, ...record.removedNodes]
							: [];
						const onlyOwnedStatsChanged = changedNodes.length > 0 && changedNodes.every((node) =>
							node.nodeType === 1 && (node.matches('.ldp-topic-stats-component') || node.closest('.ldp-topic-stats-component'))
						);
						if (!onlyOwnedStatsChanged) collectNearestCard(record.target);
						record.addedNodes?.forEach(collectAddedCards);
					});
					scheduleEmbeddedHostCardSet(changedCards, embeddedHostChangedCards);
				});
				embeddedHostObserver.observe(document.body, { childList: true, subtree: true, characterData: true });
			}
			syncEmbeddedHostRoots();
		};
		const clearEmbeddedHostRoots = () => {
			if (embeddedHostFrame) cancelAnimationFrame(embeddedHostFrame);
			if (embeddedHostCardFrame) cancelAnimationFrame(embeddedHostCardFrame);
			embeddedHostFrame = 0;
			embeddedHostCardFrame = 0;
			embeddedHostChangedCards.clear();
			embeddedHostActivityCards.clear();
			if (embeddedHostObserver) embeddedHostObserver.disconnect();
			embeddedHostObserver = null;
			clearNativeEmbeddedTopicEnhancements();
			for (const node of embeddedHostRoots.keys()) node.removeAttribute('data-ldp-reader-host-root');
			embeddedHostRoots = new Map();
		};
		const clearEmbeddedHost = () => {
			setEmbedResizing(false);
			stopHostScrollbarPointer();
			stopHostScrollbarSync();
			clearEmbeddedHostRoots();
			root.classList.remove('ldp-reader-embedded-left', 'ldp-reader-embedded-right');
			if (!root.classList.contains('ldp-reader-sidepanel-active')) root.classList.remove('ldp-reader-workspace');
			clearWorkspaceStyle('--ldp-reader-workspace-width');
			delete overlay.dataset.readerWorkspaceWidth;
			clearWorkspaceStyle('--ldp-reader-host-min-width');
			clearWorkspaceStyle('--ldp-reader-native-row-height');
			nativeListRowHeight = 0;
			root.style.removeProperty('--ldp-reader-list-zebra-color');
			root.style.removeProperty('--ldp-divider-line-color');
			root.style.removeProperty('--ldp-divider-line-width');
			document.querySelectorAll('[data-ldp-reader-active-topic]').forEach((card) => card.removeAttribute('data-ldp-reader-active-topic'));
		};
		const maximumEmbedWidth = () => Math.max(READER_EMBED_MIN_WIDTH, Math.floor(window.innerWidth - READER_HOST_MIN_WIDTH));
		const canEmbed = () => window.innerWidth >= READER_EMBED_MIN_WIDTH + READER_HOST_MIN_WIDTH;
		const clampEmbedWidth = (value) => Math.min(maximumEmbedWidth(), Math.max(READER_EMBED_MIN_WIDTH, Math.round(value)));
		const setEmbedWidth = (value) => {
			embedWidth = clampEmbedWidth(value);
			overlay.dataset.readerWorkspaceWidth = String(embedWidth);
			setWorkspaceStyle('--ldp-reader-workspace-width', `${embedWidth}px`);
			return embedWidth;
		};
		const effectiveMode = (requested) => {
			if (!availableModes.includes(requested)) return isDirectTopicRoute ? 'fullpage' : 'floating';
			if (!readerPresentation(requested).embedded) return requested;
			return canEmbed() ? requested : 'floating';
		};
		const apply = (requested = mode) => {
			const previousMode = mode;
			mode = effectiveMode(requested);
			const presentation = readerPresentation(mode);
			const embedded = presentation.embedded;
			if (readerPresentation(previousMode).embedded && !embedded) {
				stopHostScrollbarPointer();
				stopHostScrollbarSync();
			}
			overlay.dataset.readerWorkspaceMode = mode;
			overlay.classList.toggle('ldp-fullpage', presentation.fullPage);
			overlay.classList.toggle('ldp-reader-embedded', embedded);
			overlay.classList.toggle('ldp-reader-embedded-left', presentation.side === 'left');
			overlay.classList.toggle('ldp-reader-embedded-right', presentation.side === 'right');
			if (!embedded) {
				clearEmbeddedHost();
				return mode;
			}
			ensureReaderStyleMounted();
			if (!root.classList.contains('ldp-reader-workspace')) nativeListRowHeight = measureNativeListRowHeight();
			const width = currentListReaderEmbedWidth();
			root.classList.add('ldp-reader-workspace');
			root.classList.toggle('ldp-reader-embedded-left', presentation.side === 'left');
			root.classList.toggle('ldp-reader-embedded-right', presentation.side === 'right');
			setEmbedWidth(width);
			setWorkspaceStyle('--ldp-reader-host-min-width', `${READER_HOST_MIN_WIDTH}px`);
			if (nativeListRowHeight) setWorkspaceStyle('--ldp-reader-native-row-height', `${nativeListRowHeight}px`);
			const appearance = currentAppearanceProfile();
			const themeMode = resolvedReaderThemeMode();
			const dividerLineColor = appearanceThemeColor(appearance, 'dividerLineColor', themeMode);
			const dividerLineDefault = APPEARANCE_PROFILE_DEFAULT[
				appearanceThemeValueKey('dividerLineColor', themeMode)
			];
			root.style.setProperty('--ldp-reader-list-zebra-color',
				appearanceThemeColor(appearance, 'listZebraColor', themeMode));
			if (!appearance.structureColorsEnabled) root.style.setProperty('--ldp-divider-line-color', 'transparent');
			else if (dividerLineColor === dividerLineDefault) root.style.removeProperty('--ldp-divider-line-color');
			else root.style.setProperty('--ldp-divider-line-color', dividerLineColor);
			if (appearance.dividerLineWidth === DIVIDER_LINE_WIDTH_DEFAULT) root.style.removeProperty('--ldp-divider-line-width');
			else root.style.setProperty('--ldp-divider-line-width', `${appearance.dividerLineWidth}px`);
			startEmbeddedHostRootObserver();
			startHostScrollbarSync();
			return mode;
		};
		const emitChange = () => {
			if (!destroyed) overlay.dispatchEvent(new CustomEvent('ldp-reader-workspace-change'));
		};
		const setMode = (nextMode) => {
			if (!availableModes.includes(nextMode)) return false;
			if (isDirectTopicRoute) setPref('topicReaderMode', nextMode);
			else setPrefs({ listReaderMode: nextMode });
			apply(nextMode);
			emitChange();
			return true;
		};
		const applyResizePreview = () => {
			resizeFrame = 0;
			if (!pointer || !embeddedMode()) return;
			const width = mode === 'embed-left'
				? pointer.clientX
				: window.innerWidth - pointer.clientX;
			setEmbedWidth(width);
		};
		const onResizePointerMove = (event) => {
			if (!pointer || event.pointerId !== pointer.pointerId) return;
			pointer.clientX = event.clientX;
			if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResizePreview);
		};
		const stopResize = (event) => {
			if (!pointer || (event && event.pointerId !== pointer.pointerId)) {
				setEmbedResizing(false);
				return;
			}
			if (resizeFrame) {
				cancelAnimationFrame(resizeFrame);
				resizeFrame = 0;
				applyResizePreview();
			}
			setEmbedResizing(false);
			setPref('listReaderEmbedWidth', embedWidth);
			try {
				if (resizeHandle?.hasPointerCapture(pointer.pointerId)) resizeHandle.releasePointerCapture(pointer.pointerId);
			} catch (e) {}
			pointer = null;
			emitChange();
		};
		const onResizePointerDown = (event) => {
			if (event.button !== 0 || !embeddedMode()) return;
			consumeEvent(event);
			pointer = { pointerId: event.pointerId, clientX: event.clientX };
			setEmbedResizing(true);
			resizeHandle.setPointerCapture(event.pointerId);
		};
		const onViewportResize = () => {
			const requested = isDirectTopicRoute
				? currentTopicReaderMode()
				: currentListReaderMode();
			apply(requested);
			emitChange();
		};
		if (resizeHandle) {
			workspaceScope.listen(resizeHandle, 'pointerdown', onResizePointerDown);
			workspaceScope.listen(resizeHandle, 'pointermove', onResizePointerMove);
			['pointerup', 'pointercancel'].forEach((type) =>
				workspaceScope.listen(resizeHandle, type, stopResize));
		}
		workspaceScope.listen(window, 'resize', onViewportResize);
		workspaceScope.listen(window, 'pointermove', rememberHostPointer, { passive: true });
		workspaceScope.listen(window, 'wheel', rememberHostPointer, { capture: true, passive: true });
		workspaceScope.listen(window, 'scroll', onHostScroll, { passive: true });
		if (hostTopButton) workspaceScope.listen(hostTopButton, 'click', onHostTopClick);
		if (hostScrollbar) {
			workspaceScope.listen(hostScrollbar, 'pointerdown', onHostScrollbarPointerDown);
			workspaceScope.listen(hostScrollbar, 'pointermove', onHostScrollbarPointerMove);
			['pointerup', 'pointercancel'].forEach((type) =>
				workspaceScope.listen(hostScrollbar, type, stopHostScrollbarPointer));
			workspaceScope.listen(hostScrollbar, 'keydown', onHostScrollbarKeyDown);
		}
		apply(isDirectTopicRoute
			? currentTopicReaderMode()
			: currentListReaderMode());

		return {
			mode: () => mode,
			isEmbedded: embeddedMode,
			isFullPage: () => readerPresentation(mode).fullPage,
			isFloating: () => readerPresentation(mode).floating,
			setMode,
			sync: onViewportResize,
			destroy() {
				if (destroyed) return;
				destroyed = true;
				if (resizeFrame) cancelAnimationFrame(resizeFrame);
				stopResize();
				clearEmbeddedHost();
				workspaceScope.destroy();
			},
		};
	}

	function createNativeComposerWindowController(readerSession) {
		const root = PAGE_ROOT;
		const rootStyle = root.style;
		const geometryProperties = [
			'--ldp-composer-left', '--ldp-composer-top', '--ldp-composer-width',
			'--ldp-composer-height', '--ldp-composer-transform',
		];
		const positionedAttribute = 'data-ldp-reader-composer-positioned';
		const resizeDirections = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
		let composer = null;
		let composerRoot = null;
		let composerOverflowLayers = [];
		let composerOverflowFrame = 0;
		let chrome = null;
		let composerObserver = null;
		let composerHostObserver = null;
		let state = null;
		let pointer = null;
		let pointerFrame = 0;
		let pointerClientX = 0;
		let pointerClientY = 0;
		let chromeFrame = 0;
		let statePersistTimer = 0;
		let nativeFloatingFrame = 0;
		let nativeFloatingObserver = null;
		const ownedTopLayerElements = new Set();
		const nativeFloatingSurfaceSelector = '.fk-d-menu,.emoji-picker';
		let destroyed = false;

		const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
		const numeric = (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback;
		const cssPixels = (value) => `${Math.round(value * 100) / 100}px`;
		const sessionActive = () => !destroyed && (!readerSession || readerSession.isCurrent('opening', 'active'));
		const desktopWindow = () => window.innerWidth > 760;
		const elementInTopLayer = (element) => {
			try {
				return !!(element?.matches(':popover-open'));
			} catch (error) {
				return false;
			}
		};
		const releaseOwnedTopLayer = (element) => {
			if (!element || !ownedTopLayerElements.delete(element)) return;
			try {
				if (elementInTopLayer(element) && isFunction(element.hidePopover)) element.hidePopover();
			} catch (error) {}
			element.removeAttribute('popover');
			delete element.dataset.ldpReaderTopLayer;
		};
		const promoteOwnedTopLayer = (element, type) => {
			if (!element || !isFunction(element.showPopover)) return false;
			if (element.hasAttribute('popover') && !ownedTopLayerElements.has(element)) return elementInTopLayer(element);
			if (!ownedTopLayerElements.has(element)) {
				element.setAttribute('popover', 'manual');
				element.dataset.ldpReaderTopLayer = type;
				ownedTopLayerElements.add(element);
			}
			try {
				if (!elementInTopLayer(element)) element.showPopover();
				return elementInTopLayer(element);
			} catch (error) {
				releaseOwnedTopLayer(element);
				return false;
			}
		};
		const nativeFloatingSurfaceVisible = (element) => {
			if (!element || !element.isConnected || element.closest('#reply-control') || element.contains(composer) || element.closest('dialog[open]')) return false;
			if (element.classList.contains('closed')) return false;
			if (!composerElementAvailable(element)) return false;
			const elementStyle = getComputedStyle(element);
			if (elementStyle.display === 'none' || elementStyle.visibility === 'hidden') return false;
			const rect = element.getBoundingClientRect();
			return rect.width > 0 && rect.height > 0;
		};
		const syncNativeFloatingTopLayers = () => {
			nativeFloatingFrame = 0;
			if (!elementInTopLayer(composer)) return;
			const visible = [...document.querySelectorAll(nativeFloatingSurfaceSelector)]
				.filter(nativeFloatingSurfaceVisible);
			const surfaces = visible.filter((element) => !visible.some((parent) => parent !== element && parent.contains(element)));
			ownedTopLayerElements.forEach((element) => {
				if (element.dataset.ldpReaderTopLayer === 'portal' && !surfaces.includes(element)) releaseOwnedTopLayer(element);
			});
			surfaces.forEach((element) => promoteOwnedTopLayer(element, 'portal'));
		};
		const scheduleNativeFloatingTopLayerSync = () => {
			if (nativeFloatingFrame) return;
			nativeFloatingFrame = requestAnimationFrame(syncNativeFloatingTopLayers);
		};
		const nativeFloatingMutationNodeIsRelevant = (node) => {
			const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
			if (!element) return false;
			return element.matches(nativeFloatingSurfaceSelector) ||
				!!element.closest(nativeFloatingSurfaceSelector) ||
				!!element.querySelector(nativeFloatingSurfaceSelector);
		};
		const nativeFloatingMutationIsRelevant = (mutation) => {
			if (mutation.type === 'attributes') return nativeFloatingMutationNodeIsRelevant(mutation.target);
			return [...mutation.addedNodes, ...mutation.removedNodes].some(nativeFloatingMutationNodeIsRelevant);
		};
		const startNativeFloatingTopLayerSync = () => {
			if (!nativeFloatingObserver && document.body) {
				nativeFloatingObserver = new MutationObserver((mutations) => {
					if (mutations.some(nativeFloatingMutationIsRelevant)) scheduleNativeFloatingTopLayerSync();
				});
				nativeFloatingObserver.observe(document.body, {
					childList: true,
					subtree: true,
					attributes: true,
					attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'open'],
				});
			}
			scheduleNativeFloatingTopLayerSync();
		};
		const releaseOwnedComposerTopLayers = () => {
			if (nativeFloatingFrame) cancelAnimationFrame(nativeFloatingFrame);
			nativeFloatingFrame = 0;
			if (nativeFloatingObserver) nativeFloatingObserver.disconnect();
			nativeFloatingObserver = null;
			[...ownedTopLayerElements].reverse().forEach(releaseOwnedTopLayer);
		};
		const releaseComposerTopLayers = () => {
			releaseOwnedComposerTopLayers();
			root.classList.remove('ldp-composer-host-isolated');
		};
		const syncComposerTopLayers = () => {
			const composerOpen = !!(composer?.isConnected &&
				!composer.classList.contains('closed') && composerElementAvailable(composer));
			root.classList.toggle('ldp-composer-host-isolated', composerOpen);
			if (!composerOpen || !promoteOwnedTopLayer(composer, 'composer')) {
				releaseOwnedComposerTopLayers();
				return false;
			}
			if (chrome && !chrome.hidden) promoteOwnedTopLayer(chrome, 'chrome');
			startNativeFloatingTopLayerSync();
			return true;
		};
		const clearComposerRoot = () => {
			if (composerRoot) composerRoot.removeAttribute('data-ldp-reader-composer-root');
			composerRoot = null;
		};
		const syncComposerRoot = () => {
			let next = composer;
			while (next?.parentElement && next.parentElement !== document.body) next = next.parentElement;
			if (!next || next.parentElement !== document.body) next = null;
			if (next === composerRoot) return;
			clearComposerRoot();
			composerRoot = next;
			if (composerRoot) composerRoot.setAttribute('data-ldp-reader-composer-root', '1');
		};
		const clearComposerOverflowLayers = () => {
			if (composerOverflowFrame) cancelAnimationFrame(composerOverflowFrame);
			composerOverflowFrame = 0;
			composerOverflowLayers.forEach((layer) => layer.removeAttribute('data-ldp-reader-composer-overflow'));
			composerOverflowLayers = [];
		};
		const syncComposerOverflowLayers = () => {
			composerOverflowFrame = 0;
			composerOverflowLayers.forEach((layer) => layer.removeAttribute('data-ldp-reader-composer-overflow'));
			composerOverflowLayers = [];
			if (!composer || !composer.isConnected || composer.classList.contains('closed')) return;
			const composerRect = composer.getBoundingClientRect();
			if (composerRect.width <= 0 || composerRect.height <= 0) return;
			const candidates = [composer, ...composer.querySelectorAll('*')].map((candidate) => {
				if (candidate.clientWidth <= 0 || candidate.scrollWidth <= candidate.clientWidth + 1) return false;
				if (candidate.closest('.d-editor-button-bar,[role="toolbar"]')) return false;
				const overflowX = getComputedStyle(candidate).overflowX;
				if (overflowX !== 'auto' && overflowX !== 'scroll') return false;
				const rect = candidate.getBoundingClientRect();
				const coveredWidth = Math.max(0, Math.min(rect.right, composerRect.right) - Math.max(rect.left, composerRect.left));
				const coveredHeight = Math.max(0, Math.min(rect.bottom, composerRect.bottom) - Math.max(rect.top, composerRect.top));
				const widthCoverage = coveredWidth / composerRect.width;
				const heightCoverage = coveredHeight / composerRect.height;
				if (widthCoverage < .85 || heightCoverage < .75) return false;
				return { candidate, coverage: widthCoverage * heightCoverage };
			}).filter(Boolean).sort((left, right) => right.coverage - left.coverage);
			composerOverflowLayers = candidates.length ? [candidates[0].candidate] : [];
			composerOverflowLayers.forEach((layer) => layer.setAttribute('data-ldp-reader-composer-overflow', '1'));
		};
		const scheduleComposerOverflowSync = () => {
			if (composerOverflowFrame) return;
			composerOverflowFrame = requestAnimationFrame(syncComposerOverflowLayers);
		};
		const limits = () => {
			const margin = Math.max(0, Math.min(
				16,
				Math.floor((window.innerWidth - 1) / 2),
				Math.floor((window.innerHeight - 1) / 2),
			));
			const left = margin;
			const top = margin;
			const right = window.innerWidth - margin;
			const bottom = window.innerHeight - margin;
			const maxWidth = Math.max(1, right - left);
			const maxHeight = Math.max(1, bottom - top);
			return {
				left,
				top,
				right,
				bottom,
				maxWidth,
				maxHeight,
				minWidth: Math.min(520, maxWidth),
				minHeight: Math.min(300, maxHeight),
			};
		};
		const defaultState = () => {
			const bounds = limits();
			const width = Math.min(1200, bounds.maxWidth);
			const height = clamp(bounds.maxHeight * .7, bounds.minHeight, bounds.maxHeight);
			return {
				left: bounds.left + (bounds.maxWidth - width) / 2,
				top: bounds.top + (bounds.maxHeight - height) / 2,
				width,
				height,
			};
		};
		const normalizeState = (next) => {
			const bounds = limits();
			const fallback = defaultState();
			const width = clamp(numeric(next?.width, fallback.width), bounds.minWidth, bounds.maxWidth);
			const height = clamp(numeric(next?.height, fallback.height), bounds.minHeight, bounds.maxHeight);
			return {
				left: clamp(numeric(next?.left, fallback.left), bounds.left, bounds.right - width),
				top: clamp(numeric(next?.top, fallback.top), bounds.top, bounds.bottom - height),
				width,
				height,
			};
		};
		const preferredState = () => {
			const fallback = defaultState();
			return normalizeState({
				left: PREFS[`${COMPOSER_WINDOW_PREFIX}X`] || fallback.left,
				top: PREFS[`${COMPOSER_WINDOW_PREFIX}Y`] || fallback.top,
				width: PREFS[`${COMPOSER_WINDOW_PREFIX}Width`] || fallback.width,
				height: PREFS[`${COMPOSER_WINDOW_PREFIX}Height`] || fallback.height,
			});
		};
		const syncFont = (fontProfile = currentFontProfile()) => {
			const values = normalizeFontProfile(fontProfile, READER_FONT_DEFAULT);
			if (!state || !composer) {
				if (composer) {
					composer.style.removeProperty('--ldp-composer-font-size');
					clearInterfaceFontScale(composer.style);
					clearReaderFontAppearance(composer.style);
				}
				applyComposerFontScale();
				return;
			}
			const bounds = limits();
			const widthTarget = Math.min(980, bounds.maxWidth);
			const heightTarget = Math.min(720, bounds.maxHeight);
			const widthProgress = clamp((state.width - bounds.minWidth) / Math.max(1, widthTarget - bounds.minWidth), 0, 1);
			const heightProgress = clamp((state.height - bounds.minHeight) / Math.max(1, heightTarget - bounds.minHeight), 0, 1);
			const automaticPixels = 12 + Math.min(widthProgress, heightProgress) * 4;
			const preferenceRatio = normalizeFontScale(values.composer, COMPOSER_FONT_SCALE_DEFAULT) /
				COMPOSER_FONT_SCALE_DEFAULT;
			const pixels = clamp(automaticPixels * preferenceRatio, 8, 40);
			applyInterfaceFontScale(composer.style, values.interface);
			applyReaderFontAppearance(composer.style, values);
			composer.style.setProperty('--ldp-composer-font-size', `${Math.round(pixels * 100) / 100}px`);
		};
		const syncChromeNow = (syncSize = true) => {
			chromeFrame = 0;
			if (!chrome) return;
			const available = sessionActive() && state && desktopWindow() && composerElementAvailable(composer) &&
				!composer.classList.contains('closed') && !composer.classList.contains('fullscreen');
			chrome.hidden = !available;
			if (!available) return;
			if (syncSize) {
				chrome.style.setProperty('--ldp-composer-chrome-width', cssPixels(state.width));
				chrome.style.setProperty('--ldp-composer-chrome-height', cssPixels(state.height));
			}
			chrome.style.transform = `translate3d(${cssPixels(state.left)},${cssPixels(state.top)},0)`;
		};
		const scheduleChromeSync = () => {
			if (chromeFrame) return;
			chromeFrame = requestAnimationFrame(syncChromeNow);
		};
		const applyState = (next) => {
			const previous = state;
			state = normalizeState(next);
			if (!composer) return;
			const composerStyle = composer.style;
			const sizeChanged = !previous || previous.width !== state.width || previous.height !== state.height;
			if (!previous) {
				composerStyle.setProperty('--ldp-composer-left', '0px');
				composerStyle.setProperty('--ldp-composer-top', '0px');
				composerStyle.setProperty('--ldp-composer-transform', 'none');
			}
			if (sizeChanged) {
				composerStyle.setProperty('--ldp-composer-width', cssPixels(state.width));
				composerStyle.setProperty('--ldp-composer-height', cssPixels(state.height));
			}
			if (desktopWindow() && !composer.classList.contains('fullscreen')) {
				composerStyle.setProperty('translate', `${cssPixels(state.left)} ${cssPixels(state.top)}`);
			} else {
				composerStyle.removeProperty('translate');
			}
			if (sizeChanged) syncFont();
			if (chromeFrame) cancelAnimationFrame(chromeFrame);
			syncChromeNow(sizeChanged);
			composer.setAttribute(positionedAttribute, '1');
		};
		const persistState = () => {
			if (statePersistTimer) clearTimeout(statePersistTimer);
			statePersistTimer = 0;
			if (!state || !desktopWindow()) return;
			setPrefs({
				[`${COMPOSER_WINDOW_PREFIX}Width`]: Math.round(state.width),
				[`${COMPOSER_WINDOW_PREFIX}Height`]: Math.round(state.height),
				[`${COMPOSER_WINDOW_PREFIX}X`]: Math.round(state.left),
				[`${COMPOSER_WINDOW_PREFIX}Y`]: Math.round(state.top),
			});
		};
		const scheduleStatePersist = () => {
			if (statePersistTimer) clearTimeout(statePersistTimer);
			statePersistTimer = setTimeout(persistState, 160);
		};
		const syncState = () => applyState(state || preferredState());
		const clearGeometry = () => {
			const composerStyle = composer?.style;
			if (composer) composer.removeAttribute(positionedAttribute);
			geometryProperties.forEach((property) => {
				rootStyle.removeProperty(property);
				if (composerStyle) composerStyle.removeProperty(property);
			});
			if (composerStyle) {
				composerStyle.removeProperty('--ldp-composer-font-size');
				clearInterfaceFontScale(composerStyle);
				clearReaderFontAppearance(composerStyle);
				composerStyle.removeProperty('translate');
			}
			applyComposerFontScale();
		};
		const stopPointer = (event) => {
			if (!pointer) {
				if (chrome) chrome.classList.remove('is-interacting');
				root.classList.remove('ldp-composer-window-interacting');
				return;
			}
			if (event && event.pointerId !== pointer.id) return;
			if (event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
				pointerClientX = event.clientX;
				pointerClientY = event.clientY;
			}
			if (pointerFrame) cancelAnimationFrame(pointerFrame);
			pointerFrame = 0;
			applyPointerPosition();
			const activePointer = pointer;
			pointer = null;
			const target = activePointer.target;
			if (target?.hasPointerCapture(activePointer.id)) target.releasePointerCapture(activePointer.id);
			if (chrome) chrome.classList.remove('is-interacting');
			root.classList.remove('ldp-composer-window-interacting');
			scheduleComposerOverflowSync();
			persistState();
		};
		const resizeState = (start, direction, deltaX, deltaY) => {
			const bounds = limits();
			let left = start.left;
			let top = start.top;
			let right = start.left + start.width;
			let bottom = start.top + start.height;
			if (direction.includes('w')) left = clamp(start.left + deltaX, bounds.left, right - bounds.minWidth);
			if (direction.includes('e')) right = clamp(start.left + start.width + deltaX, left + bounds.minWidth, bounds.right);
			if (direction.includes('n')) top = clamp(start.top + deltaY, bounds.top, bottom - bounds.minHeight);
			if (direction.includes('s')) bottom = clamp(start.top + start.height + deltaY, top + bounds.minHeight, bounds.bottom);
			return { left, top, width: right - left, height: bottom - top };
		};
		function applyPointerPosition() {
			if (!pointer) return;
			const deltaX = pointerClientX - pointer.startX;
			const deltaY = pointerClientY - pointer.startY;
			if (pointer.mode === 'move') {
				const bounds = limits();
				applyState({
					left: clamp(pointer.state.left + deltaX, bounds.left, bounds.right - pointer.state.width),
					top: clamp(pointer.state.top + deltaY, bounds.top, bounds.bottom - pointer.state.height),
					width: pointer.state.width,
					height: pointer.state.height,
				});
			} else {
				applyState(resizeState(pointer.state, pointer.mode, deltaX, deltaY));
			}
		}
		const movePointer = (event) => {
			if (!pointer || event.pointerId !== pointer.id) return;
			pointerClientX = event.clientX;
			pointerClientY = event.clientY;
			if (!pointerFrame) {
				pointerFrame = requestAnimationFrame(() => {
					pointerFrame = 0;
					applyPointerPosition();
				});
			}
			event.preventDefault();
		};
		const startPointer = (event, mode, target) => {
			if (!state || !desktopWindow() || !nativeComposerVisible(composer) || composer.classList.contains('fullscreen')) return;
			if (event.button !== 0) return;
			pointer = {
				id: event.pointerId,
				mode,
				startX: event.clientX,
				startY: event.clientY,
				state: { ...state },
				target,
			};
			pointerClientX = event.clientX;
			pointerClientY = event.clientY;
			target.setPointerCapture(event.pointerId);
			chrome.classList.add('is-interacting');
			root.classList.add('ldp-composer-window-interacting');
			consumeEvent(event);
		};
		const ensureChrome = () => {
			if (chrome) return chrome;
			chrome = makeElement('div');
			chrome.className = 'ldp-composer-window-chrome sciapp-ldp-owned';
			chrome.hidden = true;
			chrome.innerHTML = `<button class="ldp-composer-drag-handle" type="button" aria-label="拖动回复窗口;方向键可微调位置"></button>${resizeDirections.map((direction) => direction === 'se'
				? '<button class="ldp-composer-resize-handle" data-resize="se" type="button" aria-label="拖动调整回复窗口大小;方向键可微调"></button>'
				: `<span class="ldp-composer-resize-handle" data-resize="${direction}" aria-hidden="true"></span>`).join('')}`;
			appendReaderPortalNode(chrome);
			chrome.addEventListener('pointerdown', (event) => {
				const handle = event.target instanceof Element
					? event.target.closest('.ldp-composer-drag-handle,[data-resize]')
					: null;
				if (handle && chrome.contains(handle)) startPointer(event, handle.dataset.resize || 'move', handle);
			});
			chrome.addEventListener('keydown', (event) => {
				if (!state || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
				const step = event.shiftKey ? 40 : 12;
				if (event.target.matches('.ldp-composer-drag-handle')) {
					const next = { ...state };
					if (event.key === 'ArrowLeft') next.left -= step;
					else if (event.key === 'ArrowRight') next.left += step;
					else if (event.key === 'ArrowUp') next.top -= step;
					else next.top += step;
					applyState(next);
				} else if (event.target.matches('[data-resize="se"]')) {
					const deltaX = event.key === 'ArrowLeft' ? -step : (event.key === 'ArrowRight' ? step : 0);
					const deltaY = event.key === 'ArrowUp' ? -step : (event.key === 'ArrowDown' ? step : 0);
					applyState(resizeState(state, 'se', deltaX, deltaY));
				} else return;
				scheduleStatePersist();
				event.preventDefault();
			});
			chrome.addEventListener('pointermove', movePointer);
			['pointerup', 'pointercancel', 'lostpointercapture'].forEach((type) =>
				chrome.addEventListener(type, stopPointer));
			return chrome;
		};
		const deactivate = () => {
			stopPointer();
			persistState();
			releaseComposerTopLayers();
			state = null;
			if (chrome) chrome.hidden = true;
			clearGeometry();
			clearComposerOverflowLayers();
			clearComposerRoot();
		};
		const observeComposer = (element) => {
			if (composerObserver) composerObserver.disconnect();
			if (composer && composer !== element) releaseComposerTopLayers();
			composer = element;
			composerObserver = new MutationObserver(() => {
				if (!composer || !composer.isConnected || composer.classList.contains('closed') || !composerElementAvailable(composer)) {
					deactivate();
					return;
				}
				ensureChrome();
				syncComposerRoot();
				if (!state) syncState();
				if (composer.classList.contains('fullscreen')) {
					stopPointer();
					composer.style.removeProperty('translate');
				} else if (state && !composer.style.getPropertyValue('translate')) {
					applyState(state);
				}
				scheduleChromeSync();
				syncComposerTopLayers();
				scheduleComposerOverflowSync();
			});
			composerObserver.observe(composer, {
				attributes: true,
				attributeFilter: ['class', 'hidden', 'aria-hidden'],
				childList: true,
				subtree: true,
			});
		};
		const open = (element) => {
			if (!sessionActive() || !element) return false;
			ensureChrome();
			if (composer !== element) observeComposer(element);
			syncComposerRoot();
			if (!state) syncState();
			else scheduleChromeSync();
			syncComposerTopLayers();
			scheduleComposerOverflowSync();
			return true;
		};
		const syncComposerElement = () => {
			const nextComposer = document.querySelector('#reply-control');
			if (nextComposer === composer) {
				if (nativeComposerVisible(nextComposer) && !state) open(nextComposer);
				return;
			}
			if (composerObserver) composerObserver.disconnect();
			composerObserver = null;
			if (composer) deactivate();
			composer = null;
			if (!nextComposer) return;
			observeComposer(nextComposer);
			if (nativeComposerVisible(nextComposer)) open(nextComposer);
		};
		const composerMutationNodeIsRelevant = (node) => {
			const element = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement);
			return !!(element && (element.matches('#reply-control') || element.querySelector('#reply-control')));
		};
		const composerHostMutationIsRelevant = (mutation) => (
			[...mutation.addedNodes, ...mutation.removedNodes].some(composerMutationNodeIsRelevant)
		);
		const syncLayer = () => {
			if (!composer || !nativeComposerVisible(composer)) return false;
			syncComposerRoot();
			if (state) syncState();
			syncComposerTopLayers();
			scheduleComposerOverflowSync();
			return true;
		};
		const onWindowResize = () => {
			stopPointer();
			if (state) syncState();
			else scheduleChromeSync();
			syncComposerTopLayers();
			scheduleComposerOverflowSync();
		};
		const destroy = () => {
			if (destroyed) return;
			destroyed = true;
			stopPointer();
			persistState();
			if (composerObserver) composerObserver.disconnect();
			if (composerHostObserver) composerHostObserver.disconnect();
			if (chromeFrame) cancelAnimationFrame(chromeFrame);
			window.removeEventListener('resize', onWindowResize);
			releaseComposerTopLayers();
			if (chrome) removeOwnedNode(chrome);
			chrome = null;
			state = null;
			clearGeometry();
			clearComposerOverflowLayers();
			clearComposerRoot();
			composer = null;
		};
		if (document.body) {
			composerHostObserver = new MutationObserver((mutations) => {
				if (mutations.some(composerHostMutationIsRelevant)) syncComposerElement();
			});
			composerHostObserver.observe(document.body, { childList: true, subtree: true });
		}
		syncComposerElement();
		window.addEventListener('resize', onWindowResize);
		return { open, syncFont, syncLayer, destroy };
	}

	function discourseModelValue(model, key) {
		if (!model) return undefined;
		if (isFunction(model.get)) return model.get(key);
		return model[key];
	}

	function rememberReaderNativeComposer(ctx, composer, metadata = {}) {
		const model = discourseModelValue(composer, 'model');
		if (!ctx || !composer || !model) return false;
		ctx.nativeComposerSession = { composer, model, ...metadata };
		if (isFunction(ctx.bindNativeComposerRefresh)) ctx.bindNativeComposerRefresh();
		return true;
	}

	function cancelNativeComposerDraftDebounce(composer) {
		const runloopModule = lookupDiscourseModule('@ember/runloop');
		const cancelRunloop = runloopModule?.cancel;
		if (isFunction(cancelRunloop)) cancelRunloop(composer?._saveDraftDebounce);
	}

	async function discardReaderNativeComposer(ctx) {
		const ownedSession = ctx?.nativeComposerSession;
		const visibleComposer = nativeComposerVisible(document.querySelector('#reply-control'))
			? lookupDiscourse('service:composer')
			: null;
		const visibleModel = discourseModelValue(visibleComposer, 'model');
		const composer = visibleModel ? visibleComposer : ownedSession?.composer;
		const model = visibleModel || ownedSession?.model;
		if (ctx) ctx.nativeComposerSession = null;
		if (!composer || discourseModelValue(composer, 'model') !== model) return true;

		const appEvents = composer.appEvents || lookupDiscourse('service:app-events');
		let cleanupError = null;
		try {
			composer.skipAutoSave = true;
			cancelNativeComposerDraftDebounce(composer);
		} catch (error) {
			cleanupError = error;
		}

		try {
			if (isFunction(composer.destroyDraft)) await composer.destroyDraft();
		} catch (error) {
			cleanupError = error;
		}

		if (discourseModelValue(composer, 'model') === model) {
			try {
				if (isFunction(model.clearState)) model.clearState();
			} catch (error) {
				cleanupError = cleanupError || error;
			}
			try {
				if (isFunction(composer.close)) composer.close();
			} catch (error) {
				cleanupError = cleanupError || error;
			}
			try {
				if (isFunction(appEvents?.trigger)) appEvents.trigger('composer:cancelled');
			} catch (error) {
				cleanupError = cleanupError || error;
			}
		}

		try {
			composer.skipAutoSave = false;
		} catch (error) {
			cleanupError = cleanupError || error;
		}
		if (cleanupError) console.warn('[LINUX DO reader] native composer discard cleanup failed', cleanupError);
		return !cleanupError;
	}

	async function saveAndCloseReaderNativeComposer(ctx) {
		const ownedSession = ctx?.nativeComposerSession;
		if (ctx) ctx.nativeComposerSession = null;
		const composer = ownedSession?.composer;
		const model = ownedSession?.model;
		if (!composer || discourseModelValue(composer, 'model') !== model) return true;

		try {
			cancelNativeComposerDraftDebounce(composer);
			if (!isFunction(composer.saveAndCloseComposer)) return false;
			const closeResult = composer.saveAndCloseComposer();
			const savePromise = composer._saveDraftPromise;
			await Promise.resolve(closeResult);
			if (savePromise) await savePromise;
			return true;
		} catch (error) {
			console.warn('[LINUX DO reader] native composer draft save failed', error);
			return false;
		} finally {
			try {
				composer.skipAutoSave = false;
			} catch (error) {}
		}
	}

	function nativeComposerFailure(reason, error) {
		const messages = {
			target: '无法识别要回复的楼层,请重新展开该楼层后再试',
			service: 'LINUX DO 原生回复组件尚未就绪,请稍后重试',
			model: '无法创建原生回复上下文,请刷新页面后重试',
			draft: '无法获取当前主题草稿信息,请刷新页面后重试',
			open: 'LINUX DO 原生回复打开失败,请稍后重试',
		};
		console.warn(`[LINUX DO reader] native composer: ${reason}`, error || '');
		showSelectionToast(messages[reason] || messages.open);
		return false;
	}

	async function waitForNativeComposerPopup(ctx, timeout = 5000) {
		const deadline = Date.now() + timeout;
		let preparedPopup = null;
		while (Date.now() < deadline) {
			const popup = document.querySelector('#reply-control');
			if (composerElementAvailable(popup) && !popup.classList.contains('closed') &&
					popup !== preparedPopup && ctx && ctx.nativeComposerWindow) {
				ctx.nativeComposerWindow.open(popup);
				preparedPopup = popup;
			}
			if (nativeComposerVisible(popup)) return true;
			await waitForDelay(80);
		}
		return false;
	}

	function focusReaderNativeComposerInput(ctx) {
		requestAnimationFrame(() => {
			if (ctx?.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return;
			const popup = document.querySelector('#reply-control');
			if (!nativeComposerVisible(popup)) return;
			const input = [...popup.querySelectorAll(
				'textarea.d-editor-input, textarea, .ProseMirror[contenteditable="true"]'
			)].find((candidate) => composerElementAvailable(candidate) &&
				!candidate.matches('[disabled],[aria-disabled="true"]'));
			if (!input) return;
			try { input.focus({ preventScroll: true }); } catch (error) { input.focus(); }
		});
	}

	function createDetachedComposerModels(postNode, ctx, Topic, Post) {
		const topicData = ctx.topicData || {};
		const postData = postNode?._ldpPostData || {};
		const topicModel = Topic.create({
			id: +ctx.topicId,
			title: topicData.title || '',
			fancy_title: topicData.fancy_title || topicData.title || '',
			slug: topicData.slug || 'topic',
			category_id: topicData.category_id,
			archetype: topicData.archetype || 'regular',
			draft_key: topicData.draft_key,
			draft_sequence: topicData.draft_sequence,
			posts_count: topicData.posts_count,
			highest_post_number: topicData.highest_post_number,
			last_posted_at: topicData.last_posted_at,
			chunk_size: topicData.chunk_size,
			details: Object.assign({ can_create_post: true }, topicData.details || {}),
			tags: Array.isArray(topicData.tags) ? topicData.tags.slice() : [],
		});
		if (!isFunction(Post.munge)) {
			throw new Error('Discourse 楼层模型尚未就绪');
		}
		const postAttributes = Object.assign({}, postData, {
			id: +(postNode && postNode.dataset.postId),
			topic_id: +ctx.topicId,
			post_number: +(postNode && postNode.dataset.postNumber),
			username: postData.username || (postNode && postNode.dataset.username) || '',
			name: postData.name || '',
			avatar_template: postData.avatar_template || '',
			post_type: postData.post_type || 1,
			reply_count: postData.reply_count || 0,
			topic: topicModel,
			actions_summary: Array.isArray(postData.actions_summary)
				? postData.actions_summary.map((action) => ({ ...action }))
				: [],
		});
		const postModel = Post.create(Post.munge(postAttributes));
		return { topicModel, postModel };
	}

	async function tryOpenNativeReplyComposer(post, ctx, initialRaw = '') {
		const postNumber = +(post && post.dataset.postNumber);
		const postId = +(post && post.dataset.postId);
		if (!postNumber || !postId) return nativeComposerFailure('target');
		const composer = lookupDiscourse('service:composer');
		const appEvents = composer && (composer.appEvents || lookupDiscourse('service:app-events'));
		const composerModule = lookupDiscourseModule('discourse/models/composer');
		const topicModule = lookupDiscourseModule('discourse/models/topic');
		const postModule = lookupDiscourseModule('discourse/models/post');
		const draftModule = lookupDiscourseModule('discourse/models/draft');
		const Composer = composerModule?.default;
		const Topic = topicModule?.default;
		const Post = postModule?.default;
		const Draft = draftModule?.default;
		if (!composer || !isFunction(composer.open) || !appEvents) {
			return nativeComposerFailure('service');
		}
		if (!Composer || !Composer.REPLY || !Topic || !isFunction(Topic.create) ||
				!Post || !isFunction(Post.create)) {
			return nativeComposerFailure('model');
		}
		if (!Draft || !isFunction(Draft.get)) return nativeComposerFailure('draft');
		const rememberReplyComposer = () => rememberReaderNativeComposer(ctx, composer, {
			action: 'reply',
			parentPostNumber: postNumber,
			highestPostNumber: Math.max(
				0,
				+(ctx.totalPosts || 0),
				+(ctx.topicData?.highest_post_number || 0),
			),
		});

		try {
			let topicData = ctx.topicData || {};
			if ((!topicData.draft_key || topicData.draft_sequence == null) &&
					ctx.loader && isFunction(ctx.loader.refreshTopicData)) {
				const freshTopic = await ctx.loader.refreshTopicData({ prime: false });
				if (freshTopic) {
					ctx.topicData = freshTopic;
					topicData = freshTopic;
				}
			}
			if (!topicData.draft_key || topicData.draft_sequence == null) {
				return nativeComposerFailure('draft');
			}
			if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false;

			const { topicModel, postModel } = createDetachedComposerModels(post, ctx, Topic, Post);
			const quoteRaw = String(initialRaw || '').trim();
			const currentModel = discourseModelValue(composer, 'model');
			const currentTopicId = +discourseModelValue(discourseModelValue(currentModel, 'topic'), 'id');
			const currentViewOpen = discourseModelValue(currentModel, 'viewOpen') === true ||
				String(discourseModelValue(currentModel, 'composeState') || '').toLowerCase() === 'open';
			const currentAction = discourseModelValue(currentModel, 'action');
			const currentReply = String(discourseModelValue(currentModel, 'reply') || '');
			const sameTopicReplyOpen = currentModel && currentViewOpen && currentTopicId === +ctx.topicId &&
				currentAction === Composer.REPLY;
			if (sameTopicReplyOpen && await waitForNativeComposerPopup(ctx, 640)) {
				rememberReplyComposer();
				const replyTarget = postNumber === 1 ? null : postModel;
				currentModel.set('post', replyTarget);
				if (quoteRaw) appEvents.trigger('composer:insert-block', quoteRaw);
				focusReaderNativeComposerInput(ctx);
				return true;
			}

			const currentPopup = document.querySelector('#reply-control');
			const currentPopupAvailable = composerElementAvailable(currentPopup) &&
				!currentPopup.classList.contains('closed');
			if (currentViewOpen && (currentTopicId === +ctx.topicId || !currentPopupAvailable)) {
				console.info('[LINUX DO reader] native composer: recovering stale view', {
					topicId: +ctx.topicId,
					previousTopicId: currentTopicId || 0,
					action: currentAction || '',
					popupVisible: nativeComposerVisible(currentPopup),
				});
				if (ctx.nativeComposerSession && ctx.nativeComposerSession.model === currentModel) {
					ctx.nativeComposerSession = null;
				}
				if (isFunction(composer.close)) composer.close();
				await waitForDelay(0);
			}

			const options = {
				action: Composer.REPLY,
				draftKey: topicData.draft_key,
				draftSequence: topicData.draft_sequence,
				skipJumpOnSave: true,
			};
			if (postNumber === 1) options.topic = topicModel;
			else options.post = postModel;

			const draftData = await Draft.get(options.draftKey);
			if (draftData?.draft) {
				const data = JSON.parse(draftData.draft);
				options.draftSequence = draftData.draft_sequence;
				options.reply = `${String(data.reply || '')}${quoteRaw ? `\n${quoteRaw}` : ''}`;
				if (data.whisper !== undefined) options.whisper = data.whisper;
			} else if (quoteRaw) {
				options.quote = quoteRaw;
			}
			if (currentReply && currentViewOpen && currentTopicId === +ctx.topicId) {
				options.reply = `${currentReply}${quoteRaw ? `\n${quoteRaw}` : ''}`;
				delete options.quote;
			}
			console.info('[LINUX DO reader] native composer: opening', {
				topicId: +ctx.topicId,
				postId,
				postNumber,
				quoted: !!quoteRaw,
			});
			await composer.open(options);
			rememberReplyComposer();
			if (!await waitForNativeComposerPopup(ctx)) return nativeComposerFailure('open');
			focusReaderNativeComposerInput(ctx);
			console.info('[LINUX DO reader] native composer: visible', {
				topicId: +ctx.topicId,
				postId,
				postNumber,
			});
			return true;
		} catch (error) {
			if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false;
			return nativeComposerFailure('open', error);
		}
	}

	async function tryOpenNativeEditComposer(post, ctx) {
		const postNumber = +(post && post.dataset.postNumber);
		const postId = +(post && post.dataset.postId);
		const postData = post?._ldpPostData || {};
		if (!postNumber || !postId || postData.can_edit !== true) return false;
		const composer = lookupDiscourse('service:composer');
		const composerModule = lookupDiscourseModule('discourse/models/composer');
		const topicModule = lookupDiscourseModule('discourse/models/topic');
		const postModule = lookupDiscourseModule('discourse/models/post');
		const Composer = composerModule?.default;
		const Topic = topicModule?.default;
		const Post = postModule?.default;
		if (!composer || !isFunction(composer.open) || !Composer || !Composer.EDIT ||
				!Topic || !isFunction(Topic.create) || !Post || !isFunction(Post.create)) {
			return nativeComposerFailure('service');
		}
		try {
			const freshPost = await fetchJSON(`${BASE}/posts/${postId}.json`, {
				cacheMode: 'refresh',
				allowStaleOnError: false,
			});
			post._ldpPostData = Object.assign({}, postData, freshPost);
			const { postModel } = createDetachedComposerModels(post, ctx, Topic, Post);
			const topicData = ctx.topicData || {};
			await composer.open({
				action: Composer.EDIT,
				post: postModel,
				draftKey: topicData.draft_key,
				draftSequence: topicData.draft_sequence,
			});
			rememberReaderNativeComposer(ctx, composer, {
				action: 'edit',
				postNumber,
				postId,
			});
			if (!await waitForNativeComposerPopup(ctx)) return nativeComposerFailure('open');
			return true;
		} catch (error) {
			return nativeComposerFailure('open', error);
		}
	}

	function detachedNativePostModel(post, ctx) {
		const topicModule = lookupDiscourseModule('discourse/models/topic');
		const postModule = lookupDiscourseModule('discourse/models/post');
		const Topic = topicModule?.default;
		const Post = postModule?.default;
		if (!Topic || !isFunction(Topic.create) || !Post || !isFunction(Post.create)) return null;
		return createDetachedComposerModels(post, ctx, Topic, Post).postModel;
	}

	function openNativeAdminPostMenu(post, anchor, ctx) {
		const menu = lookupDiscourse('service:menu');
		const componentModule = lookupDiscourseModule('discourse/components/admin-post-menu');
		const AdminPostMenu = componentModule?.default;
		const topicController = lookupDiscourse('controller:topic');
		const postModel = detachedNativePostModel(post, ctx);
		if (!menu || !isFunction(menu.show) || !AdminPostMenu || !topicController ||
				!isFunction(topicController.send) || !postModel) {
			showSelectionToast('LINUX DO 原生楼层管理菜单尚未就绪,请稍后重试');
			return false;
		}
		const topicAction = (name) => () => topicController.send(name, postModel);
		menu.show(anchor, {
			identifier: 'admin-post-menu',
			component: AdminPostMenu,
			modalForMobile: true,
			autofocus: true,
			data: {
				post: postModel,
				changeNotice: topicAction('changeNotice'),
				changePostOwner: topicAction('changePostOwner'),
				grantBadge: topicAction('grantBadge'),
				lockPost: topicAction('lockPost'),
				permanentlyDeletePost: topicAction('permanentlyDeletePost'),
				rebakePost: topicAction('rebakePost'),
				showPagePublish: topicAction('showPagePublish'),
				togglePostType: topicAction('togglePostType'),
				toggleWiki: topicAction('toggleWiki'),
				unhidePost: topicAction('unhidePost'),
				unlockPost: topicAction('unlockPost'),
				scheduleRerender: () => ctx.refreshPostByNumber && ctx.refreshPostByNumber(+post.dataset.postNumber),
			},
		});
		return true;
	}

	function removeReaderPost(post, ctx) {
		const postId = +(post && post.dataset.postId);
		const postNumber = +(post && post.dataset.postNumber);
		const postData = post?._ldpPostData || {};
		const parentNumber = +(postData.reply_to_post_number || post && post.dataset.replyToPostNumber || 0);
		readerPostCopies(postId, ctx).forEach((node) => removeOwnedNode(node));
		const item = ctx.streamItemMap.get(postNumber);
		if (item) {
			const index = item.index;
			cancelPostContentHydration(item.node, ctx);
			cancelPostLatexRender(item.node, ctx);
			destroyReaderMedia(item.node);
			ctx.streamHydratedNodes.delete(item.node);
			ctx.streamItems.splice(index, 1);
			ctx.streamItemMap.delete(postNumber);
			ctx.streamNodeMap.delete(postNumber);
			ctx.streamMountedNodes.delete(item.node);
			reindexStreamItems(ctx, Math.max(0, index));
			invalidateStreamLayout(ctx);
			scheduleStreamWindowSync(ctx);
		}
		const replies = parentNumber && ctx.directRepliesByParent.get(parentNumber);
		if (replies) replies.delete(postNumber);
		const state = parentNumber && ctx.subReplyState.get(parentNumber);
		const parentNode = parentNumber && ctx.streamNodeMap.get(parentNumber);
		if (state) {
			state.all = state.all.filter((reply) => +reply.post_number !== postNumber);
			if (state.nodeCache) state.nodeCache.delete(postNumber);
			state.totalCount = Math.max(0, state.totalCount - 1);
			state.renderedKey = '';
			if (parentNode?.isConnected) renderSubReplyBatch(parentNumber, ctx, 0, parentNode);
		}
		if (parentNode) {
			const total = Math.max(0, +(parentNode.dataset.childReplyTotal || 0) - 1);
			parentNode.dataset.childReplyTotal = String(total);
			if (parentNode._ldpPostData) parentNode._ldpPostData.reply_count = total;
			syncChildControls(parentNode);
		}
		const cacheEntry = TOPIC_DATA_CACHE.get(String(ctx.topicId));
		const cachedTopic = (cacheEntry?.topic) || ctx.topicData;
		if (cachedTopic?.post_stream && Array.isArray(cachedTopic.post_stream.posts)) {
			cachedTopic.post_stream.posts = cachedTopic.post_stream.posts.filter((item) => +item.id !== postId);
			setCachedTopicData(ctx.topicId, cachedTopic);
		}
		updateCommentsHeader(ctx);
		ctx.timeline.update();
	}

	async function deleteReaderPost(post, ctx) {
		const postData = post?._ldpPostData || {};
		const postId = +(post && post.dataset.postId);
		if (!postId || postData.can_delete !== true) return false;
		return runReaderActionOnce(ctx, `post:${postId}:delete`, async () => {
			if (!window.confirm('确定删除这条回复吗?')) return false;
			const postModel = detachedNativePostModel(post, ctx);
			const currentUser = lookupDiscourse('service:current-user');
			if (!postModel || !isFunction(postModel.destroy) || !currentUser) {
				throw new Error('Discourse 楼层删除接口尚未就绪');
			}
			await postModel.destroy(currentUser);
			await clearStoredResponseCache({ tags: [`post:${postId}`, `topic:${ctx.topicId}`] });
			removeReaderPost(post, ctx);
			return true;
		});
	}

	function positionNativeBoostMenu(anchor, menu) {
		const anchorRect = anchor.getBoundingClientRect();
		const menuRect = menu.getBoundingClientRect();
		const width = Math.max(menuRect.width || 0, 280);
		const height = Math.max(menuRect.height || 0, 84);
		const left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - width - 8));
		const below = anchorRect.bottom + 6;
		const top = below + height <= window.innerHeight - 8
			? below
			: Math.max(8, anchorRect.top - height - 6);
		menu.style.left = `${Math.round(left)}px`;
		menu.style.top = `${Math.round(top)}px`;
		return true;
	}

	function syncRenderedBoost(post, postData, ctx) {
		const current = post.querySelector(':scope > .ldp-boost-list');
		const holder = makeElement('template');
		holder.innerHTML = renderBoostList(postData.boosts, { canQuickBoost: canShowBoostButton(postData), ctx });
		const next = holder.content.firstElementChild;
		if (current && next) current.replaceWith(next);
		else if (next) post.insertBefore(next, post.querySelector(':scope > .ldp-collapse-replies'));
		hydratePersistentAvatarImages(post);
		const actions = post.querySelector(':scope > .ldp-reactions .ldp-actions');
		const boostButton = actions?.querySelector('.ldp-boostbtn');
		if (canShowBoostButton(postData) && actions && !boostButton) {
			const actionHolder = makeElement('template');
			actionHolder.innerHTML = `<button class="ldp-btn ldp-boostbtn" type="button" aria-label="boost">${icon('rocket')}</button>`;
			const contextSlot = actions.querySelector('.ldp-context-actions-slot');
			actions.insertBefore(actionHolder.content.firstElementChild, contextSlot || null);
		} else if (!canShowBoostButton(postData)) {
			boostButton?.remove();
		}
	}

	function syncDeletedReaderBoost(post, boostId, ctx) {
		const postId = +(post && post.dataset.postId || 0);
		if (!postId || !boostId) return;
		let latestPostData = null;
		const copies = readerPostCopies(postId, ctx);
		(copies.length ? copies : [post]).forEach((copy) => {
			if (!copy) return;
			const postData = copy._ldpPostData || {};
			const boosts = Array.isArray(postData.boosts)
				? postData.boosts
				: (postData.boosts ? [postData.boosts] : []);
			postData.boosts = boosts.filter((boost) => String(boost?.id || '') !== String(boostId));
			postData.can_boost = true;
			copy._ldpPostData = postData;
			latestPostData = postData;
			syncRenderedBoost(copy, postData, ctx);
		});
		if (latestPostData) persistReaderPostState(ctx, latestPostData);
	}

	async function deleteReaderBoost(post, boostBubble, ctx) {
		const boostId = boostBubble && boostBubble.dataset.boostId;
		if (!post || !boostId) return false;
		await apiSend(`${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}`, 'DELETE', null, null, {
			invalidateTags: [`post:${post.dataset.postId}`, `topic:${ctx.topicId}`],
		});
		syncDeletedReaderBoost(post, boostId, ctx);
		showSelectionToast('Boost 已删除');
		return true;
	}

	async function openReaderBoostReportDialog(boostBubble, ctx) {
		const boostId = boostBubble && boostBubble.dataset.boostId;
		if (!boostId) return false;
		const boost = await fetchJSON(
			`${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}.json`,
			{ cacheMode: 'bypass', allowStaleOnError: false, key: `boost:${boostId}:permissions` }
		);
		if (!boost || boost.can_flag !== true) {
			throw new Error(boost?.user_flag_status ? '你已经举报过这个 Boost' : '当前账号不能举报这个 Boost');
		}
		const availableFlags = new Set(Array.isArray(boost.available_flags) ? boost.available_flags : []);
		const site = lookupDiscourse('service:site');
		const siteFlags = [...site?.flagTypes || []];
		const descriptionText = (value) => {
			const holder = makeElement('template');
			holder.innerHTML = String(value || '');
			return String(holder.content.textContent || '').replace(/\s+/g, ' ').trim();
		};
		const reportOptions = siteFlags
			.filter((flag) => (
				flag && flag.enabled !== false && availableFlags.has(flag.name_key) &&
				(!Array.isArray(flag.applies_to) || flag.applies_to.includes('DiscourseBoosts::Boost'))
			))
			.map((flag) => ({
				id: +flag.id,
				nameKey: flag.name_key,
				label: flag.name || flag.name_key,
				description: descriptionText(flag.description || flag.short_description) || '提交给社区管理人员审核。',
				requireMessage: flag.require_message === true,
			}));
		if (!reportOptions.length) throw new Error('当前没有可用的 Boost 举报类型');
		const username = String(boost.user?.username || boostBubble.dataset.boostUser || '').trim();
		return !!openReaderReportForm(ctx, {
			title: '举报 Boost',
			intro: username
				? `举报 @${username} 的 Boost 会直接提交给社区,不会离开阅读器。`
				: '举报会直接提交给社区,不会离开阅读器。',
			options: reportOptions,
			placeholder: '选填;所选类型要求说明时必须填写',
			onSubmit: async (actionType, message) => {
				const params = { flag_type_id: actionType };
				if (message) params.message = message;
				await apiSend(`${BASE}/discourse-boosts/boosts/${encodeURIComponent(boostId)}/flags`, 'POST', params);
				return '举报已提交';
			},
		});
	}

	function renderNativeBoostMenu(post, anchor, ctx, initialRaw = '') {
		const emojiMenuIdentifier = 'ldp-native-boost-emoji-picker';
		const overlay = post?.closest('.ldp-overlay');
		const postData = post?._ldpPostData;
		const scrollRoot = ctx?.scrollRoot;
		const createBoostModule = lookupDiscourseModule('discourse/plugins/discourse-boosts/discourse/lib/create-boost');
		const createBoost = createBoostModule?.default;
		const currentUser = ME_CURRENT_USER;
		const nativeMenu = lookupDiscourse('service:menu');
		const prettyTextEmoji = lookupDiscourseModule('pretty-text/emoji');
		const discourseText = lookupDiscourseModule('discourse/lib/text');
		const unicodeEmojiRegex = prettyTextEmoji?.emojiReplacementRegex
			? new RegExp(prettyTextEmoji.emojiReplacementRegex, 'g')
			: null;
		if (!overlay || !postData || !document.body) return false;

		const previous = document.body.querySelector(':scope > .ldp-native-boost-menu');
		if (isFunction(previous?._ldpClose)) previous._ldpClose();
		else if (previous) previous.remove();

		const menu = makeElement('div');
		const menuScope = createLifecycleScope();
		menu.className = 'ldp-native-boost-menu sciapp-ldp-owned';
		menu.setAttribute('role', 'dialog');
		setLabel(menu, '助推');
		menu.innerHTML = `
			<div class="discourse-boosts__input-container">
				<div class="discourse-boosts__input" contenteditable="true" role="textbox"
					aria-label="Boost 内容" data-placeholder="写一句,最多 16 字"></div>
				<span class="ldp-native-boost-count">0/16</span>
				<button class="btn-transparent btn-icon-only discourse-boosts__emoji-btn" type="button"
					aria-label="插入表情">${icon('smile')}</button>
				<button class="btn-default --success btn-icon-only discourse-boosts__submit" type="button"
					aria-label="发送" disabled>${icon('check')}</button>
				<button class="btn-default --danger btn-icon-only discourse-boosts__cancel" type="button"
					aria-label="取消">${icon('x')}</button>
			</div>
			<span class="ldp-native-boost-error" role="status"></span>`;
		document.body.appendChild(menu);
		applyReaderThemeVariables(menu);
		positionNativeBoostMenu(anchor, menu);
		bindFloatingSurfaceWheel(menu);

		const editor = menu.querySelector('.discourse-boosts__input');
		const count = menu.querySelector('.ldp-native-boost-count');
		const emojiButton = menu.querySelector('.discourse-boosts__emoji-btn');
		const submit = menu.querySelector('.discourse-boosts__submit');
		const cancel = menu.querySelector('.discourse-boosts__cancel');
		const error = menu.querySelector('.ldp-native-boost-error');
		let submitting = false;
		let closed = false;
		let anchorSyncFrame = 0;
		let anchorObserver = null;
		let previousEditorHTML = '';
		let emojiPickerContent = null;
		let composing = false;

		const closeEmojiPicker = () => {
			if (isFunction(emojiPickerContent?._ldpStopEmojiImageCache)) {
				emojiPickerContent._ldpStopEmojiImageCache();
			}
			emojiPickerContent = null;
			if (!nativeMenu || !isFunction(nativeMenu.close)) return;
			try {
				const result = nativeMenu.close(emojiMenuIdentifier);
				if (isFunction(result?.catch)) result.catch(() => {});
			} catch (e) {}
		};
		const placeEditorCursorAtEnd = () => {
			editor.focus();
			const range = document.createRange();
			range.selectNodeContents(editor);
			range.collapse(false);
			const selection = readerSelection();
			if (!selection) return;
			selection.removeAllRanges();
			selection.addRange(range);
		};
		const textStats = (value) => {
			const text = String(value || '').replace(/\u00a0/g, ' ');
			const unicodeMatches = unicodeEmojiRegex ? text.match(unicodeEmojiRegex) : null;
			return {
				raw: text,
				length: [...unicodeMatches ? text.replace(unicodeEmojiRegex, 'x') : text].length,
				emojiCount: unicodeMatches ? unicodeMatches.length : 0,
			};
		};
		const readInput = () => {
			const stats = { raw: '', length: 0, emojiCount: 0 };
			const visit = (node) => {
				if (node.nodeType === Node.TEXT_NODE) {
					const current = textStats(node.textContent);
					stats.raw += current.raw;
					stats.length += current.length;
					stats.emojiCount += current.emojiCount;
					return;
				}
				if (node.nodeType !== Node.ELEMENT_NODE) return;
				if (node.nodeName === 'IMG' && node.classList.contains('emoji')) {
					stats.raw += node.getAttribute('alt') || '';
					stats.length += 1;
					stats.emojiCount += 1;
					return;
				}
				node.childNodes.forEach(visit);
			};
			editor.childNodes.forEach(visit);
			return stats;
		};

		const rectsOverlap = (rect, clip) => rect.bottom > clip.top && rect.top < clip.bottom &&
			rect.right > clip.left && rect.left < clip.right;
		const anchorIsVisible = () => {
			if (!anchor.isConnected || !menu.isConnected) return false;
			const rect = anchor.getBoundingClientRect();
			if (!rect.width || !rect.height) return false;
			const viewport = { top: 0, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
			if (!rectsOverlap(rect, viewport)) return false;
			return !scrollRoot || rectsOverlap(rect, scrollRoot.getBoundingClientRect());
		};

		const close = () => {
			if (closed) return;
			closed = true;
			closeEmojiPicker();
			menuScope.destroy();
			menu.remove();
		};
		const onOutside = (event) => {
			if (!menu.isConnected) return close();
			if (eventPathClosest(event, `[data-identifier="${emojiMenuIdentifier}"],.emoji-picker`)) return;
			if (!eventPathIncludes(event, menu) && !eventPathIncludes(event, anchor)) close();
		};
		const syncAnchor = () => {
			anchorSyncFrame = 0;
			if (!anchorIsVisible()) return close();
			positionNativeBoostMenu(anchor, menu);
		};
		const scheduleAnchorSync = () => {
			if (closed || anchorSyncFrame) return;
			anchorSyncFrame = menuScope.raf(syncAnchor);
		};
		const syncInput = () => {
			let stats = readInput();
			if (!composing && (stats.length > 16 || stats.emojiCount > 5)) {
				editor.innerHTML = previousEditorHTML;
				placeEditorCursorAtEnd();
				stats = readInput();
			} else if (!composing) {
				if (stats.length === 0 && editor.innerHTML) editor.innerHTML = '';
				previousEditorHTML = editor.innerHTML;
			}
			count.textContent = `${stats.length}/16`;
			emojiButton.disabled = submitting || composing ||
				stats.length + (stats.length ? 2 : 1) > 16 || stats.emojiCount >= 5;
			submit.disabled = submitting || composing || !stats.raw.trim();
			return stats.raw;
		};
		const insertEmoji = (code) => {
			const emojiCode = String(code || '').replace(/^:|:$/g, '');
			const stats = readInput();
			if (!emojiCode || stats.length + (stats.length ? 2 : 1) > 16 || stats.emojiCount >= 5) return;
			const buildEmojiUrl = prettyTextEmoji?.buildEmojiUrl;
			const isCustomEmoji = prettyTextEmoji?.isCustomEmoji;
			const emojiOptions = discourseText?.emojiOptions;
			if (!isFunction(buildEmojiUrl) || !isFunction(emojiOptions)) {
				error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
				return;
			}
			try {
				const options = emojiOptions();
				const image = makeElement('img');
				image.className = isFunction(isCustomEmoji) && isCustomEmoji(emojiCode, options)
					? 'emoji emoji-custom'
					: 'emoji';
				image.alt = `:${emojiCode}:`;
				image.src = buildEmojiUrl(emojiCode, options);
				if (stats.length) editor.appendChild(document.createTextNode(' '));
				else editor.innerHTML = '';
				editor.appendChild(image);
				error.textContent = '';
				placeEditorCursorAtEnd();
				syncInput();
			} catch (e) {
				error.textContent = '表情插入失败,请稍后重试';
			}
		};
		const positionEmojiPickerInReader = (content) => {
			const reader = post.closest('.ldp-modal');
			if (!content || !reader) return;
			if (emojiPickerContent !== content) {
				if (isFunction(emojiPickerContent?._ldpStopEmojiImageCache)) {
					emojiPickerContent._ldpStopEmojiImageCache();
				}
				emojiPickerContent = content;
				watchPersistentEmojiImages(content);
			}
			const ownsPopover = !content.hasAttribute('popover');
			try {
				if (ownsPopover) {
					content.setAttribute('popover', 'manual');
					content.dataset.ldpReaderTopLayer = 'portal';
				}
				if (!content.matches(':popover-open')) content.showPopover();
			} catch (error) {
				if (ownsPopover) {
					content.removeAttribute('popover');
					delete content.dataset.ldpReaderTopLayer;
				}
			}
			const padding = 8;
			const gap = 8;
			const readerRect = reader.getBoundingClientRect();
			const anchorRect = menu.getBoundingClientRect();
			const bounds = {
				top: Math.max(padding, readerRect.top + padding),
				right: Math.min(window.innerWidth - padding, readerRect.right - padding),
				bottom: Math.min(window.innerHeight - padding, readerRect.bottom - padding),
				left: Math.max(padding, readerRect.left + padding),
			};
			const picker = content.querySelector('.emoji-picker');
			const initialRect = content.getBoundingClientRect();
			const pickerRect = picker?.getBoundingClientRect();
			const naturalHeight = picker && +(picker.dataset.ldpBoostNaturalHeight || picker.offsetHeight || 0);
			if (picker && naturalHeight && !picker.dataset.ldpBoostNaturalHeight) {
				picker.dataset.ldpBoostNaturalHeight = String(naturalHeight);
			}
			const scale = picker?.offsetHeight ? pickerRect.height / picker.offsetHeight : 1;
			const chromeHeight = pickerRect ? Math.max(0, initialRect.height - pickerRect.height) : 0;
			const naturalVisualHeight = naturalHeight ? naturalHeight * scale + chromeHeight : initialRect.height;
			const above = Math.max(0, anchorRect.top - gap - bounds.top);
			const below = Math.max(0, bounds.bottom - anchorRect.bottom - gap);
			const placeBelow = below >= naturalVisualHeight || (above < naturalVisualHeight && below >= above);
			const availableHeight = placeBelow ? below : above;
			if (picker && naturalHeight && scale > 0) {
				const targetVisualHeight = Math.min(
					naturalHeight * scale,
					Math.max(120 * scale, availableHeight - chromeHeight),
				);
				picker.style.height = `${Math.round(targetVisualHeight / scale)}px`;
				const pickerContent = picker.querySelector('.emoji-picker__content');
				if (pickerContent) {
					pickerContent.style.height = 'auto';
					pickerContent.style.minHeight = '0';
					pickerContent.style.flex = '1 1 auto';
				}
				const sectionsNav = picker.querySelector('.emoji-picker__sections-nav');
				if (sectionsNav) sectionsNav.style.height = '100%';
			}
			Object.assign(content.style, {
				position: 'fixed',
				inset: 'auto',
				right: 'auto',
				bottom: 'auto',
				margin: '0',
				transform: 'none',
			});
			const panelRect = content.getBoundingClientRect();
			let left = anchorRect.left;
			if (left + panelRect.width > bounds.right) left = anchorRect.right - panelRect.width;
			left = Math.max(bounds.left, Math.min(left, Math.max(bounds.left, bounds.right - panelRect.width)));
			let top = placeBelow ? anchorRect.bottom + gap : anchorRect.top - panelRect.height - gap;
			top = Math.max(bounds.top, Math.min(top, Math.max(bounds.top, bounds.bottom - panelRect.height)));
			content.style.left = `${Math.round(left)}px`;
			content.style.top = `${Math.round(top)}px`;
			const placedRect = content.getBoundingClientRect();
			content.style.left = `${Math.round(left + left - placedRect.left)}px`;
			content.style.top = `${Math.round(top + top - placedRect.top)}px`;
		};
		const openEmojiPicker = () => {
			const pickerModule = lookupDiscourseModule('discourse/components/emoji-picker/detached');
			const EmojiPickerDetached = pickerModule?.default;
			if (!nativeMenu || !isFunction(nativeMenu.show) || !EmojiPickerDetached) {
				error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
				return;
			}
			error.textContent = '';
			try {
				const result = nativeMenu.show(emojiButton, {
					identifier: emojiMenuIdentifier,
					component: EmojiPickerDetached,
					modalForMobile: false,
					strategy: 'fixed',
					fallbackPlacements: ['bottom-start', 'top-start', 'bottom-end', 'top-end'],
					computePosition: positionEmojiPickerInReader,
					data: {
						term: '',
						context: 'boost',
						didSelectEmoji: insertEmoji,
					},
				});
				if (isFunction(result?.catch)) {
					result.catch(() => {
						if (!closed) error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
					});
				}
			} catch (e) {
				error.textContent = 'LinuxDo 原生表情组件尚未就绪,请稍后重试';
			}
		};
		const send = async () => {
			if (composing) return;
			const raw = syncInput().trim();
			if (!raw || submitting) return;
			if (!isFunction(createBoost) || !currentUser) {
				error.textContent = 'LinuxDo Boost 组件尚未就绪,请稍后重试';
				return;
			}
			submitting = true;
			submit.disabled = true;
			emojiButton.disabled = true;
			cancel.disabled = true;
			editor.contentEditable = 'false';
			error.textContent = '';
			try {
				await createBoost(postData, raw, currentUser);
				syncRenderedBoost(post, postData, ctx);
				persistReaderPostState(ctx, postData);
				await clearStoredResponseCache({ tags: [`post:${postData.id}`, `topic:${ctx.topicId}`] });
				close();
			} catch (e) {
				submitting = false;
				cancel.disabled = false;
				editor.contentEditable = 'true';
				error.textContent = `发送失败:${e?.message ? e.message : '请稍后重试'}`;
				syncInput();
				editor.focus();
			}
		};

		menu._ldpCloseEmojiPicker = closeEmojiPicker;
		menu._ldpClose = close;
		editor.addEventListener('compositionstart', () => {
			composing = true;
			syncInput();
		});
		editor.addEventListener('compositionend', () => {
			composing = false;
			syncInput();
		});
		editor.addEventListener('input', syncInput);
		editor.addEventListener('keydown', (event) => {
			if (event.isComposing || composing || event.keyCode === 229) return;
			if (event.key === 'Escape') {
				consumeEvent(event);
				close();
			} else if (event.key === 'Enter' && !event.shiftKey) {
				event.preventDefault();
				send();
			}
		});
		menu.addEventListener('click', (event) => {
			const button = event.target instanceof Element ? event.target.closest('button') : null;
			if (button === emojiButton) openEmojiPicker();
			else if (button === submit) send();
			else if (button === cancel) close();
		});
		if (scrollRoot) menuScope.listen(
			scrollRoot, 'scroll', scheduleAnchorSync, { passive: true }
		);
		menuScope.listen(window, 'resize', scheduleAnchorSync, { passive: true });
		if (isFunction(IntersectionObserver)) {
			anchorObserver = new IntersectionObserver((entries) => {
				const entry = entries[0];
				if (!entry || !entry.isIntersecting) close();
				else scheduleAnchorSync();
			}, { root: scrollRoot || null, threshold: 0 });
			menuScope.observe(anchorObserver, anchor);
		}
		menuScope.timer(() => {
			if (!closed) menuScope.listen(document, 'pointerdown', onOutside, true);
		}, 0);
		editor.textContent = [...String(initialRaw || '')].slice(0, 16).join('');
		syncInput();
		editor.focus();
		if (initialRaw) {
			placeEditorCursorAtEnd();
		}
		return true;
	}

	/* ============ 9. 楼中楼分批渲染 ============ */
	function renderSubReplyBatch(postNumber, ctx, pageDelta = 0, preferredParentNode = null) {
		const state = ctx.subReplyState.get(postNumber);
		const parentNode = preferredParentNode?.isConnected && +preferredParentNode.dataset.postNumber === +postNumber
			? preferredParentNode
			: findReplyListParent(postNumber, ctx);
		if (!state || !parentNode) return;
		if (parentNode.classList.contains('ldp-nested-preview')) return;
		const children = parentNode.querySelector(':scope > .ldp-children');
		if (!children) return;
		const pageSize = SUB_REPLY_PAGE_SIZE;
		const totalCount = Math.max(state.all.length, +(state.totalCount || 0));
		const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
		state.pageIndex = Math.max(0, Math.min(pageCount - 1, +(state.pageIndex || 0) + pageDelta));
		if (!state.nodeCache) state.nodeCache = new Map();
		const start = state.pageIndex * pageSize;
		const batch = state.all.slice(start, start + pageSize);
		const renderKey = `${state.pageIndex}:${state.all.length}:${totalCount}:${state.loadingMore ? 1 : 0}:${batch.map((reply) => +(reply.post_number || 0)).join(',')}`;
		const mountedReplies = [...children.children].filter((node) => node.classList.contains('ldp-post'));
		if (state.renderedParent === parentNode && state.renderedKey === renderKey &&
				mountedReplies.length === batch.length &&
				mountedReplies.every((node, index) => +node.dataset.postNumber === +(batch[index]?.post_number || 0))) {
			return;
		}
		parentNode.classList.remove('ldp-nested-branches-ready');
		clearCombinedParentPreviewLine(parentNode);
		const mountedByPostNumber = new Map(mountedReplies.map((node) => [+node.dataset.postNumber, node]));
		const desiredNodes = [];
		batch.forEach((rp) => {
			if (!rp.reply_to_post_number) rp.reply_to_post_number = postNumber;
			const replyPostNumber = +rp.post_number;
			let node = mountedByPostNumber.get(replyPostNumber) || state.nodeCache.get(replyPostNumber);
			if (!node) {
				node = attachPost(rp, ctx, {
					nestedList: true,
					preferredParentNode: parentNode,
					deferNestedSync: true,
				});
				if (node) {
					state.nodeCache.set(replyPostNumber, node);
				}
			}
			if (node) {
				state.nodeCache.set(replyPostNumber, node);
				desiredNodes.push(node);
			}
		});
		const actionEl = parentNode.querySelector(':scope > .ldp-children > .ldp-sub-actions');
		const desiredSet = new Set(desiredNodes);
		mountedReplies.forEach((node) => {
			if (desiredSet.has(node)) return;
			state.nodeCache.set(+node.dataset.postNumber, node);
			removeOwnedNode(node, children);
		});
		let before = actionEl || null;
		for (let index = desiredNodes.length - 1; index >= 0; index--) {
			const node = desiredNodes[index];
			if (node.nextSibling !== before) children.insertBefore(node, before);
			hydratePersistentAvatarImages(node);
			before = node;
		}
		parentNode.dataset.childReplyTotal = String(Math.max(+(parentNode.dataset.childReplyTotal || 0), state.all.length));
		const prevBtn = actionEl?.querySelector('.ldp-sub-page-prev');
		const nextBtn = actionEl?.querySelector('.ldp-sub-page-next');
		const infoEl = actionEl?.querySelector('.ldp-sub-page-info');
		if (actionEl && actionEl.parentNode === children && actionEl.nextSibling) children.appendChild(actionEl);
		if (pageCount > 1) {
			if (actionEl) actionEl.style.display = 'flex';
			if (prevBtn) prevBtn.disabled = !!state.loadingMore || state.pageIndex <= 0;
			if (nextBtn) nextBtn.disabled = !!state.loadingMore || state.pageIndex >= pageCount - 1;
			if (infoEl) infoEl.textContent = `${state.pageIndex + 1}/${pageCount}`;
		} else if (actionEl) {
			actionEl.style.display = 'none';
		}
		state.renderedParent = parentNode;
		state.renderedKey = renderKey;
		scheduleNestedBranchSync(parentNode, ctx);
	}

	function ensureLiveReplyShell(parentNode, totalCount, ctx) {
		if (!parentNode) return null;
		const total = Math.max(1, +totalCount || 0);
		parentNode.dataset.childReplyTotal = String(Math.max(+(parentNode.dataset.childReplyTotal || 0), total));
		if (parentNode._ldpPostData) {
			parentNode._ldpPostData.reply_count = Math.max(+(parentNode._ldpPostData.reply_count || 0), total);
		}
		let children = parentNode.querySelector(':scope > .ldp-children');
		const created = !children;
		if (!children) {
			const holder = makeElement('template');
			holder.innerHTML = childReplyShellHtml(total);
			const [collapse, nextChildren, loading] = holder.content.children;
			const footer = parentNode.querySelector(':scope > .ldp-topic-footer-actions');
			[collapse, nextChildren, loading].forEach((node) => parentNode.insertBefore(node, footer || null));
			children = nextChildren;
		}
		setNestedReplyCollapsed(parentNode, parentNode._ldpPostData?.hidden === true);
		if (created) {
			const isTopicStarter = +parentNode.dataset.postNumber === 1;
			setChildRepliesCollapsed(parentNode, isTopicStarter || PREFS.expandNestedRepliesByDefault !== true);
		}
		if (ctx?.repliesIO) ctx.repliesIO.observe(parentNode);
		syncChildControls(parentNode);
		return children;
	}

	function syncLiveNestedReply(post, ctx, options = {}) {
		const postNumber = +(post?.post_number || 0);
		const parentNumber = +(post?.reply_to_post_number || 0);
		const allowOpParent = options.allowOpParent === true;
		if (!ctx || !postNumber || parentNumber < 1 || (parentNumber === 1 && !allowOpParent)) return false;
		const previous = ctx.subReplyState.get(parentNumber);
		const previousAll = previous && Array.isArray(previous.all) ? previous.all : [];
		const wasAlreadyKnown = previousAll.some((reply) => +(reply?.post_number || 0) === postNumber);
		rememberPostData(ctx, post, parentNumber);
		const parentNode = ctx.streamNodeMap.get(parentNumber);
		if (!parentNode) return false;
		const knownMap = ctx.directRepliesByParent.get(parentNumber);
		const knownReplies = knownMap ? [...knownMap.values()] : [];
		const merged = mergeDirectReplies(previousAll, knownReplies, [post]);
		const previousTotal = Math.max(previousAll.length, +(previous?.totalCount || 0));
		const pageSize = SUB_REPLY_PAGE_SIZE;
		const previousPageCount = Math.max(1, Math.ceil(previousTotal / pageSize));
		const wasOnLastPage = !previous || +(previous.pageIndex || 0) >= previousPageCount - 1;
		const totalCount = Math.max(
			merged.length,
			previousTotal + (options.incrementTotal === true && !wasAlreadyKnown ? 1 : 0),
			+(parentNode.dataset.childReplyTotal || 0),
			+(parentNode._ldpPostData?.reply_count || 0),
		);
		ensureLiveReplyShell(parentNode, totalCount, ctx);
		const pageCount = Math.max(1, Math.ceil(totalCount / pageSize));
		const liveReplyIndex = merged.findIndex((reply) => +(reply?.post_number || 0) === postNumber);
		const liveReplyPage = liveReplyIndex >= 0 ? Math.floor(liveReplyIndex / pageSize) : pageCount - 1;
		const state = {
			all: merged,
			pageIndex: wasOnLastPage ? liveReplyPage : +(previous?.pageIndex || 0),
			nodeCache: previous?.nodeCache,
			renderedKey: '',
			renderedParent: previous?.renderedParent,
			totalCount,
			fetchedReplyCount: Math.max(merged.length, +(previous?.fetchedReplyCount || 0)),
			fetchAfter: Math.max(postNumber, +(previous?.fetchAfter || 0)),
			hasMore: totalCount > merged.length || !!(previous?.hasMore),
			loadingMore: false,
		};
		ctx.subReplyState.set(parentNumber, state);
		if (parentNode.isConnected) renderSubReplyBatch(parentNumber, ctx, 0, parentNode);
		return true;
	}

	function updateLivePostData(post, ctx, options = {}) {
		const postNumber = +(post?.post_number || 0);
		const streamNode = ctx?.streamNodeMap && ctx.streamNodeMap.get(postNumber);
		const postId = +(post?.id || streamNode && streamNode.dataset.postId || 0);
		const copies = new Set(postId ? readerPostCopies(postId, ctx) : []);
		if (streamNode) copies.add(streamNode);
		if (!copies.size) return false;
		copies.forEach((node) => {
			node._ldpPostData = Object.assign({}, node._ldpPostData || {}, post);
			syncPostReadState(node, readerPostIsRead(node._ldpPostData, ctx));
			const content = node.querySelector(':scope > .ldp-content');
			if (options.refreshContent === true && content && post.cooked != null) {
				setPostCookedContent(content, post.cooked, node._ldpPostData.link_counts);
				content.classList.remove('ldp-content-pending');
				content.removeAttribute('aria-busy');
				bindPostMediaLayoutSync(node, ctx);
			}
			if (Array.isArray(post.polls)) renderPostPolls(node, ctx);
			renderPostSpecialContent(node, ctx);
			syncPostActionControls(node, ctx);
		});
		const mergedPost = streamNode?._ldpPostData || { ...post };
		rememberPostData(ctx, mergedPost);
		const replyCount = +(post.reply_count || 0);
		if (replyCount > 0 && streamNode) ensureLiveReplyShell(streamNode, replyCount, ctx);
		return true;
	}

	async function focusLiveNestedReply(post, ctx, options = {}) {
		const postNumber = +(post?.post_number || 0);
		const parentNumber = +(post?.reply_to_post_number || 0);
		const forceNested = options.forceNested === true;
		if (!ctx || !postNumber) return false;
		if (!forceNested && parentNumber <= 1) {
			return !!ctx.timeline && ctx.timeline.jumpTo(postNumber, {
				acceptsWork: options.acceptsWork,
			});
		}
		if (parentNumber < 1) return false;
		const navigationSequence = options.navigationSequence || beginReaderNavigation(ctx);
		const jumpStillCurrent = () => readerNavigationIsCurrent(ctx, navigationSequence) &&
			(!isFunction(options.acceptsWork) || options.acceptsWork());
		if (!jumpStillCurrent()) return false;
		if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition();
		if (!ctx.streamNodeMap.has(parentNumber) && ctx.loadPost) {
			await ctx.loadPost(parentNumber, { mount: true, ensureAround: true, scope: 'around' });
		}
		if (!jumpStillCurrent()) return false;
		const parentNode = getStreamPostNode(ctx, parentNumber, { mount: true }) ||
			ctx.streamNodeMap.get(parentNumber);
		if (!parentNode) return false;
		syncLiveNestedReply(post, ctx, { allowOpParent: forceNested });
		parentNode.classList.remove('ldp-children-collapsed');
		setNestedReplyCollapsed(parentNode, false);
		const state = ctx.subReplyState.get(parentNumber);
		if (!state) return false;
		const pageSize = SUB_REPLY_PAGE_SIZE;
		const replyIndex = state.all.findIndex((reply) => +(reply?.post_number || 0) === postNumber);
		state.pageIndex = replyIndex >= 0
			? Math.floor(replyIndex / pageSize)
			: Math.max(0, Math.ceil(state.all.length / pageSize) - 1);
		state.renderedKey = '';
		renderSubReplyBatch(parentNumber, ctx, 0, parentNode);
		syncChildControls(parentNode);
		const target = parentNode.querySelector(
			`:scope > .ldp-children > .ldp-post[data-post-number="${postNumber}"]`
		);
		if (!target) return false;
		syncPostActionControls(target, ctx);
		if (!scrollToPost(parentNumber, ctx, {
			...PINNED_STREAM_SCROLL_OPTIONS,
			visualNode: target,
			highlightTarget: target,
			alignCenter: true,
		})) return false;
		await waitForReaderJumpLayout();
		if (!jumpStillCurrent() || !target.isConnected) return false;
		if (!readerTargetNodeIsVisible(target, ctx)) return false;
		if (ctx.timeline) ctx.timeline.sync();
		return true;
	}

	async function returnToReaderQuoteSource(source, ctx) {
		const topicId = +(source?.topicId || 0);
		const postNumber = +(source?.postNumber || 0);
		const parentPostNumber = +(source?.parentPostNumber || 0);
		const nested = source && source.nested === true && parentPostNumber > 0;
		if (!ctx || !topicId || !postNumber) return false;
		if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return false;
		if (topicId !== +ctx.topicId) {
			beginReaderNavigation(ctx);
			if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition();
			await openModal(topicId, {
				targetPostNumber: nested ? parentPostNumber : postNumber,
				quoteSourceTarget: source,
			});
			return true;
		}
		clearJumpBackLinks(ctx);
		if (nested) {
			if (!source.postData) return false;
			const navigationSequence = beginReaderNavigation(ctx);
			if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition();
			const postData = Object.assign({}, source.postData || {}, {
				post_number: postNumber,
				reply_to_post_number: parentPostNumber,
			});
			return focusLiveNestedReply(postData, ctx, { forceNested: true, navigationSequence });
		}
		return !!ctx.timeline && ctx.timeline.jumpTo(postNumber);
	}

	function ingestLivePosts(posts, ctx, options = {}) {
		const ordered = (Array.isArray(posts) ? posts : [])
			.filter((post) => post?.id && post.post_number)
			.sort((a, b) => +(a.post_number || 0) - +(b.post_number || 0));
		if (!ordered.length) return [];
		const fresh = [];
		ordered.forEach((post) => {
			if (!updateLivePostData(post, ctx, options)) fresh.push(post);
		});
		if (fresh.length) attachPostBatch(fresh, ctx, { syncStreamNow: true });
		const freshPostNumbers = new Set(fresh.map((post) => +(post.post_number || 0)));
		ordered.forEach((post) => syncLiveNestedReply(post, ctx, {
			incrementTotal: freshPostNumbers.has(+(post.post_number || 0)),
		}));
		if (ctx.isPostVoting) {
			ctx.streamItems.sort(comparePostVotingStreamItems);
			reindexStreamItems(ctx, 0);
			invalidateStreamLayout(ctx);
		}
		updateCommentsHeader(ctx);
		if (ctx.timeline) ctx.timeline.update();
		return fresh;
	}

	function selectedQuoteRaw(post, ctx, selectedText) {
		const postData = post?._ldpPostData || {};
		const username = cleanUsernameValue(postData.username || post && post.dataset.username);
		const postNumber = +(post && post.dataset.postNumber || 0);
		const topicId = +(ctx?.topicId || 0);
		const text = String(selectedText || '').trim();
		if (!username || !postNumber || !topicId || !text) return '';
		return `[quote="${username}, post:${postNumber}, topic:${topicId}"]\n${text}\n[/quote]\n\n`;
	}

	async function writeSelectionQuoteToClipboard(raw) {
		if (!navigator.clipboard || !isFunction(navigator.clipboard.writeText)) {
			throw new Error('clipboard unavailable');
		}
		await navigator.clipboard.writeText(raw);
	}

	function showSelectionToast(message) {
		const existingToast = readerPortalQuery('.ldp-selection-toast');
		if (existingToast) {
			existingToast.remove();
		}
		const toast = makeElement('div');
		toast.className = 'ldp-selection-toast';
		toast.setAttribute('role', 'status');
		toast.textContent = message;
		appendReaderPortalNode(toast);
		setTimeout(() => toast.remove(), 1800);
	}

	async function shareReaderLink(ctx, postNode = null) {
		const sharingPost = !!postNode;
		const postNumber = +(postNode && postNode.dataset.postNumber || 0);
		if (!ctx || !ctx.topicId || (sharingPost && !postNumber)) return;
		const title = String(ctx?.topicData && ctx.topicData.title || document.title || 'LINUX DO');
		const url = topicPageUrl(ctx.topicId, sharingPost ? postNumber : 0);
		if (!url) return;
		if (!sharingPost && navigator.share) {
			try {
				await navigator.share({ title, url });
				return;
			} catch (error) {
				if (error && error.name === 'AbortError') return;
			}
		}
		try {
			await writeSelectionQuoteToClipboard(url);
			showSelectionToast(sharingPost ? `楼层 #${postNumber} 链接已复制到剪切板` : '帖子链接已复制到剪切板');
		} catch (error) {
			showSelectionToast(sharingPost ? '复制楼层链接失败,请重试' : '复制链接失败,请重试');
		}
	}

	function syncReaderBookmarkState(ctx, type, itemId, bookmarked, bookmarkId, options = {}) {
		if (type === 'Post') {
			const updated = [];
			readerPostCopies(itemId, ctx, true).forEach((node) => {
				const postData = node._ldpPostData || options.fallback;
				if (!postData) return;
				node._ldpPostData = postData;
				Object.assign(postData, { bookmarked, bookmark_id: bookmarkId });
				syncPostActionControls(node, ctx, { bookmarkBusy: options.busy === true });
				if (options.persist === true) persistReaderPostState(ctx, postData);
				updated.push(postData);
			});
			return updated;
		}
		const topicData = options.fallback || ctx.topicData;
		ctx.topicBookmarked = bookmarked;
		if (topicData) {
			Object.assign(topicData, { bookmarked, bookmark_id: bookmarkId });
			if (options.persist === true) setCachedTopicData(ctx.topicId, topicData);
		}
		syncTopicActionControls(ctx, { bookmarkBusy: options.busy === true });
		return topicData ? [topicData] : [];
	}

	function discourseBookmarkApi() {
		const bookmarkApi = lookupDiscourse('service:bookmark-api');
		if (!bookmarkApi || !isFunction(bookmarkApi.buildNewBookmark) ||
				!isFunction(bookmarkApi.create) || !isFunction(bookmarkApi.delete)) {
			throw new Error('Discourse 收藏服务尚未就绪');
		}
		return bookmarkApi;
	}

	async function createDiscourseBookmark(type, itemId) {
		const bookmarkApi = discourseBookmarkApi();
		const formDataModule = lookupDiscourseModule('discourse/lib/bookmark-form-data');
		const BookmarkFormData = formDataModule?.BookmarkFormData;
		if (!isFunction(BookmarkFormData)) {
			throw new Error('Discourse 收藏表单模型尚未就绪');
		}
		const bookmark = bookmarkApi.buildNewBookmark(type, itemId);
		const saved = await bookmarkApi.create(new BookmarkFormData(bookmark));
		return Number(saved?.id) || null;
	}

	async function deleteDiscourseBookmark(bookmarkId) {
		if (!(Number(bookmarkId) > 0)) throw new Error('缺少收藏编号');
		return discourseBookmarkApi().delete(Number(bookmarkId));
	}

	async function toggleReaderBookmark(subject, ctx, type) {
		const postBookmark = type === 'Post';
		const item = postBookmark ? subject?._ldpPostData : subject;
		if (!item || !item.id || !ME_USERNAME) return;
		return runReaderActionOnce(ctx, `${type.toLowerCase()}:${item.id}:bookmark`, async () => {
			let { bookmarked, bookmarkId } = postBookmark
				? postBookmarkInfo(item)
				: { bookmarked: !!item.bookmarked, bookmarkId: item.bookmark_id || null };
			const original = { bookmarked, bookmarkId };
			const apply = (busy = false) => syncReaderBookmarkState(
				ctx, type, item.id, bookmarked, bookmarkId, { busy, fallback: item }
			);
			let mutationSucceeded = false;
			apply(true);
			try {
				if (!bookmarked) {
					bookmarkId = await createDiscourseBookmark(type, item.id);
					bookmarked = true;
				} else {
					if (!bookmarkId && postBookmark) {
						const fresh = await fetchJSON(`${BASE}/posts/${item.id}.json`, {
							cacheMode: 'refresh',
							allowStaleOnError: false,
						});
						bookmarkId = fresh?.bookmark_id;
					}
					if (bookmarkId) {
						await deleteDiscourseBookmark(bookmarkId);
					} else if (postBookmark) {
						throw new Error('缺少楼层书签编号');
					} else {
						const topicModule = lookupDiscourseModule('discourse/models/topic');
						const Topic = topicModule?.default;
						if (!Topic || !isFunction(Topic.create)) {
							throw new Error('Discourse 主题模型尚未就绪');
						}
						const topicModel = Topic.create({ ...item, id: Number(item.id) });
						if (!isFunction(topicModel.deleteBookmarks)) {
							throw new Error('Discourse 主题收藏接口尚未就绪');
						}
						await topicModel.deleteBookmarks();
					}
					bookmarked = false;
					bookmarkId = null;
				}
				await clearStoredResponseCache({
					tags: ['bookmarks', `${type.toLowerCase()}:${item.id}`],
				});
				mutationSucceeded = true;
				if (isFunction(ctx.refreshBookmarksPanel)) ctx.refreshBookmarksPanel();
				if (postBookmark) showSelectionToast(bookmarked ? '已收藏该楼层' : '已取消楼层收藏');
			} catch (error) {
				if (postBookmark || !mutationSucceeded) ({ bookmarked, bookmarkId } = original);
				alert(`${postBookmark ? '楼层' : ''}收藏操作失败:${error.message}`);
			} finally {
				const updated = apply(false);
				if (postBookmark) {
					if (updated[0]) persistReaderPostState(ctx, updated[0]);
				} else {
					setCachedTopicData(ctx.topicId, item);
				}
			}
		});
	}

	const READER_REPORT_COPY = Object.freeze({
		spam: { label: '垃圾信息', description: '广告、恶意推广或明显无关的内容。' },
		inappropriate: { label: '不当内容', description: '违反社区文明交流规则的内容。' },
		off_topic: { label: '偏离主题', description: '与当前主题无关,影响讨论阅读。' },
		notify_moderators: {
			label: '通知管理人员',
			description: '需要管理人员结合补充说明进行判断。',
			requireMessage: true,
		},
	});

	function readerReportOptions(postModel) {
		const site = lookupDiscourse('service:site');
		const flagTypes = discourseModelValue(site, 'flagTypes');
		const actionByName = discourseModelValue(postModel, 'actionByName');
		if (!Array.isArray(flagTypes) || !actionByName) return [];
		return flagTypes.map((type) => {
			const nameKey = String(discourseModelValue(type, 'name_key') || '');
			const action = discourseModelValue(actionByName, nameKey);
			const id = Number(discourseModelValue(type, 'id'));
			if (!nameKey || !(id > 0) || !action || discourseModelValue(action, 'can_act') !== true) {
				return null;
			}
			const copy = READER_REPORT_COPY[nameKey] || {};
			return {
				id,
				nameKey,
				label: copy.label || String(discourseModelValue(type, 'name') || nameKey),
				description: copy.description || String(discourseModelValue(type, 'description') || ''),
				requireMessage: copy.requireMessage === true,
				action,
			};
		}).filter(Boolean);
	}

	function openReaderReportForm(ctx, config) {
		const reportOptions = config.options || [];
		if (!reportOptions.length) {
			showSelectionToast('当前没有可用的举报类型');
			return null;
		}
		const postActionTypeModule = lookupDiscourseModule('discourse/models/post-action-type');
		const messageMaxLength = Math.max(
			1,
			Number(postActionTypeModule?.MAX_MESSAGE_LENGTH) || 500,
		);
		return openReaderActionDialog(ctx, {
			title: config.title,
			intro: config.intro ?? '举报会直接提交给社区,不会离开阅读器。',
			submitLabel: '提交举报',
			initialFocus: 'input[name="report_type"]',
			bodyHtml: `
        <div class="ldp-reader-action-options">${reportOptions.map((option, index) => `
					<label class="ldp-reader-action-option">
						<input type="radio" name="report_type" value="${option.id}"${index === 0 ? ' checked' : ''}>
						<span class="ldp-reader-action-option-copy">
							<strong>${esc(option.label)}</strong>
							<small>${esc(option.description)}</small>
						</span>
					</label>`).join('')}</div>
        <label class="ldp-reader-action-field">
          <span>补充说明</span>
          <textarea name="message" maxlength="${messageMaxLength}" placeholder="${escAttr(config.placeholder || '选填;通知管理人员时请填写具体原因')}"></textarea>
        </label>`,
			onSubmit: async (formData) => {
				const actionType = +(formData.get('report_type') || 0);
				const message = String(formData.get('message') || '').trim();
				const selectedOption = reportOptions.find((option) => option.id === actionType);
				if (!selectedOption) throw new Error('请选择举报类型');
				if (selectedOption.requireMessage && !message) throw new Error(
					config.requiredMessageError || `${selectedOption.label}需要填写具体原因`
				);
				return config.onSubmit(actionType, message);
			},
		});
	}

	function openReaderActionDialog(ctx, options) {
		if (!ctx || !ctx.modal) return null;
		if (ctx.readerActionDialog) ctx.readerActionDialog.close();
		const layer = makeElement('div');
		layer.className = 'ldp-reader-action-layer';
		layer.innerHTML = `
			<section class="ldp-reader-action-dialog" role="dialog" aria-modal="true" aria-labelledby="ldp-reader-action-title">
				<div class="ldp-reader-action-head">
					<strong id="ldp-reader-action-title">${esc(options.title || '操作')}</strong>
					<button class="ldp-reader-action-close" type="button" aria-label="关闭">${icon('x')}</button>
				</div>
				<form class="ldp-reader-action-form">
					<div class="ldp-reader-action-body">
						${options.intro ? `<p class="ldp-reader-action-intro">${esc(options.intro)}</p>` : ''}
						${options.bodyHtml || ''}
						<p class="ldp-reader-action-status" role="status" aria-live="polite"></p>
					</div>
					<div class="ldp-reader-action-footer">
						<button class="ldp-reader-action-cancel" type="button">取消</button>
						<button class="ldp-reader-action-submit" type="submit">${esc(options.submitLabel || '提交')}</button>
					</div>
				</form>
			</section>`;
		const form = layer.querySelector('.ldp-reader-action-form');
		const status = layer.querySelector('.ldp-reader-action-status');
		const submit = layer.querySelector('.ldp-reader-action-submit');
		let closeTimer = 0;
		let busy = false;
		const close = () => {
			if (closeTimer) clearTimeout(closeTimer);
			layer.remove();
			if (ctx.readerActionDialog && ctx.readerActionDialog.layer === layer) {
				ctx.readerActionDialog = null;
			}
		};
		const setBusy = (nextBusy) => {
			busy = nextBusy;
			form.querySelectorAll('input,textarea,select,button').forEach((control) => {
				control.disabled = nextBusy;
			});
			submit.textContent = nextBusy ? '提交中…' : (options.submitLabel || '提交');
		};
		bindFloatingSurfaceWheel(layer);
		layer.addEventListener('click', (event) => {
			const closeControl = event.target instanceof Element &&
				event.target.closest('.ldp-reader-action-close,.ldp-reader-action-cancel');
			if (closeControl && layer.contains(closeControl)) close();
			else if (event.target === layer && !busy) close();
		});
		form.addEventListener('submit', async (event) => {
			event.preventDefault();
			if (busy) return;
			status.classList.remove('success');
			status.textContent = '';
			setBusy(true);
			try {
				const successMessage = await options.onSubmit(new FormData(form));
				status.classList.add('success');
				status.textContent = successMessage || '操作已完成';
				closeTimer = setTimeout(close, 650);
			} catch (error) {
				status.textContent = error?.message ? error.message : '操作失败,请重试';
				setBusy(false);
			}
		});
		const dialogState = { layer, close };
		ctx.readerActionDialog = dialogState;
		ctx.modal.appendChild(layer);
		requestAnimationFrame(() => {
			const focusTarget = layer.querySelector(options.initialFocus || 'input,textarea,button');
			if (focusTarget) focusTarget.focus();
		});
		return dialogState;
	}

	function openReaderConfirmDialog(ctx, options) {
		if (!ctx || !ctx.modal) return null;
		if (ctx.readerActionDialog) ctx.readerActionDialog.close();
		const layer = makeElement('div');
		const previouslyFocused = document.activeElement;
		const note = String(options.note || '').trim();
		const details = Array.isArray(options.details) ? options.details.filter((detail) => detail?.label) : [];
		const secondaryLabel = String(options.secondaryLabel || '').trim();
		layer.className = 'ldp-reader-action-layer ldp-reader-confirm-layer';
		layer.innerHTML = `
			<section class="ldp-reader-action-dialog ldp-reader-confirm-dialog${options.tone === 'primary' ? ' is-primary' : ''}" role="alertdialog" aria-modal="true"
				aria-labelledby="ldp-reader-confirm-title" aria-describedby="ldp-reader-confirm-description${details.length ? ' ldp-reader-confirm-details' : ''}${note ? ' ldp-reader-confirm-note' : ''}">
				<div class="ldp-reader-confirm-content">
					<span class="ldp-reader-confirm-icon" aria-hidden="true">${icon(options.icon || 'alertTriangle')}</span>
					<div class="ldp-reader-confirm-copy">
						<strong id="ldp-reader-confirm-title">${esc(options.title || '确认操作')}</strong>
						<p id="ldp-reader-confirm-description">${esc(options.message || '')}</p>
						${details.length ? `<ul class="ldp-reader-confirm-details" id="ldp-reader-confirm-details">${details.map((detail) => `<li><span>${esc(detail.label)}</span><strong>${esc(detail.value || '')}</strong></li>`).join('')}</ul>` : ''}
						${note ? `<small id="ldp-reader-confirm-note">${esc(note)}</small>` : ''}
					</div>
				</div>
				<div class="ldp-reader-action-footer">
					<button class="ldp-reader-action-cancel" type="button">${esc(options.cancelLabel || '取消')}</button>
					${secondaryLabel ? `<button class="ldp-reader-action-secondary" type="button">${esc(secondaryLabel)}</button>` : ''}
					<button class="ldp-reader-action-submit" type="button">${esc(options.confirmLabel || '确认')}</button>
				</div>
			</section>`;
		const cancelButton = layer.querySelector('.ldp-reader-action-cancel');
		const secondaryButton = layer.querySelector('.ldp-reader-action-secondary');
		const confirmButton = layer.querySelector('.ldp-reader-action-submit');
		let closed = false;
		const close = () => {
			if (closed) return;
			closed = true;
			layer.remove();
			if (ctx.readerActionDialog && ctx.readerActionDialog.layer === layer) ctx.readerActionDialog = null;
			requestAnimationFrame(() => {
				if (previouslyFocused?.isConnected && isFunction(previouslyFocused.focus)) {
					previouslyFocused.focus();
				}
			});
		};
		bindFloatingSurfaceWheel(layer);
		layer.addEventListener('click', (event) => {
			const target = event.target instanceof Element ? event.target : null;
			if (event.target === layer || target?.closest('.ldp-reader-action-cancel')) {
				close();
			} else if (target?.closest('.ldp-reader-action-secondary')) {
				if (closed) return;
				if (isFunction(options.onSecondary)) options.onSecondary();
				close();
			} else if (target?.closest('.ldp-reader-action-submit')) {
				if (closed) return;
				if (isFunction(options.onConfirm)) options.onConfirm();
				close();
			}
		});
		layer.addEventListener('keydown', (event) => {
			if (event.key !== 'Tab') return;
			const buttons = [cancelButton, secondaryButton, confirmButton].filter(Boolean);
			if (event.shiftKey && document.activeElement === buttons[0]) {
				event.preventDefault();
				buttons[buttons.length - 1].focus();
			} else if (!event.shiftKey && document.activeElement === buttons[buttons.length - 1]) {
				event.preventDefault();
				buttons[0].focus();
			}
		});
		const dialogState = { layer, close };
		ctx.readerActionDialog = dialogState;
		(ctx.modal.closest('.ldp-overlay') || ctx.modal).appendChild(layer);
		requestAnimationFrame(() => {
			if (layer.isConnected) cancelButton.focus();
		});
		return dialogState;
	}

	function openReaderReportDialog(postNode, ctx) {
		const post = postNode && (postNode._ldpPostData || {});
		const postId = +(post?.id || postNode && postNode.dataset.postId || 0);
		const postNumber = +(post?.post_number || postNode && postNode.dataset.postNumber || 0);
		if (!postId || !postNumber) return;
		const postModel = detachedNativePostModel(postNode, ctx);
		if (!postModel) {
			showSelectionToast('Discourse 楼层模型尚未就绪');
			return;
		}
		const reportOptions = readerReportOptions(postModel);
		openReaderReportForm(ctx, {
			title: `举报 #${postNumber}`,
			options: reportOptions,
			requiredMessageError: '通知管理人员时需要填写具体原因',
			onSubmit: async (actionType, message) => {
				const selected = reportOptions.find((option) => option.id === actionType);
				if (!selected || !isFunction(selected.action.act)) {
					throw new Error('Discourse 举报接口尚未就绪');
				}
				if (postNumber === 1) selected.action.flagTopic = postModel;
				const result = await selected.action.act(postModel, { message });
				if (!result || result.acted !== true) throw new Error('举报提交失败,请重试');
				await clearStoredResponseCache({ tags: [`post:${postId}`] });
				post.can_flag = false;
				persistReaderPostState(ctx, post);
				syncAllPostActionControls(ctx);
				syncTopicActionControls(ctx);
				return '举报已提交';
			},
		});
	}

	function openReaderAssignDialog(postNode, ctx, targetType) {
		const post = postNode && (postNode._ldpPostData || {});
		const isTopic = targetType === 'Topic';
		const targetId = isTopic ? +ctx.topicId : +(post?.id || postNode && postNode.dataset.postId || 0);
		const postNumber = +(post?.post_number || postNode && postNode.dataset.postNumber || 0);
		if (!targetId) return;
		const currentAssignment = isTopic
			? ctx.topicData?.assigned_to_user
			: post?.assigned_to_user;
		const currentUsername = currentAssignment?.username || '';
		openReaderActionDialog(ctx, {
			title: isTopic ? '指定主题负责人' : `指定 #${postNumber} 负责人`,
			intro: '输入社区用户名后直接提交,不会离开阅读器。',
			submitLabel: '确认指定',
			initialFocus: 'input[name="username"]',
			bodyHtml: `
        <label class="ldp-reader-action-field">
          <span>用户名</span>
          <input name="username" type="text" value="${escAttr(currentUsername)}" maxlength="100" autocomplete="off" placeholder="不含 @" required>
        </label>
        <label class="ldp-reader-action-field">
          <span>备注(选填)</span>
          <textarea name="note" maxlength="1000" placeholder="说明这次指定的原因或任务"></textarea>
        </label>`,
			onSubmit: async (formData) => {
				const username = String(formData.get('username') || '').trim().replace(/^@+/, '');
				const note = String(formData.get('note') || '').trim();
				if (!username) throw new Error('请输入要指定的用户名');
				const assignment = {
					username,
					note,
					targetId,
					targetType,
				};
				const taskActions = lookupDiscourse('service:task-actions');
				if (!taskActions || !isFunction(taskActions.putAssignment)) {
					throw new Error('Discourse 指定任务服务尚未就绪');
				}
				await taskActions.putAssignment(assignment);
				await clearStoredResponseCache({
					tags: [isTopic ? `topic:${targetId}` : `post:${targetId}`],
				});
				const assignmentState = { username };
				if (isTopic && ctx.topicData) {
					ctx.topicData.assigned_to_user = assignmentState;
					setCachedTopicData(ctx.topicId, ctx.topicData);
				}
				if (!isTopic && post) {
					post.assigned_to_user = assignmentState;
					persistReaderPostState(ctx, post);
				}
				return `已指定给 @${username}`;
			},
		});
	}

	/* ============ 10. 事件委托 ============ */
	function bindActions(modal, ctx) {
		const floorPreview = bindFloorPreview(modal, ctx);
		const actionScope = createLifecycleScope();
		const onAction = (target, type, listener, options) => actionScope.listen(target, type, listener, options);
		ctx.floorPreview = floorPreview;
		const userCardEventRoot = ensureReaderPortal() || modal.closest('.ldp-overlay') || modal;
		let userCardPrefetchTimer = 0;
		let userCardShowTimer = 0;
		let userCardHideTimer = 0;
		const cancelUserCardOpening = () => {
			clearTimeout(userCardPrefetchTimer);
			clearTimeout(userCardShowTimer);
			userCardPrefetchTimer = 0;
			userCardShowTimer = 0;
		};
		let userCardToken = 0;
		let userCardAnchor = null;
		let userCardPositionFrame = 0;
		let userCardAnchorObserver = null;
		const fallbackUserCard = getUserCardSurface();
		const userCardFollowPanel = getUserCardFollowPanel();
		const userCardFollowPreview = getUserCardSurface(true);
		let userCardFollowPreviewAnchor = null;
		let userCardFollowPreviewToken = 0;
		let userCardFollowPreviewShowTimer = 0;
		let userCardFollowPreviewHoverArmed = true;
		const userCardNavigationStack = [];
		const selectionToolbar = makeElement('div');
		selectionToolbar.className = 'ldp-selection-toolbar ldp-action-surface';
		selectionToolbar.hidden = true;
		selectionToolbar.setAttribute('role', 'toolbar');
		setLabel(selectionToolbar, '引用所选文字');
		selectionToolbar.innerHTML = `
			<button type="button" data-selection-action="quote">引用</button>
			<button type="button" data-selection-action="copy">复制引用</button>`;
		appendReaderPortalNode(selectionToolbar);
		const imageQuoteToolbar = makeElement('div');
		imageQuoteToolbar.className = 'ldp-selection-toolbar ldp-image-quote-toolbar ldp-action-surface';
		imageQuoteToolbar.hidden = true;
		imageQuoteToolbar.setAttribute('role', 'toolbar');
		setLabel(imageQuoteToolbar, '引用图片');
		imageQuoteToolbar.innerHTML = '<button type="button">引用图片</button>';
		appendReaderPortalNode(imageQuoteToolbar);
		let selectedQuote = null;
		let selectionToolbarFrame = 0;
		let imageQuoteTarget = null;
		let imageQuotePositionFrame = 0;
		let imageQuoteHideTimer = 0;
		let imageQuoteShowTimer = 0;
		let imageQuoteCycleTimer = 0;
		let imageQuotePointerX = 0;
		let imageQuotePointerY = 0;
		let imageQuoteAnchorX = 0;
		let imageQuoteAnchorY = 0;
		let imageQuotePointerInside = false;
		let activeActionPost = null;
		const reactionHoverOpenTimers = new Map();
		const reactionHoverCloseTimers = new Map();
		let reactionVisibilityObserver = null;
		let reactionRegistryHydration = null;

		const clearReactionHoverTimer = (timers, root) => {
			const timer = timers.get(root);
			if (!timer) return;
			clearTimeout(timer);
			timers.delete(root);
		};
		const setReactionPickerExpanded = (root, open) => {
			clearReactionHoverTimer(reactionHoverOpenTimers, root);
			clearReactionHoverTimer(reactionHoverCloseTimers, root);
			if (!root || !root.isConnected) return;
			const picker = root.querySelector('.ldp-reaction-picker');
			const trigger = root.querySelector('[data-reaction-picker]');
			if (picker) picker.hidden = !open;
			if (trigger) {
				setExpanded(trigger, !!open);
				refreshTooltips(trigger);
			}
			if (open) reactionVisibilityObserver?.observe(root);
			else reactionVisibilityObserver?.unobserve(root);
		};
		const hydrateReactionRegistry = () => {
			if (reactionEmojiUrls) return Promise.resolve(reactionEmojiUrls);
			if (reactionRegistryHydration) return reactionRegistryHydration;
			reactionRegistryHydration = loadReactionEmojiRegistry().then(() => {
				if (!reactionEmojiUrls || !modal.isConnected ||
					(ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active'))) return;
				modal.querySelectorAll('.ldp-post').forEach((postNode) => {
					const root = postNode.querySelector(':scope > .ldp-reactions');
					const wasExpanded = !!(root?.querySelector('.ldp-reaction-picker:not([hidden])'));
					renderPostReactions(postNode, ctx);
					if (wasExpanded) setReactionPickerExpanded(postNode.querySelector(':scope > .ldp-reactions'), true);
				});
			}).finally(() => { reactionRegistryHydration = null; });
			return reactionRegistryHydration;
		};
		hydrateReactionRegistry();
		if (isFunction(IntersectionObserver)) {
			reactionVisibilityObserver = new IntersectionObserver((entries) => {
				entries.forEach((entry) => {
					if (entry.isIntersecting) return;
					const root = entry.target;
					setReactionPickerExpanded(root, false);
				});
			}, { root: ctx.scrollRoot, threshold: 0 });
		}
		const onReactionPointerOver = (event) => {
			const root = event.target.closest && event.target.closest('.ldp-reactions');
			if (!root || !modal.contains(root)) return;
			clearReactionHoverTimer(reactionHoverCloseTimers, root);
			const trigger = event.target.closest('[data-reaction-picker]');
			if (!trigger || (event.relatedTarget && trigger.contains(event.relatedTarget))) return;
			hydrateReactionRegistry();
			clearReactionHoverTimer(reactionHoverOpenTimers, root);
			reactionHoverOpenTimers.set(root, setTimeout(() => {
				reactionHoverOpenTimers.delete(root);
				setReactionPickerExpanded(root, true);
			}, 500));
		};
		const onReactionPointerOut = (event) => {
			const root = event.target.closest && event.target.closest('.ldp-reactions');
			if (!root || !modal.contains(root)) return;
			const trigger = event.target.closest('[data-reaction-picker]');
			if (trigger && (!event.relatedTarget || !trigger.contains(event.relatedTarget))) {
				clearReactionHoverTimer(reactionHoverOpenTimers, root);
			}
			if (event.relatedTarget && root.contains(event.relatedTarget)) return;
			clearReactionHoverTimer(reactionHoverOpenTimers, root);
			clearReactionHoverTimer(reactionHoverCloseTimers, root);
			reactionHoverCloseTimers.set(root, setTimeout(() => {
				reactionHoverCloseTimers.delete(root);
				setReactionPickerExpanded(root, false);
			}, 500));
		};

		const activatePostActions = (event) => {
			const post = event.target.closest && event.target.closest('.ldp-post');
			if (!post || !modal.contains(post)) return;
			if (activeActionPost !== post) {
				if (activeActionPost) activeActionPost.classList.remove('ldp-actions-active');
				activeActionPost = post;
				activeActionPost.classList.add('ldp-actions-active');
			}
			hydratePostContextActions(post, ctx);
		};
		const clearActivePostActions = () => {
			if (activeActionPost) activeActionPost.classList.remove('ldp-actions-active');
			activeActionPost = null;
		};
		onAction(modal, 'focusin', activatePostActions);
		onAction(modal, 'pointerleave', clearActivePostActions);

		const hideSelectionToolbar = () => {
			selectionToolbar.hidden = true;
			selectedQuote = null;
		};

		const clearSelectedText = () => {
			const selection = readerSelection();
			if (isFunction(selection?.removeAllRanges)) selection.removeAllRanges();
		};

		const updateSelectionToolbar = () => {
			const selection = readerSelection();
			if (!selection || selection.isCollapsed || !selection.rangeCount) return hideSelectionToolbar();
			const text = String(selection.toString() || '').trim();
			if (!text) return hideSelectionToolbar();
			const range = selection.getRangeAt(0);
			const startNode = range.startContainer.nodeType === Node.ELEMENT_NODE
				? range.startContainer : range.startContainer.parentElement;
			const endNode = range.endContainer.nodeType === Node.ELEMENT_NODE
				? range.endContainer : range.endContainer.parentElement;
			const startContent = startNode?.closest && startNode.closest('.ldp-content');
			const endContent = endNode?.closest && endNode.closest('.ldp-content');
			if (!startContent || startContent !== endContent || !modal.contains(startContent)) return hideSelectionToolbar();
			const post = startContent.closest('.ldp-post');
			const raw = selectedQuoteRaw(post, ctx, text);
			if (!post || !raw) return hideSelectionToolbar();
			const rect = range.getBoundingClientRect();
			if (!rect || (!rect.width && !rect.height)) return hideSelectionToolbar();
			selectedQuote = { post, raw };
			selectionToolbar.hidden = false;
			const toolbarRect = selectionToolbar.getBoundingClientRect();
			const left = Math.max(8, Math.min(
				rect.right - toolbarRect.width,
				window.innerWidth - toolbarRect.width - 8,
			));
			const above = rect.top - toolbarRect.height - 8;
			const top = above >= 8
				? above
				: Math.min(window.innerHeight - toolbarRect.height - 8, rect.bottom + 8);
			selectionToolbar.style.left = `${Math.round(left)}px`;
			selectionToolbar.style.top = `${Math.round(Math.max(8, top))}px`;
		};

		const scheduleSelectionToolbar = () => {
			if (selectionToolbarFrame) return;
			selectionToolbarFrame = requestAnimationFrame(() => {
				selectionToolbarFrame = 0;
				updateSelectionToolbar();
			});
		};
		const onSelectionPointerDown = (event) => {
			if (selectionToolbar.contains(event.target) || imageQuoteToolbar.contains(event.target)) event.preventDefault();
		};
		const onDocumentPointerDown = (event) => {
			const path = isFunction(event.composedPath) ? event.composedPath() : [];
			if (!selectionToolbar.hidden && !path.includes(selectionToolbar) && !selectionToolbar.contains(event.target)) {
				hideSelectionToolbar();
			}
		};
		const onSelectionAction = async (event) => {
			const button = event.target.closest('[data-selection-action]');
			if (!button || !selectedQuote) return;
			const { post, raw } = selectedQuote;
			if (button.dataset.selectionAction === 'quote') {
				await tryOpenNativeReplyComposer(post, ctx, raw);
				hideSelectionToolbar();
				clearSelectedText();
				return;
			}
			try {
				await writeSelectionQuoteToClipboard(raw);
				showSelectionToast('引用已复制到剪切板');
			} catch (error) {
				showSelectionToast('复制失败,请重试');
			}
			hideSelectionToolbar();
			clearSelectedText();
		};

		const clearImageQuoteCycleTimers = () => {
			clearTimeout(imageQuoteShowTimer);
			clearTimeout(imageQuoteCycleTimer);
			imageQuoteShowTimer = 0;
			imageQuoteCycleTimer = 0;
		};
		const hideImageQuoteToolbar = () => {
			clearTimeout(imageQuoteHideTimer);
			clearImageQuoteCycleTimers();
			imageQuoteHideTimer = 0;
			imageQuoteTarget = null;
			imageQuotePointerInside = false;
			imageQuoteToolbar.hidden = true;
		};
		const closeTransientActions = () => {
			if (!selectionToolbar.hidden) {
				hideSelectionToolbar();
				clearSelectedText();
				return true;
			}
			if (!imageQuoteToolbar.hidden) {
				hideImageQuoteToolbar();
				return true;
			}
			const reactionPicker = modal.querySelector('.ldp-reaction-picker:not([hidden])');
			if (!reactionPicker) return false;
			const root = reactionPicker.closest('.ldp-reactions');
			setReactionPickerExpanded(root, false);
			return true;
		};
		ctx.closeTransientActions = closeTransientActions;
		const scheduleHideImageQuoteToolbar = () => {
			clearTimeout(imageQuoteHideTimer);
			imageQuoteHideTimer = setTimeout(hideImageQuoteToolbar, 140);
		};
		const positionImageQuoteToolbar = () => {
			imageQuotePositionFrame = 0;
			if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return hideImageQuoteToolbar();
			imageQuoteToolbar.hidden = false;
			const toolbarRect = imageQuoteToolbar.getBoundingClientRect();
			const gap = 12;
			const edge = 8;
			let left = imageQuoteAnchorX + gap;
			let top = imageQuoteAnchorY + gap;
			if (left + toolbarRect.width > window.innerWidth - edge) {
				left = imageQuoteAnchorX - toolbarRect.width - gap;
			}
			if (top + toolbarRect.height > window.innerHeight - edge) top = imageQuoteAnchorY - toolbarRect.height - gap;
			imageQuoteToolbar.style.left = `${Math.round(Math.max(edge, Math.min(left, window.innerWidth - toolbarRect.width - edge)))}px`;
			imageQuoteToolbar.style.top = `${Math.round(Math.max(edge, Math.min(top, window.innerHeight - toolbarRect.height - edge)))}px`;
		};
		const scheduleImageQuoteToolbarPosition = () => {
			if (imageQuotePositionFrame) return;
			imageQuotePositionFrame = requestAnimationFrame(positionImageQuoteToolbar);
		};
		const startImageQuoteCycle = () => {
			clearImageQuoteCycleTimers();
			imageQuoteToolbar.hidden = true;
			if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return;
			imageQuoteShowTimer = setTimeout(() => {
				imageQuoteShowTimer = 0;
				if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) return;
				imageQuoteAnchorX = imageQuotePointerX;
				imageQuoteAnchorY = imageQuotePointerY;
				scheduleImageQuoteToolbarPosition();
				imageQuoteCycleTimer = setTimeout(() => {
					imageQuoteCycleTimer = 0;
					if (!imageQuoteTarget || !imageQuoteTarget.isConnected || !imageQuotePointerInside) {
						hideImageQuoteToolbar();
						return;
					}
					startImageQuoteCycle();
				}, 5000);
			}, 350);
		};
		const updateImageQuotePointer = (event) => {
			const image = lightboxImageFromEvent(event);
			if (!image) return;
			const shouldRestart = imageQuoteTarget !== image || !imageQuotePointerInside;
			clearTimeout(imageQuoteHideTimer);
			imageQuoteHideTimer = 0;
			imageQuoteTarget = image;
			imageQuotePointerInside = true;
			imageQuotePointerX = event.clientX;
			imageQuotePointerY = event.clientY;
			if (shouldRestart) startImageQuoteCycle();
		};
		const onImageQuotePointerUpdate = (event) => {
			updateImageQuotePointer(event);
		};
		const onImageQuotePointerOut = (event) => {
			if (!lightboxImageFromEvent(event)) return;
			imageQuotePointerInside = false;
			clearImageQuoteCycleTimers();
			const related = event.relatedTarget;
			if (related && (imageQuoteToolbar === related || imageQuoteToolbar.contains(related))) return;
			scheduleHideImageQuoteToolbar();
		};
		const keepImageQuoteToolbarOpen = () => {
			clearTimeout(imageQuoteHideTimer);
			imageQuoteHideTimer = 0;
			imageQuotePointerInside = false;
			clearImageQuoteCycleTimers();
		};
		const onImageQuoteAction = async (event) => {
			event.preventDefault();
			const image = imageQuoteTarget;
			const post = image?.closest('.ldp-post');
			const imageSet = image?.isConnected ? collectLightboxItems(image, ctx) : null;
			const item = imageSet && imageSet.items[imageSet.initialIndex];
			const raw = lightboxImageQuoteRaw(item, ctx);
			hideImageQuoteToolbar();
			if (!post || !raw) {
				showSelectionToast('无法确认图片引用来源');
				return;
			}
			await tryOpenNativeReplyComposer(post, ctx, raw);
		};

		onAction(modal, 'mouseup', scheduleSelectionToolbar);
		onAction(modal, 'keyup', scheduleSelectionToolbar);
		onAction(modal, 'pointerover', (event) => {
			activatePostActions(event);
			onReactionPointerOver(event);
			onImageQuotePointerUpdate(event);
		});
		onAction(modal, 'pointermove', onImageQuotePointerUpdate);
		onAction(modal, 'pointerout', (event) => {
			onReactionPointerOut(event);
			onImageQuotePointerOut(event);
		});
		onAction(selectionToolbar, 'pointerdown', onSelectionPointerDown);
		onAction(selectionToolbar, 'click', onSelectionAction);
		onAction(imageQuoteToolbar, 'pointerdown', onSelectionPointerDown);
		onAction(imageQuoteToolbar, 'pointerenter', keepImageQuoteToolbarOpen);
		onAction(imageQuoteToolbar, 'pointerleave', scheduleHideImageQuoteToolbar);
		onAction(imageQuoteToolbar, 'click', onImageQuoteAction);
		onAction(document, 'selectionchange', scheduleSelectionToolbar);
		onAction(document, 'pointerdown', onDocumentPointerDown, true);
		onAction(ctx.scrollRoot, 'scroll', () => {
			hideSelectionToolbar();
			hideImageQuoteToolbar();
		}, { passive: true });

		const closeUserCardFollowPreview = (options = {}) => {
			userCardFollowPreviewToken++;
			userCardFollowPreviewAnchor = null;
			clearTimeout(userCardFollowPreviewShowTimer);
			userCardFollowPreviewShowTimer = 0;
			if (options.suppressHoverUntilExit) userCardFollowPreviewHoverArmed = false;
			userCardFollowPreviewActionIntent = null;
			releasePersistentAvatarImages(userCardFollowPreview);
			userCardFollowPreview.classList.remove('open');
			delete userCardFollowPreview._ldpUserData;
			renderUserCardBreadcrumbs();
		};

		const navigationEntryFromUser = (user) => ({
			username: String(user?.username || ''),
			name: String(user && (user.name || user.username) || ''),
			user,
			followType: '',
			followUsers: null,
			search: '',
			page: 0,
		});
		const ensureUserCardNavigationRoot = () => {
			const user = fallbackUserCard._ldpUserData;
			const username = String(user?.username || '');
			if (!username) return null;
			if (!userCardNavigationStack.length) userCardNavigationStack.push(navigationEntryFromUser(user));
			return userCardNavigationStack[userCardNavigationStack.length - 1];
		};
		const rememberCurrentUserCardNavigationState = () => {
			const entry = ensureUserCardNavigationRoot();
			if (!entry) return;
			const activeCard = userCardNavigationStack.length > 1 ? userCardFollowPreview : fallbackUserCard;
			const displayedUser = activeCard._ldpUserData;
			if (String(displayedUser?.username || '') === entry.username) {
				entry.user = displayedUser;
			}
			entry.name = String(entry.user && (entry.user.name || entry.user.username) || entry.username);
			if (userCardFollowPanel.hidden || userCardFollowPanel.dataset.followUsername !== entry.username) return;
			entry.followType = String(userCardFollowPanel.dataset.followType || '');
			entry.followUsers = Array.isArray(userCardFollowPanel._ldpFollowUsers)
				? userCardFollowPanel._ldpFollowUsers
				: null;
			entry.search = userCardFollowPanel.querySelector('input[type="search"]')?.value || '';
			entry.page = Math.max(0, Number(userCardFollowPanel.dataset.followPage) || 0);
		};
		const renderUserCardBreadcrumbs = () => {
			const breadcrumbs = userCardFollowPanel.querySelector('.ldp-user-card-breadcrumbs');
			if (!breadcrumbs) return;
			const entries = userCardNavigationStack.map((entry, index) => ({ ...entry, index }));
			const previewUser = userCardFollowPreview.classList.contains('open') && userCardFollowPreview._ldpUserData;
			if (previewUser && String(previewUser.username || '') !== String(entries[entries.length - 1]?.username || '')) {
				entries.push({ ...navigationEntryFromUser(previewUser), index: -1, preview: true });
			}
			breadcrumbs.hidden = entries.length < 2;
			breadcrumbs.innerHTML = entries.map((entry, index) => {
				const label = entry.name || entry.username;
				const last = index === entries.length - 1;
				const crumb = last
					? `<strong>${esc(label)}</strong>`
					: `<button type="button" data-user-card-breadcrumb-index="${entry.index}">${esc(label)}</button>`;
				return `${index ? '<span aria-hidden="true">›</span>' : ''}${crumb}`;
			}).join('');
			breadcrumbs.scrollLeft = breadcrumbs.scrollWidth;
		};
		const renderSecondaryUserCard = (user) => {
			if (!user || !user.username) return false;
			userCardFollowPreviewToken++;
			userCardFollowPreviewAnchor = null;
			clearTimeout(userCardFollowPreviewShowTimer);
			userCardFollowPreviewShowTimer = 0;
			releasePersistentAvatarImages(userCardFollowPreview);
			userCardFollowPreview._ldpUserData = user;
			userCardFollowPreview.innerHTML = renderFallbackUserCard(
				user,
				user.username,
				'ready',
				user._ldp_avatar_preview_url || ''
			);
			hydratePersistentAvatarImages(userCardFollowPreview, POST_REQUEST_PRIORITY.userCard);
			syncUserCardBadgeScrollControls(userCardFollowPreview);
			requestAnimationFrame(() => userCardFollowPreview.isConnected &&
				syncUserCardBadgeScrollControls(userCardFollowPreview));
			userCardFollowPreview.classList.add('open');
			positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
			return true;
		};
		const clearUserCardFollowPanelState = () => {
			delete userCardFollowPanel._ldpFollowUsers;
			delete userCardFollowPanel.dataset.followType;
			delete userCardFollowPanel.dataset.followUsername;
			delete userCardFollowPanel.dataset.followPage;
			const list = userCardFollowPanel.querySelector('.ldp-user-card-follow-list');
			if (list) {
				releasePersistentAvatarImages(list);
				list.replaceChildren();
			}
			[fallbackUserCard, userCardFollowPreview].forEach((card) =>
				card.querySelectorAll('[data-user-card-follow-type]')
					.forEach((button) => setExpanded(button, false)));
		};
		const restoreUserCardNavigationEntry = (index) => {
			if (!Number.isInteger(index) || index < 0 || index >= userCardNavigationStack.length) return false;
			rememberCurrentUserCardNavigationState();
			const entry = userCardNavigationStack[index];
			userCardNavigationStack.length = index + 1;
			if (index === 0) closeUserCardFollowPreview();
			else if (!renderSecondaryUserCard(entry.user)) return false;
			clearUserCardFollowPanelState();
			if (entry.followType && Array.isArray(entry.followUsers)) {
				userCardFollowPanel.hidden = false;
				userCardFollowPanel.dataset.followType = entry.followType;
				userCardFollowPanel.dataset.followUsername = entry.username;
				userCardFollowPanel._ldpFollowUsers = entry.followUsers;
				userCardFollowPanel.querySelector('.ldp-user-card-follow-title').textContent =
					entry.followType === 'following' ? '关注的人员' : '被关注的人员';
				const search = userCardFollowPanel.querySelector('input[type="search"]');
				if (search) search.value = entry.search || '';
				userCardFollowPanel.dataset.followPage = String(Math.max(0, Number(entry.page) || 0));
				const activeCard = index === 0 ? fallbackUserCard : userCardFollowPreview;
				activeCard.querySelectorAll('[data-user-card-follow-type]').forEach((button) =>
					setExpanded(button, button.dataset.userCardFollowType === entry.followType));
				renderUserCardFollowList(userCardFollowPanel);
				positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
				if (index > 0) positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
			} else {
				userCardFollowPanel.hidden = true;
			}
			renderUserCardBreadcrumbs();
			return true;
		};
		const promoteUserCardFollowPreview = () => {
			const user = userCardFollowPreview._ldpUserData;
			const username = String(user?.username || '');
			if (!username) return false;
			rememberCurrentUserCardNavigationState();
			const current = userCardNavigationStack[userCardNavigationStack.length - 1];
			if (!current || current.username !== username) userCardNavigationStack.push(navigationEntryFromUser(user));
			else current.user = user;
			clearUserCardFollowPanelState();
			renderUserCardBreadcrumbs();
			return true;
		};

		const closeUserCardFollowPanel = () => {
			closeUserCardFollowPreview();
			if (userCardFollowPanel.hidden) return;
			userCardFollowPanel.hidden = true;
			userCardFollowPanel.style.removeProperty('max-height');
			fallbackUserCard.style.removeProperty('max-height');
			if (userCardNavigationStack.length > 1) userCardNavigationStack.length = 1;
			clearUserCardFollowPanelState();
			if (fallbackUserCardOpen() && userCardAnchor) positionFallbackUserCard(fallbackUserCard, userCardAnchor);
		};

		const closeUserCard = () => {
			const avatarViewer = readerPortalQuery('.ldp-avatar-viewer');
			if (avatarViewer?._ldpAnchorCard === fallbackUserCard && isFunction(avatarViewer._ldpClose)) {
				avatarViewer._ldpClose();
			}
			closeUserCardFollowPanel();
			cancelQueuedUserCardRequests(getUserCardUsername(userCardAnchor), ctx.postRequestScheduler);
			userCardToken++;
			userCardAnchor = null;
			cancelUserCardOpening();
			clearTimeout(userCardHideTimer);
			userCardHideTimer = 0;
			if (userCardPositionFrame) cancelAnimationFrame(userCardPositionFrame);
			userCardPositionFrame = 0;
			userCardAnchorObserver?.disconnect();
			userCardAnchorObserver = null;
			userCardNavigationStack.length = 0;
			hideFallbackUserCard();
		};
		const closeUserCardLayer = () => {
			if (userCardFollowPreview.classList.contains('open')) {
				const activeEntry = userCardNavigationStack[userCardNavigationStack.length - 1];
				const displayedUsername = String(userCardFollowPreview._ldpUserData?.username || '');
				if (userCardNavigationStack.length < 2 || displayedUsername !== activeEntry?.username) {
					if (userCardNavigationStack.length > 1 && activeEntry?.user) {
						userCardFollowPreviewHoverArmed = false;
						renderSecondaryUserCard(activeEntry.user);
						renderUserCardBreadcrumbs();
						return;
					}
					return closeUserCardFollowPreview({ suppressHoverUntilExit: true });
				}
			}
			if (userCardNavigationStack.length > 1) {
				return restoreUserCardNavigationEntry(userCardNavigationStack.length - 2);
			}
			if (!userCardFollowPanel.hidden) return closeUserCardFollowPanel();
			const notificationMenu = fallbackUserCard.querySelector('.ldp-user-card-notification-menu:not([hidden])');
			if (!notificationMenu) return closeUserCard();
			notificationMenu.hidden = true;
			fallbackUserCard.querySelector('[data-user-card-action="notifications"]')?.setAttribute('aria-expanded', 'false');
			if (userCardAnchor) positionFallbackUserCard(fallbackUserCard, userCardAnchor);
		};
		ctx.closeUserCard = closeUserCard;
		ctx.closeUserCardLayer = closeUserCardLayer;

		const userCardAnchorIsVisible = (anchor) => {
			if (!anchor || !anchor.isConnected) return false;
			const rect = anchor.getBoundingClientRect();
			return rect.width > 0 && rect.height > 0 &&
				rect.bottom > 0 && rect.top < window.innerHeight &&
				rect.right > 0 && rect.left < window.innerWidth;
		};
		const syncUserCardPosition = () => {
			userCardPositionFrame = 0;
			if (!fallbackUserCardOpen() || !userCardAnchor) return;
			if (!userCardAnchorIsVisible(userCardAnchor)) return closeUserCard();
			positionFallbackUserCard(fallbackUserCard, userCardAnchor);
			positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
			if (userCardFollowPreview.classList.contains('open')) {
				positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
			}
		};
		const scheduleUserCardPosition = () => {
			if (!fallbackUserCardOpen() || userCardPositionFrame) return;
			userCardPositionFrame = requestAnimationFrame(syncUserCardPosition);
		};
		const observeUserCardAnchor = (anchor) => {
			userCardAnchorObserver?.disconnect();
			userCardAnchorObserver = null;
			if (!isFunction(IntersectionObserver) || !anchor) return;
			userCardAnchorObserver = new IntersectionObserver((entries) => {
				const entry = entries.find((item) => item.target === anchor);
				if (userCardAnchor !== anchor || !entry) return;
				if (!entry.isIntersecting) closeUserCard();
				else scheduleUserCardPosition();
			}, { threshold: 0 });
			userCardAnchorObserver.observe(anchor);
		};

		onAction(document, 'scroll', scheduleUserCardPosition, true);
		onAction(window, 'scroll', scheduleUserCardPosition, { passive: true });
		onAction(window, 'resize', scheduleUserCardPosition, { passive: true });

		const scheduleCloseUserCard = (event) => {
			if (!userCardFollowPanel.hidden) return;
			const relatedTarget = event?.relatedTarget;
			const avatarViewer = readerPortalQuery('.ldp-avatar-viewer');
			if (relatedTarget && (fallbackUserCard.contains(relatedTarget) || userCardFollowPanel.contains(relatedTarget) ||
				userCardFollowPreview.contains(relatedTarget) || avatarViewer?.contains(relatedTarget))) return;
			clearTimeout(userCardHideTimer);
			userCardHideTimer = setTimeout(() => {
				userCardHideTimer = 0;
				const currentAvatarViewer = readerPortalQuery('.ldp-avatar-viewer');
				if (userCardAnchor?.matches(':hover') || fallbackUserCard.matches(':hover') ||
					userCardFollowPanel.matches(':hover') || userCardFollowPreview.matches(':hover') ||
					currentAvatarViewer?.matches(':hover')) return;
				closeUserCard();
			}, 180);
		};

		const keepUserCardOpen = () => clearTimeout(userCardHideTimer);
		const openUserCardMediaViewer = (...args) => {
			const viewer = openUserAvatarViewer(...args);
			if (!viewer) return;
			viewer.addEventListener('mouseenter', keepUserCardOpen);
			viewer.addEventListener('mouseleave', scheduleCloseUserCard);
		};
		onAction(fallbackUserCard, 'mouseenter', keepUserCardOpen);
		onAction(fallbackUserCard, 'mouseleave', scheduleCloseUserCard);
		onAction(userCardFollowPanel, 'mouseenter', keepUserCardOpen);
		onAction(userCardFollowPanel, 'mouseleave', scheduleCloseUserCard);
		onAction(userCardFollowPreview, 'mouseenter', keepUserCardOpen);

		const onUserCardHostLinkClick = (event) => {
			const link = event.target.closest && event.target.closest('a[href]');
			if (!link || (!fallbackUserCard.contains(link) && !userCardFollowPanel.contains(link) &&
				!userCardFollowPreview.contains(link))) return;
			openEmbeddedReaderLinkInHost(event, link, ctx.readerWorkspace);
		};
		onAction(fallbackUserCard, 'click', onUserCardHostLinkClick, true);
		onAction(userCardFollowPanel, 'click', onUserCardHostLinkClick, true);
		onAction(userCardFollowPreview, 'click', onUserCardHostLinkClick, true);

		const onUserCardWheel = (event) => {
			event.stopPropagation();
			const badgeList = event.target instanceof Element && event.target.closest('.ldp-user-card-badge-list');
			if (badgeList && fallbackUserCard.contains(badgeList) && badgeList.scrollWidth > badgeList.clientWidth + 1) {
				const rawHorizontalDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
				const horizontalDelta = event.deltaMode === 1
					? rawHorizontalDelta * 40
					: event.deltaMode === 2 ? rawHorizontalDelta * badgeList.clientWidth : rawHorizontalDelta;
				const maxScrollLeft = badgeList.scrollWidth - badgeList.clientWidth;
				const canScrollHorizontally = horizontalDelta < 0
					? badgeList.scrollLeft > 0
					: badgeList.scrollLeft < maxScrollLeft - 1;
				if (horizontalDelta && canScrollHorizontally) {
					event.preventDefault();
					badgeList.scrollLeft += horizontalDelta;
					return;
				}
			}
			containFloatingSurfaceWheel(fallbackUserCard, event);
		};
		onAction(fallbackUserCard, 'wheel', onUserCardWheel, { passive: false });
		const onUserCardFollowPanelWheel = (event) => {
			const list = userCardFollowPanel.querySelector('.ldp-user-card-follow-list');
			if (list && !list.contains(event.target) && event.deltaY && list.scrollHeight > list.clientHeight + 1) {
				const delta = event.deltaMode === 1
					? event.deltaY * 40
					: event.deltaMode === 2 ? event.deltaY * list.clientHeight : event.deltaY;
				consumeEvent(event);
				list.scrollTop += delta;
				return;
			}
			containFloatingSurfaceWheel(userCardFollowPanel, event);
		};
		onAction(userCardFollowPanel, 'wheel', onUserCardFollowPanelWheel, { passive: false });
		const onUserCardFollowPreviewWheel = (event) => {
			event.stopPropagation();
			containFloatingSurfaceWheel(userCardFollowPreview, event);
		};
		onAction(userCardFollowPreview, 'wheel', onUserCardFollowPreviewWheel, { passive: false });
		const onUserCardBadgeListScroll = (event) => {
			if (!(event.target instanceof Element) || !event.target.matches('.ldp-user-card-badge-list')) return;
			syncUserCardBadgeScrollControls(event.currentTarget);
		};
		onAction(fallbackUserCard, 'scroll', onUserCardBadgeListScroll, true);
		onAction(userCardFollowPreview, 'scroll', onUserCardBadgeListScroll, true);

		const setUserCardActionStatus = (card, message, isError = false) => {
			const status = card?.querySelector('.ldp-user-card-action-status');
			if (!status) return;
			status.textContent = message || '';
			status.classList.toggle('is-error', !!isError);
		};

		const onUserCardAction = async (event) => {
			const actionCard = userCardFollowPreview.contains(event.target)
				? userCardFollowPreview
				: fallbackUserCard.contains(event.target) ? fallbackUserCard : null;
			if (actionCard === userCardFollowPreview) {
				const directControl = event.target.closest && event.target.closest(
					'[data-user-card-follow-type],[data-user-card-action],[data-user-card-background],[data-user-card-avatar],[data-user-card-badge-scroll]'
				);
				if (directControl && directControl !== userCardFollowPreviewActionIntent) return;
				if (directControl) userCardFollowPreviewActionIntent = null;
			}
			const followClose = event.target.closest && event.target.closest('[data-user-card-follow-close]');
			if (followClose && userCardFollowPanel.contains(followClose)) {
				consumeEvent(event);
				closeUserCardLayer();
				return;
			}
			const breadcrumb = event.target.closest && event.target.closest('[data-user-card-breadcrumb-index]');
			if (breadcrumb && userCardFollowPanel.contains(breadcrumb)) {
				consumeEvent(event);
				restoreUserCardNavigationEntry(Number(breadcrumb.dataset.userCardBreadcrumbIndex));
				return;
			}
			const pageButton = event.target.closest && event.target.closest('[data-user-card-follow-page]');
			if (pageButton && userCardFollowPanel.contains(pageButton)) {
				consumeEvent(event);
				if (pageButton.disabled) return;
				const currentPage = Math.max(0, Number(userCardFollowPanel.dataset.followPage) || 0);
				userCardFollowPanel.dataset.followPage = String(pageButton.dataset.userCardFollowPage === 'previous'
					? Math.max(0, currentPage - 1)
					: currentPage + 1);
				const navigationIndex = Math.max(0, userCardNavigationStack.length - 1);
				if (navigationIndex === 0) closeUserCardFollowPreview();
				renderUserCardFollowList(userCardFollowPanel);
				positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
				if (navigationIndex > 0) {
					positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
				}
				rememberCurrentUserCardNavigationState();
				renderUserCardBreadcrumbs();
				return;
			}
			const followButton = event.target.closest && event.target.closest('[data-user-card-follow-type]');
			if (followButton && actionCard && actionCard.contains(followButton)) {
				consumeEvent(event);
				if (followButton.disabled || followButton.dataset.busy === '1') return;
				clearTimeout(userCardHideTimer);
				const type = followButton.dataset.userCardFollowType;
				const username = cleanUsernameValue(followButton.dataset.username || actionCard._ldpUserData?.username);
				const panel = userCardFollowPanel;
				const anchor = userCardAnchor;
				if (!username || !panel || !anchor || !['following', 'followers'].includes(type)) return;
				cancelUserCardOpening();
				const returnedToRoot = actionCard === fallbackUserCard && userCardNavigationStack.length > 1;
				if (actionCard === userCardFollowPreview) {
					if (!promoteUserCardFollowPreview()) return;
				} else if (returnedToRoot) {
					restoreUserCardNavigationEntry(0);
				}
				const navigationEntry = ensureUserCardNavigationRoot();
				if (!panel.hidden && panel.dataset.followType === type && panel.dataset.followUsername === username) {
					if (returnedToRoot) return;
					closeUserCardFollowPanel();
					return;
				}
				const notificationMenu = actionCard.querySelector('.ldp-user-card-notification-menu');
				if (notificationMenu) notificationMenu.hidden = true;
				actionCard.querySelector('[data-user-card-action="notifications"]')?.setAttribute('aria-expanded', 'false');
				[fallbackUserCard, userCardFollowPreview].forEach((card) =>
					card.querySelectorAll('[data-user-card-follow-type]').forEach((button) =>
						setExpanded(button, card === actionCard && button === followButton)));
				panel.hidden = false;
				panel.dataset.followType = type;
				panel.dataset.followUsername = username;
				panel.dataset.followPage = '0';
				delete panel._ldpFollowUsers;
				if (navigationEntry) {
					navigationEntry.user = actionCard._ldpUserData || navigationEntry.user;
					navigationEntry.followType = type;
					navigationEntry.followUsers = null;
					navigationEntry.search = '';
					navigationEntry.page = 0;
				}
				renderUserCardBreadcrumbs();
				panel.querySelector('.ldp-user-card-follow-title').textContent = type === 'following' ? '关注的人员' : '被关注的人员';
				const search = panel.querySelector('input[type="search"]');
				const summary = panel.querySelector('.ldp-user-card-follow-summary');
				const list = panel.querySelector('.ldp-user-card-follow-list');
				search.value = '';
				summary.textContent = '正在加载…';
				releasePersistentAvatarImages(list);
				list.innerHTML = '<div class="ldp-user-card-follow-empty">正在加载人员列表…</div>';
				positionUserCardFollowPanel(panel, fallbackUserCard);
				followButton.dataset.busy = '1';
				try {
					const users = await fetchUserFollowList(username, type);
					if (userCardAnchor !== anchor || !fallbackUserCardOpen() || panel.hidden ||
						panel.dataset.followType !== type || panel.dataset.followUsername !== username) return;
					panel._ldpFollowUsers = users;
					renderUserCardFollowList(panel);
					positionUserCardFollowPanel(panel, fallbackUserCard);
					if (actionCard === userCardFollowPreview) {
						positionUserCardFollowPreview(userCardFollowPreview, panel, fallbackUserCard);
					}
					rememberCurrentUserCardNavigationState();
					renderUserCardBreadcrumbs();
					search.focus({ preventScroll: true });
				} catch (error) {
					if (userCardAnchor !== anchor || !fallbackUserCardOpen() || panel.hidden ||
						panel.dataset.followType !== type || panel.dataset.followUsername !== username) return;
					summary.textContent = Number(error?.status) === 429 ? '请求受限,请过盾后重试' : '人员列表加载失败';
					list.innerHTML = `<div class="ldp-user-card-follow-empty">${esc(error?.message || '请稍后重试')}</div>`;
				} finally {
					if (followButton.isConnected) delete followButton.dataset.busy;
				}
				return;
			}
			const badgeScrollButton = event.target.closest && event.target.closest('[data-user-card-badge-scroll]');
			if (badgeScrollButton && actionCard && actionCard.contains(badgeScrollButton)) {
				consumeEvent(event);
				if (badgeScrollButton.disabled) return;
				clearTimeout(userCardHideTimer);
				const list = badgeScrollButton.closest('.ldp-user-card-badge-strip')
					?.querySelector('.ldp-user-card-badge-list');
				if (!list) return;
				const direction = Number(badgeScrollButton.dataset.userCardBadgeScroll) || 1;
				list.scrollBy({ left: direction * Math.max(96, list.clientWidth * .78), behavior: 'smooth' });
				return;
			}
			const backgroundButton = event.target.closest && event.target.closest('[data-user-card-background]');
			if (backgroundButton && actionCard && actionCard.contains(backgroundButton)) {
				consumeEvent(event);
				clearTimeout(userCardHideTimer);
				const source = String(backgroundButton.dataset.backgroundUrl || '').trim();
				const username = String(actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim();
				openUserCardMediaViewer(
					source,
					username,
					actionCard,
					'',
					actionCard === fallbackUserCard ? closeUserCard : closeUserCardFollowPreview,
					null,
					{ kind: 'background', source }
				);
				return;
			}
			const avatarButton = event.target.closest && event.target.closest('[data-user-card-avatar]');
			if (avatarButton && actionCard && actionCard.contains(avatarButton)) {
				consumeEvent(event);
				clearTimeout(userCardHideTimer);
				const template = avatarButton.dataset.avatarTemplate || actionCard._ldpUserData?.avatar_template || '';
				const username = avatarButton.dataset.avatarUsername || getUserCardUsername(userCardAnchor) || '';
				const previewImage = avatarButton.querySelector('img');
				const previewSource = previewImage && (previewImage.currentSrc || previewImage.src) || '';
				openUserCardMediaViewer(
					template,
					username,
					actionCard,
					previewSource,
					actionCard === fallbackUserCard ? closeUserCard : closeUserCardFollowPreview,
					actionCard._ldpUserData
				);
				return;
			}
			const notificationOption = event.target.closest && event.target.closest('[data-user-notification-level]');
			if (notificationOption && actionCard && actionCard.contains(notificationOption)) {
				consumeEvent(event);
				if (notificationOption.disabled || notificationOption.dataset.busy === '1') return;
				const level = notificationOption.dataset.userNotificationLevel;
				const username = String(actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim();
				const user = actionCard._ldpUserData || { username };
				const anchor = userCardAnchor;
				const isCurrent = () => userCardAnchor === anchor && fallbackUserCardOpen() && actionCard.classList.contains('open') &&
					String(actionCard._ldpUserData?.username || '') === username;
				if (!username || !anchor || !Object.hasOwn(USER_NOTIFICATION_LEVEL_LABELS, level)) return;
				clearTimeout(userCardHideTimer);
				if (level === 'ignore') {
					if (openReaderIgnoreDurationDialog(ctx, username, user)) closeUserCard();
					else setUserCardActionStatus(actionCard, '当前无法打开忽略期限选择', true);
					return;
				}
				const menu = notificationOption.closest('.ldp-user-card-notification-menu');
				const options = menu ? [...menu.querySelectorAll('[data-user-notification-level]')] : [];
				options.forEach((option) => {
					option.dataset.busy = '1';
					option.disabled = true;
				});
				setUserCardActionStatus(actionCard, `正在设为${USER_NOTIFICATION_LEVEL_LABELS[level]}…`);
				try {
					await saveReaderUserNotificationLevel(username, user, level);
					if (!isCurrent()) return;
					options.forEach((option) => {
						const active = option.dataset.userNotificationLevel === level;
						option.classList.toggle('is-active', active);
						option.setAttribute('aria-checked', String(active));
					});
					const trigger = actionCard.querySelector('[data-user-card-action="notifications"]');
					if (trigger) {
						const label = `消息设置:${USER_NOTIFICATION_LEVEL_LABELS[level]}`;
						trigger.classList.toggle('is-active', level !== 'normal');
						trigger.dataset.tooltip = label;
						setLabel(trigger, label);
						setExpanded(trigger, false);
					}
					if (menu) menu.hidden = true;
					setUserCardActionStatus(actionCard, `已设为${USER_NOTIFICATION_LEVEL_LABELS[level]}`);
					positionFallbackUserCardNotificationMenu(actionCard);
				} catch (error) {
					if (isCurrent()) {
						setUserCardActionStatus(actionCard, error?.message ? error.message : '消息设置保存失败,请重试', true);
					}
				} finally {
					if (isCurrent()) {
						options.forEach((option) => {
							delete option.dataset.busy;
							option.disabled = false;
						});
					}
				}
				return;
			}
			const button = event.target.closest && event.target.closest('[data-user-card-action]');
			if (!button || !actionCard || !actionCard.contains(button) || button.disabled || button.dataset.busy === '1') return;
			consumeEvent(event);
			const action = button.dataset.userCardAction;
			const username = String(button.dataset.username || actionCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '').trim();
			const anchor = userCardAnchor;
			if (!action || !username || !anchor) return;
			if (actionCard === userCardFollowPreview) {
				if (!promoteUserCardFollowPreview()) return;
				userCardFollowPanel.hidden = true;
			} else if (userCardNavigationStack.length > 1) {
				restoreUserCardNavigationEntry(0);
			}
			clearTimeout(userCardHideTimer);
			if (action === 'notifications') {
				const menu = actionCard.querySelector('.ldp-user-card-notification-menu');
				if (!menu) return;
				const opening = menu.hidden;
				menu.hidden = !opening;
				setExpanded(button, opening);
				setUserCardActionStatus(actionCard, '');
				positionFallbackUserCardNotificationMenu(actionCard);
				return;
			}
			button.dataset.busy = '1';
			button.setAttribute('aria-busy', 'true');
			button.disabled = true;
			setUserCardActionStatus(actionCard, '正在打开…');
			const isCurrent = () => userCardAnchor === anchor && fallbackUserCardOpen() && actionCard.classList.contains('open') &&
				String(actionCard._ldpUserData?.username || '') === username;
			const outcome = await runDirectUserCardAction(
				action,
				username,
				actionCard._ldpUserData || { username },
				ctx
			);
			if (outcome.ok) {
				if (outcome.keepOpen && button.isConnected && isCurrent()) {
					delete button.dataset.busy;
					button.removeAttribute('aria-busy');
					button.disabled = false;
					if (action === 'follow') {
						const label = outcome.followed ? '取消关注' : '关注';
						button.classList.toggle('is-active', !!outcome.followed);
						button.dataset.tooltip = label;
						setLabel(button, label);
						setUserCardActionStatus(actionCard, outcome.followed ? '已关注' : '已取消关注');
						const followerCount = Number(actionCard._ldpUserData?.total_followers);
						const followerValue = actionCard.querySelector('[data-user-card-follow-type="followers"] strong');
						if (followerValue && Number.isFinite(followerCount)) followerValue.textContent = followerCount.toLocaleString('zh-CN');
					}
					return;
				}
				closeUserCard();
				return;
			}
			if (!button.isConnected || !isCurrent()) return;
			delete button.dataset.busy;
			button.removeAttribute('aria-busy');
			button.disabled = false;
			const label = button.getAttribute('aria-label') || '该功能';
			const failureMessages = {
				'route-unavailable': `未能打开“${label}”页面,请重试`,
				'endorsement-unavailable': '当前页面暂时无法打开认可类别选择',
				'follow-not-allowed': '该用户当前不允许被关注',
				'action-failed': `执行“${label}”时发生错误,请重试`,
			};
			const failureMessage = outcome.reason === 'action-failed' && outcome.error && outcome.error.message
				? outcome.error.message
				: (failureMessages[outcome.reason] || `${label}当前不可用`);
			setUserCardActionStatus(actionCard, failureMessage, true);
		};
		onAction(fallbackUserCard, 'click', onUserCardAction);
		onAction(userCardFollowPanel, 'click', onUserCardAction);
		onAction(userCardFollowPreview, 'click', onUserCardAction);
		let userCardFollowPreviewActionIntent = null;
		const userCardFollowPreviewActionControl = (target) => target?.closest && target.closest(
			'[data-user-card-follow-type],[data-user-card-action],[data-user-card-background],[data-user-card-avatar],[data-user-card-badge-scroll]'
		);
		const armUserCardFollowPreviewAction = (event) => {
			const control = userCardFollowPreviewActionControl(event.target);
			userCardFollowPreviewActionIntent = control && userCardFollowPreview.contains(control) ? control : null;
		};
		const armUserCardFollowPreviewKeyboardAction = (event) => {
			if (event.key !== 'Enter' && event.key !== ' ') return;
			armUserCardFollowPreviewAction(event);
		};
		onAction(userCardFollowPreview, 'pointerdown', armUserCardFollowPreviewAction);
		onAction(userCardFollowPreview, 'pointercancel', () => {
			userCardFollowPreviewActionIntent = null;
		});
		onAction(userCardFollowPreview, 'keydown', armUserCardFollowPreviewKeyboardAction);
		const onUserCardFollowSearch = (event) => {
			if (!(event.target instanceof Element) || !event.target.matches('.ldp-user-card-follow-search input')) return;
			userCardFollowPanel.dataset.followPage = '0';
			renderUserCardFollowList(userCardFollowPanel);
			positionUserCardFollowPanel(userCardFollowPanel, fallbackUserCard);
		};
		onAction(userCardFollowPanel, 'input', onUserCardFollowSearch);

		const openUserCardFollowPreview = (link) => {
			const username = getUserCardUsername(link);
			if (!username || userCardFollowPanel.hidden || !userCardFollowPreviewHoverArmed) return;
			clearTimeout(userCardHideTimer);
			clearTimeout(userCardFollowPreviewShowTimer);
			userCardFollowPreviewShowTimer = 0;
			if (getUserCardUsername(userCardFollowPreviewAnchor) === username &&
				userCardFollowPreview.classList.contains('open')) {
				userCardFollowPreviewAnchor = link;
				positionUserCardFollowPreview(userCardFollowPreview, userCardFollowPanel, fallbackUserCard);
				renderUserCardBreadcrumbs();
				return;
			}
			userCardFollowPreviewAnchor = link;
			const token = ++userCardFollowPreviewToken;
			userCardFollowPreviewShowTimer = setTimeout(() => {
				userCardFollowPreviewShowTimer = 0;
				void showFallbackUserCard(link, () => token === userCardFollowPreviewToken &&
					userCardFollowPreviewAnchor === link && !userCardFollowPanel.hidden && fallbackUserCardOpen(),
				renderUserCardBreadcrumbs, {
					card: userCardFollowPreview,
					positionCard: (preview) => positionUserCardFollowPreview(preview, userCardFollowPanel, fallbackUserCard),
				});
			}, USER_CARD_SHOW_DELAY);
		};

		const onUserCardMouseOver = (event) => {
			const link = eventPathClosest(event, USER_CARD_LINK_SEL);
			if (!link) return;
			const from = event.relatedTarget;
			if (from && typeof from.nodeType === 'number' && link.contains(from)) return;
			if (userCardFollowPanel.contains(link)) {
				openUserCardFollowPreview(link);
				return;
			}
			if (!userCardFollowPanel.hidden) {
				cancelUserCardOpening();
				return;
			}
			const previousUsername = String(fallbackUserCard._ldpUserData?.username || getUserCardUsername(userCardAnchor) || '');
			const nextUsername = getUserCardUsername(link);
			if (previousUsername && previousUsername !== nextUsername) {
				if (!userCardFollowPanel.contains(link)) closeUserCardFollowPanel();
				cancelQueuedUserCardRequests(previousUsername, ctx.postRequestScheduler);
			}
			clearTimeout(userCardHideTimer);
			userCardHideTimer = 0;
			cancelUserCardOpening();
			if (previousUsername === nextUsername && fallbackUserCardOpen()) {
				userCardAnchor = link;
				observeUserCardAnchor(link);
				positionFallbackUserCard(fallbackUserCard, link);
				return;
			}
			userCardAnchor = link;
			observeUserCardAnchor(link);
			const token = ++userCardToken;
			userCardPrefetchTimer = setTimeout(() => {
				userCardPrefetchTimer = 0;
				if (token !== userCardToken || userCardAnchor !== link) return;
				void fetchUserCard(nextUsername).catch(() => {});
			}, USER_CARD_PREFETCH_DELAY);
			userCardShowTimer = setTimeout(() => {
				userCardShowTimer = 0;
				void showFallbackUserCard(link, () => token === userCardToken &&
					userCardAnchor === link && userCardAnchorIsVisible(link), (user) => {
					userCardNavigationStack.length = 0;
					if (user?.username) userCardNavigationStack.push(navigationEntryFromUser(user));
					renderUserCardBreadcrumbs();
				});
			}, USER_CARD_SHOW_DELAY);
		};

		const onUserCardMouseOut = (event) => {
			const link = eventPathClosest(event, USER_CARD_LINK_SEL);
			if (!link) return;
			const to = event.relatedTarget;
			if (userCardFollowPanel.contains(link)) {
				userCardFollowPreviewHoverArmed = true;
				const nextLink = isFunction(to?.closest) ? to.closest(USER_CARD_LINK_SEL) : null;
				if (to && typeof to.nodeType === 'number' &&
					(link.contains(to) || userCardFollowPreview.contains(to) ||
						(nextLink && userCardFollowPanel.contains(nextLink)))) return;
				clearTimeout(userCardFollowPreviewShowTimer);
				userCardFollowPreviewShowTimer = 0;
				return;
			}
			if (to && typeof to.nodeType === 'number' &&
				(link.contains(to) || fallbackUserCard.contains(to) || userCardFollowPanel.contains(to) ||
					userCardFollowPreview.contains(to))) return;
			cancelUserCardOpening();
			const nextLink = isFunction(to?.closest) ? to.closest(USER_CARD_LINK_SEL) : null;
			if (nextLink && getUserCardUsername(nextLink) === getUserCardUsername(link)) {
				return;
			}
			scheduleCloseUserCard();
		};
		const onHostUserCardMouseOver = (event) => {
			const link = eventPathClosest(event, USER_CARD_LINK_SEL);
			if (!link || userCardEventRoot.contains(link)) return;
			onUserCardMouseOver(event);
		};
		const onHostUserCardMouseOut = (event) => {
			const link = eventPathClosest(event, USER_CARD_LINK_SEL);
			if (!link || userCardEventRoot.contains(link)) return;
			onUserCardMouseOut(event);
		};
		onAction(userCardEventRoot, 'mouseover', onUserCardMouseOver, true);
		onAction(userCardEventRoot, 'mouseout', onUserCardMouseOut, true);
		onAction(document, 'mouseover', onHostUserCardMouseOver, true);
		onAction(document, 'mouseout', onHostUserCardMouseOut, true);

		const onTopicNotificationChange = async (event) => {
			const select = event.target.closest('.ldp-topic-notification-select');
			if (!select || !modal.contains(select)) return;
			const nextLevel = +select.value;
			if (!Number.isInteger(nextLevel) || nextLevel < 0 || nextLevel > 3) return;
			const previousLevel = Number.isInteger(ctx.topicNotificationLevel)
				? ctx.topicNotificationLevel
				: topicNotificationLevel(ctx.topicData);
			if (nextLevel === previousLevel) return;
			ctx.topicNotificationLevel = nextLevel;
			syncTopicActionControls(ctx, { notificationBusy: true });
			try {
				const topicModule = lookupDiscourseModule('discourse/models/topic');
				const topicDetailsModule = lookupDiscourseModule('discourse/models/topic-details');
				const Topic = topicModule?.default;
				const TopicDetails = topicDetailsModule?.default;
				if (!Topic || !isFunction(Topic.create) ||
						!TopicDetails || !isFunction(TopicDetails.create)) {
					throw new Error('Discourse 主题通知接口尚未就绪');
				}
				const topicModel = Topic.create({
					...(ctx.topicData || {}),
					id: Number(ctx.topicId),
				});
				const detailsModel = TopicDetails.create({
					...(ctx.topicData?.details || {}),
					topic: topicModel,
					notification_level: previousLevel,
				});
				await detailsModel.updateNotifications(nextLevel);
				await clearStoredResponseCache({ tags: [`topic:${ctx.topicId}`] });
				if (ctx.topicData) {
					ctx.topicData.notification_level = nextLevel;
					if (ctx.topicData.details) ctx.topicData.details.notification_level = nextLevel;
					setCachedTopicData(ctx.topicId, ctx.topicData);
				}
			} catch (error) {
				ctx.topicNotificationLevel = previousLevel;
				alert('通知设置失败:' + error.message);
			} finally {
				syncTopicActionControls(ctx);
			}
		};
		onAction(modal, 'change', onTopicNotificationChange);

		const onReaderPollChange = async (event) => {
			const input = event.target.closest('.ldp-reader-poll input[data-poll-option]');
			if (!input || !modal.contains(input) || input.disabled) return;
			const container = input.closest('.ldp-reader-poll');
			const post = input.closest('.ldp-post');
			if (!container || !post) return;
			const name = container.dataset.ldpPollName || 'poll';
			const postData = post._ldpPostData || {};
			const poll = (postData.polls || []).find((item) => pollName(item) === name);
			if (!poll) return;
			if (input.type === 'radio') {
				await updateReaderPollVote(post, ctx, name, [input.value]);
				return;
			}
			const selected = [...container.querySelectorAll('input[data-poll-option]:checked')].map((item) => item.value);
			const { min, max } = pollChoiceLimits(poll);
			const submit = container.querySelector('[data-poll-action="submit"]');
			if (submit) {
				submit.disabled = selected.length < min || selected.length > max;
				submit.textContent = pollSavedVotes(postData, poll).length ? '更新投票' : '提交投票';
			}
		};
		onAction(modal, 'change', onReaderPollChange);

		const onPostVotingCommentSubmit = async (event) => {
			const form = event.target.closest('.ldp-pv-comment-form');
			if (!form || !modal.contains(form)) return;
			event.preventDefault();
			const post = form.closest('.ldp-post');
			const input = form.querySelector('.ldp-pv-comment-input');
			const raw = String(input?.value || '').trim();
			if (!post || !raw) return;
			const postId = post.dataset.postId;
			await runReaderActionOnce(ctx, `post:${postId}:pv-comment-create`, async () => {
				setReaderPostControlsBusy(postId, ctx, '.ldp-pv-comment-form input,.ldp-pv-comment-form button', true);
				try {
					const data = await apiSend(`${BASE}/post_voting/comments`, 'POST', { post_id: postId, raw });
					const existing = readerPostVotingComments(postId, ctx);
					const comments = new Map(existing.map((comment) => [String(comment.id), comment]));
					const comment = data.comment || data;
					if (comment?.id) comments.set(String(comment.id), comment);
					readerPostCopies(postId, ctx).forEach((copy) => {
						const root = copy.querySelector(':scope > .ldp-pv-comments');
						if (root) root.dataset.expanded = '1';
					});
					syncPostVotingComments(postId, ctx, [...comments.values()]);
				} catch (error) {
					alert('发送评论失败:' + error.message);
				} finally {
					setReaderPostControlsBusy(postId, ctx, '.ldp-pv-comment-form input,.ldp-pv-comment-form button', false);
				}
			});
		};
		onAction(modal, 'submit', onPostVotingCommentSubmit);

		const showReaderAvatarPreview = (avatarPreview) => {
			const post = avatarPreview.closest('.ldp-post');
			const postData = post?._ldpPostData || {};
			const username = String(postData.username || avatarPreview.dataset.userCard || '').trim();
			const avatarImage = avatarPreview.querySelector('.ldp-avatar');
			const template = String(postData.avatar_template || avatarImage?.dataset.ldpAvatarOriginalTemplate ||
				avatarImage?.dataset.ldpAvatarTemplate || '').trim();
			const previewSource = avatarImage && (avatarImage.currentSrc || avatarImage.src) || '';
			const openPreviewFromCard = (user) => {
				if (!fallbackUserCardOpen() || getUserCardUsername(userCardAnchor) !== username) return;
				openUserCardMediaViewer(
					template,
					username,
					fallbackUserCard,
					previewSource,
					closeUserCard,
					user || postData
				);
			};
			clearTimeout(userCardHideTimer);
			clearTimeout(userCardShowTimer);
			userCardShowTimer = 0;
			const displayedUser = fallbackUserCard._ldpUserData;
			const cardShownForUser = fallbackUserCardOpen() && String(displayedUser?.username || '') === username;
			if (cardShownForUser) {
				openPreviewFromCard(displayedUser);
				return;
			}
			userCardAnchor = avatarPreview;
			observeUserCardAnchor(avatarPreview);
			const token = ++userCardToken;
			void showFallbackUserCard(
				avatarPreview,
				() => token === userCardToken && userCardAnchor === avatarPreview && userCardAnchorIsVisible(avatarPreview),
				(user) => {
					userCardNavigationStack.length = 0;
					if (user?.username) userCardNavigationStack.push(navigationEntryFromUser(user));
					renderUserCardBreadcrumbs();
					openPreviewFromCard(user);
				}
			);
		};
		const onPortalAvatarPreviewClick = (event) => {
			const avatarPreview = eventPathClosest(event, '[data-user-avatar-preview]');
			if (!avatarPreview || modal.contains(avatarPreview)) return;
			consumeEvent(event);
			showReaderAvatarPreview(avatarPreview);
		};
		onAction(userCardEventRoot, 'click', onPortalAvatarPreviewClick, true);

		const preserveOnlyOpPostExpansion = (post) => {
			const preserve = ctx.onlyOp && !post._ldpPostData?.hidden &&
				String(post.dataset.username || '') === String(ctx.op || '');
			if (preserve) keepOnlyOpPostExpanded(post, ctx);
			return preserve;
		};
		const readerActionHandlers = {
			report: (post) => openReaderReportDialog(post, ctx),
			'assign-post': (post) => openReaderAssignDialog(post, ctx, 'Post'),
			'assign-topic': (post) => openReaderAssignDialog(post, ctx, 'Topic'),
		};
		const postActionHandlers = {
			share: (post) => shareReaderLink(ctx, post),
			bookmark: (post) => toggleReaderBookmark(post, ctx, 'Post'),
			edit: (post, postId) => runReaderActionOnce(
				ctx,
				`post:${postId}:edit-composer`,
				() => tryOpenNativeEditComposer(post, ctx)
			),
		};
		const topicActionHandlers = {
			share: () => shareReaderLink(ctx),
			bookmark: () => isFunction(ctx.toggleTopicBookmark) ? ctx.toggleTopicBookmark() : undefined,
		};
		const onModalClick = async (e) => {
			const imageCandidate = eventPathClosest(e, '.ldp-content img') ||
				eventPathClosest(e, '.ldp-content a.lightbox')?.querySelector('img');
			if (imageCandidate && !reactionEmojiImageSources) {
				consumeEvent(e);
				await hydrateReactionRegistry();
			}
			const quotedImg = lightboxImageFromEventIn(
				e, '.ldp-content aside.ldp-post-quote > blockquote', isLightboxQuotedImage
			);
			if (quotedImg) {
				consumeEvent(e);
				hideImageQuoteToolbar();
				await openQuotedImageLightbox(quotedImg, ctx);
				return;
			}

			const img = lightboxImageFromEvent(e);
			if (img) {
				consumeEvent(e);
				hideImageQuoteToolbar();
				openLightbox(img, ctx);
				return;
			}

			const avatarPreview = eventPathClosest(e, '[data-user-avatar-preview]');
			if (avatarPreview) {
				consumeEvent(e);
				showReaderAvatarPreview(avatarPreview);
				return;
			}

			const codeAction = eventPathClosest(e, '[data-reader-code-action]');
			if (codeAction) {
				consumeEvent(e);
				const pre = codeAction.closest('.ldp-code-block')?.querySelector(':scope > pre');
				if (codeAction.dataset.readerCodeAction === 'copy') await copyReaderCode(pre, codeAction);
				else openReaderCodePreview(pre, modal);
				return;
			}

			const hostLink = eventPathClosest(e, 'a[href]');
			if (openEmbeddedReaderLinkInHost(e, hostLink, ctx.readerWorkspace)) return;

			const githubOnebox = eventPathClosest(
				e,
				'.ldp-content aside.onebox:is(.githubfolder,.githubrepo,[data-onebox-src*="github.com"])'
			);
			if (githubOnebox) {
				const selection = window.getSelection && window.getSelection();
				if (selection && !selection.isCollapsed && String(selection).trim()) return;
				const targetLink = hostLink || githubOnebox.querySelector('.onebox-body h3 a[href],header.source a[href],a[href]');
				const targetUrl = String(targetLink?.href || githubOnebox.dataset.oneboxSrc || '').trim();
				if (/^https?:\/\//i.test(targetUrl)) {
					consumeEvent(e);
					const openInCurrentTab = targetLink?.target === '_self' && !e.ctrlKey && !e.metaKey && !e.shiftKey;
					if (openInCurrentTab) window.location.assign(targetUrl);
					else window.open(targetUrl, '_blank', 'noopener');
					return;
				}
			}

			const quoteAction = e.target.closest('.ldp-quote-toggle,.ldp-quote-collapse-bottom');
			if (quoteAction) {
				e.preventDefault();
				const quote = quoteAction.closest('.ldp-post-quote');
				const quoteBody = quote?.querySelector(':scope > blockquote');
				const quoteToggle = quote?.querySelector(':scope > .ldp-quote-title .ldp-quote-toggle');
				if (!quote || !quoteBody || !quoteToggle) return;
				if (quote.dataset.ldpQuoteExpanded === '1') {
					preserveReaderQuotePosition(quote, ctx, () => {
						quote.querySelector(':scope > .ldp-quote-collapse-bottom')?.remove();
						setPostCookedContent(quoteBody, quote._ldpQuoteExcerptHtml || '');
						quote.dataset.ldpQuoteExpanded = '0';
						quote.classList.remove('ldp-quote-expanded');
						setExpanded(quoteToggle, false);
						setLabel(quoteToggle, '展开完整引用');
					});
					syncReaderQuoteLayout(quote, ctx);
					return;
				}
				quoteToggle.disabled = true;
				quoteToggle.setAttribute('aria-busy', 'true');
				try {
					const fullPost = await loadReaderQuotedPost(quote, ctx);
					if (!fullPost || fullPost.cooked == null) throw new Error('quoted post unavailable');
					preserveReaderQuotePosition(quote, ctx, () => {
						setPostCookedContent(quoteBody, fullPost.cooked);
						quote.dataset.ldpQuoteExpanded = '1';
						quote.classList.add('ldp-quote-expanded');
						setExpanded(quoteToggle, true);
						setLabel(quoteToggle, '恢复引用摘录');
						highlightReaderExpandedQuote(quote);
						const bottomCollapse = makeElement('button');
						bottomCollapse.type = 'button';
						bottomCollapse.className = 'ldp-quote-collapse-bottom';
						setLabel(bottomCollapse, '恢复引用摘录');
						bottomCollapse.innerHTML = icon('chevronUp');
						quote.append(bottomCollapse);
					});
					syncReaderQuoteLayout(quote, ctx);
				} catch (error) {
					setLabel(quoteToggle, '完整引用加载失败,点击重试');
				} finally {
					quoteToggle.disabled = false;
					quoteToggle.removeAttribute('aria-busy');
				}
				return;
			}

			const topicVote = e.target.closest('[data-topic-vote]');
			if (topicVote) {
				e.preventDefault();
				if (!ME_USERNAME) { alert('登录后才能为主题投票。'); return; }
				await runReaderActionOnce(ctx, `topic:${ctx.topicId}:vote`, async () => {
					const voted = !!(ctx.topicData?.user_voted);
					modal.querySelectorAll('[data-topic-vote]').forEach((button) => { button.disabled = true; });
					try {
						const data = await apiSend(`${BASE}/voting/${voted ? 'unvote' : 'vote'}`, 'POST', { topic_id: ctx.topicId });
						ctx.topicData.user_voted = !voted;
						ctx.topicData.vote_count = Math.max(0, +(data.vote_count ?? data.votes ?? ctx.topicData.vote_count) || 0);
						if (data.can_vote != null) ctx.topicData.can_vote = data.can_vote;
						setCachedTopicData(ctx.topicId, ctx.topicData);
						if (ctx.renderTopicShell) ctx.renderTopicShell(ctx.topicData);
					} catch (error) {
						alert('主题投票失败:' + error.message);
					} finally {
						modal.querySelectorAll('[data-topic-vote]').forEach((button) => {
							button.disabled = ctx.topicData && ctx.topicData.can_vote === false && !ctx.topicData.user_voted;
						});
					}
				});
				return;
			}

	      const nestedRailToggle = e.target.closest('.ldp-nested-rail-toggle,.ldp-nested-branch-toggle');
	      if (nestedRailToggle) {
	        e.preventDefault();
	        const post = nestedRailToggle.closest('.ldp-post');
	        if (preserveOnlyOpPostExpansion(post)) return;
	        const rootRect = ctx.scrollRoot.getBoundingClientRect();
	        const parentContent = post.querySelector(':scope > .ldp-content');
	        const parentReactions = post.querySelector(':scope > .ldp-reactions');
	        const parentBodyEnd = parentReactions && parentReactions.getClientRects().length
	          ? parentReactions
	          : (parentContent || post);
	        const expandedContentRect = (parentContent || parentBodyEnd).getBoundingClientRect();
	        const expandedBodyEndRect = parentBodyEnd.getBoundingClientRect();
	        const parentBodyEndVisible = expandedBodyEndRect.bottom >= rootRect.top && expandedBodyEndRect.bottom <= rootRect.bottom;
	        setChildRepliesCollapsed(post, true);
	        const collapsedContentRect = (parentContent || post).getBoundingClientRect();
	        if (parentBodyEndVisible) {
	          const topOffset = collapsedContentRect.top - expandedContentRect.top;
	          if (Math.abs(topOffset) >= 1) {
	            const previousScrollTop = ctx.scrollRoot.scrollTop;
	            ctx.scrollRoot.scrollTop += topOffset;
	            markStreamInternalScroll(ctx, previousScrollTop);
	            scheduleStreamWindowSync(ctx);
	          }
	          return;
	        }
	        const parentPostNumber = streamPostNumber(post);
	        const programmaticTarget = beginStreamProgrammaticTarget(ctx, parentPostNumber, {
	          visualNode: parentBodyEnd,
	          alignBottomToCenter: true,
	        });
	        animateStreamProgrammaticTarget(ctx, programmaticTarget);
	        return;
	      }

	      const nestedToggle = e.target.closest('.ldp-nested-toggle');
	      if (nestedToggle) {
	        const post = nestedToggle.closest('.ldp-post');
	        if (preserveOnlyOpPostExpansion(post)) return;
	        const willCollapse = !post.classList.contains('ldp-nested-collapsed');
	        if (willCollapse) delete post.dataset.ldpJumpExpanded;
	        setNestedReplyCollapsed(post, willCollapse);
	        if (willCollapse) floorPreview.hide(post);
	        return;
	      }

	      const rowToggleTarget = e.target.closest('.ldp-post.ldp-nested-collapsed');
	      if (rowToggleTarget && !e.target.closest('a,button,input,textarea,select,label')) {
	        setNestedReplyCollapsed(rowToggleTarget, false);
	        return;
	      }

	      const collapseBtn = e.target.closest('.ldp-collapse-replies');
			if (collapseBtn) {
				const post = collapseBtn.closest('.ldp-post');
				if (preserveOnlyOpPostExpansion(post)) return;
				setChildRepliesCollapsed(post, !post.classList.contains('ldp-children-collapsed'));
				return;
			}

			// 楼中楼分页按钮
			const pageBtn = e.target.closest('.ldp-sub-page-prev, .ldp-sub-page-next');
			if (pageBtn) {
				if (pageBtn.disabled) return;
				const post = pageBtn.closest('.ldp-post');
				const postNumber = +post.dataset.postNumber;
				const direction = pageBtn.classList.contains('ldp-sub-page-next') ? 1 : -1;
				if (direction > 0) {
					const state = ctx.subReplyState.get(postNumber);
					const pageSize = SUB_REPLY_PAGE_SIZE;
					const targetPage = +(state?.pageIndex || 0) + 1;
					const requiredCount = Math.min(+(state?.totalCount || 0), (targetPage + 1) * pageSize);
					if (state && +(state.fetchedReplyCount || 0) < requiredCount && state.hasMore !== false) {
						await ctx.repliesIO.loadNextPage(post, requiredCount);
					}
					const updatedState = ctx.subReplyState.get(postNumber);
					if (!updatedState || targetPage * pageSize >= updatedState.all.length) return;
				}
				renderSubReplyBatch(postNumber, ctx, direction, post);
				return;
			}

			const quoteJump = e.target.closest('.ldp-quote-jump');
			const preview = quoteJump?.closest('.ldp-floor-preview');
			const post = e.target.closest('.ldp-post') || (preview?._ldpRelatedPost);
			if (!post) return;
			const postId = post.dataset.postId, postNumber = +post.dataset.postNumber;
			if (quoteJump) {
				const targetTopicId = +quoteJump.dataset.targetTopicId || 0;
				if (targetTopicId && targetTopicId !== +ctx.topicId) {
					e.preventDefault();
					const targetPostNumber = +quoteJump.dataset.targetPostNumber || 1;
					const quoteHighlight = quoteHighlightFromJump(quoteJump, ctx);
					openModal(targetTopicId, { targetPostNumber, quoteHighlight });
					return;
				}
			}
			const reactionPicker = e.target.closest('[data-reaction-picker]');
			if (reactionPicker) {
				hydrateReactionRegistry();
				const reactionsRoot = reactionPicker.closest('.ldp-reactions');
				const picker = reactionsRoot?.querySelector('.ldp-reaction-picker');
				const open = picker?.hidden;
				setReactionPickerExpanded(reactionsRoot, open);
				return;
			}
			const reactionButton = e.target.closest('[data-reaction]');
			if (reactionButton) {
				const reaction = reactionButton.dataset.reaction;
				const reactionsRoot = reactionButton.closest('.ldp-reactions');
				const picker = reactionButton.closest('.ldp-reaction-picker');
				if (picker) {
					setReactionPickerExpanded(reactionsRoot, false);
				}
				await toggleReaderPostReaction(post, ctx, reaction, null, (error) => alert('回应失败:' + error.message));
				return;
			}
			const pvVote = e.target.closest('[data-pv-vote]');
			if (pvVote) {
				const direction = pvVote.dataset.pvVote;
				const postData = post._ldpPostData || {};
				const remove = postData.post_voting_user_voted_direction === direction;
				await runReaderActionOnce(ctx, `post:${postId}:pv-vote`, async () => {
					setReaderPostControlsBusy(postId, ctx, '.ldp-pv-vote', true);
					try {
						const votingModule = lookupDiscourseModule(
							'discourse/plugins/discourse-post-voting/discourse/lib/post-voting-utilities'
						);
						const mutateVote = votingModule && (remove ? votingModule.removeVote : votingModule.castVote);
						if (!isFunction(mutateVote)) {
							throw new Error('Discourse 回答投票接口尚未就绪');
						}
						const data = await mutateVote(
							remove ? { post_id: postId } : { post_id: postId, direction }
						);
						await clearStoredResponseCache({ tags: [`post:${postId}`] });
						const fresh = data.post || data;
						if (fresh.post_voting_vote_count == null && data.vote_count != null) fresh.post_voting_vote_count = data.vote_count;
						if (fresh.post_voting_user_voted_direction == null) fresh.post_voting_user_voted_direction = remove ? null : direction;
						syncSpecialPostData(post, ctx, fresh);
					} catch (error) {
						alert('回答投票失败:' + error.message);
						readerPostCopies(postId, ctx).forEach((copy) => renderPostVoting(copy, ctx));
					} finally {
						setReaderPostControlsBusy(postId, ctx, '.ldp-pv-vote', false);
					}
				});
				return;
			}
			const commentsToggle = e.target.closest('.ldp-pv-comments-toggle');
			const commentsMore = !commentsToggle && e.target.closest('.ldp-pv-comments-more');
			const commentsAction = commentsToggle || commentsMore;
			if (commentsAction) {
				const root = commentsToggle?.closest('.ldp-pv-comments');
				const open = !commentsToggle || root.dataset.expanded !== '1';
				if (commentsToggle) root.dataset.expanded = open ? '1' : '0';
				const loadComments = commentsMore || (open &&
					!(post._ldpPostData._ldpPvComments || post._ldpPostData.comments || []).length &&
					+post._ldpPostData.comments_count > 0);
				if (loadComments) {
					commentsAction.disabled = true;
					try { await fetchPostVotingComments(post, ctx); } catch (error) { alert('加载评论失败:' + error.message); }
				}
				renderPostVoting(post, ctx);
				return;
			}
			const pvCommentVote = e.target.closest('[data-pv-comment-vote]');
			if (pvCommentVote) {
				const commentNode = pvCommentVote.closest('.ldp-pv-comment');
				const commentId = commentNode && commentNode.dataset.commentId;
				const remove = pvCommentVote.classList.contains('on');
				await runReaderActionOnce(ctx, `post-voting-comment:${commentId}:vote`, async () => {
					setReaderPostControlsBusy(postId, ctx, '[data-pv-comment-vote]', true);
					try {
						const data = await apiSend(
							`${BASE}/post_voting/vote/comment`,
							remove ? 'DELETE' : 'POST',
							{ comment_id: commentId },
							null,
							{ invalidateTags: [`post:${postId}`] }
						);
						const existing = readerPostVotingComments(postId, ctx);
						const comments = existing.slice();
						const comment = comments.find((item) => String(item.id) === String(commentId));
						if (comment) {
							comment.user_voted = !remove;
							comment.post_voting_vote_count = +(data.vote_count ?? comment.post_voting_vote_count) || 0;
						}
						syncPostVotingComments(postId, ctx, comments);
					} catch (error) {
						alert('评论投票失败:' + error.message);
					} finally {
						setReaderPostControlsBusy(postId, ctx, '[data-pv-comment-vote]', false);
					}
				});
				return;
			}
			const eventAction = e.target.closest('[data-event-status]');
			if (eventAction) {
				const status = eventAction.dataset.eventStatus;
				const eventData = post._ldpPostData?.event || {};
				const invitee = eventData.watching_invitee || {};
				await runReaderActionOnce(ctx, `post:${postId}:event-attendance`, async () => {
					setReaderPostControlsBusy(postId, ctx, '.ldp-event-action', true);
					try {
						const eventApi = lookupDiscourse('service:discourse-post-event-api');
						const eventModule = lookupDiscourseModule(
							'discourse/plugins/discourse-calendar/discourse/models/discourse-post-event-event'
						);
						const EventModel = eventModule?.default;
						if (!eventApi || !isFunction(eventApi.updateEventAttendance) ||
								!isFunction(eventApi.joinEvent) ||
								!EventModel || !isFunction(EventModel.create)) {
							throw new Error('Discourse 活动出席服务尚未就绪');
						}
						const eventModel = EventModel.create({
							...eventData,
							id: Number(eventData.id) || Number(postId),
						});
						const attendance = { status, recurring: false };
						if (invitee.id) await eventApi.updateEventAttendance(eventModel, attendance);
						else await eventApi.joinEvent(eventModel, attendance);
						eventData.watching_invitee = eventModel.watchingInvitee;
						eventData.stats = eventModel.stats;
						await clearStoredResponseCache({ tags: [`post:${postId}`] });
						syncSpecialPostData(post, ctx, { event: eventData });
					} catch (error) {
						alert('活动报名失败:' + error.message);
						readerPostCopies(postId, ctx).forEach(renderPostEvent);
					} finally {
						setReaderPostControlsBusy(postId, ctx, '.ldp-event-action', false);
					}
				});
				return;
			}
			const pollAction = e.target.closest('[data-poll-action]');
			if (pollAction) {
				e.preventDefault();
				const container = pollAction.closest('.ldp-reader-poll');
				const name = container && (container.dataset.ldpPollName || 'poll');
				if (!container || !name) return;
				const action = pollAction.dataset.pollAction;
				if (action === 'toggle-results') {
					const showResults = container.dataset.ldpPollShowResults !== '1';
					readerPostCopies(postId, ctx).forEach((copy) => renderPostPolls(copy, ctx, { name, showResults }));
					return;
				}
				if (action === 'remove') {
					await updateReaderPollVote(post, ctx, name);
					return;
				}
				if (action === 'submit') {
					const selected = [...container.querySelectorAll('input[data-poll-option]:checked')].map((item) => item.value);
					const postData = post._ldpPostData || {};
					const poll = (postData.polls || []).find((item) => pollName(item) === name);
					const { min, max } = pollChoiceLimits(poll);
					if (!poll || selected.length < min || selected.length > max) return;
					await updateReaderPollVote(post, ctx, name, selected);
					return;
				}
			}
			const readerAction = e.target.closest('[data-reader-action]');
			if (readerAction) {
				e.preventDefault();
				readerActionHandlers[readerAction.dataset.readerAction]?.(post);
				return;
			}
			const postAction = e.target.closest('[data-post-action]');
			if (postAction) {
				e.preventDefault();
				const action = postAction.dataset.postAction;
				const handler = postActionHandlers[action];
				if (handler && (action === 'share' || !postAction.disabled)) {
					await handler(post, postId);
					return;
				}
				if (action === 'delete' && !postAction.disabled) {
					postAction.disabled = true;
					try {
						await deleteReaderPost(post, ctx);
					} catch (error) {
						alert('删除失败:' + error.message);
					} finally {
						if (postAction.isConnected) postAction.disabled = false;
					}
					return;
				}
				if (action === 'admin' && !postAction.disabled) {
					openNativeAdminPostMenu(post, postAction, ctx);
					return;
				}
			}
			const topicAction = e.target.closest('[data-topic-action]');
			if (topicAction) {
				const action = topicAction.dataset.topicAction;
				if (action === 'shared-issue' && !topicAction.disabled) {
					if (!ME_USERNAME) syncCurrentUserFromHost();
					if (!ME_USERNAME) {
						alert('登录后才能使用“俺也一样”。');
						return;
					}
					const firstPostNode = topicAction.closest('.ldp-post');
					const sharedIssue = topicSharedIssueState(firstPostNode?._ldpPostData, ctx);
					if (!sharedIssue.visible || sharedIssue.isAuthor) return;
					await runReaderActionOnce(ctx, `topic:${ctx.topicId}:shared-issue`, async () => {
						ctx.topicSharedIssueBusy = true;
						syncTopicActionControls(ctx);
						try {
							const data = await apiSend(`${BASE}/solution/shared_issue`, 'POST', { topic_id: ctx.topicId });
							ctx.topicData.shared_issue_count = Math.max(0, Number(data?.count) || 0);
							ctx.topicData.user_created_shared_issue = !!(data?.user_created_shared_issue);
							if (ctx.topicData.user_created_shared_issue) {
								const currentLevel = Number.isInteger(ctx.topicNotificationLevel)
									? ctx.topicNotificationLevel
									: topicNotificationLevel(ctx.topicData);
								if (currentLevel < 2) {
									ctx.topicNotificationLevel = 2;
									ctx.topicData.notification_level = 2;
									if (ctx.topicData.details) ctx.topicData.details.notification_level = 2;
								}
							}
							setCachedTopicData(ctx.topicId, ctx.topicData);
						} catch (error) {
							if (Number(error?.status) === 403) {
								SHARED_ISSUE_FORBIDDEN_TOPIC_IDS.add(Number(ctx.topicId));
							} else {
								alert('“俺也一样”操作失败:' + error.message);
							}
						} finally {
							ctx.topicSharedIssueBusy = false;
							syncTopicActionControls(ctx);
						}
					});
					return;
				}
				const handler = topicActionHandlers[action];
				if (handler) {
					await handler();
					return;
				}
			}
			const jumpBtn = e.target.closest('.ldp-jump-parent, .ldp-jump-back, .ldp-jump-self, .ldp-jump-preview-self, .ldp-quote-jump');
			if (jumpBtn) {
				const targetPostNumber = +jumpBtn.dataset.targetPostNumber;
				const quoteHighlight = quoteJump ? quoteHighlightFromJump(jumpBtn, ctx) : null;
				jumpBtn.blur();
				if (!targetPostNumber) return;
				if (jumpBtn.classList.contains('ldp-jump-parent') &&
						post && post.classList.contains('ldp-nested-collapsed')) {
					if (jumpBtn.getAttribute('aria-busy') === 'true') return;
					clearStreamViewportAnchor(ctx);
					ctx.streamScrollDirection = 0;
					ctx.scrollRoot.classList.add('ldp-floor-preview-positioning');
					const collapsedBottom = post.getBoundingClientRect().bottom;
					jumpBtn.setAttribute('aria-busy', 'true');
					setLabel(jumpBtn, `正在准备父楼层 #${targetPostNumber}`);
					try {
						await new Promise((resolve) => {
							requestAnimationFrame(resolve);
						});
						if (!jumpBtn.isConnected || !post.classList.contains('ldp-nested-collapsed')) return;
						await floorPreview.show(jumpBtn, {
							collapsedBottom,
							allowCollapsed: true,
							beforePlace: () => {
								setNestedReplyCollapsed(post, false);
								hydratePostContent(post, ctx);
							},
						});
					} finally {
						jumpBtn.removeAttribute('aria-busy');
						if (jumpBtn.isConnected && post.classList.contains('ldp-nested-collapsed')) {
							setLabel(jumpBtn, `展开楼层并预览父楼层 #${targetPostNumber}`);
						}
						requestAnimationFrame(() => ctx.scrollRoot.classList.remove('ldp-floor-preview-positioning'));
					}
					return;
				}
				let prepareTarget = null;
				if (jumpBtn.classList.contains('ldp-jump-self')) {
					const parentPostNumber = +jumpBtn.dataset.parentPostNumber || +(post && post.dataset.replyToPostNumber) || 0;
					prepareTarget = () => ensureParentJumpOnStreamPost(targetPostNumber, parentPostNumber, ctx);
				} else if (jumpBtn.classList.contains('ldp-jump-parent')) {
					prepareTarget = () => showJumpBackLink(targetPostNumber, postNumber, ctx);
				} else {
					clearJumpBackLinks(ctx);
					if (quoteJump && quoteHighlight) {
						prepareTarget = () => applyReaderQuoteHighlight(
							targetPostNumber,
							ctx,
							quoteHighlight.text,
							{ source: quoteHighlight.source },
						);
					} else if (!quoteJump && !jumpBtn.classList.contains('ldp-jump-preview-self')) {
						prepareTarget = () => ensureParentJumpOnStreamPost(targetPostNumber, postNumber, ctx);
					}
				}
				const jumped = await ctx.timeline.jumpTo(targetPostNumber, {
					prepareTarget,
					alignNearest: !!quoteHighlight,
				});
				if (!jumped && quoteHighlight) clearReaderQuoteHighlight(ctx);
				return;
			}
			const boostDeleteAction = e.target.closest('.ldp-boost-delete-action');
			const boostItemAction = boostDeleteAction || e.target.closest('.ldp-boost-report-action');
			if (boostItemAction && !boostItemAction.disabled) {
				e.preventDefault();
				const boostBubble = boostItemAction.closest('.ldp-boost-bubble');
				const boostId = boostBubble && boostBubble.dataset.boostId;
				const report = !boostDeleteAction;
				boostItemAction.disabled = true;
				try {
					await runReaderActionOnce(
						ctx,
						`boost:${boostId}:${report ? 'report-dialog' : 'delete'}`,
						() => report ? openReaderBoostReportDialog(boostBubble, ctx) : deleteReaderBoost(post, boostBubble, ctx)
					);
				} catch (error) {
					if (report) showSelectionToast(error?.message ? error.message : '举报 Boost 失败,请重试');
					else alert('删除 Boost 失败:' + error.message);
				} finally {
					if (boostItemAction.isConnected) boostItemAction.disabled = false;
				}
				return;
			}
			const boostCopyAction = e.target.closest('.ldp-boost-copy-action');
			const boostMenuAction = boostCopyAction || e.target.closest('.ldp-boostbtn');
			if (boostMenuAction) {
				if (boostCopyAction) e.preventDefault();
				const initialRaw = boostCopyAction
					? applyBoostCopyRule(boostBubblePlainText(boostCopyAction.closest('.ldp-boost-bubble')))
					: '';
				renderNativeBoostMenu(post, boostMenuAction, ctx, initialRaw);
				return;
			}
			const likeBtn = e.target.closest('.ldp-like');
			if (likeBtn && !likeBtn.disabled) {
				await runReaderActionOnce(ctx, `post:${postId}:like`, async () => {
					const current = readerPostCopies(postId, ctx).find((copy) => copy.querySelector('.ldp-like')) || post;
					const currentButton = current.querySelector('.ldp-like');
					await toggleReaderPostLike(postId, ctx, currentButton, {
						setBusy: (busy) => setReaderPostControlsBusy(postId, ctx, '.ldp-like', busy),
						postData: post._ldpPostData,
						onError: (error) => alert('操作失败:' + error.message),
					});
				});
				return;
			}
			const replyBtn = e.target.closest('.ldp-replybtn');
			if (replyBtn) {
				await runReaderActionOnce(
					ctx,
					`post:${postId}:reply-composer`,
					() => tryOpenNativeReplyComposer(post, ctx)
				);
			}
		};
		onAction(modal, 'click', onModalClick);
		return () => {
			const boostMenu = document.body?.querySelector(':scope > .ldp-native-boost-menu');
			if (isFunction(boostMenu?._ldpClose)) boostMenu._ldpClose();
			closeUserCard();
			if (ctx.closeUserCard === closeUserCard) ctx.closeUserCard = null;
			if (ctx.closeUserCardLayer === closeUserCardLayer) ctx.closeUserCardLayer = null;
			if (ctx.closeTransientActions === closeTransientActions) ctx.closeTransientActions = null;
			clearActivePostActions();
			actionScope.destroy();
			if (selectionToolbarFrame) cancelAnimationFrame(selectionToolbarFrame);
			selectionToolbarFrame = 0;
			if (imageQuotePositionFrame) cancelAnimationFrame(imageQuotePositionFrame);
			imageQuotePositionFrame = 0;
			clearTimeout(imageQuoteHideTimer);
			clearImageQuoteCycleTimers();
			reactionHoverOpenTimers.forEach((timer) => clearTimeout(timer));
			reactionHoverCloseTimers.forEach((timer) => clearTimeout(timer));
			reactionVisibilityObserver?.disconnect();
			reactionVisibilityObserver = null;
			reactionHoverOpenTimers.clear();
			reactionHoverCloseTimers.clear();
			selectionToolbar.remove();
			imageQuoteToolbar.remove();
			readerPortalQuery('.ldp-selection-toast')?.remove();
			floorPreview.destroy();
		};
	}

	/* ============ 11. 楼中楼补全(分批渲染 + 节流 + 停顿检测) ============ */
	function directRepliesForParent(replies, postNumber) {
		const parent = +postNumber;
		const posts = Array.isArray(replies) ? replies : postsFromPayload(replies);
		return posts.filter((reply) => {
			const rawReplyTo = reply?.reply_to_post_number;
			const replyTo = +(rawReplyTo || 0);
			// /posts/:id/replies.json is already scoped to this post. LinuxDo can
			// omit reply_to_post_number here, but an explicit different parent is a
			// descendant and must stay out of this two-level reply component.
			return !replyTo || replyTo === parent;
		});
	}

	function mergeDirectReplies(...groups) {
		const merged = new Map();
		groups.flat().forEach((reply) => {
			if (!reply) return;
			const key = +(reply.post_number || 0) || String(reply.id || '');
			if (key) merged.set(key, reply);
		});
		return [...merged.values()].sort((a, b) => +(a.post_number || 0) - +(b.post_number || 0));
	}

	function replyPageCursor(posts) {
		return posts.reduce(
			(latest, reply) => Math.max(latest, +(reply?.post_number || 0)),
			0,
		);
	}

	function createRepliesIO(ctx) {
		const fetchedNodes = new WeakSet();
		const hoverTimers = new WeakMap();
		const hoverTimerNodes = new Set();
		const emptyRetryCounts = new WeakMap();
		const transientRetryCounts = new WeakMap();
		const retryTimers = new Map();
		const replyPagesByKey = new Map();
		const pendingReplyPageStarts = new Map();
		const completeDirectReplyPostIds = new Set();
		const pendingPrefetchNodes = new Set();
		const visiblePrefetchNodes = new Set();
		let nestedPrefetchRunning = false;
		let nestedPrefetchStopped = false;
		let nestedPrefetchResumeTimer = 0;
		const sessionActive = () => !ctx.readerSession || ctx.readerSession.isCurrent('opening', 'active');
		const assertSessionActive = () => {
			if (!sessionActive()) throw new DOMException('Reader session ended', 'AbortError');
		};

		const replyRequestKey = (postId, after = 0) => `replies:${postId}:${Math.max(0, +after || 0)}`;

		const cancelNestedPrefetch = (post, cancelQueued = true) => {
			if (!post) return;
			if (hoverTimers.has(post)) clearTimeout(hoverTimers.get(post));
			hoverTimers.delete(post);
			hoverTimerNodes.delete(post);
			if (retryTimers.has(post)) clearTimeout(retryTimers.get(post));
			retryTimers.delete(post);
			visiblePrefetchNodes.delete(post);
			transientRetryCounts.delete(post);
			pendingPrefetchNodes.delete(post);
			if (cancelQueued) ctx.postRequestScheduler.cancelQueued(replyRequestKey(post.dataset.postId, 0));
		};

		function scheduleNestedPrefetchDrain() {
			if (nestedPrefetchStopped || nestedPrefetchResumeTimer || !pendingPrefetchNodes.size) return;
			const scrollAge = Date.now() - +(ctx.streamLastScrollAt || 0);
			nestedPrefetchResumeTimer = setTimeout(() => {
				nestedPrefetchResumeTimer = 0;
				if (sessionActive()) drainNestedPrefetchQueue();
			}, Math.max(120, 240 - scrollAge));
		}

		async function drainNestedPrefetchQueue() {
			if (nestedPrefetchRunning || nestedPrefetchStopped) return;
			if (streamScrollIsActive(ctx, 220)) {
				scheduleNestedPrefetchDrain();
				return;
			}
			nestedPrefetchRunning = true;
			try {
				while (!nestedPrefetchStopped && sessionActive() && pendingPrefetchNodes.size) {
					if (streamScrollIsActive(ctx, 220)) {
						scheduleNestedPrefetchDrain();
						break;
					}
					const candidates = [...pendingPrefetchNodes]
						.filter((node) => node?.isConnected);
					let post = candidates[0] || null;
					if (candidates.length > 1) {
						const rootRect = ctx.scrollRoot.getBoundingClientRect();
						const rootCenter = (rootRect.top + rootRect.bottom) / 2;
						post = candidates
							.map((node, order) => {
								const rect = node.getBoundingClientRect();
								return {
									node,
									order,
									distance: Math.abs((rect.top + rect.bottom) / 2 - rootCenter),
								};
							})
							.sort((a, b) => a.distance - b.distance || a.order - b.order)[0].node;
					}
					if (!post) {
						pendingPrefetchNodes.clear();
						break;
					}
					pendingPrefetchNodes.delete(post);
					try {
						const loaded = await loadRepliesFor(post, POST_REQUEST_PRIORITY.nested);
						if (loaded) io.unobserve(post);
						transientRetryCounts.delete(post);
					} catch (error) {
						const status = Number(error?.status || 0);
						const transient = !!error && (
							error.name === 'AbortError' ||
							error.name === 'TypeError' ||
							status === 408 ||
							status === 429 ||
							status >= 500
						);
						if (transient) scheduleNestedPrefetchRetry(post, error);
					}
				}
			} finally {
				nestedPrefetchRunning = false;
				if (!nestedPrefetchStopped && pendingPrefetchNodes.size) scheduleNestedPrefetchDrain();
			}
		}

		const enqueueNestedPrefetch = (post) => {
			if (nestedPrefetchStopped || !sessionActive() || !post || !post.isConnected || fetchedNodes.has(post)) return;
			pendingPrefetchNodes.add(post);
			drainNestedPrefetchQueue();
		};

		function scheduleNestedPrefetchRetry(post, cause = null) {
			if (nestedPrefetchStopped || !sessionActive() || !post || !post.isConnected ||
					!visiblePrefetchNodes.has(post) || fetchedNodes.has(post) || retryTimers.has(post)) return;
			const retryCount = +(transientRetryCounts.get(post) || 0) + 1;
			transientRetryCounts.set(post, retryCount);
			let requestState = null;
			try { requestState = ctx.postRequestScheduler.snapshot(); } catch (error) {}
			const controllerWait = Math.max(
				Number(requestState?.cooldownRemaining) || 0,
				Number(requestState?.nextPermitDelay) || 0,
				Math.max(0, Number(cause?.retryAt || 0) - Date.now())
			);
			const delay = controllerWait > 0
				? Math.min(60000, controllerWait + 80)
				: Math.min(15000, 500 * Math.pow(2, Math.min(5, retryCount - 1)));
			const timer = setTimeout(() => {
				retryTimers.delete(post);
				if (sessionActive() && post.isConnected && visiblePrefetchNodes.has(post)) {
					enqueueNestedPrefetch(post);
				}
			}, Math.max(250, delay));
			retryTimers.set(post, timer);
		}

		async function fetchReplyPage(postId, after = 0, options = {}) {
			assertSessionActive();
			if (!postId) return [];
			const normalizedAfter = Math.max(0, +after || 0);
			const pageKey = `${postId}:${normalizedAfter}`;
			if (replyPagesByKey.has(pageKey)) {
				const pendingSince = +(pendingReplyPageStarts.get(pageKey) || 0);
				const cached = await replyPagesByKey.get(pageKey);
				if (options.refresh !== true || pendingSince && responseCacheValueStoredAt(cached) >= pendingSince) return cached;
				replyPagesByKey.delete(pageKey);
			}
			const url = new URL(`${BASE}/posts/${postId}/replies.json`);
			if (normalizedAfter > 0) url.searchParams.set('after', String(normalizedAfter));
			const request = fetchJSON(url.toString(), {
				scheduler: ctx.postRequestScheduler,
				fetchOptions: { credentials: 'include', cache: 'no-store' },
				cacheMode: options.refresh === true ? 'refresh' : 'default',
				key: replyRequestKey(postId, normalizedAfter),
				priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.nested,
			});
			pendingReplyPageStarts.set(pageKey, Date.now());
			replyPagesByKey.set(pageKey, request);
			try {
				const payload = await request;
				assertSessionActive();
				replyPagesByKey.set(pageKey, payload);
				return payload;
			} catch (error) {
				replyPagesByKey.delete(pageKey);
				throw error;
			} finally {
				pendingReplyPageStarts.delete(pageKey);
			}
		}

		async function loadDirectReplies(post, options = {}) {
			assertSessionActive();
			const postId = +(post?.id || 0);
			const postNumber = +(post?.post_number || 0);
			if (!postId || !postNumber) return [];
			const knownMap = ctx.directRepliesByParent.get(postNumber);
			let merged = mergeDirectReplies(knownMap ? [...knownMap.values()] : []);
			const expected = Math.max(0, +(post.reply_count || 0));
			if ((options.force !== true || completeDirectReplyPostIds.has(postId)) && expected <= merged.length) return merged;
			let after = 0;
			const seenCursors = new Set();
			do {
				const payload = await fetchReplyPage(
					postId, after, {
						priority: Number.isFinite(options.priority) ? options.priority : POST_REQUEST_PRIORITY.background,
						refresh: options.force === true,
					}
				);
				const pagePosts = postsFromPayload(payload);
				const direct = directRepliesForParent(pagePosts, postNumber);
				direct.forEach((reply) => rememberPostData(ctx, reply, postNumber));
				ctx.loader.persistFetchedPosts(direct);
				merged = mergeDirectReplies(merged, direct);
				const nextAfter = replyPageCursor(pagePosts);
				if (!pagePosts.length || pagePosts.length < SUB_REPLY_FETCH_SIZE ||
						nextAfter <= after || seenCursors.has(nextAfter)) break;
				seenCursors.add(nextAfter);
				after = nextAfter;
			} while (!expected || merged.length < expected);
			completeDirectReplyPostIds.add(postId);
			return merged;
		}

		function mergeReplyPageIntoState(state, payload, postNumber, expectedCount, after = 0) {
			const pagePosts = postsFromPayload(payload);
			const pageReplies = directRepliesForParent(pagePosts, postNumber);
			const nextAfter = replyPageCursor(pagePosts);
			pageReplies.forEach((reply) => rememberPostData(ctx, reply, postNumber));
			ctx.loader.persistFetchedPosts(pageReplies);
			state.all = mergeDirectReplies(state.all, pageReplies);
			state.fetchedReplyCount = after > 0
				? +(state.fetchedReplyCount || 0) + pageReplies.length
				: pageReplies.length;
			state.fetchAfter = Math.max(after, nextAfter);
			const previousTotalCount = after > 0 ? +(state.totalCount || 0) : 0;
			const totalCount = Math.max(state.all.length, previousTotalCount, +(expectedCount || 0));
			state.hasMore = nextAfter > after && pagePosts.length >= SUB_REPLY_FETCH_SIZE &&
				state.fetchedReplyCount < totalCount;
			state.totalCount = state.hasMore ? totalCount : state.all.length;
			return pagePosts;
		}

		async function loadNextReplyPages(post, requiredCount) {
			if (!sessionActive()) return false;
			const postNumber = +(post && post.dataset.postNumber || 0);
			const state = ctx.subReplyState.get(postNumber);
			if (!postNumber || !state || state.loadingMore || state.hasMore === false) return false;
			const loadingEl = post.querySelector(':scope > .ldp-sub-loading');
			state.loadingMore = true;
			if (loadingEl) loadingEl.style.display = 'block';
			renderSubReplyBatch(postNumber, ctx, 0, post);
			try {
				while (state.hasMore !== false && +(state.fetchedReplyCount || 0) < requiredCount) {
					const after = +(state.fetchAfter || 0);
					if (!after) {
						state.hasMore = false;
						break;
					}
					const payload = await fetchReplyPage(post.dataset.postId, after, {
						priority: POST_REQUEST_PRIORITY.scroll,
					});
					if (!sessionActive() || !post.isConnected) return false;
					const pagePosts = mergeReplyPageIntoState(
						state, payload, postNumber, post.dataset.childReplyTotal, after
					);
					if (state.fetchAfter <= after || !pagePosts.length) break;
				}
				return state.all.length >= requiredCount;
			} catch (error) {
				return false;
			} finally {
				state.loadingMore = false;
				if (loadingEl) loadingEl.style.display = 'none';
				if (sessionActive() && post.isConnected) renderSubReplyBatch(postNumber, ctx, 0, post);
			}
		}

		async function loadRepliesFor(post, priority = POST_REQUEST_PRIORITY.nested) {
			if (!sessionActive()) return false;
			const postId = post && post.dataset.postId;
			const postNumber = +(post && post.dataset.postNumber);
			if (!postId || fetchedNodes.has(post)) return false;
			fetchedNodes.add(post);
			const loadingEl = post.querySelector(':scope > .ldp-sub-loading');
			if (loadingEl) loadingEl.style.display = 'block';
			const expectedCount = +(post.dataset.childReplyTotal || 0);
			const knownReplyMap = ctx.directRepliesByParent.get(postNumber);
			const knownReplies = knownReplyMap ? [...knownReplyMap.values()] : [];
			if (knownReplies.length) {
				const previousState = ctx.subReplyState.get(postNumber);
				const knownComplete = knownReplies.length >= expectedCount;
				const previousHasCursor = +(previousState?.fetchAfter || 0) > 0;
				const needsInitialFetch = !knownComplete && !previousHasCursor &&
					(!previousState || previousState.hasMore !== false);
				ctx.subReplyState.set(postNumber, {
					...previousState,
					all: mergeDirectReplies(knownReplies),
					pageIndex: +(previousState?.pageIndex || 0),
					totalCount: previousState && previousState.hasMore === false
						? Math.max(knownReplies.length, +(previousState.totalCount || 0))
						: Math.max(expectedCount, knownReplies.length),
					fetchedReplyCount: knownComplete
						? knownReplies.length
						: +(previousState?.fetchedReplyCount || 0),
					fetchAfter: +(previousState?.fetchAfter || 0),
					hasMore: knownComplete ? false : previousState ? previousState.hasMore !== false : true,
					loadingMore: needsInitialFetch,
				});
				renderSubReplyBatch(postNumber, ctx, 0, post);
				if (!needsInitialFetch) {
					if (loadingEl) loadingEl.style.display = 'none';
					emptyRetryCounts.delete(post);
					return true;
				}
			}
			try {
				const replies = await fetchReplyPage(postId, 0, { priority });
				if (!sessionActive()) return false;
				if (!post.isConnected) {
					fetchedNodes.delete(post);
					return false;
				}
				const previousState = ctx.subReplyState.get(postNumber);
				const nextState = {
					...previousState,
					all: knownReplies,
					pageIndex: +(previousState?.pageIndex || 0),
					loadingMore: false,
				};
				mergeReplyPageIntoState(nextState, replies, postNumber, expectedCount);
				const directReplies = nextState.all;
				if (loadingEl) loadingEl.style.display = 'none';
				if (!directReplies.length) {
					fetchedNodes.delete(post);
					replyPagesByKey.delete(`${postId}:0`);
					const retryCount = +(emptyRetryCounts.get(post) || 0);
					if (expectedCount > 0 && retryCount < 2 && post.isConnected) {
						await clearStoredResponseCache({ tags: [`post:${postId}`] });
						emptyRetryCounts.set(post, retryCount + 1);
						const retryTimer = setTimeout(() => {
							retryTimers.delete(post);
							enqueueNestedPrefetch(post);
						}, 1200 * (retryCount + 1));
						retryTimers.set(post, retryTimer);
					}
					return false;
				}
				emptyRetryCounts.delete(post);
				ctx.subReplyState.set(postNumber, nextState);
				renderSubReplyBatch(postNumber, ctx, 0, post); // 首批只渲染当前分页条数
				return true;
			} catch (e) {
				if (loadingEl) loadingEl.style.display = 'none';
				const state = ctx.subReplyState.get(postNumber);
				if (state) {
					state.loadingMore = false;
					renderSubReplyBatch(postNumber, ctx, 0, post);
				}
				fetchedNodes.delete(post); // 失败允许该 DOM 副本下次进入视口重试
				replyPagesByKey.delete(`${postId}:0`);
				throw e;
			}
		}

		const scheduleInitialLoad = (post) => {
			if (!sessionActive() || !post || !post.dataset.postId || fetchedNodes.has(post) || hoverTimers.has(post)) return;
			const timer = setTimeout(() => {
				hoverTimers.delete(post);
				hoverTimerNodes.delete(post);
				if (!sessionActive() || !post.isConnected) return;
				enqueueNestedPrefetch(post);
			}, REPLIES_HOVER_DELAY);
			hoverTimers.set(post, timer);
			hoverTimerNodes.add(post);
		};

		const nestedPrefetchPixels = Math.round(
			Math.max(ctx.scrollRoot.clientHeight, window.innerHeight * 0.5) *
			streamPerformance(ctx).nestedPrefetchViewports
		);
		const io = new IntersectionObserver((entries) => {
			if (!sessionActive()) return;
			entries.forEach((en) => {
				if (!en.target.dataset.postId) return;

				if (en.isIntersecting) {
					visiblePrefetchNodes.add(en.target);
					scheduleInitialLoad(en.target);
				} else {
					// 离开视口时若尚未真正发起请求,则取消该次触发
					cancelNestedPrefetch(en.target);
				}
			});
		}, { root: ctx.scrollRoot, rootMargin: `${nestedPrefetchPixels}px 0px ${nestedPrefetchPixels}px 0px`, threshold: 0.01 });
		const nativeObserve = io.observe.bind(io);
		io.observe = (node) => {
			if (!node || fetchedNodes.has(node) || !(+(node.dataset.childReplyTotal || 0) > 0)) return;
			nativeObserve(node);
		};
		const nativeUnobserve = io.unobserve.bind(io);
		io.unobserve = (node) => {
			cancelNestedPrefetch(node);
			nativeUnobserve(node);
		};
		const nativeDisconnect = io.disconnect.bind(io);
		io.disconnect = () => {
			nestedPrefetchStopped = true;
			if (nestedPrefetchResumeTimer) clearTimeout(nestedPrefetchResumeTimer);
			nestedPrefetchResumeTimer = 0;
			new Set([...hoverTimerNodes, ...retryTimers.keys()]).forEach((node) => cancelNestedPrefetch(node, false));
			pendingPrefetchNodes.clear();
			visiblePrefetchNodes.clear();
			replyPagesByKey.clear();
			pendingReplyPageStarts.clear();
			completeDirectReplyPostIds.clear();
			nativeDisconnect();
		};
		io.loadNextPage = (node, requiredCount) => loadNextReplyPages(node, requiredCount);
		io.loadDirectReplies = loadDirectReplies;
		return io;
	}
	function syncCommentsHeaderPlacement(ctx) {
		if (!ctx || !ctx.commentsHeader || !ctx.streamNodeMap) return;
		if (ctx.readerSession && !ctx.readerSession.isCurrent('opening', 'active')) return;
		const firstPost = ctx.streamNodeMap.get(1);
		const firstPostMounted = firstPost && firstPost.parentElement === ctx.commentsEl &&
			ctx.streamMountedNodes && ctx.streamMountedNodes.has(firstPost);
		if (!firstPostMounted) {
			ctx.commentsHeader.hidden = true;
			return;
		}
		const children = firstPost.querySelector(':scope > .ldp-children');
		const childLoading = firstPost.querySelector(':scope > .ldp-sub-loading');
		const footerActions = firstPost.querySelector(':scope > .ldp-topic-footer-actions');
		if (footerActions) {
			const replyEnd = childLoading || children;
			if (replyEnd && footerActions.previousElementSibling !== replyEnd) {
				firstPost.insertBefore(footerActions, replyEnd.nextSibling);
			}
			firstPost.insertBefore(ctx.commentsHeader, footerActions.nextSibling);
		} else if (children) firstPost.insertBefore(ctx.commentsHeader, (childLoading || children).nextSibling);
		else firstPost.appendChild(ctx.commentsHeader);
		ctx.commentsHeader.hidden = false;
	}

	function updateCommentsHeader(ctx) {
		const commentCount = Math.max(0, +(ctx?.totalPosts || 0) - 1);
		if (ctx.countEl) ctx.countEl.textContent = `(${commentCount})`;
		if (ctx.emptyEl) ctx.emptyEl.style.display = ctx.totalPosts ? 'none' : '';
		syncCommentsHeaderPlacement(ctx);
	}

	function bindReaderTopicPresence(ctx) {
		if (!ctx || !ctx.commentsHeader || !ctx.topicId) return () => {};
		let presence = ctx.commentsHeader.querySelector(':scope > .ldp-topic-presence');
		if (!presence) {
			presence = makeElement('span');
			presence.className = 'ldp-topic-presence';
			presence.hidden = true;
			presence.setAttribute('aria-live', 'polite');
			ctx.commentsHeader.appendChild(presence);
		}
		const channel = `/discourse-presence/reply/${ctx.topicId}`;
		let stopped = false;
		let presenceChannel = null;
		let presenceChangeHandler = null;

		const render = (source) => {
			const users = (Array.isArray(source) ? source : [])
				.filter((user) => user && (!ME_USERNAME || user.username !== ME_USERNAME));
			if (!users.length) {
				presence.hidden = true;
				presence.replaceChildren();
				return;
			}
			const visibleUsers = users.slice(0, 3);
			const avatars = visibleUsers.map((user) => {
				const username = String(user.username || '');
				const name = String(user.name || username || '用户');
				const avatar = persistentAvatarHtml(user.avatar_template, 24, 'ldp-topic-presence-avatar', name);
				return `<a class="ldp-topic-presence-user ldp-user-link" href="${escAttr(userProfileUrl(username))}" target="_blank" rel="noopener" data-user-card="${escAttr(username)}" aria-label="${escAttr(name)}">${avatar}</a>`;
			}).join('');
			const first = users[0];
			const firstName = String(first.name || first.username || '用户');
			const label = users.length === 1 ? `${firstName} 正在回复…` : `${users.length} 人正在回复…`;
			presence.innerHTML = `<span class="ldp-topic-presence-avatars">${avatars}</span><span class="ldp-topic-presence-text">${esc(label)}</span>`;
			presence.hidden = false;
			hydratePersistentAvatarImages(presence, POST_REQUEST_PRIORITY.background);
		};

		const unsubscribe = async (
			channelProxy = presenceChannel,
			changeHandler = presenceChangeHandler
		) => {
			if (!channelProxy) return;
			if (changeHandler && isFunction(channelProxy.off)) {
				channelProxy.off('change', changeHandler);
			}
			try {
				await channelProxy.unsubscribe();
			} catch (error) {}
			if (presenceChannel === channelProxy) {
				presenceChangeHandler = null;
				presenceChannel = null;
			}
		};
		const start = async () => {
			const presenceService = lookupDiscourse('service:presence');
			if (!presenceService || !isFunction(presenceService.getChannel)) {
				render([]);
				return;
			}
			const channelProxy = presenceService.getChannel(channel);
			presenceChannel = channelProxy;
			if (!channelProxy || !isFunction(channelProxy.subscribe)) {
				presenceChannel = null;
				render([]);
				return;
			}
			const changeHandler = () => {
				if (!stopped) render([...channelProxy.users || []]);
			};
			presenceChangeHandler = changeHandler;
			if (isFunction(channelProxy.on)) {
				channelProxy.on('change', changeHandler);
			}
			try {
				await channelProxy.subscribe();
				if (stopped) {
					await unsubscribe(channelProxy, changeHandler);
					return;
				}
				changeHandler();
			} catch (error) {
				if (!stopped) render([]);
				await unsubscribe(channelProxy, changeHandler);
			}
		};
		void start();

		return () => {
			stopped = true;
			void unsubscribe();
			presence.hidden = true;
			presence.replaceChildren();
		};
	}

	const READER_LOADING_ANIMATIONS = Object.freeze([
		{ key: 'portal', label: '主题开卷', markup: '<div class="ldp-loading-visual-inner ldp-loader-portal"><span style="--i:0" data-copy="TOPIC"></span><span style="--i:1" data-copy="#1"></span><span style="--i:2"></span><span style="--i:3"></span><span style="--i:4"></span><span style="--i:5"></span></div>' },
		{ key: 'constellation', label: '回帖脉络', markup: '<div class="ldp-loading-visual-inner ldp-loader-thread-index"><span style="--i:0;--depth:0" data-user="OP" data-floor="#1"></span><span style="--i:1;--depth:1" data-user="↳ 回帖" data-floor="#2"></span><span style="--i:2;--depth:2" data-user="↳ 楼中楼" data-floor="#6"></span><span style="--i:3;--depth:1" data-user="↳ 回帖" data-floor="#9"></span><span style="--i:4;--depth:2" data-user="↳ 楼中楼" data-floor="#12"></span><span style="--i:5;--depth:3" data-user="↳ 继续回复" data-floor="#18"></span></div>' },
		{ key: 'corridor', label: '楼层时间轴', markup: '<div class="ldp-loading-visual-inner ldp-loader-floor-reel"><span style="--i:0" data-floor="#01" data-time="首帖"></span><span style="--i:1" data-floor="#02" data-time="回复"></span><span style="--i:2" data-floor="#03" data-time="当前"></span><span style="--i:3" data-floor="#04" data-time="回复"></span><span style="--i:4" data-floor="#05" data-time="最新"></span></div>' },
		{ key: 'typewave', label: 'Markdown 解析', markup: '<div class="ldp-loading-visual-inner ldp-loader-typewave"><span style="--row:0" data-source="# 标题"></span><span style="--row:1" data-source="**重点**"></span><span style="--row:2" data-source="> 引用"></span><span style="--row:0" data-render="标题"></span><span style="--row:1" data-render="重点"></span><span style="--row:2" data-render="引用内容"></span></div>' },
		{ key: 'crystal', label: '缓存回环', markup: '<div class="ldp-loading-visual-inner ldp-loader-cache-lanes"><span style="--i:0;--lane:0"></span><span style="--i:1;--lane:1"></span><span style="--i:2;--lane:2"></span><span style="--i:3;--lane:0"></span><span style="--i:4;--lane:1"></span><span style="--i:5;--lane:2"></span><span style="--i:6;--lane:0"></span><span style="--i:7;--lane:1"></span><span style="--i:8;--lane:2"></span><span style="--i:9;--lane:0"></span><span style="--i:10;--lane:1"></span><span style="--i:11;--lane:2"></span></div>' },
		{ key: 'marginalia', label: '只看楼主', markup: '<div class="ldp-loading-visual-inner ldp-loader-marginalia"><span style="--i:0" data-user="OP" data-floor="#1"></span><span style="--i:1" data-user="佬友" data-floor="#2"></span><span style="--i:2" data-user="OP" data-floor="#7"></span><span style="--i:3" data-user="佬友" data-floor="#8"></span><span style="--i:4" data-user="OP" data-floor="#16"></span></div>' },
		{ key: 'chapters', label: '分类标签', markup: '<div class="ldp-loading-visual-inner ldp-loader-index-fan"><span style="--i:0" data-index="01" data-tag="类别"></span><span style="--i:1" data-index="02" data-tag="标签"></span><span style="--i:2" data-index="03" data-tag="楼主"></span><span style="--i:3" data-index="04" data-tag="楼层"></span><span style="--i:4" data-index="05" data-tag="回复"></span></div>' },
		{ key: 'quoteecho', label: '社区信条', markup: '<div class="ldp-loading-visual-inner ldp-loader-quoteecho"><span style="--i:0" data-word="真诚"></span><span style="--i:1" data-word="友善"></span><span style="--i:2" data-word="团结"></span><span style="--i:3" data-word="专业"></span></div>' },
		{ key: 'footnotes', label: '新回复抵达', markup: '<div class="ldp-loading-visual-inner ldp-loader-inbox-rain"><span style="--i:0;--x:34px;--y:112px;--drop:-72px" data-floor="#128"></span><span style="--i:1;--x:77px;--y:98px;--drop:-88px" data-floor="#129"></span><span style="--i:2;--x:120px;--y:84px;--drop:-64px" data-floor="#130"></span><span style="--i:3;--x:163px;--y:70px;--drop:-94px" data-floor="#131"></span><span style="--i:4;--x:206px;--y:56px;--drop:-76px" data-floor="#132"></span></div>' },
		{ key: 'inkverse', label: '互动汇流', markup: '<div class="ldp-loading-visual-inner ldp-loader-inkverse"><span style="--i:0" data-action="赞"></span><span style="--i:1" data-action="Boost"></span><span style="--i:2" data-action="回应"></span><span style="--i:3" data-action="收藏"></span></div>' },
	]);

	function resolveReaderLoadingAnimation(preference = PREFS.loadingAnimation, randomKey = '', excludedKey = '') {
		const normalized = normalizeReaderLoadingAnimation(preference);
		if (normalized !== 'random') {
			return READER_LOADING_ANIMATIONS.find((animation) => animation.key === normalized) || READER_LOADING_ANIMATIONS[0];
		}
		const requestedRandom = READER_LOADING_ANIMATIONS.find((animation) => animation.key === randomKey);
		if (requestedRandom) return requestedRandom;
		const candidates = READER_LOADING_ANIMATIONS.filter((animation) => animation.key !== excludedKey);
		const pool = candidates.length ? candidates : READER_LOADING_ANIMATIONS;
		return pool[Math.floor(Math.random() * pool.length)];
	}

	function readerLoadingVisualHTML(animation) {
		return `<div class="ldp-loading-visual" data-animation="${animation.key}" aria-hidden="true">
			${animation.markup}
		</div>`;
	}

	let lastRandomReaderLoadingAnimationKey = '';
	function createReaderLoadingHTML(preference = PREFS.loadingAnimation) {
		const normalized = normalizeReaderLoadingAnimation(preference);
		const animation = resolveReaderLoadingAnimation(normalized, '',
			normalized === 'random' ? lastRandomReaderLoadingAnimationKey : '');
		if (normalized === 'random') lastRandomReaderLoadingAnimationKey = animation.key;
		return `
		<div class="ldp-loading-stage" role="status" aria-live="polite" aria-atomic="true" aria-label="正在载入">
			${readerLoadingVisualHTML(animation)}
			<div class="ldp-loading-copy">
				<div class="ldp-loading-mode">LINUX DO READER · ${animation.label}</div>
				<div class="ldp-loading-status"><span>正在载入帖子</span><strong class="ldp-loading-target"></strong></div>
				<div class="ldp-loading-detail">正在准备阅读现场…</div>
			</div>
		</div>`;
	}

	function parkReaderCommentsHeader(overlay, preferredHeader = null) {
		const body = overlay?.querySelector('.ldp-body');
		if (!body) return null;
		const readerShell = overlay._ldpReaderShell;
		let commentsHeader = preferredHeader || (readerShell?.commentsHeader) ||
			overlay.querySelector('.ldp-comments-header');
		if (!commentsHeader) {
			commentsHeader = makeElement('div');
			commentsHeader.className = 'ldp-comments-header';
			commentsHeader.hidden = true;
			commentsHeader.innerHTML = `${icon('messageSquare')}<span>评论</span><span class="ldp-comments-count" aria-live="polite"></span>`;
		}
		const loadingTip = body.querySelector('.ldp-loading-tip');
		commentsHeader.hidden = true;
		commentsHeader.remove();
		if (loadingTip) body.insertBefore(commentsHeader, loadingTip);
		else body.appendChild(commentsHeader);
		if (readerShell) readerShell.commentsHeader = commentsHeader;
		return commentsHeader;
	}

	function resetReaderTopicShell(overlay) {
		const body = overlay?.querySelector('.ldp-body');
		if (!body) return;
		const comments = body.querySelector('.ldp-comments');
		parkReaderCommentsHeader(overlay);
		const loadingTip = body.querySelector('.ldp-loading-tip');
		const endTip = body.querySelector('.ldp-end-tip');
		const timeline = overlay.querySelector('.ldp-topic-timeline');
		const title = overlay.querySelector('.ldp-title-jump');
		const metaStats = overlay.querySelector('.ldp-meta-stats');
		const metaOwner = overlay.querySelector('.ldp-meta-owner');
		const tags = overlay.querySelector('.ldp-topic-tags');
		const voteSlot = overlay.querySelector('.ldp-topic-vote-slot');
		const onlyOp = overlay.querySelector('.ldp-only-op-toggle');
		const onlyOpProgress = overlay.querySelector('.ldp-only-op-progress');
		const nativeOpen = overlay.querySelector('.ldp-open');
		const liveUpdate = overlay.querySelector('.ldp-live-update');
		const rateLimitNotice = overlay.querySelector('.ldp-rate-limit-notice');

		body.querySelector('.ldp-loadmask')?.remove();
		const mask = makeElement('div');
		mask.className = 'ldp-loadmask';
		mask.innerHTML = createReaderLoadingHTML();
		body.prepend(mask);
		if (comments) {
			const empty = makeElement('div');
			empty.className = 'ldp-comments-empty';
			empty.style.display = 'none';
			empty.textContent = '暂无评论';
			comments.replaceChildren(empty);
		}
		if (loadingTip) loadingTip.classList.remove('show');
		if (endTip) endTip.classList.remove('show');
		if (liveUpdate) liveUpdate.hidden = true;
		if (rateLimitNotice) {
			rateLimitNotice.hidden = true;
			delete rateLimitNotice.dataset.cooldownSeconds;
		}
		if (timeline) {
			timeline.hidden = true;
			timeline.querySelector('.ldp-topic-timeline-current')?.replaceChildren();
			timeline.querySelector('.ldp-topic-timeline-total')?.replaceChildren();
			timeline.querySelector('.ldp-topic-timeline-preview')?.replaceChildren();
		}
		if (title) title.textContent = '正在切换主题…';
		if (metaStats) metaStats.textContent = '正在加载主题…';
		if (metaOwner) metaOwner.hidden = true;
		if (tags) {
			tags.replaceChildren();
			tags.hidden = true;
		}
		if (voteSlot) {
			voteSlot.replaceChildren();
			voteSlot.hidden = true;
		}
		if (onlyOp) {
			onlyOp.disabled = true;
			onlyOp.classList.remove('active');
			setPressed(onlyOp, false);
			setLabel(onlyOp, '只看楼主');
		}
		if (onlyOpProgress) {
			onlyOpProgress.hidden = true;
			onlyOpProgress.dataset.state = '';
			onlyOpProgress.style.removeProperty('--ldp-only-op-progress');
		}
		if (nativeOpen) {
			nativeOpen.hidden = true;
			nativeOpen.removeAttribute('href');
		}
		body.scrollTop = 0;
	}

	/* ============ 13. 弹窗主体 + 循环泵加载 ============ */
	let CURRENT_OVERLAY = null;
	restoreReaderQueueState();
	let READER_SESSION_SEQUENCE = 0;
	let READER_OPEN_REQUEST_SEQUENCE = 0;
	let ACTIVE_READER_SESSION_ID = 0;
	let HOST_DOCUMENT_TITLE;
	let AUTO_OPEN_SUPPRESSED_TOPIC_ID = bypassReaderForThisTab ? String(INITIAL_TOPIC_ID || '') : '';
	let DISCOURSE_ROUTE_HANDLER_INSTALLED = false;

	function syncReaderDocumentTitle(topicTitle) {
		const title = String(topicTitle || '').trim();
		if (!title) return;
		const documentTitle = lookupDiscourse('service:document-title');
		if (!documentTitle || !isFunction(documentTitle.setTitle)) return;
		if (HOST_DOCUMENT_TITLE === undefined) {
			HOST_DOCUMENT_TITLE = isFunction(documentTitle.getTitle)
				? documentTitle.getTitle()
				: null;
		}
		documentTitle.setTitle(title);
	}

	function restoreHostDocumentTitle() {
		if (HOST_DOCUMENT_TITLE === undefined) return;
		const documentTitle = lookupDiscourse('service:document-title');
		if (isFunction(documentTitle?.setTitle)) {
			documentTitle.setTitle(HOST_DOCUMENT_TITLE);
		}
		HOST_DOCUMENT_TITLE = undefined;
	}

	function currentHostRouteKey() {
		return `${location.pathname}${location.search}${location.hash}`;
	}

	function embeddedReaderViewportState(ctx) {
		if (!ctx || !ctx.scrollRoot) return null;
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const visible = [...ctx.streamMountedNodes || []]
			.filter((node) => node?.isConnected && +node.dataset.postNumber > 0)
			.map((node) => ({ node, rect: node.getBoundingClientRect() }))
			.filter((item) => item.rect.bottom > rootRect.top && item.rect.top < rootRect.bottom)
			.sort((left, right) => left.rect.top - right.rect.top)[0];
		return {
			postNumber: visible ? +visible.node.dataset.postNumber : 0,
			postOffset: visible ? visible.rect.top - rootRect.top : 0,
			scrollTop: Math.max(0, Number(ctx.scrollRoot.scrollTop) || 0),
		};
	}

	function embeddedReaderPostViewportState(ctx, postNumber) {
		const safePostNumber = Math.max(0, Number(postNumber) || 0);
		if (!ctx || !ctx.scrollRoot || !safePostNumber) return null;
		const node = ctx.streamNodeMap?.get(safePostNumber);
		const rootRect = ctx.scrollRoot.getBoundingClientRect();
		const postOffset = node?.isConnected
			? node.getBoundingClientRect().top - rootRect.top
			: 0;
		return {
			postNumber: safePostNumber,
			postOffset,
			scrollTop: Math.max(0, Number(ctx.scrollRoot.scrollTop) || 0),
		};
	}

	function saveEmbeddedReaderReloadState() {
		const overlay = CURRENT_OVERLAY;
		const readerShell = overlay?._ldpReaderShell;
		const readerWorkspace = readerShell?.readerWorkspace;
		const ctx = readerShell?.activeContext;
		if (!overlay || !readerWorkspace || !readerWorkspace.isEmbedded() || !ctx || !ctx.readerSession ||
			!ctx.readerSession.isCurrent('opening', 'active')) return;
		const viewport = embeddedReaderViewportState(ctx) || {};
		const state = {
			savedAt: Date.now(),
			hostRoute: currentHostRouteKey(),
			mode: readerWorkspace.mode(),
			topicId: Number(overlay.dataset.topicId || ctx.topicId) || 0,
			targetPostNumber: Number(viewport.postNumber || readerShell.targetPostNumber) || 1,
			postOffset: Number(viewport.postOffset) || 0,
			scrollTop: Number(viewport.scrollTop) || 0,
			onlyOp: ctx.onlyOp === true,
			topicNavMetadata: readerShell.topicNavMetadata || null,
		};
		if (!state.topicId) return;
		try { sessionStorage.setItem(LDP_EMBEDDED_RELOAD_STATE_KEY, JSON.stringify(state)); } catch (e) {}
	}

	function readEmbeddedReaderReloadState({ consume = false } = {}) {
		let state;
		try {
			state = JSON.parse(sessionStorage.getItem(LDP_EMBEDDED_RELOAD_STATE_KEY) || 'null');
			if (consume) sessionStorage.removeItem(LDP_EMBEDDED_RELOAD_STATE_KEY);
		} catch (e) {
			if (consume) {
				try { sessionStorage.removeItem(LDP_EMBEDDED_RELOAD_STATE_KEY); } catch (storageError) {}
			}
			return null;
		}
		const navigationEntry = performance.getEntriesByType && performance.getEntriesByType('navigation')[0];
		const reloaded = navigationEntry ? navigationEntry.type === 'reload' : performance.navigation?.type === 1;
		if (!reloaded || !state || Date.now() - Number(state.savedAt || 0) > LDP_EMBEDDED_RELOAD_STATE_TTL ||
			state.hostRoute !== currentHostRouteKey() || !readerPresentation(state.mode).embedded || !(Number(state.topicId) > 0)) {
			return null;
		}
		return state;
	}

	async function restoreEmbeddedReaderAfterReload() {
		const state = readEmbeddedReaderReloadState({ consume: true });
		if (!state) return false;
		setPref('listReaderMode', state.mode);
		await openModal(state.topicId, {
			targetPostNumber: state.targetPostNumber,
			topicNavMetadata: state.topicNavMetadata || undefined,
			restoreOnlyOp: state.onlyOp === true,
			restoreViewport: {
				postNumber: state.targetPostNumber,
				postOffset: Number(state.postOffset) || 0,
				scrollTop: Number(state.scrollTop) || 0,
			},
		});
		return true;
	}

	function timelineMonthLabel(createdAt) {
		const date = new Date(createdAt || 0);
		if (!Number.isFinite(date.getTime())) return '';
		return `${date.getFullYear()} 年\n${date.getMonth() + 1} 月 ${date.getDate()} 日`;
	}

	function bindTopicTimeline(timeline, ctx) {
		const trackTopInset = 10;
		const trackBottomInset = 10;
		const track = timeline.querySelector('.ldp-topic-timeline-track');
		const dateEl = timeline.querySelector('.ldp-topic-timeline-date');
		const currentEl = timeline.querySelector('.ldp-topic-timeline-current');
		const totalEl = timeline.querySelector('.ldp-topic-timeline-total');
		const relativeEl = timeline.querySelector('.ldp-topic-timeline-relative');
		const previewEl = timeline.querySelector('.ldp-topic-timeline-preview');
		const cursorEl = timeline.querySelector('.ldp-topic-timeline-cursor');
		const lensFloorElements = new Map();
		const spareLensFloorElements = [];
		const timelineScope = createLifecycleScope();
		let frame = 0;
		let previewFrame = 0;
		let jumpAnimationFrame = 0;
		let jumpAnimationTimer = 0;
		let previewClientY = 0;
		let previewVisible = false;
		let previewTarget = 0;
		let dragging = false;
		let activePointerId = 0;
		let dragTarget = 1;
		let stablePostNumber = 0;
		let stableCreatedAt = '';
		let lensTargetPostNumber = 0;
		let jumpSequence = 0;
		let timelineTrackRect = null;

		const totalPostCount = () => Math.max(1, ctx.totalPosts || ctx.streamItems.length || 1);
		const onlyOpTimelineItems = () => ctx.onlyOp ? streamLayoutItems(ctx) : null;
		const progressForPost = (postNumber) => {
			const items = onlyOpTimelineItems();
			if (items?.length) {
				const item = ctx.streamItemMap.get(+postNumber);
				const index = item && streamItemMatchesFilter(ctx, item) && Number.isInteger(item.layoutIndex)
					? item.layoutIndex
					: -1;
				if (index >= 0) return items.length > 1 ? index / (items.length - 1) : 0;
			}
			const total = totalPostCount();
			const current = Math.max(1, Math.min(total, +postNumber || 1));
			return total > 1 ? (current - 1) / (total - 1) : 0;
		};
		const targetFromRatio = (ratio) => {
			const items = onlyOpTimelineItems();
			if (items?.length) {
				const index = Math.round(ratio * (items.length - 1));
				return +items[Math.max(0, Math.min(items.length - 1, index))].postNumber;
			}
			const total = totalPostCount();
			return Math.max(1, Math.min(total, Math.round(1 + ratio * (total - 1))));
		};
		const targetFromKey = (current, delta) => {
			const items = onlyOpTimelineItems();
			if (items?.length) {
				const item = ctx.streamItemMap.get(+current);
				const index = item && streamItemMatchesFilter(ctx, item) && Number.isInteger(item.layoutIndex)
					? item.layoutIndex
					: 0;
				return +items[Math.max(0, Math.min(items.length - 1, index + delta))].postNumber;
			}
			const total = totalPostCount();
			return Math.max(1, Math.min(total, current + delta));
		};
		const refreshTimelineTrackRect = () => {
			timelineTrackRect = track.getBoundingClientRect();
			return timelineTrackRect;
		};
		const timelineTrackMetrics = () => {
			const rect = timelineTrackRect || refreshTimelineTrackRect();
			return {
				rect,
				usableHeight: Math.max(1, rect.height - trackTopInset - trackBottomInset),
			};
		};
		const timelineLensTopAtRatio = (ratio) => {
			const { usableHeight } = timelineTrackMetrics();
			return trackTopInset + Math.max(0, Math.min(1, ratio)) * usableHeight;
		};
		const timelineLensLayout = () => {
			const items = onlyOpTimelineItems();
			if (items?.length) {
				return { length: items.length, floorAt: (index) => +items[index].postNumber };
			}
			return { length: totalPostCount(), floorAt: (index) => index + 1 };
		};
		const timelineLensProfile = (offset) => {
			const distance = Math.abs(offset);
			const focus = Math.exp(-0.72 * distance * distance);
			return {
				opacity: Math.pow(Math.max(0, 1 - distance / 3), 1.25),
				scale: 0.46 + 0.54 * focus,
				shiftX: -Math.min(6, distance * 2),
			};
		};
		const renderTimelineLensAtRatio = (ratio, lensTop = timelineLensTopAtRatio(ratio)) => {
			if (!cursorEl) return;
			cursorEl.style.setProperty('--ldp-timeline-lens-top', `${lensTop.toFixed(2)}px`);
			const layout = timelineLensLayout();
			const continuousIndex = Math.max(0, Math.min(layout.length - 1, ratio * (layout.length - 1)));
			const nearestIndex = Math.round(continuousIndex);
			const selectedFloor = layout.floorAt(nearestIndex);
			const firstIndex = Math.max(0, nearestIndex - 3);
			const lastIndex = Math.min(layout.length - 1, nearestIndex + 3);
			const desiredFloors = new Map();
			for (let index = firstIndex; index <= lastIndex; index++) {
				desiredFloors.set(layout.floorAt(index), index - continuousIndex);
			}
			lensFloorElements.forEach((element, floor) => {
				if (desiredFloors.has(floor)) return;
				element.hidden = true;
				lensFloorElements.delete(floor);
				spareLensFloorElements.push(element);
			});
			desiredFloors.forEach((offset, floor) => {
				let element = lensFloorElements.get(floor);
				if (!element) {
					element = spareLensFloorElements.pop() || makeElement('span');
					element.textContent = `#${floor}`;
					lensFloorElements.set(floor, element);
					if (!element.isConnected) cursorEl.append(element);
					element.hidden = false;
				}
				const profile = timelineLensProfile(offset);
				element.style.setProperty('--ldp-lens-offset', String(offset));
				element.style.setProperty('--ldp-lens-scale', profile.scale.toFixed(4));
				element.style.setProperty('--ldp-lens-opacity', profile.opacity.toFixed(4));
				element.style.setProperty('--ldp-lens-shift-x', `${profile.shiftX.toFixed(2)}px`);
				element.classList.toggle('ldp-timeline-lens-selected', floor === selectedFloor);
				element.classList.toggle('ldp-timeline-lens-focus', Math.abs(offset) < 0.001);
			});
			lensTargetPostNumber = selectedFloor || lensTargetPostNumber;
		};

		const renderPosition = (postNumber, createdAt = '') => {
			const total = totalPostCount();
			const current = Math.max(1, Math.min(total, +postNumber || 1));
			const progress = progressForPost(current);
			timeline.style.setProperty('--ldp-timeline-progress', String(progress));
			currentEl.textContent = String(current);
			totalEl.textContent = String(total);
			dateEl.textContent = timelineMonthLabel(createdAt);
			dateEl.disabled = !dateEl.textContent;
			const latestReplyAt = ctx.latestReplyAt || (ctx.topicData?.last_posted_at) || '';
			relativeEl.textContent = latestReplyAt ? discourseRelativeTime(latestReplyAt) : '';
			relativeEl.disabled = !latestReplyAt;
			track.setAttribute('aria-valuemin', '1');
			track.setAttribute('aria-valuemax', String(total));
			track.setAttribute('aria-valuenow', String(current));
			track.setAttribute('aria-valuetext', `第 ${current} 楼,共 ${total} 楼`);
			if (ctx.queueViewportTracker) ctx.queueViewportTracker.sync(current);
			if (previewVisible) {
				track.classList.toggle('ldp-timeline-previewing', previewTarget > 0 && previewTarget !== current);
			}
			timeline.hidden = total <= 1;
		};

		const renderStablePosition = (postNumber, createdAt = '') => {
			stablePostNumber = +postNumber || stablePostNumber;
			stableCreatedAt = createdAt || stableCreatedAt;
			renderPosition(stablePostNumber, stableCreatedAt);
		};

		const renderBoundaryPosition = (postNumber) => {
			const node = ctx.streamNodeMap.get(postNumber);
			const createdAt = (node && node.dataset.createdAt) ||
				(postNumber === 1 ? ctx.topicData?.created_at : (ctx.latestReplyAt || ctx.topicData?.last_posted_at)) || '';
			renderStablePosition(postNumber, createdAt);
		};

		const updateNow = () => {
			frame = 0;
			if (!ctx.streamItems.length) {
				timeline.hidden = true;
				return;
			}
			const atScrollStart = ctx.scrollRoot.scrollTop <= 10;
			const lastLoadedPostNumber = +(ctx.streamItems[ctx.streamItems.length - 1]?.postNumber || 0);
			if (atScrollStart && ctx.streamItemMap.has(1)) {
				renderBoundaryPosition(1);
				return;
			}
			const layoutItems = streamLayoutItems(ctx);
			const atScrollEnd = ctx.scrollRoot.scrollTop + ctx.scrollRoot.clientHeight >= ctx.scrollRoot.scrollHeight - 16;
			const atLoadedEnd = atScrollEnd &&
				lastLoadedPostNumber >= Math.max(1, ctx.totalPosts || 1);
			if (atLoadedEnd) {
				const lastTimelineItem = ctx.onlyOp ? layoutItems[layoutItems.length - 1] : null;
				renderBoundaryPosition(lastTimelineItem ? lastTimelineItem.postNumber : totalPostCount());
				return;
			}
			const anchoredOwnerNode = ctx.streamViewportAnchor?.owner;
			const visibleAnchor = anchoredOwnerNode?.isConnected
				? null
				: findTopVisibleStreamPost(ctx);
			const visibleOwnerNode = anchoredOwnerNode?.isConnected
				? anchoredOwnerNode
				: visibleAnchor?.ownerNode;
			const visiblePostNumber = streamPostNumber(visibleOwnerNode);
			const visibleItem = visiblePostNumber && ctx.streamItemMap.get(visiblePostNumber);
			if (visiblePostNumber && (!ctx.onlyOp || streamItemMatchesFilter(ctx, visibleItem))) {
				renderStablePosition(visiblePostNumber, visibleOwnerNode.dataset.createdAt || '');
				return;
			}
			if (stablePostNumber) renderPosition(stablePostNumber, stableCreatedAt);
		};
		const update = () => {
			if (frame || dragging) return;
			frame = requestAnimationFrame(updateNow);
		};
		const sync = () => {
			if (dragging) return;
			if (frame) cancelAnimationFrame(frame);
			frame = 0;
			updateNow();
		};
		const releaseJump = () => {
			jumpSequence++;
			cancelReaderNavigation(ctx);
			update();
		};
		const refreshRelativeTimes = () => refreshPostRelativeTimes(ctx.commentsEl);
		let relativeTimer = 0;
		const setActive = (active) => {
			if (relativeTimer) clearInterval(relativeTimer);
			relativeTimer = 0;
			if (!active) return;
			relativeTimer = setInterval(() => {
				update();
				refreshRelativeTimes();
			}, 30000);
		};
		setActive(document.visibilityState === 'visible');

		const timelinePointerPosition = (clientY) => {
			const { rect, usableHeight } = timelineTrackMetrics();
			const top = Math.max(trackTopInset, Math.min(rect.height - trackBottomInset, clientY - rect.top));
			return { top, ratio: Math.max(0, Math.min(1, (top - trackTopInset) / usableHeight)) };
		};
		const targetFromPointer = (clientY) => targetFromRatio(timelinePointerPosition(clientY).ratio);
		const showPointerPreview = (clientY) => {
			previewVisible = true;
			previewClientY = clientY;
			if (previewFrame) return;
			previewFrame = requestAnimationFrame(() => {
				previewFrame = 0;
				if (!previewVisible) return;
				const pointerPosition = timelinePointerPosition(previewClientY);
				const target = targetFromRatio(pointerPosition.ratio);
				const progress = pointerPosition.ratio;
				const current = +(track.getAttribute('aria-valuenow') || 0);
				const targetChanged = previewTarget !== target;
				previewTarget = target;
				renderTimelineLensAtRatio(progress, pointerPosition.top);
				if (dragging) previewPointerTarget(previewClientY, target);
				if (targetChanged) previewEl.textContent = `#${target}`;
				const previewing = target !== current;
				if (track.classList.contains('ldp-timeline-previewing') !== previewing) {
					track.classList.toggle('ldp-timeline-previewing', previewing);
				}
				if (!track.classList.contains('ldp-timeline-hovering')) track.classList.add('ldp-timeline-hovering');
			});
		};
		const hidePointerPreview = () => {
			previewVisible = false;
			previewTarget = 0;
			if (previewFrame) cancelAnimationFrame(previewFrame);
			previewFrame = 0;
			timelineTrackRect = null;
			track.classList.remove('ldp-timeline-previewing', 'ldp-timeline-hovering');
		};
		const finishTimelineJumpAnimation = () => {
			if (jumpAnimationFrame) cancelAnimationFrame(jumpAnimationFrame);
			if (jumpAnimationTimer) clearTimeout(jumpAnimationTimer);
			jumpAnimationFrame = 0;
			jumpAnimationTimer = 0;
			track.classList.remove('ldp-timeline-jumping');
			if (previewVisible) showPointerPreview(previewClientY);
		};
		const beginTimelineJumpAnimation = (targetPostNumber) => {
			const reducedMotion = prefersReducedMotion();
			if (reducedMotion || !cursorEl) return;
			if (jumpAnimationFrame) cancelAnimationFrame(jumpAnimationFrame);
			if (jumpAnimationTimer) clearTimeout(jumpAnimationTimer);
			jumpAnimationFrame = 0;
			jumpAnimationTimer = 0;
			const currentPostNumber = stablePostNumber || +(track.getAttribute('aria-valuenow') || 1);
			const alreadyFocusedOnTarget = previewVisible && lensTargetPostNumber === targetPostNumber;
			const currentProgress = progressForPost(currentPostNumber);
			const targetProgress = progressForPost(targetPostNumber);
			if (!alreadyFocusedOnTarget) {
				renderTimelineLensAtRatio(currentProgress);
			}
			track.classList.remove('ldp-timeline-jumping');
			void cursorEl.offsetWidth;
			track.classList.add('ldp-timeline-jumping');
			const shouldTravel = !alreadyFocusedOnTarget && Math.abs(targetProgress - currentProgress) > 0.0001;
			if (!shouldTravel) {
				jumpAnimationFrame = requestAnimationFrame(() => {
					jumpAnimationFrame = 0;
					renderTimelineLensAtRatio(targetProgress);
					jumpAnimationTimer = setTimeout(finishTimelineJumpAnimation, 460);
				});
				return;
			}
			const duration = 360;
			let startedAt = 0;
			const animateJump = (timestamp) => {
				if (!startedAt) startedAt = timestamp;
				const elapsedRatio = Math.min(1, (timestamp - startedAt) / duration);
				const easedRatio = 1 - Math.pow(1 - elapsedRatio, 3);
				const progress = currentProgress + (targetProgress - currentProgress) * easedRatio;
				renderTimelineLensAtRatio(progress);
				if (elapsedRatio < 1) {
					jumpAnimationFrame = requestAnimationFrame(animateJump);
					return;
				}
				jumpAnimationFrame = 0;
				jumpAnimationTimer = setTimeout(finishTimelineJumpAnimation, 300);
			};
			jumpAnimationFrame = requestAnimationFrame(animateJump);
		};
		const previewPointerTarget = (clientY, targetPostNumber = targetFromPointer(clientY)) => {
			if (dragTarget === targetPostNumber) return;
			dragTarget = targetPostNumber;
			const node = ctx.streamNodeMap.get(dragTarget);
			renderPosition(dragTarget, node ? node.dataset.createdAt : '');
		};
		const jumpTo = async (targetPostNumber, options = {}) => {
			targetPostNumber = +targetPostNumber || 0;
			if (!targetPostNumber) return false;
			beginTimelineJumpAnimation(targetPostNumber);
			const currentJumpSequence = ++jumpSequence;
			const navigationSequence = beginReaderNavigation(ctx);
			const jumpStillCurrent = () => currentJumpSequence === jumpSequence &&
				readerNavigationIsCurrent(ctx, navigationSequence) &&
				(!isFunction(options.acceptsWork) || options.acceptsWork());
			if (!jumpStillCurrent()) return false;
			if (ctx.cancelInitialTargetPosition) ctx.cancelInitialTargetPosition();
			const loaded = !ctx.loadPost || await ctx.loadPost(targetPostNumber, {
				ensureAround: targetPostNumber > 1,
				scope: targetPostNumber > 1 ? 'around' : 'single',
			});
			if (!jumpStillCurrent()) return false;
			if (!loaded) {
				update();
				return false;
			}
			if (!jumpStillCurrent()) return false;
			let visualTarget = null;
			const scrollOptions = {
				...PINNED_STREAM_SCROLL_OPTIONS,
				resolveVisualTarget: isFunction(options.prepareTarget)
					? () => {
						visualTarget = options.prepareTarget(targetPostNumber);
						return visualTarget;
					}
					: null,
				alignCenter: options.alignCenter === true,
				alignNearest: options.alignNearest === true,
				alignTop: options.alignBottom !== true,
				alignBottom: options.alignBottom === true,
				highlight: options.highlight !== false,
			};
			if (!scrollToPost(targetPostNumber, ctx, scrollOptions)) {
				update();
				return false;
			}
			await waitForReaderJumpLayout();
			if (!jumpStillCurrent()) return false;
			const settledTarget = visualTarget?.isConnected
				? visualTarget
				: getStreamPostNode(ctx, targetPostNumber);
			if (!readerTargetNodeIsVisible(settledTarget, ctx)) {
				clearStreamProgrammaticTarget(ctx);
				update();
				return false;
			}
			sync();
			return true;
		};
		const jumpToBoundary = async (targetPostNumber, options = {}) => {
			const sequenceBeforeAttempt = jumpSequence;
			let jumped = await jumpTo(targetPostNumber, options);
			if (jumped) return true;
			const attemptWasNotCancelled = jumpSequence === sequenceBeforeAttempt + 1 &&
				(!isFunction(options.acceptsWork) || options.acceptsWork());
			if (!attemptWasNotCancelled) return false;
			ctx.streamWindowResetPending = true;
			syncStreamWindow(ctx);
			await waitForReaderJumpLayout();
			if (jumpSequence !== sequenceBeforeAttempt + 1) return false;
			jumped = await jumpTo(targetPostNumber, options);
			return jumped;
		};
		const completeBoundaryJump = async (targetPostNumber, atEnd, options = {}) => {
			if (!await jumpToBoundary(targetPostNumber, options)) return false;
			const completedJumpSequence = jumpSequence;
			if (!atEnd) ctx.streamWindowResetPending = true;
			const scrollToBoundary = () => {
				const previousScrollTop = ctx.scrollRoot.scrollTop;
				ctx.scrollRoot.scrollTop = atEnd ? ctx.scrollRoot.scrollHeight : 0;
				markStreamInternalScroll(ctx, previousScrollTop);
			};
			scrollToBoundary();
			syncStreamWindow(ctx);
			await waitForReaderJumpLayout();
			if (completedJumpSequence !== jumpSequence) return false;
			scrollToBoundary();
			renderBoundaryPosition(targetPostNumber);
			return true;
		};
		let startJumpPromise = null;
		const jumpToStart = (options = {}) => {
			if (startJumpPromise) return startJumpPromise;
			startJumpPromise = completeBoundaryJump(1, false, options)
				.finally(() => { startJumpPromise = null; });
			return startJumpPromise;
		};
		let endJumpPromise = null;
		const jumpToEnd = () => {
			if (endJumpPromise) return endJumpPromise;
			endJumpPromise = (async () => {
				const items = onlyOpTimelineItems();
				let target = items?.length ? items[items.length - 1].postNumber : totalPostCount();
				if (!items && ctx.loader && isFunction(ctx.loader.loadLastPost)) {
					try {
						const lastPosts = await ctx.loader.loadLastPost({ priority: POST_REQUEST_PRIORITY.target });
						const lastPost = lastPosts.reduce((latest, post) =>
							+(post?.post_number || 0) > +(latest?.post_number || 0) ? post : latest, null);
						if (lastPost) {
							attachPostBatch(lastPosts.filter((post) => !ctx.streamNodeMap.has(+post.post_number)), ctx);
							target = +lastPost.post_number;
						}
					} catch (error) {}
				}
				return completeBoundaryJump(target, true, { alignBottom: true });
			})().finally(() => { endJumpPromise = null; });
			return endJumpPromise;
		};
		const onRelativeClick = async () => {
			if (!await jumpToEnd()) showSelectionToast('最后一楼加载失败,请重试');
		};
		const onTrackPointerLeave = () => {
			if (!dragging) hidePointerPreview();
		};
		const onTrackPointerDown = (e) => {
			e.preventDefault();
			dragging = true;
			activePointerId = e.pointerId;
			refreshTimelineTrackRect();
			dragTarget = 0;
			track.setPointerCapture(e.pointerId);
			showPointerPreview(e.clientY);
		};
		const onTrackPointerUp = (e) => {
			if (!dragging) return;
			dragging = false;
			if (track.hasPointerCapture(e.pointerId)) track.releasePointerCapture(e.pointerId);
			activePointerId = 0;
			dragTarget = targetFromPointer(e.clientY);
			if (!track.matches(':hover')) hidePointerPreview();
			jumpTo(dragTarget);
		};
		const onTrackPointerCancel = () => {
			dragging = false;
			activePointerId = 0;
			hidePointerPreview();
			update();
		};
		const onTrackKeyDown = (e) => {
			const current = +(track.getAttribute('aria-valuenow') || 1);
			let target;
			if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') target = targetFromKey(current, -1);
			else if (e.key === 'ArrowDown' || e.key === 'ArrowRight') target = targetFromKey(current, 1);
			else if (e.key === 'PageUp') target = targetFromKey(current, -streamPerformance(ctx).pageSize);
			else if (e.key === 'PageDown') target = targetFromKey(current, streamPerformance(ctx).pageSize);
			else if (e.key === 'Home') target = targetFromKey(current, -Infinity);
			else if (e.key === 'End') target = targetFromKey(current, Infinity);
			else return;
			e.preventDefault();
			jumpTo(target);
		};
		timelineScope.listen(dateEl, 'click',
			() => timeline.querySelector('.ldp-topic-timeline-top')?.click());
		timelineScope.listen(relativeEl, 'click', onRelativeClick);
		timelineScope.listen(track, 'pointerenter',
			(event) => {
				refreshTimelineTrackRect();
				showPointerPreview(event.clientY);
			});
		timelineScope.listen(track, 'pointerleave', onTrackPointerLeave);
		timelineScope.listen(track, 'pointerdown', onTrackPointerDown);
		timelineScope.listen(track, 'pointermove', (event) => {
			if (previewVisible) showPointerPreview(event.clientY);
		});
		timelineScope.listen(track, 'pointerup', onTrackPointerUp);
		timelineScope.listen(track, 'pointercancel', onTrackPointerCancel);
		timelineScope.listen(track, 'keydown', onTrackKeyDown);

		return {
			update,
			sync,
			jumpTo,
			jumpToStart,
			releaseJump,
			refreshRelativeTimes,
			setActive,
			destroy() {
				if (frame) cancelAnimationFrame(frame);
				if (previewFrame) cancelAnimationFrame(previewFrame);
				finishTimelineJumpAnimation();
				setActive(false);
				hidePointerPreview();
				if (cursorEl) cursorEl.replaceChildren();
				lensFloorElements.clear();
				spareLensFloorElements.length = 0;
				lensTargetPostNumber = 0;
				if (activePointerId && track.hasPointerCapture && track.hasPointerCapture(activePointerId)) {
					try { track.releasePointerCapture(activePointerId); } catch (e) {}
				}
				dragging = false;
				activePointerId = 0;
				timelineScope.destroy();
			},
		};
	}

	async function openModal(topicId, options = {}) {
		const openRequestId = ++READER_OPEN_REQUEST_SEQUENCE;
		const previousQueueTopicId = Number(CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId) || 0;
		const performance = Object.freeze(currentPerformanceConfig());
		let targetPostNumber = +options.targetPostNumber || 0;
		let unavailableTargetPostNumber = 0;
		const initialQuoteHighlight = options.quoteHighlight && typeof options.quoteHighlight.text === 'string'
			? { text: options.quoteHighlight.text, source: options.quoteHighlight.source || null }
			: null;
		const initialQuoteSourceTarget = options.quoteSourceTarget && typeof options.quoteSourceTarget === 'object'
			? options.quoteSourceTarget
			: null;
		let restoreViewport = options.restoreViewport && typeof options.restoreViewport === 'object'
			? options.restoreViewport
			: null;
		const restoreOnlyOp = options.restoreOnlyOp === true;
		const settleTargetJump = options.settleTargetJump !== false;
		const directTopicRoute = !!extractTopicRouteFromUrl(location.href);
		const requestedReaderMode = directTopicRoute ? currentTopicReaderMode() : currentListReaderMode();
		const hostTopicPointerAnchor = options.hostTopicPointerAnchor || null;
		const cachedTopicPreview = getCachedTopicData(topicId);
		let sourceTopicNavMetadata = mergeTopicNavMetadata(
			options.topicNavMetadata || readTopicNavMetadata(null, topicId),
			cachedTopicNavMetadata(cachedTopicPreview),
		);
		const queueEntry = ensureReaderQueueEntry(topicId, {
			...(options.readerQueueMetadata || {}),
			navMetadata: sourceTopicNavMetadata,
		}, { prefetch: false, silent: true });
		const inheritedHistoryPositions = normalizeReaderHistoryPositions(
			CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderHistoryNavigation?.positions
		);
		const historyNavigation = createReaderHistoryNavigation(topicId, options.historyNavigation);
		historyNavigation.positions = { ...inheritedHistoryPositions, ...historyNavigation.positions };
		let reusableOverlay = null;
		if (CURRENT_OVERLAY) {
			const previousOverlay = CURRENT_OVERLAY;
			const previousTopicId = Number(previousOverlay.dataset.topicId);
			const previousViewport = normalizeReaderHistoryViewport(
				isFunction(previousOverlay._ldpCaptureHistoryViewport)
					? previousOverlay._ldpCaptureHistoryViewport()
					: null
			);
			if (previousTopicId > 0 && previousViewport) {
				historyNavigation.positions[previousTopicId] = previousViewport;
				saveReaderQueueViewport(previousTopicId, previousViewport);
			}
			// 阅读器壳层与展示形态都不属于单篇帖子;同一路由上下文切换主题时统一复用。
			const canReuseReaderShell =
				previousOverlay.dataset.readerListModeAllowed === String(!directTopicRoute) &&
				previousOverlay.dataset.readerShellDestroyed !== '1' &&
				previousOverlay._ldpReaderSession && previousOverlay._ldpReaderSession.state !== 'failed' &&
				isFunction(previousOverlay._ldpPrepareTopicSwitch);
			if (canReuseReaderShell) {
				await previousOverlay._ldpPrepareTopicSwitch();
				if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE || CURRENT_OVERLAY !== previousOverlay || !previousOverlay.isConnected) return;
				reusableOverlay = previousOverlay;
			} else if (isFunction(previousOverlay._ldpDispose)) await previousOverlay._ldpDispose();
			else {
				if (isFunction(previousOverlay._ldpStopLatestReplyUpdates)) {
					previousOverlay._ldpStopLatestReplyUpdates();
				}
				previousOverlay.remove();
				if (CURRENT_OVERLAY === previousOverlay) CURRENT_OVERLAY = null;
				setPageScrollLocked(false);
			}
		}
		if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE) return;
		if (PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active') &&
				!await closeChromeSidePanelForReader()) return;
		if (openRequestId !== READER_OPEN_REQUEST_SEQUENCE) return;
		if (queueEntry) activateReaderQueueEntry(queueEntry);
		else {
			READER_QUEUE_ACTIVE_TOPIC_ID = '';
			syncReaderQueueSurfaces();
		}
		if (directTopicRoute) PAGE_ROOT.classList.add('ldp-route-takeover');
		const readerSessionId = ++READER_SESSION_SEQUENCE;
		const reusingShell = !!reusableOverlay;
		const overlay = reusableOverlay || makeElement('div');
		const readerElement = (selector) => overlay.querySelector(selector);
		const readerElements = (selector) => overlay.querySelectorAll(selector);
		const readerElementGroup = (...selectors) => selectors.map(readerElement);
		const readerElementsGroup = (...selectors) => selectors.map(readerElements);
		let readerOpenPhase = 'create-overlay';
		const setReaderOpenPhase = (phase) => {
			readerOpenPhase = phase;
		};
		const readerSession = {
			id: readerSessionId,
			state: 'opening',
			isCurrent(...allowedStates) {
				if (ACTIVE_READER_SESSION_ID !== readerSessionId || CURRENT_OVERLAY !== overlay || !overlay.isConnected) {
					return false;
				}
				return !allowedStates.length || allowedStates.includes(this.state);
			},
		};
		ACTIVE_READER_SESSION_ID = readerSessionId;
		if (!reusingShell) {
			overlay.className = 'ldp-overlay';
			overlay.dataset.readerWorkspaceMode = 'floating';
			overlay.dataset.readerListModeAllowed = String(!directTopicRoute);
			overlay.innerHTML = `
			<div class="ldp-reader-window-capsule">
				<button class="ldp-reader-drag-handle" type="button" aria-label="拖动阅读器">${icon('minus')}</button>
				<button class="ldp-reader-pin-button" type="button" aria-label="置顶浮窗" aria-pressed="false">${icon('pin')}</button>
				<button class="ldp-reader-lock-button" type="button" aria-label="锁定浮窗" aria-pressed="false">${icon('unlock')}</button>
				<div class="ldp-reader-placement-control" hidden>
					<button class="ldp-reader-placement-trigger" type="button" aria-label="显示方式" aria-haspopup="true">${icon('layoutGrid')}</button>
					<div class="ldp-reader-placement-strip" role="group" aria-label="切换阅读器显示方式" hidden>
						<button class="ldp-reader-placement-option" type="button" data-reader-placement="embed-left" data-reader-placement-label="嵌入左侧" aria-label="嵌入左侧">${icon('panelLeft')}</button>
						<button class="ldp-reader-placement-option" type="button" data-reader-placement="embed-right" data-reader-placement-label="嵌入右侧" aria-label="嵌入右侧">${icon('panelRight')}</button>
						<button class="ldp-reader-placement-option" type="button" data-reader-placement="floating" data-reader-placement-label="浮窗阅读器" aria-label="浮窗阅读器">${icon('floatingWindow')}</button>
						<button class="ldp-reader-placement-option" type="button" data-reader-placement="fullpage" data-reader-placement-label="全屏阅读器" aria-label="全屏阅读器">${icon('maximize2')}</button>
					</div>
				</div>
			</div>
			<div class="ldp-reader-host-scrollbar ldp-reader-host-scrollbar-inactive" role="scrollbar" aria-label="Linux DO 主题列表滚动条" aria-orientation="vertical" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0" aria-disabled="true" tabindex="0">
				<span class="ldp-reader-host-scrollbar-thumb" aria-hidden="true"></span>
			</div>
			<button class="ldp-reader-host-top" type="button" aria-label="回到宿主页面顶部" title="回到宿主页面顶部" hidden>${icon('arrowUp')}<span class="ldp-reader-host-top-countdown" aria-hidden="true">3</span></button>
			<div class="ldp-modal">
				<span class="ldp-reader-embed-resize" aria-hidden="true"></span>
				${['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'].map((direction) =>
					`<span class="ldp-reader-resize-handle" data-reader-resize="${direction}" aria-hidden="true"></span>`).join('')}
				<div class="ldp-header">
					<a class="ldp-home-logo" href="${BASE}/" aria-label="回到 LINUX DO 首页">
						<img class="ldp-logo" src="${LINUXDO_LOGO_URL}" alt="" loading="lazy" decoding="async">
					</a>
					<div class="ldp-title-wrap">
						<h2 class="ldp-title">
							<span class="ldp-title-jump" role="button" tabindex="0" aria-label="跳到 #1">正在载入主题…</span>
						</h2>
						<div class="ldp-title-subline">
							<div class="ldp-meta-row">
								<div class="ldp-meta">
									<span class="ldp-meta-stats">正在读取主题信息…</span>
									<span class="ldp-meta-owner" hidden><span class="ldp-meta-owner-copy">楼主&nbsp;<span class="ldp-meta-owner-value">@?</span></span><button class="ldp-only-op-toggle" type="button" aria-label="只看楼主" aria-pressed="false" disabled>${icon('userRound')}</button><span class="ldp-only-op-progress" role="status" aria-live="polite" hidden><span class="ldp-only-op-progress-track"><i class="ldp-only-op-progress-fill"></i></span><span class="ldp-only-op-progress-value"></span></span></span>
								</div>
							</div>
							<div class="ldp-title-topic-row">
								<div class="ldp-topic-tags" hidden></div>
								<div class="ldp-topic-vote-slot" hidden></div>
							</div>
						</div>
					</div>
					<div class="ldp-head-btns">
						<button class="ldp-layout-toggle" type="button"></button>
						<button class="ldp-notifications-toggle" type="button" aria-label="消息" aria-expanded="false">${icon('headerBell')}<span>消息</span><b class="ldp-notification-unread-badge" hidden></b></button>
						<div class="ldp-notifications-popover" hidden>
							<div class="ldp-notification-tabs" role="tablist" aria-label="消息分类">
								${Object.entries(NOTIFICATION_GROUPS).map(([key, group], index) =>
									`<button class="ldp-notification-tab${index === 0 ? ' active' : ''}" type="button" role="tab" data-notification-group="${key}" aria-selected="${index === 0 ? 'true' : 'false'}">${group.label}</button>`).join('')}
							</div>
							<div class="ldp-notification-toolbar">
								<span class="ldp-notification-unread-status">没有未读消息</span>
								<button class="ldp-notification-mark-all" type="button" disabled>${icon('check')}<span>全部已读</span></button>
							</div>
							${popoverSearchMarkup('notification', '搜索用户名或用户 ID', '搜索消息', '清空消息搜索')}
							<div class="ldp-notification-list"><div class="ldp-notification-empty">正在加载消息…</div></div>
							${popoverPagerMarkup('notification')}
						</div>
						<button class="ldp-history-toggle" type="button" aria-label="浏览历史" aria-expanded="false">${icon('headerHistory')}<span>历史</span></button>
						<div class="ldp-history-popover" hidden>
							<div class="ldp-collection-title">
								<span>浏览历史</span>
								<div class="ldp-collection-title-actions ldp-history-default-actions">
									<button class="ldp-collection-action ldp-history-sort-toggle" type="button"></button>
									<button class="ldp-collection-action ldp-history-multi" type="button" aria-label="多选浏览历史">${icon('listChecks')}</button>
									<button class="ldp-collection-action danger ldp-history-clear" type="button" aria-label="清空全部浏览历史" disabled>${icon('trash')}</button>
								</div>
								${collectionBulkActionsMarkup('history', '历史', '浏览历史', '删除所选浏览历史')}
							</div>
							${popoverSearchMarkup('history', '搜索标题或拼音', '搜索浏览历史', '清空历史搜索')}
							<div class="ldp-history-list ldp-notification-list"><div class="ldp-notification-empty">暂无浏览历史</div></div>
							${popoverPagerMarkup('history')}
						</div>
						<button class="ldp-bookmarks-toggle" type="button" aria-label="收藏与回应" aria-expanded="false">${icon('headerBookmark')}<span>收藏与回应</span></button>
						<div class="ldp-bookmarks-popover" hidden>
							<div class="ldp-collection-title">
								<span>收藏与回应</span>
								<div class="ldp-collection-title-actions ldp-bookmarks-default-actions">
									<button class="ldp-collection-action ldp-bookmarks-multi" type="button" aria-label="多选收藏">${icon('listChecks')}</button>
								</div>
								${collectionBulkActionsMarkup('bookmarks', '收藏', '收藏', '取消所选收藏')}
							</div>
							<div class="ldp-bookmark-tabs" role="tablist" aria-label="收藏与回应类型">
								${PREFS.bookmarkTabOrder.map((type, index) => `<button class="ldp-bookmark-tab${index === 0 ? ' active' : ''}" type="button" role="tab" data-bookmark-type="${type}" data-ldp-tooltip-label="拖动排序 · 首项默认" aria-selected="${String(index === 0)}">${BOOKMARK_TAB_LABELS[type]}</button>`).join('')}
							</div>
							${popoverSearchMarkup('bookmarks', '搜索收藏标题或内容', '搜索收藏', '清空收藏搜索')}
							<div class="ldp-reaction-filters" role="group" aria-label="按回应表情筛选" hidden></div>
							<div class="ldp-bookmarks-list ldp-notification-list"><div class="ldp-notification-empty">正在加载收藏…</div></div>
							${popoverPagerMarkup('bookmarks')}
						</div>
						<button class="ldp-settings-toggle" type="button" aria-label="设置" aria-expanded="false">${icon('headerSettings')}<span>设置</span></button>
						<div class="ldp-settings-popover" hidden>
							<div class="ldp-settings-tabs">
								<div class="ldp-settings-brand" aria-label="awesome linuxdo reader">
									<img class="ldp-settings-brand-logo" src="${LINUXDO_LOGO_URL}" alt="" loading="lazy" decoding="async">
									<span class="ldp-settings-brand-name"><span>AWESOME</span><span>LINUXDO</span><span>READER</span></span>
								</div>
								<div class="ldp-settings-nav-shell">
									<button class="ldp-settings-nav-scroll ldp-settings-nav-scroll-up" type="button" aria-label="显示上方更多设置" hidden>${icon('chevronRight')}</button>
									<div class="ldp-settings-nav">
										${settingsNavigationMarkup()}
									</div>
									<button class="ldp-settings-nav-scroll ldp-settings-nav-scroll-down" type="button" aria-label="显示下方更多设置" hidden>${icon('chevronRight')}</button>
								</div>
								<div class="ldp-settings-footer">
									<div class="ldp-settings-theme" role="group" aria-label="明暗模式">
										${READER_THEME_MODES.map((mode) => `<button class="ldp-settings-theme-button" type="button" data-reader-theme-mode="${mode}" aria-pressed="false">${icon(mode === 'light' ? 'sun' : mode === 'dark' ? 'moon' : 'monitor')}</button>`).join('')}
									</div>
								</div>
							</div>
							<div class="ldp-settings-panel">
								<div class="ldp-settings-section" data-settings-panel="user">
									<div class="ldp-user-info-content" aria-live="polite">
										<div class="ldp-user-info-loading">正在加载用户信息…</div>
									</div>
								</div>
								<div class="ldp-settings-section" data-settings-panel="image" hidden>
									<div class="ldp-settings-category-groups">
										${settingsCategoryMarkup(
											'image-lightbox', '灯箱行为', '控制灯箱打开后的评论、描述和原图加载方式。',
											settingSwitchesMarkup('image-lightbox'), 'ldp-settings-fields ldp-settings-category-list'
										)}
										${settingsCategoryMarkup(
											'image-display', '帖子图片显示', '图片比例统一用于所有显示形态,并随阅读器容器宽度参与响应式排版。', `
												<div class="ldp-settings-fields">
													<div class="ldp-setting-row">
														<span class="ldp-setting-label">图片比例</span>
														<span class="ldp-image-scale-control">
															<input class="ldp-image-scale-range" type="range" min="${IMAGE_SCALE_MIN}" max="${IMAGE_SCALE_MAX}" step="1" aria-label="图片比例">
															<span class="ldp-image-scale-value"></span>
															<button class="ldp-color-reset ldp-image-scale-reset" type="button">${icon('rotateCcw')}<span>默认</span></button>
														</span>
													</div>
												</div>
											`)}
									</div>
								</div>
								<div class="ldp-settings-section" data-settings-panel="font" hidden>
									<div class="ldp-settings-category-groups">
										${settingsCategoryMarkup(
											'font-rendering', '字体渲染', '控制增强阅读器及 LINUX DO 宿主页面是否使用内置字体渲染。',
											settingSwitchesMarkup('font-rendering'),
											'ldp-settings-fields ldp-settings-category-list ldp-font-rendering-settings'
										)}
										${settingsCategoryMarkup(
											'host-font', '宿主主题列表文字', '标题、Label Card 和右下角信息共享字体、字重和颜色;修改后立即保存。',
											fontFieldRowsMarkup('host', HOST_FONT_CONFIG),
											'ldp-settings-fields ldp-settings-category-list ldp-host-font-settings'
										)}
										${settingsCategoryMarkup(
											'host-embed-size', '宿主嵌入态字号', '分别调整阅读器嵌入 LINUX DO 页面时,原帖、主题列表和通知区域的显示尺寸。', `
											${Object.entries(HOST_EMBED_SIZE_SETTINGS).map(([key, config]) => `
												<div class="ldp-setting-row">
													<span class="ldp-setting-label">${config.label}</span>
													<span class="ldp-host-embed-size-control">
														<input type="range" data-host-embed-size="${key}" min="${HOST_EMBED_SIZE_MIN}" max="${HOST_EMBED_SIZE_MAX}" step="1" aria-label="宿主嵌入态${config.label}大小">
														<output class="ldp-host-embed-size-value" data-host-embed-size-value="${key}"></output>
														<button class="ldp-color-reset ldp-host-embed-size-reset" type="button" data-host-embed-size-reset="${key}">${icon('rotateCcw')}<span>默认</span></button>
													</span>
												</div>`).join('')}
											`, 'ldp-settings-fields ldp-settings-category-list ldp-host-embed-size-settings')}
										${settingsCategoryMarkup(
											'reader-font', '阅读器字体', '一套界面、正文和回复字体用于所有显示形态。', `
												<div class="ldp-font-scope-tabs" role="tablist" aria-label="字体作用范围">
													${FONT_SCOPE_KEYS.map((scope) => `<button class="ldp-font-scope-tab${scope === 'interface' ? ' active' : ''}" id="ldp-font-scope-tab-${scope}" type="button" role="tab" data-font-scope-tab="${scope}" aria-controls="ldp-font-scope-panel-${scope}" aria-selected="${scope === 'interface'}" tabindex="${scope === 'interface' ? '0' : '-1'}">${FONT_SCOPE_CONFIG[scope].label}</button>`).join('')}
												</div>
												<div class="ldp-settings-fields ldp-font-settings-fields">
													${FONT_SCOPE_KEYS.map(fontScopeSettingsMarkup).join('')}
												</div>
											`)}
									</div>
									<div class="ldp-font-family-menu" id="ldp-font-family-menu" hidden>
										<label class="ldp-font-family-search ldp-picker-search">
											${icon('search')}
											<input type="search" autocomplete="off" spellcheck="false" placeholder="搜索字体名称" aria-label="搜索字体" aria-controls="ldp-font-family-options">
										</label>
										<p class="ldp-font-family-source-status" role="status" aria-live="polite"></p>
										<div class="ldp-font-family-options ldp-picker-options" id="ldp-font-family-options" role="listbox" aria-label="可用字体"></div>
										<div class="ldp-font-family-empty" hidden>没有匹配的字体</div>
										<div class="ldp-font-family-manual"><span>列表里没有?</span><button type="button">手动输入字体名</button></div>
									</div>
									${settingsApplyFooterMarkup('ldp-font-status', 'ldp-font-apply', '全部恢复默认', 'ldp-font-reset')}
								</div>
								<div class="ldp-settings-section" data-settings-panel="layout" hidden>
									<div class="ldp-settings-category-groups">
										<div class="ldp-settings-category-group">
											<div class="ldp-settings-category-content">
												<div class="ldp-settings-fields ldp-layout-fields">
													${LAYOUT_REGION_KEYS.map((name) => `<div class="ldp-setting-row">
														<span class="ldp-setting-label">${LAYOUT_REGION_LABELS[name]}</span>
														<span class="ldp-layout-ratio-control">
															<input type="range" data-layout-region="${name}" min="0" max="100" step="0.1" aria-valuemin="${LAYOUT_MIN_RATIOS[name]}" aria-label="${LAYOUT_REGION_LABELS[name]}比例">
															<span class="ldp-layout-ratio-value" data-layout-value="${name}"></span>
														</span>
													</div>`).join('')}
												</div>
											</div>
										</div>
									</div>
									${settingsApplyFooterMarkup('', '', '恢复默认')}
								</div>
								<div class="ldp-settings-section" data-settings-panel="window" hidden>
									<div class="ldp-settings-category-groups">
										${settingsCategoryMarkup(
											'reader-window-geometry', '尺寸与位置', '设置浮窗宽高和左上角坐标,拖动或缩放浮窗后会同步更新。', `
												<div class="ldp-reader-window-fields">${readerWindowGeometryMarkup()}</div>
											`)}
										${settingsCategoryMarkup(
											'reader-window-behavior', '窗口行为', '控制浮窗离开焦点后的显示状态,以及是否允许拖动和缩放。', `
												<label class="ldp-reader-window-option"><input class="ldp-reader-window-pin-input" type="checkbox"><span>置顶浮窗(点击页面其他位置时保持显示)</span></label>
												<label class="ldp-reader-window-option"><input class="ldp-reader-window-lock-input" type="checkbox"><span>锁定浮窗(锁定后可点小锁或在这里解锁)</span></label>
											`, 'ldp-settings-category-content ldp-reader-window-options')}
									</div>
									<div class="ldp-reader-window-footer">
										<span class="ldp-reader-window-status" role="status"></span>
										<button class="ldp-reader-window-reset" type="button">${icon('rotateCcw')}<span>恢复浮窗默认</span></button>
										</div>
								</div>
								<div class="ldp-settings-section" data-settings-panel="appearance" hidden>
									<p class="ldp-appearance-theme-note" role="status"></p>
									<div class="ldp-color-groups">${APPEARANCE_SETTING_GROUPS.map(appearanceSettingGroupMarkup).join('')}</div>
									${settingsApplyFooterMarkup('ldp-appearance-status', 'ldp-appearance-apply')}
								</div>
								<div class="ldp-settings-section" data-settings-panel="flash" hidden>
									<div class="ldp-settings-category-groups">
										<div class="ldp-settings-category-group">
											<div class="ldp-settings-category-content">
									<div class="ldp-settings-fields ldp-flash-fields">${jumpHighlightSettingsMarkup()}</div>
											</div>
										</div>
									</div>
									${settingsApplyFooterMarkup('ldp-flash-status', 'ldp-flash-apply')}
									<div class="ldp-settings-category-groups">
										${settingsCategoryMarkup(
											'loading-motion', '等待区域动画', '选择“每次随机”时,每次打开或切换帖子都会从 10 个系统动效中重新抽取。', `
												<label class="ldp-setting-row ldp-motion-choice-row">
													<span class="ldp-setting-label">使用动效</span>
													<select class="ldp-reader-select ldp-loading-animation-select" aria-label="等待区域动效">
														<option value="random">每次随机(推荐)</option>
														${READER_LOADING_ANIMATIONS.map((animation) => `<option value="${animation.key}">${animation.label}</option>`).join('')}
													</select>
												</label>
											`)}
									</div>
									<div class="ldp-loading-settings-preview">
										<div class="ldp-loading-settings-preview-head">
											<span><strong>页面预览</strong><small class="ldp-loading-settings-preview-label"></small></span>
											<button class="ldp-loading-preview-reroll" type="button">换一个</button>
										</div>
										<div class="ldp-loading-preview-stage" aria-live="polite"></div>
									</div>
									<p class="ldp-loading-settings-note">预览与阅读器等待区域共用同一套动效代码;偏好会自动保存在当前浏览器。</p>
								</div>
								<div class="ldp-settings-section" data-settings-panel="performance" hidden>
									<div class="ldp-performance-presets" role="group" aria-label="性能预设">
										<button class="ldp-performance-preset" type="button" data-performance-preset="low">省资源</button>
										<button class="ldp-performance-preset" type="button" data-performance-preset="balanced">均衡</button>
										<button class="ldp-performance-preset" type="button" data-performance-preset="high">低等待</button>
										<button class="ldp-performance-preset" type="button" data-performance-preset="custom">自定义</button>
									</div>
									<div class="ldp-settings-category-groups">
										${performanceSettingsMarkup()}
									</div>
									<div class="ldp-performance-footer">
										<span class="ldp-performance-status" role="status"></span>
										<button class="ldp-performance-reset" type="button">${icon('rotateCcw')}<span>恢复默认</span></button>
									</div>
								</div>
								<div class="ldp-settings-section" data-settings-panel="resource-monitor" hidden>
									<div class="ldp-resource-monitor-health" data-level="normal" role="status">
										<strong class="ldp-resource-monitor-health-state">建立基线</strong>
										<p class="ldp-resource-monitor-health-detail">连续取得 10 个真实快照后,开始判断 DOM 增长、共享主线程长任务和阅读器请求排队。</p>
										<span class="ldp-resource-monitor-updated">等待采样</span>
									</div>
									<section class="ldp-resource-monitor-evidence" aria-labelledby="ldp-resource-monitor-evidence-title">
										<div class="ldp-resource-monitor-evidence-head"><strong id="ldp-resource-monitor-evidence-title">最近 60 秒前后台实测</strong><span class="ldp-resource-monitor-evidence-window">等待事件</span></div>
										<div class="ldp-resource-monitor-scope-table" role="table" aria-label="阅读器与宿主前后台资源证据">
											<div class="ldp-resource-monitor-scope-head" role="row"><span>范围</span><span>当前结构</span><span>前台事件</span><span>后台事件</span></div>
											<div class="ldp-resource-monitor-scope-row" role="row" data-resource-monitor-scope="reader"><strong>阅读器</strong><span data-resource-monitor-scope-current>—</span><span data-resource-monitor-scope-visible>—</span><span data-resource-monitor-scope-hidden>—</span><small data-resource-monitor-scope-basis>—</small></div>
											<div class="ldp-resource-monitor-scope-row" role="row" data-resource-monitor-scope="host"><strong>宿主 / 未标记</strong><span data-resource-monitor-scope-current>—</span><span data-resource-monitor-scope-visible>—</span><span data-resource-monitor-scope-hidden>—</span><small data-resource-monitor-scope-basis>—</small></div>
											<div class="ldp-resource-monitor-scope-row" role="row" data-resource-monitor-scope="shared"><strong>页面共享</strong><span data-resource-monitor-scope-current>—</span><span data-resource-monitor-scope-visible>—</span><span data-resource-monitor-scope-hidden>—</span><small data-resource-monitor-scope-basis>—</small></div>
										</div>
									</section>
									<div class="ldp-resource-monitor-table" role="table" aria-label="阅读器实时资源数据">
										<div class="ldp-resource-monitor-table-head" role="row"><span>指标</span><span>当前</span><span>样本均</span><span>观测峰</span><span>首末变化</span></div>
										${RESOURCE_MONITOR_ROWS.map(resourceMonitorRowMarkup).join('')}
									</div>
									<section class="ldp-resource-monitor-trends" aria-labelledby="ldp-resource-monitor-trend-title">
										<div class="ldp-resource-monitor-trend-head"><strong id="ldp-resource-monitor-trend-title">最近 10 分钟趋势</strong><span class="ldp-resource-monitor-trend-window">等待采样</span></div>
										<div class="ldp-resource-monitor-trend-list">
											<div class="ldp-resource-monitor-trend-row" data-resource-monitor-chart="heapUsed"><span>页面内存估计</span><strong data-resource-monitor-chart-current="heapUsed">—</strong><svg viewBox="0 0 240 34" preserveAspectRatio="none" aria-hidden="true"><polyline points=""></polyline></svg></div>
											<div class="ldp-resource-monitor-trend-row" data-resource-monitor-chart="dom"><span>阅读器 DOM</span><strong data-resource-monitor-chart-current="dom">—</strong><svg viewBox="0 0 240 34" preserveAspectRatio="none" aria-hidden="true"><polyline points=""></polyline></svg></div>
											<div class="ldp-resource-monitor-trend-row" data-resource-monitor-chart="retainedFloors"><span>保留主楼层</span><strong data-resource-monitor-chart-current="retainedFloors">—</strong><svg viewBox="0 0 240 34" preserveAspectRatio="none" aria-hidden="true"><polyline points=""></polyline></svg></div>
										</div>
									</section>
									<section class="ldp-resource-monitor-events" aria-labelledby="ldp-resource-monitor-events-title">
										<div class="ldp-resource-monitor-events-head"><strong id="ldp-resource-monitor-events-title">毫秒级事件记录</strong><span>时间 · 前后台 · 范围 · 原始事件</span></div>
										<div class="ldp-resource-monitor-event-log" role="log" aria-live="off"><div class="ldp-resource-monitor-event-empty">等待 Performance Timeline、Resource Timing、DOM 或可见性事件。</div></div>
									</section>
									<p class="ldp-resource-monitor-boundary">“前台 / 后台”严格取自 Page Visibility,网络与性能条目按开始时状态归档;MutationRecord 没有原生时间戳,DOM 变更只能按观察器回调时状态记录。阅读器请求以显式元数据归因;未标记的页面 fetch/XHR 只能记为“宿主 / 未标记”;浏览器资源、垃圾回收、样式布局及无法确认脚本来源的长任务保留在“页面共享”。浏览器不提供单个脚本的真实总 CPU 或独占内存,本页不会用推算值冒充。跨源隔离不可用时不读取已弃用且不可靠的 performance.memory。</p>
								</div>
								<div class="ldp-settings-section" data-settings-panel="request-flow" hidden>
									<div class="ldp-request-flow-summary" aria-label="请求速率摘要">
										<div class="ldp-request-flow-metric"><span>本页 10 秒</span><strong data-request-flow-metric="rate10">0 次</strong></div>
										<div class="ldp-request-flow-metric"><span>本页 60 秒</span><strong data-request-flow-metric="rate60">0 次</strong></div>
										<div class="ldp-request-flow-metric"><span>本页 100ms 峰值</span><strong data-request-flow-metric="peak">0 次</strong></div>
										<div class="ldp-request-flow-metric"><span>本页 60 秒传输</span><strong data-request-flow-metric="transfer">0 B</strong></div>
										<div class="ldp-request-flow-metric"><span>本页 60 秒异常</span><strong data-request-flow-metric="issues">0 次</strong></div>
									</div>
									<section class="ldp-request-flow-block ldp-request-flow-chart-block" aria-labelledby="ldp-request-flow-timeline-title">
										<div class="ldp-request-flow-block-head">
											<strong id="ldp-request-flow-timeline-title">毫秒请求脉络</strong>
											<span class="ldp-request-flow-window">等待采样</span>
										</div>
										<div class="ldp-request-flow-trace" role="img" aria-label="最近 10 秒按来源显示排队、网络耗时与异常点"></div>
										<div class="ldp-request-flow-axis"><span>10 秒前</span><span>虚线排队 · 短线放行 · 实色网络 · 圆点异常</span><span>现在</span></div>
										<div class="ldp-request-flow-legend" aria-label="请求类型图例"></div>
									</section>
									<div class="ldp-request-flow-bottleneck" data-level="normal" role="status">
										<strong class="ldp-request-flow-bottleneck-state">采样中</strong>
										<p class="ldp-request-flow-bottleneck-detail">打开帖子并正常滚动后,这里会判断限流、排队、响应延迟或静态资源是否成为主要瓶颈。</p>
									</div>
									<section class="ldp-request-flow-block ldp-request-flow-anomaly-block" aria-labelledby="ldp-request-flow-anomaly-title">
										<div class="ldp-request-flow-block-head"><strong id="ldp-request-flow-anomaly-title">最近异常点</strong><span class="ldp-request-flow-anomaly-window">最近 60 秒</span></div>
										<div class="ldp-request-flow-anomalies"></div>
									</section>
									<section class="ldp-request-flow-block ldp-request-flow-types-block" aria-labelledby="ldp-request-flow-types-title">
										<div class="ldp-request-flow-type-head"><strong id="ldp-request-flow-types-title">最近 60 秒按类型</strong><span>数量</span><span>P95</span><span>异常</span></div>
										<div class="ldp-request-flow-types"></div>
									</section>
									<section class="ldp-request-flow-block ldp-request-flow-log-block" aria-labelledby="ldp-request-flow-log-title">
										<div class="ldp-request-flow-block-head"><strong id="ldp-request-flow-log-title">毫秒请求记录</strong><span>路径与发起点已脱敏,不保存查询参数</span></div>
										<div class="ldp-request-flow-log" role="log" aria-live="off"></div>
									</section>
									<section class="ldp-request-flow-limit" aria-labelledby="ldp-request-flow-limit-title">
										<h4 id="ldp-request-flow-limit-title">LINUX DO 可能的请求上限</h4>
										<p class="ldp-request-flow-observed">当前还没有观察到服务器限流信息。</p>
										<p><strong>Discourse 公开默认:</strong>应用请求约 50 次/10 秒、200 次/分钟;头像、CSS 等静态资源约 200 次/10 秒。LINUX DO 可以覆盖这些数字,搜索、发帖、私信、插件和反向代理也可能有更低的独立限制。</p>
										<p>因此这里只作为风险刻度;真实边界以本页收到的 <strong>429、Retry-After 和限流错误码</strong>为准。<a href="https://meta.discourse.org/t/available-settings-for-global-rate-limits-and-throttling/78612" target="_blank" rel="noopener">查看 Discourse 公开说明</a></p>
									</section>
								</div>
								<div class="ldp-settings-section" data-settings-panel="other" hidden>
									<div class="ldp-settings-fields ldp-other-settings-fields">
										${otherSettingGroupMarkup('history', '历史导航', '控制历史前进、后退按钮的显示方式与历史列表顺序。', `
										${settingSwitchesMarkup('history')}
										<label class="ldp-setting-row ldp-setting-option-row ldp-history-edge-trigger-row">
											<span class="ldp-setting-option-copy">
												<strong>左右按钮触发区域</strong>
												<small>鼠标进入阅读器左右边缘后显示对应按钮;0% 为关闭,左右两侧各自最大 15%。</small>
											</span>
											<span class="ldp-history-edge-trigger-control">
												<input class="ldp-history-edge-trigger-range" type="range" min="${HISTORY_EDGE_TRIGGER_MIN}" max="${HISTORY_EDGE_TRIGGER_MAX}" step="1" aria-label="左右按钮触发区域">
												<output class="ldp-history-edge-trigger-value"></output>
											</span>
										</label>
										<label class="ldp-setting-row ldp-setting-option-row">
											<span class="ldp-setting-option-copy">
												<strong>浏览历史排序</strong>
												<small>默认按最后一次打开时间倒序,距离现在越近越靠前;也可固定为首次点击顺序。</small>
											</span>
											<select class="ldp-reader-select ldp-history-sort-mode" aria-label="浏览历史排序规则">
												<option value="recent-viewed">最近点击(默认)</option>
												<option value="first-viewed">首次点击(固定)</option>
											</select>
										</label>
											`)}
										${otherSettingGroupMarkup('topic-opening', '帖子打开', '决定从普通帖子链接进入阅读器时采用的起始楼层。', `
                    ${settingSwitchesMarkup('topic-opening')}
                      `)}
										${otherSettingGroupMarkup('replies', '回复展示', '控制二级回复在父楼层和主信息流中的默认展开位置。', `
										${settingSwitchesMarkup('replies')}
										<p class="ldp-nested-display-warning" role="status" aria-live="polite" hidden>两种方式同时开启时,同一条二级回复可能会在父楼层下和主信息流中重复出现,阅读时可能看到重复信息。</p>
											`)}
										${otherSettingGroupMarkup('boost', 'Boost 复制', '复制结果 = 开头文字 + 原 Boost + 小尾巴;不用填写正则。', `
												${boostCopySettingsMarkup()}
												<div class="ldp-setting-row ldp-boost-rule-row">
													<span class="ldp-setting-label">结果预览</span>
													<span class="ldp-boost-rule-preview">
														<code class="ldp-boost-copy-preview"></code>
														<small>最多 16 字</small>
													</span>
												</div>
											`)}
									</div>
								</div>
								<div class="ldp-settings-section" data-settings-panel="cache" hidden>
									<div class="ldp-settings-category-groups">
									${settingsCategoryMarkup(
										'config-management', '设置配置',
										'配置文件包含当前开发版全部规范化设置(含图片与灯箱、字体、布局、浮窗、外观、闪烁、性能、主题及其他功能)和脚本版本,不包含浏览历史、帖子内容、API 响应、图片缓存或账号数据。只接受当前格式;导入与恢复后会刷新页面。', `
										<div class="ldp-config-actions">
											<button class="ldp-config-action ldp-config-export" type="button">${icon('download')}<span>导出设置</span></button>
											<button class="ldp-config-action ldp-config-import" type="button">${icon('upload')}<span>导入设置</span></button>
											<button class="ldp-config-action ldp-config-reset danger" type="button">${icon('rotateCcw')}<span>恢复全部默认</span></button>
										</div>
										<input class="ldp-config-file" type="file" accept="application/json,.json" hidden>
										`)}
									${settingsCategoryMarkup(
										'local-cache', '本地缓存',
										'只保存可复用的结构化数据,并按浏览记录、主题快照、楼层正文、用户资料、消息分页和通用 API 分组管理;头像、表情和查看过的原图使用独立资源缓存。各项统计会区分业务对象与底层记录,并对重复对象去重。新鲜数据直接读取,过期后联网校准,网络异常时可回退到保留期内的数据。', `
										<div class="ldp-cache-list">
											${CACHE_TYPES.map((type) => {
												return `<label class="ldp-cache-row">
													<input class="ldp-cache-select" type="checkbox" value="${type.key}">
													<span><strong>${type.label}</strong><small data-cache-size="${type.key}"></small></span>
													<em data-cache-retention="${type.key}">${cacheRetentionLabel(type.key)}</em>
												</label>`;
											}).join('')}
										</div>
										<button class="ldp-cache-clear" type="button" disabled>清理已选缓存</button>
										`, 'ldp-settings-category-content', 'ldp-cache-note')}
									</div>
								</div>
								<div class="ldp-settings-section" data-settings-panel="about" hidden>
									<div class="ldp-about-content">
										<section class="ldp-about-hero" aria-labelledby="ldp-about-name">
											<img class="ldp-about-logo" src="${LINUXDO_LOGO_URL}" alt="" loading="lazy" decoding="async">
											<div class="ldp-about-identity">
												<h4 class="ldp-about-name" id="ldp-about-name">awesome linuxdo reader</h4>
												<p class="ldp-about-tagline">在原站能力之上,提供更连贯、更可控的阅读体验。</p>
											</div>
											<span class="ldp-about-version">v${READER_VERSION}</span>
										</section>
										<nav class="ldp-about-links" aria-label="项目链接">
											<a class="ldp-about-link" href="${READER_MANUAL_URL}" target="_blank" rel="noopener noreferrer">
												<span class="ldp-about-link-icon">${icon('listChecks')}</span>
												<span class="ldp-about-link-copy"><strong>在线用户手册</strong><small>无需安装,使用浏览器直接打开</small></span>
												${icon('externalLink')}
											</a>
										</nav>
										<div class="ldp-about-features" aria-label="阅读器核心特性">${aboutFeaturesMarkup()}</div>
										<section class="ldp-about-credits" aria-labelledby="ldp-about-credits-title">
											<strong id="ldp-about-credits-title">特别致谢</strong>
											<p>字体渲染参数与实现思路参考 <a href="${FONT_RENDERING_PROJECT_URL}" target="_blank" rel="noopener">F9y4ng / GreasyFork-Scripts 的 Font Rendering</a>;感谢作者的长期维护。上游项目采用 <a href="${FONT_RENDERING_LICENSE_URL}" target="_blank" rel="noopener">GPL-3.0-only</a>。</p>
										</section>
									</div>
								</div>
							</div>
							<button class="ldp-settings-close" type="button" aria-label="关闭设置">${icon('x')}</button>
						</div>
					</div>
					<div class="ldp-title-actions">
						<button class="ldp-topic-edit-trigger" type="button" aria-label="编辑帖子标题、类别和 label" aria-haspopup="dialog" aria-expanded="false" hidden>${icon('pencil')}</button>
						<button class="ldp-reader-refresh ldp-icon-btn" type="button" aria-label="清除当前帖子缓存并刷新" disabled>${icon('rotateCcw')}</button>
						<a class="ldp-open" href="#" target="_blank" rel="noopener" aria-label="打开原帖" hidden>${icon('externalLink')}</a>
						<button class="ldp-close ldp-icon-btn" type="button" aria-label="关闭">${icon('x')}</button>
					</div>
				</div>
				<div class="ldp-reader-action-layer ldp-topic-edit-layer" hidden>
					<section class="ldp-reader-action-dialog ldp-topic-edit-dialog" role="dialog" aria-modal="true" aria-labelledby="ldp-topic-edit-dialog-title">
						<div class="ldp-reader-action-head">
							<strong id="ldp-topic-edit-dialog-title">编辑帖子信息</strong>
							<button class="ldp-reader-action-close ldp-topic-edit-close" type="button" aria-label="关闭编辑弹层">${icon('x')}</button>
						</div>
						<form class="ldp-reader-action-form ldp-topic-edit-form">
							<div class="ldp-reader-action-body ldp-topic-edit-body">
								<label class="ldp-topic-edit-field ldp-topic-edit-title">
									<span>标题</span>
									<input name="title" type="text" maxlength="255" autocomplete="off" required>
								</label>
								<div class="ldp-topic-edit-field">
									<span id="ldp-topic-edit-category-label">类别</span>
									<div class="ldp-topic-edit-category">
										<button class="ldp-topic-edit-category-trigger ldp-picker-trigger" type="button" aria-haspopup="listbox" aria-expanded="false" aria-labelledby="ldp-topic-edit-category-label ldp-topic-edit-category-value" disabled>
											<span id="ldp-topic-edit-category-value" class="ldp-topic-edit-category-value">正在加载…</span>
											${icon('chevronDown')}
										</button>
										<div class="ldp-topic-edit-category-menu" hidden>
											<label class="ldp-topic-edit-category-search ldp-picker-search">
												${icon('search')}
												<input type="search" autocomplete="off" spellcheck="false" placeholder="搜索类别" aria-label="搜索类别">
											</label>
											<div class="ldp-topic-edit-category-options ldp-picker-options" role="listbox" aria-labelledby="ldp-topic-edit-category-label"></div>
										</div>
									</div>
								</div>
								<label class="ldp-topic-edit-field">
									<span>Label</span>
									<span class="ldp-topic-edit-label-control">
										<span class="ldp-topic-edit-label-values"></span>
										<input class="ldp-topic-edit-label-input" type="text" autocomplete="off" list="ldp-topic-edit-label-options" placeholder="搜索 label" aria-label="添加 label" disabled>
									</span>
									<datalist id="ldp-topic-edit-label-options" class="ldp-topic-edit-label-options"></datalist>
								</label>
								<p class="ldp-topic-edit-status" role="status" aria-live="polite"></p>
							</div>
							<div class="ldp-reader-action-footer ldp-topic-edit-actions">
								<button class="ldp-topic-edit-save" type="submit" aria-label="保存帖子信息">${icon('check')}</button>
								<button class="ldp-topic-edit-cancel" type="button" aria-label="取消编辑">${icon('x')}</button>
							</div>
						</form>
					</section>
				</div>
				<div class="ldp-reader-history-edge ldp-reader-history-edge-back" hidden>
					<button class="ldp-reader-history-nav ldp-reader-history-back" type="button" hidden>${icon('chevronRight')}</button>
				</div>
				<aside class="ldp-reader-queue" aria-label="阅读队列" hidden>
					<button class="ldp-reader-queue-toggle" type="button" aria-expanded="false" aria-pressed="true"
						aria-haspopup="listbox">${icon('layers')}<b>0</b></button>
					<div class="ldp-reader-queue-bubbles" aria-label="队列文章头像,可滚动查看"></div>
					<button class="ldp-reader-queue-scroll-hint" type="button"
						aria-label="显示下方更多队列头像" hidden>${icon('chevronRight')}</button>
					<section class="ldp-reader-queue-panel" aria-label="阅读队列文章列表" hidden>
						<header class="ldp-reader-queue-panel-head">
							<strong>阅读队列</strong>
							<span class="ldp-reader-queue-panel-count">0 篇</span>
							<button class="ldp-reader-queue-clear" type="button"
								aria-label="清理其他队列文章,保留当前文章">${icon('trash')}</button>
							<button class="ldp-reader-queue-close" type="button" aria-label="收起阅读队列">${icon('x')}</button>
						</header>
						<div class="ldp-reader-queue-list" role="listbox" aria-label="队列文章"></div>
					</section>
				</aside>
				<div class="ldp-reader-main">
					<div class="ldp-rate-limit-notice" role="status" aria-live="polite" aria-atomic="true" hidden>
						<span class="ldp-rate-limit-icon" aria-hidden="true">${icon('alertTriangle')}</span>
						<span class="ldp-rate-limit-copy">
							<strong>请求已暂停</strong>
							<span class="ldp-rate-limit-detail">触发 429,冷却结束后将自动打开过盾浮窗。</span>
						</span>
						<a class="ldp-rate-limit-challenge" href="${BASE}/" target="_blank" rel="noopener" aria-label="在独立浮窗打开 LINUX DO 官网完成 Cloudflare 验证">${icon('externalLink')}<span>Cloudflare 验证</span></a>
					</div>
					<div class="ldp-body">
						<div class="ldp-loadmask">${createReaderLoadingHTML()}</div>
						<div class="ldp-comments"><div class="ldp-comments-empty" style="display:none">暂无评论</div></div>
						<div class="ldp-comments-header" hidden>${icon('messageSquare')}<span>评论</span><span class="ldp-comments-count" aria-live="polite"></span></div>
						<div class="ldp-loading-tip">${icon('loader', 'ldp-tip-icon')}<span>正在加载楼层…</span></div>
						<div class="ldp-end-tip">已经到底了~</div>
						<div class="ldp-sentinel"></div>
					</div>
					<aside class="ldp-topic-timeline" hidden aria-label="帖子时间轴">
						<button class="ldp-topic-timeline-date" type="button" aria-label="跳到首帖"></button>
						<button class="ldp-topic-timeline-track" type="button" role="slider" aria-label="跳转楼层" aria-orientation="vertical">
							<span class="ldp-topic-timeline-cursor ldp-timeline-lens-composited" aria-hidden="true"></span>
							<span class="ldp-topic-timeline-thumb"></span>
							<strong class="ldp-topic-timeline-count"><span class="ldp-topic-timeline-current">1</span><span>/</span><span class="ldp-topic-timeline-total">1</span></strong>
							<span class="ldp-topic-timeline-preview" aria-hidden="true"></span>
						</button>
						<div class="ldp-topic-timeline-footer">
							<button class="ldp-topic-timeline-relative" type="button" aria-label="跳到最后一楼"></button>
							<button class="ldp-topic-timeline-top" type="button" aria-label="回到顶部,第 1 楼">${icon('arrowUp')}</button>
						</div>
					</aside>
				</div>
				<div class="ldp-reader-history-edge ldp-reader-history-edge-forward" hidden>
					<button class="ldp-reader-history-nav ldp-reader-history-forward" type="button" hidden>${icon('chevronRight')}</button>
				</div>
				<div class="ldp-live-update" hidden aria-live="polite">
					<button class="ldp-live-update-jump" type="button">${icon('messageSquare')}<span></span></button>
					<button class="ldp-live-update-dismiss" type="button" aria-label="暂时关闭新消息提示">${icon('x')}</button>
				</div>
				<div class="ldp-setting-help-tooltip ldp-transient-surface" role="tooltip" hidden></div>
			</div>`;
			mountSettingsPanelIntros(overlay);
			appendReaderPortalNode(overlay);
			ensureReaderIconTooltip();
		}
		overlay.dataset.topicId = String(topicId);
		CURRENT_OVERLAY = overlay;
		bindReaderQueueSurface(overlay);
		syncReaderFontRenderingState();
		overlay._ldpReaderHistoryNavigation = historyNavigation;
		const disposeHistoryNavigation = bindReaderHistoryNavigation(overlay, topicId, historyNavigation);
		overlay._ldpReaderSession = readerSession;
		PAGE_ROOT.classList.add('ldp-reader-open');
		overlay.dataset.targetPostNumber = targetPostNumber ? String(targetPostNumber) : '';
		overlay.classList.toggle('ldp-initial-target-layout', targetPostNumber > 1);

		const modal = readerElement('.ldp-modal'), body = readerElement('.ldp-body');
		if (reusingShell) resetReaderTopicShell(overlay);
		const readerShell = reusingShell ? overlay._ldpReaderShell : {};
		const shellScope = readerShell.lifecycleScope || createLifecycleScope();
		readerShell.lifecycleScope = shellScope;
		const onShell = (target, type, listener, options) => shellScope.listen(target, type, listener, options);
		const shouldRestoreHostTopicAnchor = !!hostTopicPointerAnchor && !directTopicRoute &&
			readerPresentation(requestedReaderMode).embedded;
		if (shouldRestoreHostTopicAnchor) PAGE_ROOT.classList.add('ldp-reader-host-anchor-restoring');
		const readerWorkspace = readerShell.readerWorkspace || createReaderWorkspaceController(overlay, modal, directTopicRoute);
		readerWorkspace.sync();
		if (shouldRestoreHostTopicAnchor) {
			if (readerWorkspace.isEmbedded()) restoreHostTopicPointerAnchor(hostTopicPointerAnchor);
			shellScope.raf(() => PAGE_ROOT.classList.remove('ldp-reader-host-anchor-restoring'));
		}
		setPageScrollLocked(readerWorkspace.isFullPage() || window.innerWidth <= READER_WINDOW_COMPACT_WIDTH);
		const readerCapsule = readerElement('.ldp-reader-window-capsule');
		const readerPlacementControl = readerElement('.ldp-reader-placement-control');
		const readerPlacementStrip = readerElement('.ldp-reader-placement-strip');
		const readerPlacementOptions = readerElements('[data-reader-placement]');
		const commentsEl = readerElement('.ldp-comments');
		const commentsHeader = readerElement('.ldp-comments-header');
		const countEl = readerElement('.ldp-comments-count'), emptyEl = readerElement('.ldp-comments-empty');
		const maskEl = readerElement('.ldp-loadmask');
		const loadingStage = maskEl?.querySelector('.ldp-loading-stage');
		const loadingStatus = maskEl?.querySelector('.ldp-loading-status span');
		const loadingTarget = maskEl?.querySelector('.ldp-loading-target');
		const loadingDetail = maskEl?.querySelector('.ldp-loading-detail');
		const loadingTip = readerElement('.ldp-loading-tip');
		const loadingTipText = loadingTip?.querySelector('span');
		const syncLoadingState = (state, options = {}) => {
			const isTarget = options.target === true && targetPostNumber > 1;
			const cachedCount = Math.max(0, Number(options.cachedCount) || 0);
			const missingCount = Math.max(0, Number(options.missingCount) || 0);
			const copy = state === 'cache'
				? {
					status: isTarget ? '正在读取目标楼层缓存' : '正在读取帖子缓存',
					detail: cachedCount ? `已读取 ${cachedCount} 条缓存,正在恢复楼层…` : '正在恢复已缓存楼层…',
				}
				: state === 'network'
					? {
						status: isTarget ? '正在请求目标楼层' : cachedCount ? '正在补全帖子数据' : '正在请求帖子数据',
						detail: cachedCount
							? `已读取 ${cachedCount} 条缓存,正在下载 ${missingCount} 条缺失楼层…`
							: missingCount ? `正在下载 ${missingCount} 条缺失楼层…` : '正在下载缺失楼层…',
					}
					: { status: '正在渲染帖子', detail: '正在生成页面…' };
			if (loadingStatus) loadingStatus.textContent = copy.status;
			if (loadingDetail) loadingDetail.textContent = copy.detail;
			if (loadingTipText) loadingTipText.textContent = copy.detail;
			if (loadingTarget) loadingTarget.textContent = isTarget ? `#${targetPostNumber}` : '';
			if (loadingStage) setLabel(loadingStage, `${copy.status},${copy.detail.replace('…', '')}`);
		};
		const cachedTargetAvailable = targetPostNumber <= 1 || !!(cachedTopicPreview?.post_stream &&
			Array.isArray(cachedTopicPreview.post_stream.posts) &&
			cachedTopicPreview.post_stream.posts.some((post) => +post.post_number === targetPostNumber));
		syncLoadingState(cachedTopicPreview && cachedTargetAvailable ? 'cache' : 'network', {
			target: targetPostNumber > 1,
		});
		const endTip = readerElement('.ldp-end-tip');
		const liveUpdateBtn = readerElement('.ldp-live-update');
		const liveUpdateJumpBtn = liveUpdateBtn.querySelector('.ldp-live-update-jump');
		const liveUpdateDismissBtn = liveUpdateBtn.querySelector('.ldp-live-update-dismiss');
		const timelineEl = readerElement('.ldp-topic-timeline');
		const layoutBtn = readerElement('.ldp-layout-toggle');
		const onlyOpBtn = readerElement('.ldp-only-op-toggle');
		const onlyOpProgress = readerElement('.ldp-only-op-progress');
		const onlyOpProgressValue = onlyOpProgress?.querySelector('.ldp-only-op-progress-value');
		const rateLimitNotice = readerElement('.ldp-rate-limit-notice');
		const rateLimitDetail = rateLimitNotice?.querySelector('.ldp-rate-limit-detail');
		const rateLimitChallenge = rateLimitNotice?.querySelector('.ldp-rate-limit-challenge');
		const notificationsBtn = readerElement('.ldp-notifications-toggle');
		const notificationsPopover = readerElement('.ldp-notifications-popover');
		const notificationTabs = readerElements('.ldp-notification-tab');
		const queryReaderPanelElements = (prefix, fields) => Object.fromEntries(fields.map((field) => [
			field, readerElement(`.ldp-${prefix}-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`),
		]));
		const notificationPanel = queryReaderPanelElements('notification', [
			'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'markAll',
		]);
		const historyPanel = queryReaderPanelElements('history', [
			'toggle', 'popover', 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'defaultActions',
			'bulkActions', 'sortToggle', 'multi', 'multiDone', 'selectScope', 'selectToggle', 'deleteSelected',
			'deleteSelectedLabel', 'clear',
		]);
		const bookmarksPanel = queryReaderPanelElements('bookmarks', [
			'toggle', 'popover', 'list', 'pagePrev', 'pageNext', 'pageInfo', 'search', 'searchClear', 'defaultActions',
			'bulkActions', 'multi', 'multiDone', 'selectScope', 'selectToggle', 'deleteSelected', 'deleteSelectedLabel',
		]);
		const [
			bookmarksReactionFilters, bookmarksTabList, settingsBtn, settingsPopover, settingsNav,
			settingsNavScrollUp, settingsNavScrollDown, settingsPanel, settingsCloseBtn, settingHelpTooltip,
		] = readerElementGroup(
			'.ldp-reaction-filters', '.ldp-bookmark-tabs', '.ldp-settings-toggle', '.ldp-settings-popover',
			'.ldp-settings-nav', '.ldp-settings-nav-scroll-up', '.ldp-settings-nav-scroll-down',
			'.ldp-settings-panel', '.ldp-settings-close', '.ldp-setting-help-tooltip',
		);
		const [bookmarksTabs, themeButtons] = readerElementsGroup(
			'.ldp-bookmark-tab', '.ldp-settings-theme-button');
		const settingsSurfaceBounds = () => {
			const rect = modal.getBoundingClientRect();
			const left = rect.left + modal.clientLeft;
			const top = rect.top + modal.clientTop;
			const width = modal.clientWidth;
			const height = modal.clientHeight;
			return { left, top, width, height, right: left + width, bottom: top + height };
		};
		const settingsSurfaceIsCompact = () =>
			settingsSurfaceBounds().width <= READER_WINDOW_COMPACT_WIDTH;
		const syncReaderSurfaceDensity = () => {
			const shortSurface = modal.clientHeight <= READER_SETTINGS_SHORT_HEIGHT;
			modal.classList.toggle(
				'ldp-settings-surface-short',
				shortSurface,
			);
			modal.classList.toggle('ldp-reader-surface-short', shortSurface);
		};
		if (notificationsPopover && notificationsPopover.parentElement !== overlay) overlay.append(notificationsPopover);
		if (historyPanel.popover && historyPanel.popover.parentElement !== overlay) overlay.append(historyPanel.popover);
		if (bookmarksPanel.popover && bookmarksPanel.popover.parentElement !== overlay) overlay.append(bookmarksPanel.popover);
		if (settingsPopover && settingsPopover.parentElement !== modal) modal.append(settingsPopover);
		if (settingHelpTooltip && settingHelpTooltip.parentElement !== modal) modal.append(settingHelpTooltip);
		syncReaderSurfaceDensity();
		syncReaderThemeSurfaces();
		const [
			userInfoContent, userInfoLastRefresh, userInfoTitleRefresh,
			imageScaleRange, imageScaleValue, imageScaleReset,
			lightboxOriginalByDefaultInput, lightboxCommentsExpandedInput, lightboxDescriptionExpandedInput,
			fontFamilyMenu, fontFamilySearch, fontFamilyOptions, fontFamilyEmpty,
			fontFamilySourceStatus, fontFamilyManualButton,
		] = readerElementGroup(
			'.ldp-user-info-content', '.ldp-user-info-last-refresh', '.ldp-user-info-title-refresh',
			'.ldp-image-scale-range', '.ldp-image-scale-value', '.ldp-image-scale-reset',
			'.ldp-lightbox-original-by-default', '.ldp-lightbox-comments-expanded',
			'.ldp-lightbox-description-expanded', '.ldp-font-family-menu', '.ldp-font-family-search input',
			'.ldp-font-family-options', '.ldp-font-family-empty', '.ldp-font-family-source-status',
			'.ldp-font-family-manual button',
		);
		const [settingsTabs, settingsSections, fontFamilyTriggers, fontFamilyCustomInputs] = readerElementsGroup(
			'.ldp-settings-tab', '.ldp-settings-section', '.ldp-font-family-trigger', '.ldp-font-family-custom');
		if (fontFamilyMenu && fontFamilyMenu.parentElement !== modal) modal.append(fontFamilyMenu);
		const [
			interfaceFontScaleRange, interfaceFontScaleValue, fontScaleRange, fontScaleValue,
			composerFontScaleRange, composerFontScaleValue, fontStatus, fontResetBtn, fontApplyBtn,
			fontRenderingEnabledInput, fontRenderingOnHostInput,
		] = readerElementGroup(
			'.ldp-interface-font-scale-range', '.ldp-interface-font-scale-value', '.ldp-font-scale-range',
			'.ldp-font-scale-value', '.ldp-composer-font-scale-range', '.ldp-composer-font-scale-value',
			'.ldp-font-status', '.ldp-font-reset', '.ldp-font-apply', '.ldp-font-rendering-enabled',
			'.ldp-font-rendering-on-host',
		);
		const [
			fontWeightSelects, fontColorInputs, fontColorValues, hostEmbeddedSizeInputs, hostEmbeddedSizeValues,
			hostEmbeddedSizeResetButtons, fontFieldResetButtons, fontScopeTabs, fontScopePanels,
		] = readerElementsGroup(
			'.ldp-font-weight-select', '.ldp-font-color', '.ldp-font-color-value', '[data-host-embed-size]',
			'[data-host-embed-size-value]', '[data-host-embed-size-reset]',
			'[data-host-font-reset],[data-font-field-reset]', '[data-font-scope-tab]', '[data-font-scope-panel]',
		);
		const [layoutRatioInputs, layoutRatioValues] = readerElementsGroup(
			'[data-layout-region]', '[data-layout-value]');
		const [
			layoutTotal, layoutResetBtn, layoutApplyBtn, readerWindowWidthInput, readerWindowHeightInput,
			readerWindowXInput, readerWindowYInput, readerWindowPinInput, readerWindowLockInput,
			readerWindowStatus, readerWindowResetBtn,
		] = readerElementGroup(
			'[data-settings-panel="layout"] .ldp-layout-total',
			'[data-settings-panel="layout"] .ldp-layout-reset',
			'[data-settings-panel="layout"] .ldp-layout-apply',
			'.ldp-reader-window-width', '.ldp-reader-window-height', '.ldp-reader-window-x',
			'.ldp-reader-window-y', '.ldp-reader-window-pin-input', '.ldp-reader-window-lock-input',
			'.ldp-reader-window-status', '.ldp-reader-window-reset',
		);
		const appearanceSettingElements = APPEARANCE_SETTING_FIELDS.map((field) => {
			const className = appearanceSettingClassName(field.key);
			return Object.assign({}, field, {
				input: readerElement(`[data-settings-panel="appearance"] .${className}${field.range ? '-range' : ''}`),
				output: readerElement(`[data-settings-panel="appearance"] .${className}-value`),
			});
		});
		const [
			appearanceThemeNote, appearanceStatus, appearanceApplyBtn, performanceStatus, performanceResetBtn,
		] = readerElementGroup(
			'.ldp-appearance-theme-note', '.ldp-appearance-status', '.ldp-appearance-apply',
			'.ldp-performance-status', '.ldp-performance-reset',
		);
		const [
			appearanceGroupToggleInputs, colorResetButtons, performancePresetButtons, performanceInputs,
			resourceMonitorMetrics, resourceMonitorAverages, resourceMonitorPeaks, resourceMonitorTrends,
		] = readerElementsGroup(
			'[data-appearance-group-toggle]', '[data-color-reset]', '.ldp-performance-preset',
			'[data-performance-key]', '[data-resource-monitor-metric]', '[data-resource-monitor-average]',
			'[data-resource-monitor-peak]', '[data-resource-monitor-trend]',
		);
		const resourceMonitorValueColumns = [
			[resourceMonitorMetrics, 'resourceMonitorMetric', ''],
			[resourceMonitorAverages, 'resourceMonitorAverage', 'Average'],
			[resourceMonitorPeaks, 'resourceMonitorPeak', 'Peak'],
			[resourceMonitorTrends, 'resourceMonitorTrend', 'Trend'],
		];
		const [resourceMonitorChartRows, resourceMonitorScopeRows, requestFlowMetrics] = readerElementsGroup(
			'[data-resource-monitor-chart]', '[data-resource-monitor-scope]', '[data-request-flow-metric]');
		const [
			resourceMonitorHealth, resourceMonitorHealthState, resourceMonitorHealthDetail,
			resourceMonitorTrendWindow, resourceMonitorUpdated, resourceMonitorEvidenceWindow,
			resourceMonitorEventLog, resourceMonitorSection, requestFlowWindow, requestFlowChart,
			requestFlowLegend, requestFlowBottleneck, requestFlowBottleneckState, requestFlowBottleneckDetail,
			requestFlowAnomalyWindow, requestFlowAnomalies, requestFlowTypes, requestFlowLog,
			requestFlowObserved, requestFlowSection,
		] = readerElementGroup(
			'.ldp-resource-monitor-health', '.ldp-resource-monitor-health-state',
			'.ldp-resource-monitor-health-detail', '.ldp-resource-monitor-trend-window',
			'.ldp-resource-monitor-updated', '.ldp-resource-monitor-evidence-window',
			'.ldp-resource-monitor-event-log',
			'.ldp-settings-section[data-settings-panel="resource-monitor"]',
			'.ldp-request-flow-window', '.ldp-request-flow-trace', '.ldp-request-flow-legend',
			'.ldp-request-flow-bottleneck', '.ldp-request-flow-bottleneck-state',
			'.ldp-request-flow-bottleneck-detail', '.ldp-request-flow-anomaly-window',
			'.ldp-request-flow-anomalies', '.ldp-request-flow-types', '.ldp-request-flow-log',
			'.ldp-request-flow-observed', '.ldp-settings-section[data-settings-panel="request-flow"]',
		);
		const jumpHighlightControls = Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => {
			const className = `.ldp-flash-${field.key}`;
			return [field.key, {
				input: readerElement(className),
				output: readerElement(`${className}-value`),
			}];
		}));
		const jumpHighlightInputs = JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => jumpHighlightControls[field.key].input);
		const [
			jumpHighlightStatus, jumpHighlightApplyBtn, loadingAnimationSelect, loadingAnimationPreview,
			loadingAnimationPreviewLabel, loadingAnimationReroll, historySortModeSelect,
			historyButtonsAlwaysVisibleInput, historyEdgeTriggerRange, historyEdgeTriggerValue,
			openTopicsAtFirstPostInput, expandNestedRepliesByDefaultInput, expandLeafNestedRepliesInput,
			nestedReplyDisplayWarning, boostCopyModeSelect, boostCopyPrefixInput, boostCopyCounterMarkerInput,
			boostCopyCounterStepInput, boostCopyFixedSuffixInput, boostCopyPreview, configExportBtn,
			configImportBtn, configResetBtn, configFileInput, cacheClearBtn,
		] = readerElementGroup(
			'.ldp-flash-status', '.ldp-flash-apply', '.ldp-loading-animation-select',
			'.ldp-loading-preview-stage', '.ldp-loading-settings-preview-label',
			'.ldp-loading-preview-reroll', '.ldp-history-sort-mode', '.ldp-history-buttons-always-visible',
			'.ldp-history-edge-trigger-range', '.ldp-history-edge-trigger-value',
			'.ldp-open-topics-first-post', '.ldp-expand-nested-replies-default',
			'.ldp-expand-leaf-nested-replies', '.ldp-nested-display-warning', '.ldp-boost-copy-mode',
			'.ldp-boost-copy-prefix', '.ldp-boost-copy-counter-marker', '.ldp-boost-copy-counter-step',
			'.ldp-boost-copy-fixed-suffix', '.ldp-boost-copy-preview', '.ldp-config-export',
			'.ldp-config-import', '.ldp-config-reset', '.ldp-config-file', '.ldp-cache-clear',
		);
		const [
			boostCopyCounterRows, boostCopyTextRows, cacheSelects, cacheSizeEls, cacheRetentionEls,
			settingsColorInputs,
		] = readerElementsGroup(
			'.ldp-boost-counter-row', '.ldp-boost-text-row', '.ldp-cache-select', '[data-cache-size]',
			'[data-cache-retention]', '.ldp-color-control input[type="color"]',
		);
		const colorPickerPresets = ['#F0FFF0', '#FFFFFF', '#E5E7EB', '#94A3B8', '#475569', '#111827',
			'#47855F', '#22C55E', '#2563EB', '#7C3AED', '#D97706', '#DC2626'];
		let colorPickerPopover = modal.querySelector(':scope > .ldp-color-picker-popover');
		if (!colorPickerPopover) {
			colorPickerPopover = makeElement('div');
			colorPickerPopover.className = 'ldp-color-picker-popover';
			colorPickerPopover.setAttribute('role', 'dialog');
			colorPickerPopover.setAttribute('aria-modal', 'false');
			setLabel(colorPickerPopover, '选择颜色');
			colorPickerPopover.hidden = true;
			colorPickerPopover.innerHTML = `
				<div class="ldp-color-picker-title">选择颜色</div>
				<div class="ldp-color-picker-presets" role="group" aria-label="常用颜色">
					${colorPickerPresets.map((color) => `<button class="ldp-color-picker-preset" type="button" data-color="${color}" style="--ldp-color-picker-preset:${color}" aria-label="使用颜色 ${color}" aria-pressed="false"></button>`).join('')}
				</div>
				<div class="ldp-color-picker-fields">
					<input class="ldp-color-picker-hex" type="text" inputmode="text" maxlength="7" autocomplete="off" spellcheck="false" aria-label="十六进制颜色" placeholder="#RRGGBB">
					<button class="ldp-color-picker-more" type="button" aria-expanded="false">更多颜色</button>
				</div>
				<div class="ldp-color-picker-advanced" hidden>
					<label class="ldp-color-picker-slider"><span>色相</span><input class="ldp-color-picker-hue" type="range" min="0" max="359" step="1"><output class="ldp-color-picker-hue-value"></output></label>
					<label class="ldp-color-picker-slider"><span>饱和度</span><input class="ldp-color-picker-saturation" type="range" min="0" max="100" step="1"><output class="ldp-color-picker-saturation-value"></output></label>
					<label class="ldp-color-picker-slider"><span>明度</span><input class="ldp-color-picker-brightness" type="range" min="0" max="100" step="1"><output class="ldp-color-picker-brightness-value"></output></label>
				</div>`;
			modal.append(colorPickerPopover);
		}
		const colorPickerTitle = colorPickerPopover.querySelector('.ldp-color-picker-title');
		const colorPickerHexInput = colorPickerPopover.querySelector('.ldp-color-picker-hex');
		const colorPickerPresetButtons = colorPickerPopover.querySelectorAll('.ldp-color-picker-preset');
		const colorPickerMoreButton = colorPickerPopover.querySelector('.ldp-color-picker-more');
		const colorPickerAdvanced = colorPickerPopover.querySelector('.ldp-color-picker-advanced');
		const colorPickerHueInput = colorPickerPopover.querySelector('.ldp-color-picker-hue');
		const colorPickerSaturationInput = colorPickerPopover.querySelector('.ldp-color-picker-saturation');
		const colorPickerBrightnessInput = colorPickerPopover.querySelector('.ldp-color-picker-brightness');
		const colorPickerHueValue = colorPickerPopover.querySelector('.ldp-color-picker-hue-value');
		const colorPickerSaturationValue = colorPickerPopover.querySelector('.ldp-color-picker-saturation-value');
		const colorPickerBrightnessValue = colorPickerPopover.querySelector('.ldp-color-picker-brightness-value');
		let activeColorInput = null;
		let colorPickerHsv = { h: 0, s: 0, v: 100 };
		let colorPickerCommitFrame = 0;
		let pendingColorPickerCommit = null;
		const normalizeColorPickerHex = (rawValue) => {
			const match = /^#?([\da-f]{3}|[\da-f]{6})$/i.exec(String(rawValue || '').trim());
			if (!match) return '';
			const digits = match[1].length === 3 ? Array.from(match[1], (digit) => `${digit}${digit}`).join('') : match[1];
			return `#${digits.toUpperCase()}`;
		};
		const colorPickerHexToHsv = (hex) => {
			const value = normalizeColorPickerHex(hex).slice(1);
			const red = parseInt(value.slice(0, 2), 16) / 255;
			const green = parseInt(value.slice(2, 4), 16) / 255;
			const blue = parseInt(value.slice(4, 6), 16) / 255;
			const max = Math.max(red, green, blue);
			const min = Math.min(red, green, blue);
			const delta = max - min;
			let hue = 0;
			if (delta) {
				if (max === red) hue = 60 * (((green - blue) / delta) % 6);
				else if (max === green) hue = 60 * ((blue - red) / delta + 2);
				else hue = 60 * ((red - green) / delta + 4);
			}
			if (hue < 0) hue += 360;
			return { h: Math.round(hue) % 360, s: max ? Math.round((delta / max) * 100) : 0, v: Math.round(max * 100) };
		};
		const colorPickerHsvToHex = ({ h, s, v }) => {
			const saturation = s / 100;
			const brightness = v / 100;
			const chroma = brightness * saturation;
			const sector = h / 60;
			const intermediate = chroma * (1 - Math.abs((sector % 2) - 1));
			const offset = brightness - chroma;
			const channels = sector < 1 ? [chroma, intermediate, 0]
				: sector < 2 ? [intermediate, chroma, 0]
					: sector < 3 ? [0, chroma, intermediate]
						: sector < 4 ? [0, intermediate, chroma]
							: sector < 5 ? [intermediate, 0, chroma] : [chroma, 0, intermediate];
			return `#${channels.map((channel) => Math.round((channel + offset) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
		};
		const syncColorPickerAdvanced = () => {
			const { h, s, v } = colorPickerHsv;
			colorPickerHueInput.value = String(h);
			colorPickerSaturationInput.value = String(s);
			colorPickerBrightnessInput.value = String(v);
			colorPickerHueValue.textContent = `${h}°`;
			colorPickerSaturationValue.textContent = `${s}%`;
			colorPickerBrightnessValue.textContent = `${v}%`;
			colorPickerHueInput.style.setProperty('--ldp-color-slider', 'linear-gradient(to right,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)');
			colorPickerHueInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s: 100, v: 100 }));
			colorPickerSaturationInput.style.setProperty('--ldp-color-slider', `linear-gradient(to right,${colorPickerHsvToHex({ h, s: 0, v })},${colorPickerHsvToHex({ h, s: 100, v })})`);
			colorPickerSaturationInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s, v }));
			colorPickerBrightnessInput.style.setProperty('--ldp-color-slider', `linear-gradient(to right,#000000,${colorPickerHsvToHex({ h, s, v: 100 })})`);
			colorPickerBrightnessInput.style.setProperty('--ldp-color-slider-thumb', colorPickerHsvToHex({ h, s, v }));
		};
		const syncColorPickerPopover = () => {
			if (!activeColorInput) return;
			const color = activeColorInput.value.toUpperCase();
			colorPickerTitle.textContent = activeColorInput.getAttribute('aria-label') || '选择颜色';
			colorPickerHexInput.value = color;
			colorPickerHsv = colorPickerHexToHsv(color);
			syncColorPickerAdvanced();
			colorPickerHexInput.setAttribute('aria-invalid', 'false');
			colorPickerPresetButtons.forEach((button) => setPressed(button, button.dataset.color === color));
		};
		const commitScheduledColorPickerValue = () => {
			colorPickerCommitFrame = 0;
			const pending = pendingColorPickerCommit;
			pendingColorPickerCommit = null;
			if (!pending || !pending.input.isConnected) return;
			pending.input.value = pending.value;
			pending.input.dispatchEvent(new window.Event('input', { bubbles: true }));
		};
		const scheduleColorPickerValue = (value) => {
			if (!activeColorInput) return;
			pendingColorPickerCommit = { input: activeColorInput, value };
			if (!colorPickerCommitFrame) colorPickerCommitFrame = window.requestAnimationFrame(commitScheduledColorPickerValue);
		};
		const flushScheduledColorPickerValue = () => {
			if (!pendingColorPickerCommit) return;
			if (colorPickerCommitFrame) window.cancelAnimationFrame(colorPickerCommitFrame);
			commitScheduledColorPickerValue();
		};
		const closeColorPickerPopover = ({ restoreFocus = false } = {}) => {
			if (colorPickerPopover.hidden) return;
			flushScheduledColorPickerValue();
			const previousInput = activeColorInput;
			colorPickerPopover.hidden = true;
			activeColorInput = null;
			if (restoreFocus && previousInput) previousInput.focus();
		};
		const positionColorPickerPopover = (input) => {
			const margin = 12;
			const gap = 8;
			const bounds = settingsSurfaceBounds();
			const inputRect = input.getBoundingClientRect();
			const popoverRect = colorPickerPopover.getBoundingClientRect();
			const minimumLeft = bounds.left + margin;
			const minimumTop = bounds.top + margin;
			const left = Math.min(
				Math.max(minimumLeft, inputRect.left),
				Math.max(minimumLeft, bounds.right - popoverRect.width - margin),
			);
			const below = inputRect.bottom + gap;
			const top = below + popoverRect.height <= bounds.bottom - margin
				? below
				: Math.max(minimumTop, inputRect.top - popoverRect.height - gap);
			colorPickerPopover.style.left = `${Math.round(left - bounds.left)}px`;
			colorPickerPopover.style.top = `${Math.round(top - bounds.top)}px`;
		};
		const openColorPickerPopover = (input) => {
			if (!input || input.disabled) return;
			hideSettingHelp();
			closeFontFamilyMenu();
			stopSettingsRangeDrag();
			stopSettingsColorPick();
			activeColorInput = input;
			syncColorPickerPopover();
			colorPickerPopover.hidden = false;
			positionColorPickerPopover(input);
			colorPickerHexInput.focus();
			colorPickerHexInput.select();
		};
		const applyColorPickerValue = (value, closeAfter = false) => {
			if (!activeColorInput) return;
			flushScheduledColorPickerValue();
			activeColorInput.value = value;
			activeColorInput.dispatchEvent(new window.Event('input', { bubbles: true }));
			syncColorPickerPopover();
			if (closeAfter) closeColorPickerPopover({ restoreFocus: true });
		};
		if (!reusingShell) {
			settingsColorInputs.forEach((input) => {
				input.setAttribute('aria-haspopup', 'dialog');
				onShell(input, 'click', (event) => {
					event.preventDefault();
					openColorPickerPopover(input);
				});
				onShell(input, 'keydown', (event) => {
					if (event.key !== 'Enter' && event.key !== ' ') return;
					event.preventDefault();
					openColorPickerPopover(input);
				});
			});
			colorPickerPresetButtons.forEach((button) => onShell(button, 'click', () => {
				applyColorPickerValue(button.dataset.color, true);
			}));
			onShell(colorPickerHexInput, 'input', () => {
				const rawValue = colorPickerHexInput.value.trim();
				const color = /^#?[\da-f]{6}$/i.test(rawValue) ? normalizeColorPickerHex(rawValue) : '';
				colorPickerHexInput.setAttribute('aria-invalid', String(rawValue.length >= 7 && !color));
				if (color) applyColorPickerValue(color);
			});
			onShell(colorPickerHexInput, 'keydown', (event) => {
				if (event.key === 'Escape') {
					event.preventDefault();
					closeColorPickerPopover({ restoreFocus: true });
					return;
				}
				if (event.key !== 'Enter') return;
				const color = normalizeColorPickerHex(colorPickerHexInput.value);
				if (!color) {
					colorPickerHexInput.setAttribute('aria-invalid', 'true');
					return;
				}
				event.preventDefault();
				applyColorPickerValue(color, true);
			});
			onShell(colorPickerHexInput, 'blur', () => {
				if (!activeColorInput || colorPickerPopover.contains(document.activeElement)) return;
				colorPickerHexInput.value = activeColorInput.value.toUpperCase();
				colorPickerHexInput.setAttribute('aria-invalid', 'false');
			});
			onShell(colorPickerMoreButton, 'click', () => {
				const expanded = colorPickerAdvanced.hidden;
				colorPickerAdvanced.hidden = !expanded;
				setExpanded(colorPickerMoreButton, expanded);
				colorPickerMoreButton.textContent = expanded ? '收起调色' : '更多颜色';
				if (activeColorInput) positionColorPickerPopover(activeColorInput);
			});
			[colorPickerHueInput, colorPickerSaturationInput, colorPickerBrightnessInput].forEach((input) =>
				onShell(input, 'input', () => {
					colorPickerHsv = {
						h: Number(colorPickerHueInput.value),
						s: Number(colorPickerSaturationInput.value),
						v: Number(colorPickerBrightnessInput.value),
					};
					const color = colorPickerHsvToHex(colorPickerHsv);
					colorPickerHexInput.value = color;
					colorPickerHexInput.setAttribute('aria-invalid', 'false');
					syncColorPickerAdvanced();
					colorPickerPresetButtons.forEach((button) =>
						setPressed(button, button.dataset.color === color));
					scheduleColorPickerValue(color);
				}));
			[colorPickerHueInput, colorPickerSaturationInput, colorPickerBrightnessInput]
				.forEach((input) => onShell(input, 'change', flushScheduledColorPickerValue));
			onShell(colorPickerPopover, 'keydown', (event) => {
				if (event.key !== 'Escape') return;
				event.preventDefault();
				closeColorPickerPopover({ restoreFocus: true });
			});
			onShell(colorPickerPopover, 'pointerdown', (event) => event.stopPropagation());
			onShell(colorPickerPopover, 'click', (event) => event.stopPropagation());
			onShell(overlay, 'pointerdown', (event) => {
				if (colorPickerPopover.hidden || colorPickerPopover.contains(event.target) || event.target === activeColorInput) return;
				closeColorPickerPopover();
			}, true);
		}
		const setSettingHelp = (target, copy) => {
			if (target && copy) target.dataset.settingHelp = copy;
		};
		const setSettingRowHelp = (control, copy) => {
			setSettingHelp(control?.closest('.ldp-setting-row'), copy);
		};
		setSettingRowHelp(imageScaleRange, '控制所有显示形态中帖子正文图片的显示大小。100% 是正常尺寸;调小能少占屏幕,调大方便看细节。修改后立即保存。');
		setSettingRowHelp(lightboxOriginalByDefaultInput, '开启后,灯箱首次打开和切换图片时会自动请求原图;关闭后不会新请求原图,但本地已缓存原图时仍优先展示,否则沿用预览图。修改后立即保存。');
		setSettingRowHelp(lightboxCommentsExpandedInput, '控制图片灯箱首次打开时是否展开评论面板;灯箱内仍可随时手动收起或展开。修改后立即保存。');
		setSettingRowHelp(lightboxDescriptionExpandedInput, '控制图片描述的默认收纳状态;灯箱内手动收纳或拖动描述高度后也会自动记住。修改后立即保存。');
		const layoutHelp = {
			left: '控制阅读内容左边保留多少空白。数值越大,正文会整体向右并变窄;它需要和另外四个区域合计正好 100%。',
			main: '控制主帖和楼层正文实际占用的宽度。太小会增加换行,太大会挤压时间轴和两侧留白;五个区域必须合计 100%。',
			gap: '控制正文与右侧时间轴之间的空隙。调大后两块内容更分开,但正文可用宽度会减少;五个区域必须合计 100%。',
			timeline: '控制右侧楼层时间轴、日期和楼层计数占用的宽度。太小可能显得拥挤;五个区域必须合计 100%。',
			right: '控制时间轴右边保留多少空白。调大能让右侧不贴边,但会减少正文或时间轴的空间;五个区域必须合计 100%。',
		};
		layoutRatioInputs.forEach((input) => setSettingRowHelp(input, layoutHelp[input.dataset.layoutRegion]));
		setSettingHelp(readerWindowWidthInput.closest('.ldp-reader-window-field'), '设置桌面浮窗的宽度。数值越大,一行能显示的正文越多;只影响浮窗模式,修改后立即保存。');
		setSettingHelp(readerWindowHeightInput.closest('.ldp-reader-window-field'), '设置桌面浮窗的高度。数值越大,一屏能看到的楼层越多;只影响浮窗模式,修改后立即保存。');
		setSettingHelp(readerWindowXInput.closest('.ldp-reader-window-field'), '设置浮窗左边缘距离浏览器左侧的像素数。数值越大,浮窗越靠右;只影响桌面浮窗并立即保存。');
		setSettingHelp(readerWindowYInput.closest('.ldp-reader-window-field'), '设置浮窗上边缘距离浏览器顶部的像素数。数值越大,浮窗越靠下;只影响桌面浮窗并立即保存。');
		setSettingHelp(readerWindowPinInput.closest('.ldp-reader-window-option'), '开启后,点击阅读器外面的网页区域也不会自动收起浮窗,适合边看帖子边操作原页面。');
		setSettingHelp(readerWindowLockInput.closest('.ldp-reader-window-option'), '开启后禁止拖动和缩放浮窗,避免误操作改变位置或大小;仍可通过小锁或这里重新解锁。');
		appearanceSettingElements.forEach(({ input, help, range }) => setSettingRowHelp(input,
			range ? help : `${help}确定颜色后会保留色相,并按用途自动生成明亮与暗色主题的安全亮度;切换主题时自动应用。`));
		appearanceGroupToggleInputs.forEach((input) => setSettingHelp(input.closest('.ldp-setting-switch'),
			'关闭后隐藏回复关系线、引用线和界面分隔线;当前形态已经设置的颜色、粗细和圆角不会丢失,重新开启即可恢复。'));
		const presetHelp = {
			low: '同时收紧请求来源、DOM 窗口和共享调度,优先降低网络、内存与主线程占用;下次打开阅读器生效。',
			balanced: '在请求需求、滚动跟手、DOM 占用和鲁棒调度之间取默认平衡;下次打开阅读器生效。',
			high: '扩大请求批量、DOM 窗口和提前请求距离,优先减少滚动等待,但会增加内存、网络和限流压力;下次打开阅读器生效。',
			custom: '表示下面的性能参数已经手动调整。点击它不会自动改值,可直接修改下方各项;下次打开阅读器生效。',
		};
		performancePresetButtons.forEach((button) => setSettingHelp(button, presetHelp[button.dataset.performancePreset]));
		performanceInputs.forEach((input) => setSettingRowHelp(input,
			PERFORMANCE_SETTING_FIELDS.find((field) => field.key === input.dataset.performanceKey).help));
		JUMP_HIGHLIGHT_SETTING_FIELDS.forEach((field) => setSettingRowHelp(jumpHighlightControls[field.key].input, field.help));
		setSettingRowHelp(historyButtonsAlwaysVisibleInput, '开启后,浏览历史中可用的左右导航按钮会一直显示,并禁用“左右按钮触发区域”滑块;关闭后,按钮仅在鼠标进入对应边缘区域或键盘聚焦时显示。修改后立即保存。');
		setSettingRowHelp(historyEdgeTriggerRange, `设置阅读器左右两侧各自用于唤出历史导航按钮的宽度,占当前阅读器宽度的 ${HISTORY_EDGE_TRIGGER_MIN}%–${HISTORY_EDGE_TRIGGER_MAX}%;区域透明且不会遮挡正文操作,0% 表示关闭鼠标触发。修改后立即保存。`);
		setSettingRowHelp(historySortModeSelect, '默认的“最近点击”使用每次打开的最新时间倒序排列,距离现在越近越靠前,因此重开旧帖后它会回到列表顶部;“首次点击”会固定每条记录第一次进入历史时的时间,重开不会改变位置。现有记录无法还原更早的首次点击,会以当前保存时间作为起点。修改后立即保存。');
		setSettingRowHelp(openTopicsAtFirstPostInput, '开启后,普通帖子链接会从 #1 主楼开始;消息、历史和收藏面板中的链接仍优先打开各自目标楼层。关闭后,所有链接都会尊重其中指定的楼层号。修改后立即保存。');
		setSettingRowHelp(expandNestedRepliesByDefaultInput, '开启后,父楼层下面默认铺开直属二级回复;可与对应正式楼层同时展开,但至少要保留一种显示方式。修改后立即保存并应用到当前帖子。');
		setSettingRowHelp(expandLeafNestedRepliesInput, '开启后,二级回复在主信息流中的对应楼层默认完整展开;可与父楼层下的直属回复同时展开,但至少要保留一种显示方式。修改后立即保存并应用到当前帖子。');
		setSettingRowHelp(boostCopyModeSelect, '选择复制 Boost 时尾巴如何生成:“数字递增”会在每次复制后加步长,“固定文字”每次都追加同一段文字。修改后立即保存。');
		setSettingRowHelp(boostCopyPrefixInput, '填写复制结果最前面的文字,例如“赞同:”。留空就直接从原 Boost 内容开始,最多 16 个字,修改后立即保存。');
		setSettingRowHelp(boostCopyCounterMarkerInput, '数字递增模式下,填写数字前面的符号或文字,例如“+”会得到“原 Boost +1”。留空时数字会直接接在原文后面。');
		setSettingRowHelp(boostCopyCounterStepInput, '数字递增模式下,每复制一次增加多少。设为 1 会依次得到 1、2、3;设为 5 会得到 5、10、15。');
		setSettingRowHelp(boostCopyFixedSuffixInput, '固定文字模式下,每次复制都会把这里的文字追加到原 Boost 后面,例如“俺也一样”。最多 16 个字。');
		setSettingRowHelp(boostCopyPreview, '这里展示当前规则实际会复制出的结果。数字递增模式会同时展示连续两次复制,方便确认步长是否正确。');
		cacheSelects.forEach((input, index) => setSettingHelp(input.closest('.ldp-cache-row'), CACHE_TYPES[index].help));
		setSettingHelp(cacheClearBtn, '只清理上面已经勾选的缓存类型。不会退出登录,也不会删除站点上的帖子、消息或图片;清理后需要时会重新联网获取。');
		const readerWindow = readerShell.readerWindow || createReaderWindowController(overlay, modal);
		readerShell.readerWorkspace = readerWorkspace;
		readerShell.readerWindow = readerWindow;
		readerShell.topicId = Number(topicId);
		readerShell.targetPostNumber = targetPostNumber;
		readerShell.topicNavMetadata = sourceTopicNavMetadata;
		readerShell.commentsHeader = commentsHeader;
		overlay._ldpReaderShell = readerShell;
		const syncReaderPlacementUi = () => {
			const allowedModes = directTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES;
			const visibleModeCount = allowedModes.length;
			readerPlacementControl.hidden = visibleModeCount < 2;
			readerPlacementStrip.hidden = visibleModeCount < 2;
			readerCapsule.classList.toggle('ldp-reader-placement-available', visibleModeCount > 1);
			if (visibleModeCount < 2) readerCapsule.classList.remove('ldp-reader-placement-expanded');
			const activeMode = readerWorkspace.mode();
			readerPlacementOptions.forEach((option) => {
				const optionMode = option.dataset.readerPlacement;
				option.hidden = !allowedModes.includes(optionMode);
				option.classList.toggle('active', option.dataset.readerPlacement === activeMode);
			});
		};
		const applyReaderPlacement = (nextMode) => {
			const allowedModes = directTopicRoute ? TOPIC_READER_MODES : LIST_READER_MODES;
			if (!allowedModes.includes(nextMode)) return;
			readerWorkspace.setMode(nextMode);
			readerWindow.syncMode();
			syncLayoutBtn();
			syncReaderBackgroundMode();
			applyLayoutRatios();
			if (nextMode !== readerWorkspace.mode()) showSelectionToast('页面空间不足,暂时保持浮窗');
			syncReaderPlacementUi();
		};
		if (!reusingShell) {
			onShell(readerPlacementControl, 'pointerenter', () => {
				readerCapsule.classList.add('ldp-reader-placement-expanded');
			});
			onShell(readerCapsule, 'pointerleave', () => {
				readerCapsule.classList.remove('ldp-reader-placement-expanded');
			});
			readerPlacementOptions.forEach((option) => {
				onShell(option, 'click', () => applyReaderPlacement(option.dataset.readerPlacement));
			});
		}
		syncReaderPlacementUi();

		let latestTopicShellData = cachedTopicPreview;
		let topicNavMetadataObserver = null;
		let topicNavMetadataSyncFrame = 0;
		let topicNavMetadataStopTimer = 0;
		let topicNavMetadataOverride = null;
		let topicEditorState = null;
		const stopTopicNavMetadataHydration = () => {
			if (topicNavMetadataObserver) topicNavMetadataObserver.disconnect();
			if (topicNavMetadataSyncFrame) cancelAnimationFrame(topicNavMetadataSyncFrame);
			if (topicNavMetadataStopTimer) clearTimeout(topicNavMetadataStopTimer);
			topicNavMetadataObserver = null;
			topicNavMetadataSyncFrame = 0;
			topicNavMetadataStopTimer = 0;
		};
		const renderTopicShell = (topicData) => {
			if (!topicData) return;
			latestTopicShellData = topicData;
			const liveTopicNavMetadata = readTopicNavMetadata(null, topicId);
			sourceTopicNavMetadata = mergeTopicNavMetadata(
				topicNavMetadataOverride || liveTopicNavMetadata,
				mergeTopicNavMetadata(sourceTopicNavMetadata, cachedTopicNavMetadata(topicData)),
			);
			if (queueEntry) updateReaderQueueEntryFromTopic(queueEntry, topicData);
			readerShell.topicNavMetadata = sourceTopicNavMetadata;
			const topicMetadataFilled = fillTopicNavMetadata(topicData, sourceTopicNavMetadata);
			const topicMetadataRemembered = rememberTopicNavMetadata(topicId, topicData, sourceTopicNavMetadata);
			if (topicMetadataFilled && !topicMetadataRemembered) setCachedTopicData(topicId, topicData);
			const cachedPosts = Array.isArray(topicData.post_stream?.posts)
				? topicData.post_stream.posts
				: [];
			const owner = topicData._opUsername
				|| (topicData.details?.created_by && topicData.details.created_by.username)
				|| (cachedPosts.find((post) => +post.post_number === 1) || {}).username
				|| '';
			const title = String(topicData.title || '').trim();
			if (title) {
				readerElement('.ldp-title-jump').textContent = title;
				syncReaderDocumentTitle(title);
			}
			const topicEditTrigger = readerElement('.ldp-topic-edit-trigger');
			const canEditTopic = topicData.details && topicData.details.can_edit === true;
			topicEditTrigger.hidden = !canEditTopic;
			if (!canEditTopic && topicEditorState) topicEditorState.close(true);
			const ownerHtml = owner
				? `<a class="ldp-user-link ldp-topic-owner-link" href="${userProfileUrl(owner)}" target="_blank" rel="noopener" data-user-card="${escAttr(owner)}">@${esc(owner)}</a>`
				: '@?';
			const metaParts = [
				`${esc(String(topicData.posts_count || 0))} 帖`,
				`${esc(String(topicData.views || 0))} 浏览`,
			];
			if (topicData.like_count != null) {
				metaParts.push(`${esc(String(Math.max(0, +topicData.like_count || 0)))} 赞`);
			}
			if (topicData.participant_count != null) {
				metaParts.push(`${esc(String(Math.max(0, +topicData.participant_count || 0)))} 用户`);
			}
			readerElement('.ldp-meta-stats').innerHTML = metaParts.join(' · ');
			readerElement('.ldp-meta-owner-value').innerHTML = ownerHtml;
			readerElement('.ldp-meta-owner').hidden = false;
			const tagsEl = readerElement('.ldp-topic-tags');
			tagsEl.innerHTML = renderTopicNavLinks(topicData, sourceTopicNavMetadata);
			tagsEl.hidden = !tagsEl.innerHTML;
			const voteSlot = readerElement('.ldp-topic-vote-slot');
			const hasTopicVote = topicData.can_vote === true || topicData.user_voted === true || +topicData.vote_count > 0;
			voteSlot.hidden = !hasTopicVote;
			voteSlot.innerHTML = hasTopicVote
				? `<button class="ldp-topic-vote${topicData.user_voted ? ' on' : ''}" type="button" data-topic-vote aria-pressed="${topicData.user_voted ? 'true' : 'false'}"${topicData.can_vote === false && !topicData.user_voted ? ' disabled' : ''}>▲ <span>${Math.max(0, +topicData.vote_count || 0)}</span> 票</button>`
				: '';
		};
		const syncTopicNavMetadataFromHost = () => {
			if (topicNavMetadataOverride) return false;
			if (!overlay.isConnected) {
				stopTopicNavMetadataHydration();
				return false;
			}
			const liveMetadata = readTopicNavMetadata(null, topicId);
			if (!topicNavMetadataHasIcons(liveMetadata)) return false;
			stopTopicNavMetadataHydration();
			sourceTopicNavMetadata = mergeTopicNavMetadata(liveMetadata, sourceTopicNavMetadata);
			readerShell.topicNavMetadata = sourceTopicNavMetadata;
			renderTopicShell(latestTopicShellData);
			return true;
		};
		const queueTopicNavMetadataSync = () => {
			if (topicNavMetadataSyncFrame) return;
			topicNavMetadataSyncFrame = requestAnimationFrame(() => {
				topicNavMetadataSyncFrame = 0;
				syncTopicNavMetadataFromHost();
			});
		};
		const startTopicNavMetadataHydration = () => {
			if (!directTopicRoute || topicNavMetadataHasIcons(sourceTopicNavMetadata) || !document.body) return;
			topicNavMetadataObserver = new MutationObserver(queueTopicNavMetadataSync);
			topicNavMetadataObserver.observe(document.body, { childList: true, subtree: true });
			topicNavMetadataStopTimer = setTimeout(stopTopicNavMetadataHydration, 5000);
			queueTopicNavMetadataSync();
		};
		renderTopicShell(cachedTopicPreview);
		startTopicNavMetadataHydration();
		const postRequestScheduler = createReaderRequestScheduler(performance);
		ACTIVE_READER_REQUEST_SCHEDULER = postRequestScheduler;
		scheduleReaderQueuePrefetch();
		let rateLimitNoticeTimer = 0;
		let rateLimitChallengePending = false;
		const rateLimitCooldownRemaining = () => {
			try {
				const state = postRequestScheduler.snapshot();
				return Math.max(0, Number(state?.cooldownRemaining) || 0);
			} catch (error) {}
			return 0;
		};
		const syncRateLimitNotice = () => {
			if (!rateLimitNotice || !rateLimitDetail) return;
			const remaining = rateLimitCooldownRemaining();
			if (remaining <= 0) {
				rateLimitNotice.hidden = true;
				delete rateLimitNotice.dataset.cooldownSeconds;
				if (rateLimitChallengePending && rateLimitChallenge) {
					rateLimitChallengePending = false;
					openCloudflareChallengePopup(rateLimitChallenge.href);
				}
				return;
			}
			rateLimitChallengePending = true;
			const seconds = Math.max(1, Math.ceil(remaining / 1000));
			if (rateLimitNotice.dataset.cooldownSeconds !== String(seconds)) {
				rateLimitNotice.dataset.cooldownSeconds = String(seconds);
				rateLimitDetail.textContent = `触发 429,正在等待 ${seconds} 秒;倒计时结束后将自动打开过盾浮窗。`;
			}
			rateLimitNotice.hidden = false;
		};
		const stopRateLimitNotice = () => {
			if (rateLimitNoticeTimer) clearInterval(rateLimitNoticeTimer);
			rateLimitNoticeTimer = 0;
			if (!rateLimitNotice) return;
			rateLimitNotice.hidden = true;
			delete rateLimitNotice.dataset.cooldownSeconds;
		};
		syncRateLimitNotice();
		rateLimitNoticeTimer = setInterval(syncRateLimitNotice, 500);
		const loader = createLoader(topicId, postRequestScheduler, performance);
		const initialTopicRequest = loader.init();

		const notificationState = { group: 'all', page: 0, token: 0, total: 0, hasNext: false, query: '' };
		let notificationQueuedRequestKey = '';
		const currentReaderRequestScheduler = () => (
			readerShell.activeContext?.postRequestScheduler
		) || ACTIVE_READER_REQUEST_SCHEDULER || postRequestScheduler;
		const historyState = { page: 0, query: '', multi: false, scope: 'page' };
		const historySelection = new Set();
		let historyScopeIds = [];
		const bookmarksState = {
			page: 0,
			query: '',
			token: 0,
			hasNext: false,
			entries: [],
			allEntries: [],
			reactionEntries: [],
			reactionLoadedAt: 0,
			reactionUsername: '',
			reactionFilter: '',
			type: PREFS.bookmarkTabOrder[0],
			multi: false,
			scope: 'page',
			allIds: null,
		};
		const givenReactionOverrides = readerShell.givenReactionOverrides instanceof Map
			? readerShell.givenReactionOverrides
			: new Map();
		readerShell.givenReactionOverrides = givenReactionOverrides;
		const bookmarksSelection = new Set();
		const clearBookmarksSelectionScope = () => {
			bookmarksState.allIds = null;
			bookmarksSelection.clear();
		};
		const cachedNotificationWarmup = getCachedNotificationPage(notificationPageCacheKey('all', 0));
		if (cachedNotificationWarmup) {
			notificationState.total = cachedNotificationWarmup.total;
			notificationState.hasNext = cachedNotificationWarmup.hasNext;
		}
		syncNotificationUnreadUi(ME_CURRENT_USER ? NOTIFICATION_UNREAD_COUNT : cachedUnreadNotificationCount());
		const positionHeaderPopover = (button, popover) => {
			if (!button || !popover || popover.hidden) return;
			const buttonRect = button.getBoundingClientRect();
			const popoverRect = popover.getBoundingClientRect();
			const gap = 8;
			const left = Math.max(12, Math.min(window.innerWidth - popoverRect.width - 12,
				buttonRect.right - popoverRect.width));
			const below = buttonRect.bottom + gap;
			const above = buttonRect.top - popoverRect.height - gap;
			popover.style.left = `${Math.round(left)}px`;
			popover.style.top = `${Math.round(
				below + popoverRect.height <= window.innerHeight - 12 || above < 12 ? below : above
			)}px`;
		};
		const createCollectionPanelController = ({ toggle, popover, panel, state, close }) => ({
			toggle,
			popover,
			position: () => positionHeaderPopover(toggle, popover),
			open() {
				popover.hidden = false;
				setExpanded(toggle, true);
			},
			close() {
				popover.hidden = true;
				setExpanded(toggle, false);
				close?.();
			},
			message(copy, error = false) {
				releasePersistentAvatarImages(panel.list);
				panel.list.innerHTML = `<div class="ldp-notification-${error ? 'error' : 'empty'}">${copy}</div>`;
				panel.pagePrev.disabled = true;
				panel.pageNext.disabled = true;
			},
			paging(totalPages, hasEntries = true, hasNext = state.hasNext) {
				panel.pagePrev.disabled = state.page <= 0;
				panel.pageNext.disabled = !hasNext;
				panel.pageInfo.textContent = hasEntries ? `${state.page + 1} / ${Math.max(1, totalPages)}` : '暂无记录';
			},
		});
		const notificationCollection = createCollectionPanelController({
			toggle: notificationsBtn, popover: notificationsPopover, panel: notificationPanel, state: notificationState,
		});
		const historyCollection = createCollectionPanelController({
			toggle: historyPanel.toggle, popover: historyPanel.popover, panel: historyPanel, state: historyState,
			close: () => {
				historyState.multi = false;
				historySelection.clear();
			},
		});
		const bookmarksCollection = createCollectionPanelController({
			toggle: bookmarksPanel.toggle, popover: bookmarksPanel.popover, panel: bookmarksPanel, state: bookmarksState,
			close: () => {
				readerShell.cancelBookmarksSearch?.();
				bookmarksState.token++;
				bookmarksState.multi = false;
				clearBookmarksSelectionScope();
			},
		});
		const positionNotificationsPopover = notificationCollection.position;
		const closeNotifications = notificationCollection.close;
		const positionHistoryPopover = historyCollection.position;
		const closeHistory = historyCollection.close;
		const positionBookmarksPopover = bookmarksCollection.position;
		const closeBookmarks = bookmarksCollection.close;
		const collectionPanels = [notificationCollection, historyCollection, bookmarksCollection];
		const positionHeaderPopovers = () => collectionPanels.forEach((panel) => panel.position());
		const syncHistorySortToggle = () => {
			const recentFirst = PREFS.historySortMode === 'recent-viewed';
			const label = recentFirst
				? '最近打开优先;点击切换为首次打开顺序'
				: '首次打开顺序固定;点击切换为最近打开优先';
			historyPanel.sortToggle.innerHTML = icon(recentFirst ? 'history' : 'pin');
			setLabel(historyPanel.sortToggle, label);
			setPressed(historyPanel.sortToggle, !recentFirst);
			historyPanel.sortToggle.dataset.ldpTooltipLabel = label;
		};
		const syncCollectionSelectionControls = (
			panel, state, selection, scopeIds, itemLabel, multiDisabled, selectDisabled, unavailable = false
		) => {
			const fullySelected = Array.isArray(scopeIds) && scopeIds.length > 0 &&
				scopeIds.every((id) => selection.has(id));
			panel.defaultActions.hidden = unavailable || state.multi;
			panel.bulkActions.hidden = unavailable || !state.multi;
			panel.multi.disabled = multiDisabled;
			panel.selectScope.value = state.scope;
			panel.selectToggle.disabled = selectDisabled;
			panel.selectToggle.innerHTML = icon(fullySelected ? 'squareCheck' : 'square');
			setPressed(panel.selectToggle, fullySelected);
			setLabel(panel.selectToggle, `${fullySelected ? '全不选' : '全选'}${state.scope === 'all' ? '全部页面' : '本页'}${itemLabel}`);
			panel.deleteSelected.disabled = selection.size === 0;
			panel.deleteSelectedLabel.textContent = String(selection.size);
			panel.deleteSelectedLabel.hidden = selection.size === 0;
		};
		const localCollectionPage = (entries, state, pageSize) => {
			const totalPages = Math.max(1, Math.ceil(entries.length / pageSize));
			state.page = Math.max(0, Math.min(totalPages - 1, state.page));
			const start = state.page * pageSize;
			return { entries: entries.slice(start, start + pageSize), totalPages };
		};
		function renderHistoryPage() {
			syncHistorySortToggle();
			const storedHistory = readTopicHistory();
			const allEntries = orderedTopicHistory(storedHistory);
			const availableTopicIds = new Set(allEntries.map((entry) => Number(entry.topicId)));
			historySelection.forEach((topicId) => {
				if (!availableTopicIds.has(topicId)) historySelection.delete(topicId);
			});
			const entries = historyState.query
				? allEntries.filter((entry) => historyMatchesSearch(entry, historyState.query))
				: allEntries;
			const { entries: pageEntries, totalPages } =
				localCollectionPage(entries, historyState, HISTORY_PAGE_SIZE);
			releasePersistentAvatarImages(historyPanel.list);
			historyPanel.list.innerHTML = pageEntries.length
				? pageEntries.map((entry) => {
					const cachedSource = entry.avatarTemplate &&
						touchResourceObjectUrl(AVATAR_OBJECT_URL_CACHE, avatarUrl(entry.avatarTemplate, 64));
					const historyAvatar = cachedSource
						? `<img class="ldp-notification-avatar" src="${escAttr(cachedSource)}" alt="" loading="lazy" decoding="async">`
						: entry.avatarTemplate
							? `<span class="ldp-notification-avatar ldp-notification-avatar-fallback" data-ldp-avatar-template="${escAttr(entry.avatarTemplate)}">${esc(String(entry.ownerUsername || '?').slice(0, 1).toUpperCase())}</span>`
							: `<span class="ldp-notification-avatar ldp-notification-avatar-fallback">${icon('history')}</span>`;
					const historyUserAvatar = userCardAvatarHtml(historyAvatar, entry.ownerUsername);
					const selected = historySelection.has(Number(entry.topicId));
					const targetPostNumber = Math.max(1, Number(entry.postNumber) || 1);
					return `<div class="ldp-history-item ldp-collection-item${historyState.multi ? ' multi' : ''}${selected ? ' selected' : ''}" data-history-topic-id="${entry.topicId}" data-history-post-number="${targetPostNumber}" data-history-actor="${escAttr(entry.ownerUsername || '')}">
						${historyState.multi ? `<label class="ldp-history-select ldp-collection-select" data-ldp-tooltip-label="选择这条浏览历史">
							<input class="ldp-history-select-input ldp-collection-select-input" type="checkbox" aria-label="选择《${escAttr(entry.title)}》"${selected ? ' checked' : ''}>
						</label>` : ''}
						${collectionLinkMarkup('ldp-history-link', topicPageUrl(entry.topicId, targetPostNumber, true),
							historyUserAvatar, entry.title,
							`${entry.postsCount || 0} 帖 · ${discourseRelativeTime(historyEntrySortTime(entry))}`)}
						${historyState.multi ? '' : `<button class="ldp-history-delete ldp-collection-delete" type="button" aria-label="删除这条浏览历史">${icon('trash')}</button>`}
					</div>`;
				}).join('')
				: `<div class="ldp-notification-empty">${historyState.query ? '没有匹配的浏览历史' : '暂无浏览历史'}</div>`;
			hydrateHistoryAvatars(historyPanel.list, pageEntries, storedHistory);
			historyState.hasNext = historyState.page < totalPages - 1;
			const scopeEntries = historyState.scope === 'all' ? entries : pageEntries;
			historyScopeIds = scopeEntries.map((entry) => Number(entry.topicId));
			syncCollectionSelectionControls(
				historyPanel, historyState, historySelection, historyScopeIds,
				'浏览历史', allEntries.length === 0, scopeEntries.length === 0
			);
			historyPanel.clear.disabled = allEntries.length === 0;
			historyCollection.paging(totalPages, entries.length > 0);
			positionHistoryPopover();
		}
		readerShell.refreshHistoryPanel = () => {
			if (historyPanel.popover.hidden) return;
			if (PREFS.historySortMode === 'recent-viewed') historyState.page = 0;
			renderHistoryPage();
		};
		const bookmarkAvatarHtml = (entry) => {
			const user = entry?.user || {};
			const avatar = persistentAvatarHtml(
				user.avatar_template,
				64,
				'ldp-notification-avatar',
				user.name || user.username || '?'
			);
			return avatar
				? userCardAvatarHtml(avatar, user.username)
				: `<span class="ldp-notification-avatar ldp-notification-avatar-fallback">${icon('bookmark')}</span>`;
		};
		const bookmarkTarget = (entry) => ({
			topicId: Number(entry?.topic_id) || 0,
			postNumber: Math.max(1, Number(entry?.linked_post_number) || 1),
		});
		const reactionPageEntries = (result) => {
			if (Array.isArray(result)) return result;
			if (Array.isArray(result?.user_reactions)) return result.user_reactions;
			if (Array.isArray(result?.reaction_users)) return result.reaction_users;
			if (Array.isArray(result?.reactions)) return result.reactions;
			return [];
		};
		const givenReactionRecord = (post, topic, reaction, id = 0, reactedAt = '', title = '') => {
			const postId = Number(post?.id) || 0;
			const topicId = Number(post?.topic_id || topic?.id) || 0;
			return {
				id: Number(id) || postId,
				topicId,
				postId,
				postNumber: Math.max(1, Number(post?.post_number) || 1),
				reaction,
				title: String(post?.topic_title || title || topic?.title || `帖子 #${topicId}`),
				authorUsername: String(post?.username || ''),
				avatarTemplate: String(post?.avatar_template || ''),
				reactedAt: String(reactedAt || ''),
			};
		};
		const normalizeGivenReaction = (entry) => {
			const post = entry?.post || {};
			const topic = post.topic || {};
			const user = post.user || {};
			const reactionData = entry?.reaction || {};
			const id = Number(entry?.id) || 0;
			const reaction = String(reactionData.reaction_value || entry?.reaction_value || '');
			const sourcePost = {
				id: entry?.post_id || post.id,
				topic_id: post.topic_id || topic.id || entry?.topic_id,
				post_number: post.post_number || entry?.post_number,
				topic_title: post.topic_title || topic.title || entry?.topic_title,
				username: post.username || user.username,
				avatar_template: post.avatar_template || user.avatar_template,
			};
			if (!id || !Number(sourcePost.topic_id) || !Number(sourcePost.id) || !reaction) return null;
			return givenReactionRecord(sourcePost, null, reaction, id, entry?.created_at || reactionData.created_at);
		};
		const normalizeGivenLike = (entry) => {
			const topicId = Number(entry?.topic_id) || 0;
			const postId = Number(entry?.post_id) || 0;
			if (Number(entry?.action_type) !== 1 || !topicId || !postId) return null;
			return givenReactionRecord({ ...entry, id: postId }, null, 'heart', entry.id, entry.created_at, entry.title);
		};
		const givenReactionEntriesByPostId = (entries) => new Map((Array.isArray(entries) ? entries : [])
			.map((entry) => [Number(entry?.postId), entry])
			.filter(([postId]) => postId > 0));
		const mergeGivenReactionOverrides = (entries) => {
			const entriesByPostId = givenReactionEntriesByPostId(entries);
			givenReactionOverrides.forEach((override, postId) => {
				const serverEntry = entriesByPostId.get(postId);
				if (override && Number(serverEntry?.id) > 0 && serverEntry.reaction === override.reaction) {
					givenReactionOverrides.delete(postId);
					return;
				}
				if (!override && !serverEntry) {
					givenReactionOverrides.delete(postId);
					return;
				}
				entriesByPostId.delete(postId);
				if (override) entriesByPostId.set(postId, override);
			});
			return [...entriesByPostId.values()].sort(compareGivenReactionRecords);
		};
		const confirmedGivenReactionRecord = (post, topic, reaction) => {
			const postId = Number(post?.id) || 0;
			return givenReactionRecord(post, topic, reaction, -postId, new Date().toISOString());
		};
		const rememberConfirmedGivenReaction = (post, topic) => {
			const postId = Number(post?.id) || 0;
			if (!postId) return false;
			const current = post?.current_user_reaction;
			const reaction = String(current?.id || '');
			givenReactionOverrides.set(postId, reaction ? confirmedGivenReactionRecord(post, topic, reaction) : null);
			return true;
		};
		const applyConfirmedGivenLike = (entries, post, topic, acted) => {
			const postId = Number(post?.id) || 0;
			if (!postId) return Array.isArray(entries) ? entries : [];
			const entriesByPostId = givenReactionEntriesByPostId(entries);
			const current = entriesByPostId.get(postId);
			if (acted && (!current || current.reaction === 'heart')) {
				entriesByPostId.set(postId, confirmedGivenReactionRecord(post, topic, 'heart'));
			} else if (!acted && current && current.reaction === 'heart') {
				entriesByPostId.delete(postId);
			}
			return [...entriesByPostId.values()].sort(compareGivenReactionRecords);
		};
		const fetchGivenReactionPage = (username, beforeReactionUserId = 0) => {
			const reactionModule = lookupDiscourseModule(
				'discourse/plugins/discourse-reactions/discourse/models/discourse-reactions-custom-reaction'
			);
			const CustomReaction = reactionModule?.default;
			if (!CustomReaction || !isFunction(CustomReaction.findReactions)) {
				return Promise.reject(new Error('Discourse 回应记录接口尚未就绪'));
			}
			return CustomReaction.findReactions('reactions', username, {
				beforeReactionUserId: beforeReactionUserId > 0 ? beforeReactionUserId : undefined,
			});
		};
		const collectPagedEntries = async (loadPage, pageRecords, normalize, identity, isCurrent) => {
			const entriesById = new Map();
			const seenCursors = new Set();
			let cursor = 0;
			while (true) {
				const result = await loadPage(cursor);
				if (!isCurrent()) return null;
				const [records, nextCursor] = pageRecords(result, cursor);
				records.forEach((record) => {
					const entry = normalize(record);
					if (entry) entriesById.set(identity(entry), entry);
				});
				if (nextCursor == null || seenCursors.has(nextCursor)) break;
				seenCursors.add(nextCursor);
				cursor = nextCursor;
			}
			return [...entriesById.values()];
		};
		const reactionCollectionIsCurrent = (token) => token === bookmarksState.token &&
			!bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction';
		const fetchAllGivenReactions = (username, token = bookmarksState.token) => collectPagedEntries(
			(cursor) => fetchGivenReactionPage(username, cursor),
			(result) => {
				const records = reactionPageEntries(result);
				const next = records.length >= GIVEN_REACTIONS_PAGE_SIZE && records.reduce((lowest, entry) => {
					const id = Number(entry?.id) || 0;
					return id > 0 && (!lowest || id < lowest) ? id : lowest;
				}, 0);
				return [records, next || null];
			},
			normalizeGivenReaction, (entry) => entry.id, () => reactionCollectionIsCurrent(token)
		);
		const fetchGivenLikePage = (username, offset = 0) => {
			const params = new URLSearchParams({
				username,
				filter: '1',
				offset: String(Math.max(0, Number(offset) || 0)),
				limit: String(GIVEN_LIKES_PAGE_SIZE),
			});
			return fetchJSON(`${BASE}/user_actions.json?${params}`, {
				key: `likes-given:${username}:${offset}`,
				priority: POST_REQUEST_PRIORITY.auxiliary,
				cacheMode: 'default',
			});
		};
		const fetchAllGivenLikes = (username, token = bookmarksState.token) => collectPagedEntries(
			(offset) => fetchGivenLikePage(username, offset),
			(result, offset) => {
				const records = Array.isArray(result?.user_actions) ? result.user_actions : [];
				return [records, records.length < GIVEN_LIKES_PAGE_SIZE ? null : offset + records.length];
			},
			normalizeGivenLike, (entry) => entry.postId, () => reactionCollectionIsCurrent(token)
		);
		const reactionRecordEntries = () => {
			const needle = normalizeSearchText(bookmarksState.query);
			return bookmarksState.reactionEntries.filter((entry) =>
				(!bookmarksState.reactionFilter || entry.reaction === bookmarksState.reactionFilter) &&
				reactionRecordMatchesSearch(entry, needle));
		};
		const reactionAvatarHtml = (entry) => userCardAvatarHtml(
			persistentAvatarHtml(
				entry?.avatarTemplate,
				64,
				'ldp-notification-avatar',
				entry?.authorUsername || '?'
			) || `<span class="ldp-notification-avatar ldp-notification-avatar-fallback">${esc(String(entry?.authorUsername || '?').slice(0, 1).toUpperCase())}</span>`,
			entry?.authorUsername
		);
		const bookmarkTypeEntries = () => bookmarksState.type === 'Reaction'
			? reactionRecordEntries()
			: bookmarksState.allEntries.filter((entry) => entry && entry.bookmarkable_type === bookmarksState.type);
		const renderBookmarksPage = () => {
			const isReaction = bookmarksState.type === 'Reaction';
			const reactionTypeCounts = new Map();
			if (isReaction) bookmarksState.reactionEntries.forEach((entry) => {
				const reaction = String(entry?.reaction || '');
				if (reaction) reactionTypeCounts.set(reaction, (reactionTypeCounts.get(reaction) || 0) + 1);
			});
			const availableReactionTypes = [...reactionTypeCounts.keys()];
			if (bookmarksState.reactionFilter && !availableReactionTypes.includes(bookmarksState.reactionFilter)) {
				bookmarksState.reactionFilter = '';
			}
			bookmarksReactionFilters.hidden = !isReaction || availableReactionTypes.length === 0;
			bookmarksReactionFilters.innerHTML = isReaction && availableReactionTypes.length
				? [`<button class="ldp-reaction-filter${bookmarksState.reactionFilter ? '' : ' active'}" type="button" data-reaction-filter="" aria-pressed="${String(!bookmarksState.reactionFilter)}"><span>全部</span><span class="ldp-reaction-filter-count">${bookmarksState.reactionEntries.length}</span></button>`,
					...availableReactionTypes.map((reaction) => {
						const active = reaction === bookmarksState.reactionFilter;
						const reactionIcon = reactionLabel(reaction) || esc(`:${reaction}:`);
						const count = reactionTypeCounts.get(reaction) || 0;
						return `<button class="ldp-reaction-filter${active ? ' active' : ''}" type="button" data-reaction-filter="${escAttr(reaction)}" aria-label="只看 ${escAttr(reaction)} 回应,共 ${count} 条" aria-pressed="${String(active)}">${reactionIcon}<span class="ldp-reaction-filter-count">${count}</span></button>`;
					})].join('')
				: '';
			const typeEntries = bookmarkTypeEntries();
			const { entries, totalPages } =
				localCollectionPage(typeEntries, bookmarksState, BOOKMARKS_PAGE_SIZE);
			bookmarksState.entries = entries;
			bookmarksState.hasNext = bookmarksState.page < totalPages - 1;
			if (bookmarksSelection.size) {
				const availableIds = new Set(isReaction ? [] : typeEntries.map((entry) => Number(entry.id)));
				bookmarksSelection.forEach((bookmarkId) => {
					if (!availableIds.has(bookmarkId)) bookmarksSelection.delete(bookmarkId);
				});
			}
			releasePersistentAvatarImages(bookmarksPanel.list);
			if (isReaction) {
				bookmarksPanel.list.innerHTML = entries.length
					? entries.map((entry) => {
						const reaction = String(entry.reaction || '');
						const reactionIcon = reactionLabel(reaction) || esc(`:${reaction}:`);
						const authorLabel = entry.authorUsername ? ` · @${entry.authorUsername}` : '';
						const topicId = Number(entry.topicId);
						const postNumber = Math.max(1, Number(entry.postNumber) || 1);
						return `<div class="ldp-reaction-record ldp-collection-item" data-reaction-record data-reaction-topic-id="${topicId}" data-reaction-post-number="${postNumber}">
							${collectionLinkMarkup('ldp-bookmark-link', topicPageUrl(topicId, postNumber, true),
								reactionAvatarHtml(entry), entry.title || `帖子 #${entry.topicId}`,
								`<span class="ldp-reaction-record-icon" role="img" aria-label="${escAttr(reaction)} 回应">${reactionIcon}</span>${esc(`回应 · 楼层 #${postNumber}${authorLabel} · ${discourseRelativeTime(entry.reactedAt)}`)}`, true)}
						</div>`;
					}).join('')
					: `<div class="ldp-notification-empty">${bookmarksState.query
						? '没有匹配的回应记录'
						: '暂无回应记录;在楼层下方点回应后会出现在这里。'}</div>`;
			} else {
				bookmarksPanel.list.innerHTML = entries.length
					? entries.map((entry) => {
					const bookmarkId = Number(entry.id);
					const target = bookmarkTarget(entry);
					const selected = bookmarksSelection.has(bookmarkId);
					const typeLabel = entry.bookmarkable_type === 'Post' ? `楼层 #${target.postNumber}` : '帖子';
					const postsLabel = Number(entry.highest_post_number) > 0 ? ` · ${Number(entry.highest_post_number)} 帖` : '';
					const nameLabel = String(entry.name || '').trim() ? ` · ${String(entry.name).trim()}` : '';
					return `<div class="ldp-bookmark-item ldp-collection-item${bookmarksState.multi ? ' multi' : ''}${selected ? ' selected' : ''}" data-bookmark-id="${bookmarkId}" data-bookmark-topic-id="${target.topicId}" data-bookmark-post-number="${target.postNumber}">
						${bookmarksState.multi ? `<label class="ldp-bookmark-select ldp-collection-select" data-ldp-tooltip-label="选择这条收藏">
							<input class="ldp-bookmark-select-input ldp-collection-select-input" type="checkbox" aria-label="选择《${escAttr(entry.title || '收藏')}》"${selected ? ' checked' : ''}>
						</label>` : ''}
						${collectionLinkMarkup('ldp-bookmark-link', topicPageUrl(target.topicId, target.postNumber, true),
							bookmarkAvatarHtml(entry), entry.title || `帖子 #${target.topicId}`,
							`${typeLabel}${postsLabel}${nameLabel} · ${discourseRelativeTime(entry.created_at)}`)}
						${bookmarksState.multi ? '' : `<button class="ldp-bookmark-delete ldp-collection-delete" type="button" aria-label="取消这条收藏">${icon('trash')}</button>`}
					</div>`;
					}).join('')
					: `<div class="ldp-notification-empty">${bookmarksState.query
						? `没有匹配的${bookmarksState.type === 'Post' ? '楼层' : '帖子'}收藏`
						: `暂无${bookmarksState.type === 'Post' ? '楼层' : '帖子'}收藏`}</div>`;
			}
			hydratePersistentAvatarImages(bookmarksPanel.list, POST_REQUEST_PRIORITY.auxiliary);
			syncTabSelection(bookmarksTabs, 'bookmarkType', bookmarksState.type);
			bookmarksPanel.search.placeholder = isReaction ? '搜索回应、帖子或用户' : '搜索收藏标题或内容';
			setLabel(bookmarksPanel.search, isReaction ? '搜索回应记录' : '搜索收藏');
			setLabel(bookmarksPanel.searchClear, isReaction ? '清空回应搜索' : '清空收藏搜索');
			const currentIds = isReaction ? [] : entries.map((entry) => Number(entry.id)).filter((id) => id > 0);
			const scopeIds = bookmarksState.scope === 'all' ? bookmarksState.allIds : currentIds;
			syncCollectionSelectionControls(
				bookmarksPanel, bookmarksState, bookmarksSelection, scopeIds, '收藏', typeEntries.length === 0,
				bookmarksState.scope === 'page' && currentIds.length === 0, isReaction
			);
			bookmarksCollection.paging(totalPages, typeEntries.length > 0);
			positionBookmarksPopover();
		};
		const fetchAllBookmarkEntries = (query = bookmarksState.query, token = bookmarksState.token) => collectPagedEntries(
			async (page) => {
				const username = syncCurrentUserFromHost();
				if (!username) throw new Error('登录后才能查看收藏');
				const params = new URLSearchParams({ page: String(Math.max(0, Number(page) || 0)) });
				if (query) params.set('q', query);
				return fetchJSON(`${BASE}/u/${encodeURIComponent(username)}/bookmarks.json?${params}`, {
					key: `bookmarks:${page}:${query}`,
					priority: POST_REQUEST_PRIORITY.auxiliary,
					cacheMode: 'default',
				});
			},
			(result, page) => {
				const payload = result?.user_bookmark_list || result || {};
				return [Array.isArray(payload.bookmarks) ? payload.bookmarks : [], payload.more_bookmarks_url ? page + 1 : null];
			},
			(entry) => Number(entry?.id) > 0 ? entry : null,
			(entry) => Number(entry.id),
			() => token === bookmarksState.token && !bookmarksPanel.popover.hidden
		);
		const loadBookmarksPage = async () => {
			const token = ++bookmarksState.token;
			const isReaction = bookmarksState.type === 'Reaction';
			let reactionSnapshotVisible = isReaction && bookmarksState.reactionLoadedAt > 0 &&
				bookmarksState.reactionUsername === ME_USERNAME;
			let reactionIconHydration = null;
			const hydrateReactionIcons = () => {
				if (reactionEmojiUrls || reactionIconHydration) return;
				reactionIconHydration = loadReactionEmojiRegistry().then(() => {
					if (reactionSnapshotVisible && token === bookmarksState.token &&
						!bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction') renderBookmarksPage();
				}).finally(() => { reactionIconHydration = null; });
			};
			if (reactionSnapshotVisible) {
				renderBookmarksPage();
			} else {
				bookmarksCollection.message(`正在加载${isReaction ? '回应' : '收藏'}…`);
			}
			try {
				if (isReaction) {
					const username = syncCurrentUserFromHost();
					if (!username) throw new Error('登录后才能查看回应记录');
					if (bookmarksState.reactionUsername && bookmarksState.reactionUsername !== username) {
						bookmarksState.reactionEntries = [];
						bookmarksState.reactionLoadedAt = 0;
						reactionSnapshotVisible = false;
					}
					if (!reactionSnapshotVisible && bookmarksState.reactionLoadedAt > 0 &&
						bookmarksState.reactionUsername === username) {
						reactionSnapshotVisible = true;
						renderBookmarksPage();
					}
					if (!reactionSnapshotVisible) {
						const snapshot = await getGivenReactionSnapshot(username);
						if (token !== bookmarksState.token || bookmarksPanel.popover.hidden || bookmarksState.type !== 'Reaction') return;
						if (snapshot) {
							bookmarksState.reactionEntries = mergeGivenReactionOverrides(snapshot.entries);
							bookmarksState.reactionLoadedAt = snapshot.storedAt;
							bookmarksState.reactionUsername = username;
							reactionSnapshotVisible = true;
							renderBookmarksPage();
							hydrateReactionIcons();
							if (snapshot.fresh) return;
						}
					} else {
						hydrateReactionIcons();
						if (Date.now() - bookmarksState.reactionLoadedAt <= MINUTE) return;
					}
					const [reactionEntries, likeEntries] = await Promise.all([
						fetchAllGivenReactions(username, token),
						fetchAllGivenLikes(username, token),
					]);
					if (!reactionEntries || !likeEntries || token !== bookmarksState.token || bookmarksPanel.popover.hidden || bookmarksState.type !== 'Reaction') return;
					bookmarksState.reactionEntries = mergeGivenReactionOverrides([...likeEntries, ...reactionEntries]);
					bookmarksState.reactionLoadedAt = Date.now();
					bookmarksState.reactionUsername = username;
					reactionSnapshotVisible = true;
					setGivenReactionSnapshot(username, bookmarksState.reactionEntries);
					bookmarksState.multi = false;
					clearBookmarksSelectionScope();
					renderBookmarksPage();
					hydrateReactionIcons();
					return;
				}
				const entries = await fetchAllBookmarkEntries(bookmarksState.query, token);
				if (!entries || token !== bookmarksState.token || bookmarksPanel.popover.hidden) return;
				bookmarksState.allEntries = entries;
				bookmarksState.allIds = null;
				renderBookmarksPage();
			} catch (error) {
				if (token !== bookmarksState.token || bookmarksPanel.popover.hidden || error && error.name === 'AbortError') return;
				if (isReaction && reactionSnapshotVisible) return;
				bookmarksState.entries = [];
				if (isReaction) bookmarksState.reactionEntries = [];
				else bookmarksState.allEntries = [];
				bookmarksState.hasNext = false;
				bookmarksCollection.message(`${isReaction ? '回应记录' : '收藏'}加载失败:${esc(error.message)}`, true);
				bookmarksPanel.pageInfo.textContent = `第 ${bookmarksState.page + 1} 页`;
			}
		};
		const applyRemovedBookmarkState = (entry) => {
			const activeCtx = readerShell.activeContext;
			const target = bookmarkTarget(entry);
			if (!activeCtx || Number(activeCtx.topicId) !== target.topicId) return;
			const type = entry && entry.bookmarkable_type === 'Post' ? 'Post' : 'Topic';
			syncReaderBookmarkState(activeCtx, type, entry?.bookmarkable_id, false, null, { persist: true });
		};
		const renderNotificationPage = (result, options = {}) => {
			const visibleNotifications = result.notifications;
			notificationState.total = result.total;
			notificationState.hasNext = result.hasNext;
			releasePersistentAvatarImages(notificationPanel.list);
			notificationPanel.list.innerHTML = visibleNotifications.length
				? renderNotificationList(visibleNotifications)
				: `<div class="ldp-notification-empty">${options.search ? '本地缓存中没有匹配消息' : '暂无消息'}</div>`;
			hydrateNotificationAvatars(notificationPanel.list, visibleNotifications);
			if (notificationPanel.markAll.dataset.ldpRequestBusy !== '1') {
				notificationPanel.markAll.disabled = cachedUnreadNotificationCount(notificationState.group) <= 0;
			}
			const totalPages = Math.max(1, Math.ceil(notificationState.total / NOTIFICATION_PAGE_SIZE));
			notificationCollection.paging(totalPages, notificationState.total > 0);
			if (!notificationState.total && options.search) notificationPanel.pageInfo.textContent = '本地缓存';
			positionNotificationsPopover();
		};
		const renderNotificationSearchPage = () => {
			const needle = normalizeSearchText(notificationState.query);
			const matches = cachedNotificationsForGroup(notificationState.group)
				.filter((notification) => notificationMatchesSearch(notification, needle));
			const { entries, totalPages } =
				localCollectionPage(matches, notificationState, NOTIFICATION_PAGE_SIZE);
			renderNotificationPage({
				notifications: entries,
				total: matches.length,
				hasNext: notificationState.page < totalPages - 1,
			}, { search: true });
		};
		const loadNotificationPage = async (groupKey, page, options = {}) => {
			const token = ++notificationState.token;
			const force = options.force === true;
			notificationState.group = Object.hasOwn(NOTIFICATION_GROUPS, groupKey) ? groupKey : 'all';
			notificationState.page = Math.max(0, Number(page) || 0);
			syncTabSelection(notificationTabs, 'notificationGroup', notificationState.group);
			if (notificationState.query) {
				if (notificationQueuedRequestKey) currentReaderRequestScheduler().cancelQueued(notificationQueuedRequestKey);
				notificationQueuedRequestKey = '';
				renderNotificationSearchPage();
				return;
			}
			const cacheKey = notificationPageCacheKey(notificationState.group, notificationState.page);
			const requestKey = `notifications:${cacheKey}`;
			if (notificationQueuedRequestKey && notificationQueuedRequestKey !== requestKey) {
				currentReaderRequestScheduler().cancelQueued(notificationQueuedRequestKey);
			}
			const cached = getCachedNotificationPage(cacheKey);
			const cachedEntry = cached && NOTIFICATION_CACHE.get(cacheKey);
			const cachedIsFresh = cachedEntry &&
				Date.now() - Number(cachedEntry.at || 0) <= NOTIFICATION_CACHE_FRESH_FOR;
			if (cached) {
				renderNotificationPage(cached);
				if (cachedIsFresh && !force) {
					notificationQueuedRequestKey = '';
					return;
				}
			}
			notificationQueuedRequestKey = requestKey;
			if (!cached) {
				notificationCollection.message('正在加载消息…');
			}
			try {
				const result = await fetchNotificationPage(notificationState.group, notificationState.page, {
					force: force || !!cached,
				});
				if (token !== notificationState.token || notificationsPopover.hidden) return;
				renderNotificationPage(result);
			} catch (error) {
				if (error && error.name === 'AbortError') return;
				if (token !== notificationState.token || notificationsPopover.hidden) return;
				if (cached) return;
				notificationCollection.message(`消息加载失败:${esc(error.message)}`, true);
				notificationPanel.pageInfo.textContent = `第 ${notificationState.page + 1} 页`;
			} finally {
				if (notificationQueuedRequestKey === requestKey) notificationQueuedRequestKey = '';
			}
		};
		let notificationLiveAppEvents = null;
		let notificationLiveHandler = null;
		let notificationLiveRefreshTimer = 0;
		const stopNotificationLiveEvents = () => {
			if (notificationLiveRefreshTimer) clearTimeout(notificationLiveRefreshTimer);
			notificationLiveRefreshTimer = 0;
			if (notificationLiveAppEvents && notificationLiveHandler &&
					isFunction(notificationLiveAppEvents.off)) {
				try {
					notificationLiveAppEvents.off('notifications:changed', readerShell, notificationLiveHandler);
				} catch (error) {}
			}
			notificationLiveAppEvents = null;
			notificationLiveHandler = null;
		};
		const scheduleVisibleNotificationRefresh = () => {
			if (notificationsPopover.hidden || notificationLiveRefreshTimer) return;
			notificationLiveRefreshTimer = setTimeout(() => {
				notificationLiveRefreshTimer = 0;
				if (notificationsPopover.hidden) return;
				loadNotificationPage(notificationState.group, notificationState.page, { force: true });
			}, NOTIFICATION_LIVE_REFRESH_DEBOUNCE);
		};
		const applyNotificationLiveEvent = () => {
			syncCurrentUserFromHost();
			expireNotificationFirstPageCaches();
			scheduleVisibleNotificationRefresh();
		};
		const startNotificationLiveEvents = () => {
			if (notificationLiveAppEvents && notificationLiveHandler) return true;
			stopNotificationLiveEvents();
			const appEvents = lookupDiscourse('service:app-events');
			if (!appEvents || !isFunction(appEvents.on)) return false;
			const handler = () => applyNotificationLiveEvent();
			try {
				appEvents.on('notifications:changed', readerShell, handler);
				notificationLiveAppEvents = appEvents;
				notificationLiveHandler = handler;
				return true;
			} catch (error) {
				stopNotificationLiveEvents();
				return false;
			}
		};
		if (!reusingShell) {
			readerShell.startNotificationLiveEvents = startNotificationLiveEvents;
			readerShell.stopNotificationLiveEvents = stopNotificationLiveEvents;
			onShell(document, 'visibilitychange', () => {
				if (document.visibilityState !== 'visible') return;
				syncCurrentUserFromHost();
				readerShell.startNotificationLiveEvents?.();
				expireNotificationFirstPageCaches();
				if (!notificationsPopover.hidden) {
					loadNotificationPage(notificationState.group, notificationState.page, { force: true });
				}
			});
		}

		const syncReaderBackgroundMode = () => {
			setPageScrollLocked(readerWorkspace.isFullPage() || window.innerWidth <= READER_WINDOW_COMPACT_WIDTH);
		};
		let layoutDraft = currentLayoutRatios();
		let fontSettingsScope = 'interface';
		let fontDraft = currentFontProfile();
		let appearanceDraft = currentAppearanceProfile();
		let jumpHighlightDraft = jumpHighlightConfigFromPrefs(PREFS);
		const currentFontDraft = () => fontDraft;
		const setCurrentFontDraft = (values) => { fontDraft = normalizeFontProfile(values, READER_FONT_DEFAULT); };
		const currentFontDefaults = () => normalizeFontProfile(READER_FONT_DEFAULT);
		const currentLayoutDraft = () => layoutDraft;
		const setCurrentLayoutDraft = (values) => { layoutDraft = normalizeLayoutRatios(values, READER_LAYOUT_DEFAULT); };
		const currentLayoutDefaults = () => normalizeLayoutRatios(READER_LAYOUT_DEFAULT);
		const currentAppearanceDraft = () => appearanceDraft;
		const setCurrentAppearanceDraft = (values) => {
			appearanceDraft = normalizeAppearanceProfile(values, APPEARANCE_PROFILE_DEFAULT);
		};
		const currentAppearanceDefaults = () => normalizeAppearanceProfile(APPEARANCE_PROFILE_DEFAULT);
		const applyReaderVisualSettings = () => {
			applyImageScale();
			applyLayoutRatios();
			applyFontScales();
			applyAppearanceColors();
		};
		const syncOpenSettingsControls = (includeWindowControls = false) => {
			if (settingsPopover.hidden) return;
			syncImageScaleControls();
			syncLayoutControls();
			syncFontControls();
			syncAppearanceControls();
			if (includeWindowControls) syncReaderWindowControls();
		};
		const syncLayoutBtn = () => {
			const fullPage = readerWorkspace.isFullPage();
			layoutBtn.hidden = !directTopicRoute && !fullPage;
			layoutBtn.innerHTML = `${icon(fullPage ? 'minimize2' : 'maximize2')}<span>${fullPage ? '浮窗' : '全页'}</span>`;
			setLabel(layoutBtn, fullPage ? '切换到浮窗' : '切换到全页');
			readerWindow.syncMode();
			syncReaderBackgroundMode();
		};
		const onReaderWorkspaceChange = () => {
			syncLayoutBtn();
			syncReaderPlacementUi();
			if (readerShell.syncSettingsSurfaceGeometry) readerShell.syncSettingsSurfaceGeometry();
			else syncReaderSurfaceDensity();
			syncReaderThemeSurfaces();
			applyReaderVisualSettings();
			const activeContext = readerShell.activeContext;
			if (activeContext?.nativeComposerWindow) activeContext.nativeComposerWindow.syncLayer();
			positionHeaderPopovers();
			syncOpenSettingsControls(true);
		};
		if (!reusingShell) {
			syncLayoutBtn();
			onShell(overlay, 'ldp-reader-workspace-change', onReaderWorkspaceChange);
			onShell(layoutBtn, 'click', () => {
				if (!directTopicRoute && !readerWorkspace.isFullPage()) return;
				readerWorkspace.setMode(readerWorkspace.isFullPage() ? 'floating' : 'fullpage');
				syncLayoutBtn();
				syncOpenSettingsControls();
				applyReaderVisualSettings();
				positionHeaderPopovers();
				const activeContext = readerShell.activeContext;
				if (!activeContext) return;
				activeContext.streamListOffset = NaN;
				activeContext.streamNeedsRepair = true;
				scheduleStreamWindowSync(activeContext);
			});
		}

		const applyImageScale = () => {
			overlay.style.setProperty('--ldp-image-zoom', String(currentImageScalePercent() / 100));
		};
		const fontSettingsSection = readerElement('[data-settings-panel="font"]');
		const layoutSettingsSection = readerElement('[data-settings-panel="layout"]');
		const appearanceSettingsSection = readerElement('[data-settings-panel="appearance"]');
		const appearanceValuesChanged = () => {
			const values = currentAppearanceDraft();
			const saved = currentAppearanceProfile();
			return APPEARANCE_PROFILE_VALUE_KEYS.some((key) => values[key] !== saved[key]);
		};
		const activeAppearanceColors = () => {
			const previewing = !settingsPopover.hidden && !appearanceSettingsSection.hidden;
			return previewing ? currentAppearanceDraft() : currentAppearanceProfile();
		};
		const activeFontScaleProfile = () => {
			const previewing = !settingsPopover.hidden && !fontSettingsSection.hidden;
			return previewing ? currentFontDraft() : currentFontProfile();
		};
		const applyFontScales = () => {
			const values = activeFontScaleProfile();
			applyInterfaceFontScale(overlay.style, values.interface);
			applyInterfaceFontScale(READER_PORTAL_HOST?.style, values.interface);
			applyReaderFontAppearance(overlay.style, values);
			applyReaderFontAppearance(READER_PORTAL_HOST?.style, values);
			applyReaderFontAppearance(PAGE_ROOT.style, values);
			const postFontSize = fontScalePixels(POST_FONT_BASE_PX, values.post);
			overlay.style.setProperty('--ldp-post-font-size', postFontSize);
			READER_PORTAL_HOST?.style.setProperty('--ldp-post-font-size', postFontSize);
			applyComposerFontScale(values.composer);
			const activeContext = readerShell.activeContext;
			if (activeContext?.nativeComposerWindow) {
				activeContext.nativeComposerWindow.syncFont(values);
			}
		};
		const applyLayoutRatios = () => {
			const previewing = !settingsPopover.hidden && !layoutSettingsSection.hidden;
			const ratios = previewing ? currentLayoutDraft() : currentLayoutRatios();
			LAYOUT_REGION_KEYS.forEach((name) => {
				overlay.style.setProperty(`--ldp-layout-${name}`, `${ratios[name]}%`);
			});
		};
		const applyInteractionColors = () => {
			const surfaces = new Set([READER_PORTAL_HOST, overlay]);
			[...READER_PORTAL_HOST?.children || []].forEach((node) => surfaces.add(node));
			document.querySelectorAll('[data-ldp-reader-composer-root]').forEach((node) => surfaces.add(node));
			surfaces.forEach((node) => applyAppearanceInteractionColors(node, activeAppearanceColors()));
		};
		const applyZebraAppearance = () => {
			const values = activeAppearanceColors();
			overlay.style.setProperty('--ldp-zebra-color', appearanceThemeColor(values, 'zebraColor'));
			overlay.style.setProperty('--ldp-zebra-radius', `${values.zebraRadius}px`);
		};
		const applyListZebraColor = () => {
			const value = appearanceThemeColor(activeAppearanceColors(), 'listZebraColor');
			if (!readerWorkspace.isEmbedded()) {
				PAGE_ROOT.style.removeProperty('--ldp-reader-list-zebra-color');
				return;
			}
			PAGE_ROOT.style.setProperty('--ldp-reader-list-zebra-color', value);
		};
		const applyLineAppearance = (type, emphasis) => {
			const values = activeAppearanceColors();
			const key = `${type}Line`;
			const property = `--ldp-${type}-line`;
			const colorKey = `${key}Color`;
			const value = appearanceThemeColor(values, colorKey);
			const defaultValue = APPEARANCE_PROFILE_DEFAULT[appearanceThemeValueKey(colorKey)];
			const width = values[`${key}Width`];
			const enabled = values.structureColorsEnabled !== false;
			overlay.style.setProperty(`${property}-color`, enabled ? value : 'transparent');
			overlay.style.setProperty(`${property}-width`, `${width}px`);
			overlay.style.setProperty(`${property}-emphasis-width`, `${width * emphasis}px`);
			if (type === 'reply') {
				overlay.style.setProperty(`${property}-secondary-width`, `${width + 0.5}px`);
				overlay.style.setProperty(`${property}-radius`, `${values.replyLineRadius}px`);
				readerElements('.ldp-children').forEach(syncNestedReplyBranches);
				const activeContext = readerShell.activeContext;
				if (activeContext?.floorPreview && activeContext.floorPreview.isOpen()) {
					scheduleFloorPreviewSync(activeContext);
				}
				return;
			}
			if (type !== 'divider') return;
			if (!readerWorkspace.isEmbedded()) {
				PAGE_ROOT.style.removeProperty('--ldp-divider-line-color');
			} else if (!enabled) {
				PAGE_ROOT.style.setProperty('--ldp-divider-line-color', 'transparent');
			} else if (value === defaultValue) {
				PAGE_ROOT.style.removeProperty('--ldp-divider-line-color');
			} else {
				PAGE_ROOT.style.setProperty('--ldp-divider-line-color', value);
			}
			if (!readerWorkspace.isEmbedded() || width === DIVIDER_LINE_WIDTH_DEFAULT) {
				PAGE_ROOT.style.removeProperty('--ldp-divider-line-width');
			} else {
				PAGE_ROOT.style.setProperty('--ldp-divider-line-width', `${width}px`);
			}
		};
		const applyReplyLineAppearance = () => applyLineAppearance('reply', 2);
		const applyQuoteLineAppearance = () => applyLineAppearance('quote', 5);
		const applyDividerLineAppearance = () => applyLineAppearance('divider', 2);
		const applyFloorPreviewAppearance = () => {
			const values = activeAppearanceColors();
			overlay.style.setProperty('--ldp-floor-preview-color',
				appearanceThemeColor(values, 'floorPreviewColor'));
			overlay.style.setProperty('--ldp-floor-preview-height', `${values.floorPreviewHeight}px`);
			const activeContext = readerShell.activeContext;
			if (activeContext?.floorPreview && activeContext.floorPreview.isOpen()) {
				scheduleFloorPreviewSync(activeContext);
			}
		};
		const applyAppearanceColors = () => {
			applyInteractionColors();
			applyZebraAppearance();
			applyListZebraColor();
			applyReplyLineAppearance();
			applyQuoteLineAppearance();
			applyDividerLineAppearance();
			applyFloorPreviewAppearance();
		};
		const applyJumpHighlightSettings = (config = jumpHighlightConfigFromPrefs(PREFS)) => {
			overlay.style.setProperty('--ldp-jump-highlight-color', config.color);
			overlay.style.setProperty('--ldp-jump-highlight-radius', `${config.radius}px`);
			overlay.style.setProperty('--ldp-jump-highlight-border-width', `${config.borderWidth}px`);
			overlay.style.setProperty('--ldp-jump-highlight-duration', `${jumpHighlightDurationMs(config)}ms`);
			overlay.style.setProperty('--ldp-jump-highlight-count', String(config.count));
		};
		overlay._ldpApplyThemeDependentColors = () => {
			applyAppearanceColors();
			if (!settingsPopover.hidden && !appearanceSettingsSection.hidden) syncAppearanceControls();
		};
		const syncRangeVisual = (input) => {
			const min = Number(input.min) || 0;
			const max = Number(input.max) || 100;
			const value = Number(input.value);
			const progress = max > min && Number.isFinite(value)
				? Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100))
				: 0;
			input.style.setProperty('--ldp-range-progress', `${progress}%`);
		};
		const syncImageScaleControls = () => {
			const scale = currentImageScalePercent();
			imageScaleRange.value = String(scale);
			imageScaleValue.textContent = `${scale}%`;
			syncRangeVisual(imageScaleRange);
			const defaults = IMAGE_PROFILE_DEFAULT;
			const defaultScale = normalizeImageScalePercent(
				defaults.preset === 'custom' ? defaults.custom : defaults.preset,
				100
			);
			imageScaleReset.disabled = scale === defaultScale;
			lightboxOriginalByDefaultInput.checked = PREFS.lightboxOriginalByDefault === true;
			lightboxCommentsExpandedInput.checked = PREFS.lightboxCommentsExpandedByDefault === true;
			lightboxDescriptionExpandedInput.checked = PREFS.lightboxDescriptionExpanded === true;
		};
		let pageFontFamilies = [];
		let localFontFamilies = [];
		let localFontAccessState = 'idle';
		let localFontAccessRequest = null;
		let activeFontFamilyScope = 'interface';
		const fontFamilyManualEditing = new Set();
		const fontScopeConfig = (scope = activeFontFamilyScope) => scope === 'host'
			? HOST_FONT_CONFIG
			: FONT_SCOPE_CONFIG[scope] || FONT_SCOPE_CONFIG.interface;
		const fontValuesForScope = (scope = activeFontFamilyScope, readerValues = currentFontDraft()) => scope === 'host'
			? PREFS
			: readerValues;
		const activeFontFamilyTrigger = () => [...fontFamilyTriggers]
			.find((trigger) => trigger.dataset.fontScope === activeFontFamilyScope);
		const collectAvailableFontFamilies = (fonts, searchFields, fallbackNameField = '') => {
			const entries = new Map();
			[...fonts || []].forEach((font) => {
				const name = normalizeCustomFontFamily(font && (font.family || font[fallbackNameField]));
				if (!name) return;
				const key = name.toLocaleLowerCase();
				const entry = entries.get(key) || { name, search: new Set() };
				searchFields.forEach((field) => {
					if (font?.[field]) entry.search.add(String(font[field]));
				});
				entries.set(key, entry);
			});
			return [...entries.values()].map((entry) => ({
				name: entry.name,
				search: [...entry.search],
			})).sort((left, right) => String(left.name || '').localeCompare(
				String(right.name || ''), 'zh-CN', { sensitivity: 'base', numeric: true }
			));
		};
		const collectPageFontFamilies = () => {
			try {
				return collectAvailableFontFamilies(
					document.fonts, ['family', 'style', 'weight', 'stretch']
				);
			} catch (e) { return []; }
		};
		const syncFontFamilySelection = (values = currentFontDraft()) => {
			fontFamilyTriggers.forEach((trigger) => {
				const scope = trigger.dataset.fontScope;
				const value = trigger.querySelector('.ldp-font-family-value');
				const config = fontScopeConfig(scope);
				const scopeValues = fontValuesForScope(scope, values);
				const family = normalizeFontFamily(scopeValues[config.family]);
				value.textContent = family !== 'custom'
					? FONT_FAMILY_OPTIONS[family].label
					: normalizeCustomFontFamily(scopeValues[config.customFamily]) || '手动输入字体名';
				trigger.dataset.ldpTooltipLabel = value.textContent;
			});
			const config = fontScopeConfig();
			const selectedValues = fontValuesForScope(activeFontFamilyScope, values);
			const selectedFamily = normalizeFontFamily(selectedValues[config.family]);
			const selectedName = normalizeSearchText(selectedValues[config.customFamily]);
			fontFamilyOptions.querySelectorAll('.ldp-font-family-option').forEach((option) => {
				const selected = option.dataset.fontKind === 'preset'
					? selectedFamily === option.dataset.fontValue
					: selectedFamily === 'custom' && normalizeSearchText(option.dataset.fontValue) === selectedName;
				option.setAttribute('aria-selected', String(selected));
			});
		};
		const createFontFamilyOption = ({ kind, value, label, source, stack, search = [] }, values) => {
			const option = makeElement('button');
			const config = fontScopeConfig();
			const selected = kind === 'preset'
				? normalizeFontFamily(values[config.family]) === value
				: normalizeFontFamily(values[config.family]) === 'custom'
					&& normalizeSearchText(values[config.customFamily]) === normalizeSearchText(value);
			option.className = 'ldp-font-family-option ldp-picker-option';
			option.type = 'button';
			option.dataset.fontKind = kind;
			option.dataset.fontValue = value;
			option.dataset.searchText = [label, source, ...search]
				.flatMap((searchValue) => pinyinSearchForms(searchValue)).join(' ');
			option.setAttribute('role', 'option');
			option.setAttribute('aria-selected', String(selected));
			option.innerHTML = `<span class="ldp-font-family-option-copy ldp-picker-option-copy"><strong>${esc(label)}</strong><small>${esc(source)}</small></span>`
				+ '<span class="ldp-font-family-preview">Aa 字</span>';
			option.querySelector('.ldp-font-family-preview').style.fontFamily = stack;
			return option;
		};
		const appendFontFamilyGroup = (fragment, title, items, values) => {
			if (!items.length) return;
			const group = makeElement('div');
			group.className = 'ldp-font-family-group';
			group.setAttribute('role', 'group');
			setLabel(group, title);
			const heading = makeElement('div');
			heading.className = 'ldp-font-family-group-title';
			heading.textContent = `${title} · ${items.length}`;
			group.append(heading);
			items.forEach((item) => group.append(createFontFamilyOption(item, values)));
			fragment.append(group);
		};
		const applyFontFamilySearch = () => {
			const query = normalizeSearchText(fontFamilySearch.value);
			let visibleCount = 0;
			fontFamilyOptions.querySelectorAll('.ldp-font-family-group').forEach((group) => {
				let groupVisible = 0;
				group.querySelectorAll('.ldp-font-family-option').forEach((option) => {
					const visible = !query || String(option.dataset.searchText || '').includes(query);
					option.hidden = !visible;
					if (visible) groupVisible += 1;
				});
				group.hidden = groupVisible === 0;
				visibleCount += groupVisible;
			});
			fontFamilyEmpty.hidden = visibleCount !== 0;
		};
		const renderFontFamilyOptions = () => {
			const values = fontValuesForScope();
			const config = fontScopeConfig();
			const namedItem = (item, source) => ({
				kind: 'named', value: item.name, label: item.name, source,
				stack: `${JSON.stringify(item.name)},${FONT_FAMILY_OPTIONS.system.stack}`, search: item.search,
			});
			const presetItems = Object.entries(FONT_FAMILY_OPTIONS)
				.filter(([value]) => value !== 'custom')
				.map(([value, option]) => ({
					kind: 'preset', value, label: option.label,
					source: value === 'site' ? '站点样式' : value === FONT_FAMILY_DEFAULT ? '默认字体' : '阅读器预设',
					stack: value === 'site' ? resolvedSiteFontFamily() : option.stack, search: [option.stack],
				}));
			const pageItems = pageFontFamilies.map((item) => namedItem(item, '页面已加载'));
			const localItems = localFontFamilies.map((item) => namedItem(item, '本机已安装'));
			const currentName = normalizeCustomFontFamily(values[config.customFamily]);
			const availableNames = new Set([...pageFontFamilies, ...localFontFamilies]
				.map((item) => normalizeSearchText(item.name)));
			const currentItems = normalizeFontFamily(values[config.family]) === 'custom' && currentName
				&& !availableNames.has(normalizeSearchText(currentName))
				? [namedItem({ name: currentName, search: [currentName] }, '当前手动输入')]
				: [];
			const fragment = document.createDocumentFragment();
			appendFontFamilyGroup(fragment, '预设', presetItems, values);
			appendFontFamilyGroup(fragment, '页面已加载', pageItems, values);
			appendFontFamilyGroup(fragment, '本机已安装', localItems, values);
			appendFontFamilyGroup(fragment, '当前选择', currentItems, values);
			fontFamilyOptions.replaceChildren(fragment);
			let statusCopy = '';
			if (localFontAccessState === 'loading') statusCopy = '正在读取浏览器允许访问的本机字体…';
			else if (localFontAccessState === 'unsupported') statusCopy = '当前浏览器未开放本机字体列表;仍可选择页面字体或手动输入。';
			else if (localFontAccessState === 'denied') statusCopy = '未获得本机字体读取权限;仍可选择页面字体或手动输入。';
			else if (localFontAccessState === 'error') statusCopy = '本机字体读取失败;仍可选择页面字体或手动输入。';
			else if (localFontAccessState === 'ready' && !localFontFamilies.length) {
				statusCopy = '浏览器没有返回可用的本机字体;仍可手动输入字体名。';
			}
			fontFamilySourceStatus.textContent = statusCopy;
			fontFamilySourceStatus.hidden = !statusCopy;
			applyFontFamilySearch();
		};
		const positionFontFamilyMenu = () => {
			const trigger = activeFontFamilyTrigger();
			if (fontFamilyMenu.hidden || !trigger || !trigger.isConnected) return;
			const margin = 12;
			const gap = 6;
			const bounds = settingsSurfaceBounds();
			const triggerRect = trigger.getBoundingClientRect();
			const width = Math.min(360, Math.max(260, bounds.width - margin * 2));
			const minimumLeft = bounds.left + margin;
			const minimumTop = bounds.top + margin;
			const left = Math.min(
				Math.max(minimumLeft, triggerRect.left),
				Math.max(minimumLeft, bounds.right - width - margin),
			);
			fontFamilyMenu.style.width = `${Math.round(width)}px`;
			fontFamilyMenu.style.top = `${margin}px`;
			const menuHeight = fontFamilyMenu.getBoundingClientRect().height;
			const belowTop = triggerRect.bottom + gap;
			const aboveTop = triggerRect.top - menuHeight - gap;
			const top = belowTop + menuHeight <= bounds.bottom - margin
				? belowTop
				: aboveTop >= minimumTop
					? aboveTop
					: Math.max(minimumTop, bounds.bottom - menuHeight - margin);
			const coversTrigger = top <= triggerRect.top && top + menuHeight >= triggerRect.bottom;
			const positionedLeft = coversTrigger ? Math.max(minimumLeft, left - 2) : left;
			fontFamilyMenu.style.left = `${Math.round(positionedLeft - bounds.left)}px`;
			fontFamilyMenu.style.top = `${Math.round(top - bounds.top)}px`;
		};
		const closeFontFamilyMenu = (restoreFocus = false) => {
			if (fontFamilyMenu.hidden) return;
			const trigger = activeFontFamilyTrigger();
			fontFamilyMenu.hidden = true;
			fontFamilyTriggers.forEach((button) => setExpanded(button, false));
			if (restoreFocus && trigger && trigger.isConnected) trigger.focus();
		};
		const requestLocalFontFamilies = () => {
			if (localFontAccessState !== 'idle') return localFontAccessRequest;
			if (!isFunction(globalThis.queryLocalFonts)) {
				localFontAccessState = 'unsupported';
				renderFontFamilyOptions();
				return null;
			}
			localFontAccessState = 'loading';
			renderFontFamilyOptions();
			try {
				localFontAccessRequest = Promise.resolve(globalThis.queryLocalFonts()).then((fonts) => {
					localFontFamilies = collectAvailableFontFamilies(
						fonts, ['family', 'fullName', 'postscriptName', 'style'], 'fullName'
					);
					localFontAccessState = 'ready';
				}).catch((error) => {
					localFontAccessState = error && (error.name === 'NotAllowedError' || error.name === 'SecurityError')
						? 'denied' : 'error';
				}).finally(() => {
					localFontAccessRequest = null;
					renderFontFamilyOptions();
					positionFontFamilyMenu();
				});
			} catch (error) {
				localFontAccessState = error && (error.name === 'NotAllowedError' || error.name === 'SecurityError')
					? 'denied' : 'error';
				renderFontFamilyOptions();
			}
			return localFontAccessRequest;
		};
		const openFontFamilyMenu = (scope) => {
			if (FONT_PICKER_SCOPE_KEYS.includes(scope)) activeFontFamilyScope = scope;
			fontFamilyTriggers.forEach((button) => setExpanded(button, button.dataset.fontScope === activeFontFamilyScope));
			pageFontFamilies = collectPageFontFamilies();
			fontFamilySearch.value = '';
			renderFontFamilyOptions();
			fontFamilyMenu.hidden = false;
			positionFontFamilyMenu();
			requestLocalFontFamilies();
			fontFamilySearch.focus({ preventScroll: true });
			if (document.fonts?.ready) {
				void Promise.resolve(document.fonts.ready).then(() => {
					pageFontFamilies = collectPageFontFamilies();
					renderFontFamilyOptions();
					positionFontFamilyMenu();
				});
			}
		};
		const fontScaleSettings = [
			{ input: interfaceFontScaleRange, output: interfaceFontScaleValue, key: 'interface', normalize: normalizeFontScale },
			{ input: fontScaleRange, output: fontScaleValue, key: 'post', normalize: normalizeFontScale },
			{ input: composerFontScaleRange, output: composerFontScaleValue, key: 'composer', normalize: normalizeFontScale },
		];
		const syncFontControls = () => {
			const values = currentFontDraft();
			const savedValues = currentFontProfile();
			const defaultValues = currentFontDefaults();
			const profileLabel = '阅读器';
			hostEmbeddedSizeInputs.forEach((input) => {
				const config = HOST_EMBED_SIZE_SETTINGS[input.dataset.hostEmbedSize];
				if (!config) return;
				const scale = normalizeHostEmbeddedSizeScale(PREFS[config.pref]);
				input.value = String(scale);
				syncRangeVisual(input);
				const output = [...hostEmbeddedSizeValues]
					.find((value) => value.dataset.hostEmbedSizeValue === input.dataset.hostEmbedSize);
				if (output) {
					output.value = `${scale}%`;
					output.textContent = `${scale}%`;
				}
				const detail = input.dataset.hostEmbedSize === 'labelCard'
					? '卡片高度、文字、SVG/图片图标、间距和横向内边距会按同一比例变化。'
					: input.dataset.hostEmbedSize === 'stats'
						? '右下角的回复、最近回复、活跃和回应四组信息会一起变化。'
						: '';
				setSettingRowHelp(input, `调整宿主嵌入态主题列表的${config.label}大小,默认 ${config.defaultScale}%。${detail}修改后立即保存。`);
			});
			syncTabSelection(fontScopeTabs, 'fontScopeTab', fontSettingsScope, true);
			fontScopePanels.forEach((panel) => {
				panel.hidden = panel.dataset.fontScopePanel !== fontSettingsScope;
			});
			syncFontFamilySelection(values);
			FONT_PICKER_SCOPE_KEYS.forEach((scope) => {
				const host = scope === 'host';
				const scopeValues = host ? PREFS : values;
				const scopeDefaults = host ? HOST_FONT_DEFAULTS : defaultValues;
				const config = fontScopeConfig(scope);
				const customInput = [...fontFamilyCustomInputs].find((input) => input.dataset.fontScope === scope);
				const weightSelect = [...fontWeightSelects].find((select) => select.dataset.fontScope === scope);
				const colorInput = [...fontColorInputs].find((input) => input.dataset.fontScope === scope);
				const colorValue = [...fontColorValues].find((value) => value.dataset.fontScope === scope);
				const color = normalizeHexColor(scopeValues[config.color], scopeDefaults[config.color]);
				if (normalizeFontFamily(scopeValues[config.family]) !== 'custom') fontFamilyManualEditing.delete(scope);
				customInput.value = scopeValues[config.customFamily];
				customInput.hidden = normalizeFontFamily(scopeValues[config.family]) !== 'custom' || !fontFamilyManualEditing.has(scope);
				weightSelect.value = String(scopeValues[config.weight]);
				colorInput.value = color || READER_THEME_VARIABLES[resolvedReaderThemeMode()]['--primary'];
				colorValue.textContent = color ? color.toUpperCase() : '跟随主题';
				const trigger = [...fontFamilyTriggers].find((button) => button.dataset.fontScope === scope);
				if (host) {
					setSettingRowHelp(trigger,
						`选择嵌入态 topic 列表标题、Label Card 和右下角信息共享的字体。默认“${FONT_FAMILY_OPTIONS[scopeDefaults[config.family]].label}”;支持页面字体、本机字体和手动输入。修改后立即保存。`);
					setSettingRowHelp(weightSelect,
						`调整嵌入态 topic 列表标题、Label Card 和右下角信息共享的字重。默认 ${scopeDefaults[config.weight]} · ${FONT_WEIGHT_LABELS[scopeDefaults[config.weight]]}。修改后立即保存。`);
					setSettingRowHelp(colorInput,
						'调整嵌入态 topic 列表标题、Label Card 和右下角信息共享的颜色。默认跟随当前明暗主题。修改后立即保存。');
				} else {
					setSettingRowHelp(trigger,
						`选择${profileLabel}${config.label}文字使用的字体。下拉按预设、页面已加载和本机已安装分组并支持搜索;首次读取本机字体时浏览器可能询问权限。默认“${FONT_FAMILY_OPTIONS[scopeDefaults[config.family]].label}”。`);
					setSettingRowHelp(weightSelect,
						`调整${profileLabel}${config.label}文字的粗细。默认 ${scopeDefaults[config.weight]} · ${FONT_WEIGHT_LABELS[scopeDefaults[config.weight]]}。`);
					setSettingRowHelp(colorInput,
						`调整${profileLabel}${config.label}文字的基础颜色。默认跟随当前明暗主题;自定义颜色只作用于${config.label}文字,不改变链接、错误和状态等语义颜色。`);
				}
			});
			fontScaleSettings.forEach(({ input, output, key }) => {
				input.value = String(values[key]);
				output.textContent = `${values[key]}%`;
				syncRangeVisual(input);
			});
			setSettingRowHelp(interfaceFontScaleRange,
				`调整${profileLabel}界面文字字号,包括标题、元信息、楼层头部、操作区、时间轴、设置面板和提示。默认 ${defaultValues.interface}%;不会改变帖子正文和回复输入文字。`);
			setSettingRowHelp(fontScaleRange,
				`调整${profileLabel}中的帖子正文和各楼层内容,不改变标题、按钮等界面文字。默认 ${defaultValues.post}%;拖动时实时预览,确认应用后保存。`);
			setSettingRowHelp(composerFontScaleRange,
				`调整${profileLabel}回复弹窗里的输入文字和编辑区域字号,不改变已发布正文。默认 ${defaultValues.composer}%;拖动时实时预览,确认应用后保存。`);
			fontFieldResetButtons.forEach((button) => {
				const host = button.dataset.hostFontReset != null;
				const profileField = button.dataset.fontFieldReset;
				const scope = host ? 'host' : FONT_SCOPE_KEYS.find((key) => Object.values(fontScopeConfig(key)).includes(profileField));
				const config = fontScopeConfig(scope);
				const field = host ? config[button.dataset.hostFontReset] : profileField;
				const scopeValues = host ? PREFS : values;
				const scopeDefaults = host ? HOST_FONT_DEFAULTS : defaultValues;
				const defaultValue = scopeDefaults[field];
				const familyField = field === config.family;
				const weightField = field === config.weight;
				const colorField = field === config.color;
				const fieldLabel = `${host ? '' : profileLabel}${config.label}${familyField ? '字体' : weightField ? '字重' : colorField ? '颜色' : '字号'}`;
				const defaultLabel = familyField ? FONT_FAMILY_OPTIONS[defaultValue].label :
					weightField ? String(defaultValue) : colorField ? '跟随主题' : `${defaultValue}%`;
				button.disabled = familyField
					? scopeValues[config.family] === scopeDefaults[config.family]
						&& scopeValues[config.customFamily] === scopeDefaults[config.customFamily]
					: scopeValues[field] === defaultValue;
				button.querySelector('span').textContent = `默认 ${defaultLabel}`;
				setLabel(button, `恢复${fieldLabel}默认值 ${defaultLabel}`);
			});
			const changed = FONT_PROFILE_VALUE_KEYS.some((name) => values[name] !== savedValues[name]);
			fontStatus.textContent = changed
				? `${profileLabel}字体正在实时预览;点击“确认应用”后保存。`
				: `${profileLabel}字体当前配置已应用。`;
			fontStatus.classList.toggle('balanced', !changed);
			const hostFontAtDefault = Object.keys(HOST_FONT_DEFAULTS).every((name) => PREFS[name] === HOST_FONT_DEFAULTS[name]);
			fontResetBtn.disabled = hostFontAtDefault && FONT_PROFILE_VALUE_KEYS.every((name) =>
				values[name] === defaultValues[name]
			);
			fontApplyBtn.disabled = !changed;
			syncSettingsDraftIndicators();
		};
		const syncLayoutControls = () => {
			const ratios = currentLayoutDraft();
			const savedRatios = currentLayoutRatios();
			const defaultRatios = currentLayoutDefaults();
			const profileLabel = '阅读器';
			const remaining = Math.max(0, roundLayoutRatio(100 - layoutRatioTotal(ratios)));
			layoutRatioInputs.forEach((input) => {
				const name = input.dataset.layoutRegion;
				if (!LAYOUT_REGION_KEYS.includes(name)) return;
				input.max = '100';
				input.value = String(ratios[name]);
				syncRangeVisual(input);
			});
			layoutRatioValues.forEach((valueEl) => {
				const name = valueEl.dataset.layoutValue;
				if (!LAYOUT_REGION_KEYS.includes(name)) return;
				valueEl.textContent = `${Number(ratios[name].toFixed(1))}%`;
			});
			layoutTotal.classList.toggle('warning', remaining > 0);
			layoutTotal.classList.toggle('balanced', remaining <= 0);
			const changed = LAYOUT_REGION_KEYS.some((name) => Math.abs(ratios[name] - savedRatios[name]) >= 0.01);
			layoutTotal.textContent = remaining > 0
				? `${profileLabel}布局还剩 ${Number(remaining.toFixed(1))}% 未分配,必须分配完才能确认。`
				: changed
					? `${profileLabel}布局已分配 100%,正在实时预览;点击“确认应用”后保存。`
					: `${profileLabel}布局当前配置已应用。`;
			layoutResetBtn.disabled = LAYOUT_REGION_KEYS.every((name) =>
				Math.abs(ratios[name] - defaultRatios[name]) < 0.01
			);
			layoutApplyBtn.disabled = remaining > 0 || !changed;
			syncSettingsDraftIndicators();
		};
		const syncReaderWindowControls = () => {
			const state = readerWindow.getState();
			const compact = window.innerWidth <= READER_WINDOW_COMPACT_WIDTH;
			const floating = readerWorkspace.isFloating();
			const geometry = `${Math.round(state.width)} × ${Math.round(state.height)} · (${Math.round(state.left)}, ${Math.round(state.top)})${readerWindow.isPinned() ? ' · 已置顶' : ''}${readerWindow.isLocked() ? ' · 已锁定' : ''}`;
			readerWindowWidthInput.max = String(Math.max(READER_WINDOW_MIN_WIDTH, window.innerWidth - READER_WINDOW_MARGIN * 2));
			readerWindowHeightInput.max = String(Math.max(READER_WINDOW_MIN_HEIGHT, window.innerHeight - READER_WINDOW_MARGIN * 2));
			readerWindowXInput.max = String(Math.max(READER_WINDOW_MARGIN, window.innerWidth - READER_WINDOW_MARGIN - state.width));
			readerWindowYInput.min = String(READER_WINDOW_MARGIN);
			readerWindowYInput.max = String(Math.max(READER_WINDOW_MARGIN, window.innerHeight - READER_WINDOW_MARGIN - state.height));
			readerWindowWidthInput.value = String(Math.round(state.width));
			readerWindowHeightInput.value = String(Math.round(state.height));
			readerWindowXInput.value = String(Math.round(state.left));
			readerWindowYInput.value = String(Math.round(state.top));
			readerWindowPinInput.checked = readerWindow.isPinned();
			readerWindowLockInput.checked = readerWindow.isLocked();
			[readerWindowWidthInput, readerWindowHeightInput, readerWindowXInput, readerWindowYInput,
				readerWindowPinInput, readerWindowLockInput].forEach((input) => { input.disabled = compact; });
			readerWindowStatus.textContent = compact
				? '当前视口较窄,阅读器使用同一套窄屏响应式布局。'
				: floating
					? `${geometry}${readerWindow.isLocked() ? '' : ' · 可拖动缩放'}`
					: `${readerWorkspace.isEmbedded() ? '当前为嵌入阅读' : '当前为全屏阅读'};以下配置将在切换到浮窗后生效。浮窗:${geometry}`;
			readerWindowResetBtn.disabled = compact || readerWindow.isDefault();
		};
		const appearanceApplyByGroup = {
			interaction: applyInteractionColors,
			zebra: applyZebraAppearance,
			'list-zebra': applyListZebraColor,
			'reply-line': applyReplyLineAppearance,
			'quote-line': applyQuoteLineAppearance,
			'divider-line': applyDividerLineAppearance,
			'floor-preview': applyFloorPreviewAppearance,
		};
		const appearanceControlSettings = appearanceSettingElements.map((field) => Object.assign({}, field, {
			apply: appearanceApplyByGroup[field.section],
		}));
		const syncAppearanceValueControls = () => {
			const values = currentAppearanceDraft();
			appearanceControlSettings.forEach(({ input, output, key }) => {
				const range = input.type === 'range';
				const value = values[key];
				input.value = range ? String(value) : value;
				output.textContent = range ? `${value}px` : value;
				if (!range) output.title = `当前主题实际应用 ${appearanceThemeColor(values, key).toUpperCase()}`;
				if (range) syncRangeVisual(input);
			});
		};
		const syncColorResetButtons = () => {
			const values = currentAppearanceDraft();
			const defaults = currentAppearanceDefaults();
			colorResetButtons.forEach((button) => {
				const baseKey = button.dataset.colorReset;
				const valueKeys = appearanceSettingValueFields(baseKey);
				const structureDisabled = button.closest('[data-appearance-group="structure"]')
					&& values.structureColorsEnabled === false;
				button.disabled = structureDisabled || valueKeys.every((key) =>
					defaults[key] == null || values[key] === defaults[key]);
			});
		};
		const syncAppearanceGroupControls = () => {
			const values = currentAppearanceDraft();
			appearanceGroupToggleInputs.forEach((input) => {
				const key = input.dataset.appearanceGroupToggle;
				const enabled = values[key] !== false;
				const group = input.closest('.ldp-color-group');
				input.checked = enabled;
				group.classList.toggle('is-disabled', !enabled);
				group.querySelector('.ldp-settings-fields').setAttribute('aria-disabled', String(!enabled));
				group.querySelectorAll('.ldp-settings-fields input').forEach((control) => {
					control.disabled = !enabled;
				});
			});
		};
		const syncAppearanceControls = () => {
			const profileLabel = '阅读器';
			const configuredTheme = resolvedReaderThemeMode();
			const selectedTheme = normalizeReaderThemeMode(currentReaderThemeMode());
			const themeLabel = configuredTheme === 'dark' ? '暗色主题' : '明亮主题';
			appearanceThemeNote.innerHTML = `当前预览 <strong>${themeLabel}适应色</strong>`
				+ `${selectedTheme === 'system' ? '(跟随系统)' : ''};HEX 保留所选原色,实际呈现自动限制亮度与对比。`;
			syncAppearanceValueControls();
			syncAppearanceGroupControls();
			syncColorResetButtons();
			const changed = appearanceValuesChanged();
			appearanceStatus.textContent = changed
				? `${profileLabel}外观正在实时预览;点击“确认应用”后保存。`
				: `${profileLabel}外观当前配置已应用。`;
			appearanceStatus.classList.toggle('balanced', !changed);
			appearanceApplyBtn.disabled = !changed;
			syncSettingsDraftIndicators();
		};
		let performanceSettingsChanged = false;
		const syncPerformanceControls = () => {
			const config = currentPerformanceConfig();
			const preset = performancePresetForConfig(config);
			performancePresetButtons.forEach((button) => {
				const active = button.dataset.performancePreset === preset;
				button.classList.toggle('active', active);
				setPressed(button, active);
			});
			performanceInputs.forEach((input) => {
				const name = input.dataset.performanceKey;
				const value = config[name];
				if (value == null) return;
				input.value = PERFORMANCE_LIMITS[name]?.integer
					? String(value)
					: String(Number(value.toFixed(2)));
			});
			performanceResetBtn.disabled = preset === 'balanced';
			const presetLabels = { low: '省资源', balanced: '均衡', high: '低等待', custom: '自定义' };
			const nextConfig = `主楼层 ${config.pageSize} 条/批 · DOM ≤ ${config.streamMaxItems} 个 · 楼中楼提前 ${config.nestedPrefetchViewports} 屏 · 调度目标 ${config.requestRateTarget}%`;
			const requestState = currentReaderRequestScheduler().snapshot();
			const windowStatus = `窗口预算 ${requestState.shortWindowBudget}/10 秒、${requestState.longWindowBudget}/分钟`;
			const runtimeStatus = requestState.endpointCoolingDown && !requestState.coolingDown
				? ` 当前隔离 ${requestState.endpointCooldownCount} 个异常端点,最长约 ${Math.max(1, Math.ceil(requestState.endpointCooldownRemaining / 1000))} 秒后允许再次尝试;其他请求继续按${windowStatus}运行。`
				: requestState.coolingDown
				? ` 当前会话命中${requestState.lastRateLimitWindow || '动态'}限流,正在退避,约 ${Math.max(1, Math.ceil(requestState.cooldownRemaining / 1000))} 秒后继续;${windowStatus}。`
				: requestState.learnedRateLimit
					? ` 当前会话已按最近边界调节;${windowStatus}${requestState.minInterval > requestState.configuredMinInterval ? `,最小启动间隔 ${requestState.minInterval}ms` : ''},稳定承压后继续小步提速,并发按需最高 ${requestState.maxConcurrent} 路。`
					: ` 当前会话正常自适应;${windowStatus},稳定承压后小步上探,并发按需最高 ${requestState.maxConcurrent} 路。`;
			const savedStatus = performanceSettingsChanged
				? `已保存为${presetLabels[preset] || '自定义'}:${nextConfig};下次打开阅读器生效。`
				: `下次打开采用${presetLabels[preset] || '自定义'}:${nextConfig}。`;
			performanceStatus.textContent = savedStatus + runtimeStatus;
		};
		const formatRequestFlowBytes = (value) => {
			const bytes = Math.max(0, Number(value) || 0);
			if (bytes < 1024) return `${Math.round(bytes)} B`;
			if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
			return `${(bytes / 1024 / 1024).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
		};
		const formatRequestFlowDuration = (value) => {
			const milliseconds = Math.max(0, Number(value) || 0);
			if (milliseconds < 10) return `${milliseconds.toFixed(1)}ms`;
			if (milliseconds < 1000) return `${Math.round(milliseconds)}ms`;
			if (milliseconds < 60000) return `${(milliseconds / 1000).toFixed(milliseconds < 10000 ? 1 : 0)}s`;
			return `${(milliseconds / 60000).toFixed(1)}min`;
		};
		const formatRequestFlowTimestamp = (value) => {
			const date = new Date(Number(value) || 0);
			const clock = date.toLocaleTimeString('zh-CN', {
				hour12: false,
				hour: '2-digit',
				minute: '2-digit',
				second: '2-digit',
			});
			return `${clock}.${String(date.getMilliseconds()).padStart(3, '0')}`;
		};
		const requestFlowSourceLabel = (source) => REQUEST_FLOW_SOURCE_LABELS[source] || '其他';
		const requestFlowPriorityLabel = (priority) => {
			if (!Number.isFinite(priority)) return '';
			if (priority <= POST_REQUEST_PRIORITY.topic) return '核心';
			if (priority === POST_REQUEST_PRIORITY.scroll) return '滚动';
			if (priority === POST_REQUEST_PRIORITY.visible) return '可见';
			if (priority === POST_REQUEST_PRIORITY.nested) return '预取';
			if (priority === POST_REQUEST_PRIORITY.auxiliary) return '辅助';
			return '后台';
		};
		const requestFlowWaitReasonLabel = (reason) => REQUEST_FLOW_WAIT_REASON_LABELS[reason] || '调度';
		const requestFlowIssue = (event, at = requestFlowNow()) => {
			if (!event) return null;
			const networkDuration = event.pending
				? Math.max(0, at - event.startedAt)
				: Math.max(0, Number(event.duration) || 0);
			const queueDuration = Math.max(0, Number(event.permitWait) || 0);
			if (event.controlOnly) {
				const reason = String(event.controlReason || event.waitReason || 'cancelled');
				if (reason === 'queue-limit') return { level: 'warning', kind: '队列已满', detail: '辅助请求在发出前被队列上限丢弃' };
				if (reason === 'endpoint-cooldown') return { level: 'warning', kind: '端点熔断', detail: '同一异常 URL 在冷却期内未再次发出,不影响其他请求' };
				if (reason === 'rate-limit-priority' || reason === 'cooldown') {
					return { level: 'warning', kind: '限流让位', detail: '低优先级请求在发出前为限流恢复让位' };
				}
				if (reason === 'context-close') return { level: 'warning', kind: '页面取消', detail: '阅读器上下文结束,请求未发出' };
				return { level: 'warning', kind: '调度取消', detail: `请求未发出(${requestFlowWaitReasonLabel(event.waitReason)})` };
			}
			if (event.status === 429) {
				return { level: 'danger', kind: '429 限流', detail: event.rateLimitCode || '服务器拒绝了当前请求速率' };
			}
			if (event.status >= 500) return { level: 'danger', kind: `HTTP ${event.status}`, detail: '服务器或上游服务返回错误' };
			if (event.status >= 400) return { level: 'danger', kind: `HTTP ${event.status}`, detail: '请求参数、权限或目标状态异常' };
			if (event.error) {
				const aborted = /abort/i.test(event.error);
				return { level: aborted ? 'warning' : 'danger', kind: aborted ? '中止/超时' : '网络错误', detail: event.error };
			}
			if (event.pending && event.type !== 'realtime' && networkDuration >= POST_REQUEST_TIMEOUT) {
				return { level: 'danger', kind: '请求卡住', detail: `网络阶段已持续 ${formatRequestFlowDuration(networkDuration)}` };
			}
			if (queueDuration >= 1000) {
				return {
					level: 'warning',
					kind: '排队过久',
					detail: `${requestFlowWaitReasonLabel(event.waitReason)}等待 ${formatRequestFlowDuration(queueDuration)}`,
				};
			}
			if (!event.pending && event.type !== 'realtime' && networkDuration >= REQUEST_FLOW_SLOW_MS) {
				return { level: 'warning', kind: '响应偏慢', detail: `网络阶段耗时 ${formatRequestFlowDuration(networkDuration)}` };
			}
			return null;
		};
		const requestFlowTimingLabel = (event, at = requestFlowNow()) => {
			const queueDuration = Math.max(0, Number(event.permitWait) || 0);
			const dispatchDuration = Math.max(0, Number(event.dispatchDuration) || 0);
			const networkDuration = event.pending
				? Math.max(0, at - event.startedAt)
				: Math.max(0, Number(event.duration) || 0);
			if (event.controlOnly) return `${queueDuration > 0 ? `排 ${formatRequestFlowDuration(queueDuration)} · ` : ''}未发出`;
			return `${queueDuration > 0 ? `排 ${formatRequestFlowDuration(queueDuration)} · ` : ''}${dispatchDuration >= 1 ? `放行 ${formatRequestFlowDuration(dispatchDuration)} · ` : ''}网 ${formatRequestFlowDuration(networkDuration)}`;
		};
		const requestFlowSnapshot = (at = requestFlowNow()) => {
			pruneRequestFlowEvents(at);
			const session = REQUEST_FLOW_EVENTS.slice();
			const recent60 = session.filter((event) => !event.controlOnly && event.startedAt >= at - 60000);
			const recent10 = recent60.filter((event) => event.startedAt >= at - REQUEST_FLOW_TRACE_WINDOW_MS);
			const binEnd = Math.ceil(at / 100) * 100;
			const binStart = binEnd - REQUEST_FLOW_TRACE_WINDOW_MS;
			const bins100 = Array.from({ length: REQUEST_FLOW_TRACE_WINDOW_MS / 100 }, () => 0);
			const typeStats = new Map();
			const sourceCounts = { reader: 0, host: 0, browser: 0 };
			let transfer = 0;
			const durations = [];
			recent60.forEach((event) => {
				const binIndex = Math.floor((event.startedAt - binStart) / 100);
				if (binIndex >= 0 && binIndex < bins100.length) bins100[binIndex]++;
				const stat = typeStats.get(event.type) || { type: event.type, count: 0, completed: 0, durations: [], issues: 0 };
				stat.count++;
				if (!event.pending) {
					stat.completed++;
					if (event.duration > 0 && event.type !== 'realtime') stat.durations.push(event.duration);
					if (event.duration > 0 && event.type !== 'realtime') durations.push(event.duration);
				}
				if (requestFlowIssue(event, at)) stat.issues++;
				typeStats.set(event.type, stat);
				if (Object.hasOwn(sourceCounts, event.source)) sourceCounts[event.source]++;
				transfer += event.size;
			});
			durations.sort((a, b) => a - b);
			const p95Index = durations.length ? Math.min(durations.length - 1, Math.ceil(durations.length * .95) - 1) : -1;
			const latestLimit = session.slice().reverse().find((event) =>
				event.status === 429 || event.retryAfter || event.rateLimitCode || event.serverLimit);
			const recentIssues = session
				.filter((event) => (Number(event.endedAt) || event.startedAt) >= at - 60000)
				.map((event) => ({ event, issue: requestFlowIssue(event, at) }))
				.filter((entry) => entry.issue);
			const traceEvents = session.filter((event) => {
				const lifecycleStart = Number(event.queuedAt) || event.startedAt;
				const lifecycleEnd = event.pending ? at : Number(event.endedAt) || event.startedAt;
				return lifecycleStart <= at && lifecycleEnd >= at - REQUEST_FLOW_TRACE_WINDOW_MS;
			});
			const sortedTypeStats = [...typeStats.values()].map((stat) => {
				stat.durations.sort((a, b) => a - b);
				const index = stat.durations.length
					? Math.min(stat.durations.length - 1, Math.ceil(stat.durations.length * .95) - 1)
					: -1;
				return { ...stat, p95: index >= 0 ? stat.durations[index] : 0 };
			}).sort((a, b) => b.count - a.count);
			return {
				at,
				session,
				recent60,
				recent10,
				traceEvents,
				typeStats: sortedTypeStats,
				recentIssues,
				sourceCounts,
				transfer,
				peak100: Math.max(0, ...bins100),
				p95: p95Index >= 0 ? durations[p95Index] : 0,
				completed: durations.length,
				active: session.filter((event) => event.pending).length,
				latestLimit,
			};
		};
		const requestFlowBottleneckInfo = (snapshot, schedulerState) => {
			const latest429 = snapshot.recent60.slice().reverse().find((event) => event.status === 429);
			if (latest429 || (schedulerState?.coolingDown)) {
				const event = latest429 || snapshot.latestLimit;
				const typeLabel = REQUEST_FLOW_TYPE_META[event?.type] && REQUEST_FLOW_TYPE_META[event.type].label || '请求';
				const wait = event?.retryAfter
					? `,服务器要求等待 ${event.retryAfter} 秒`
					: schedulerState?.cooldownRemaining
						? `,约 ${Math.max(1, Math.ceil(schedulerState.cooldownRemaining / 1000))} 秒后继续`
						: '';
				return { level: 'danger', state: '服务器限流', detail: `${typeLabel}在最近一分钟收到 429${wait};未启动的辅助和后台请求会让位。` };
			}
			const latestDanger = snapshot.recentIssues.slice().reverse().find((entry) => entry.issue.level === 'danger');
			if (latestDanger) {
				const { event, issue } = latestDanger;
				const typeLabel = REQUEST_FLOW_TYPE_META[event.type]?.label || event.type;
				const caller = event.callSite ? `,发起点 ${event.callSite}` : '';
				return {
					level: 'danger',
					state: issue.kind,
					detail: `${typeLabel} ${event.method} ${event.path}:${issue.detail}${caller}。`,
				};
			}
			if (schedulerState && Math.max(schedulerState.queued, schedulerState.globalQueued || 0) >= 3) {
				const wait = Math.max(0, Number(schedulerState.nextPermitDelay) || 0);
				const reason = schedulerState.blockingReason === '10s'
					? '10 秒窗口'
					: schedulerState.blockingReason === '60s'
						? '60 秒窗口'
						: schedulerState.blockingReason === 'interval'
							? '最小启动间隔'
							: '';
				const windowDetail = reason
					? `;正在等待${reason}${wait ? `,约 ${Math.max(1, Math.ceil(wait / 1000))} 秒后放行` : ''}`
					: '';
				return {
					level: 'warning',
					state: '阅读器排队',
					detail: `全局 ${schedulerState.globalActive || schedulerState.active} 路执行、${Math.max(schedulerState.queued, schedulerState.globalQueued || 0)} 条排队${windowDetail};宿主操作、目标楼层、正文和可见楼中楼会先启动。`,
				};
			}
			if (snapshot.completed >= 5 && snapshot.p95 >= 1800) {
				return {
					level: 'warning',
					state: '响应偏慢',
					detail: `最近一分钟完成请求的 P95 响应时间为 ${formatRequestFlowDuration(snapshot.p95)},瓶颈更可能在网络或服务器响应,而不是启动间隔。`,
				};
			}
			const resourceCount = snapshot.typeStats
				.filter((stat) => ['avatar', 'media', 'asset'].includes(stat.type))
				.reduce((sum, stat) => sum + stat.count, 0);
			if (snapshot.recent60.length >= 12 && resourceCount / snapshot.recent60.length >= .7 && snapshot.transfer >= 4 * 1024 * 1024) {
				return {
					level: 'warning',
					state: '资源占用',
					detail: `头像、图片和静态资源占最近一分钟请求的 ${Math.round(resourceCount / snapshot.recent60.length * 100)}%,已传输 ${formatRequestFlowBytes(snapshot.transfer)}。`,
				};
			}
			if (!snapshot.recent60.length) {
				return { level: 'normal', state: '等待采样', detail: '打开帖子并滚动后,这里会根据排队、响应耗时、传输量和 429 判断主要瓶颈。' };
			}
			return {
				level: 'normal',
				state: '节奏正常',
				detail: `最近一分钟 ${snapshot.recent60.length} 次请求,${snapshot.active} 次进行中,P95 ${snapshot.p95 ? formatRequestFlowDuration(snapshot.p95) : '暂无'};当前未发现明显限流或排队瓶颈。`,
			};
		};
		const requestFlowTraceMarkup = (snapshot) => {
			const windowStart = snapshot.at - REQUEST_FLOW_TRACE_WINDOW_MS;
			const position = (value) => Math.max(0, Math.min(100,
				(Number(value) - windowStart) / REQUEST_FLOW_TRACE_WINDOW_MS * 100));
			const width = (start, end) => Math.max(0, position(end) - position(start));
			const sources = [
				{ source: 'reader', label: '阅读器' },
				{ source: 'host', label: '宿主' },
				{ source: 'browser', label: '资源' },
			];
			return sources.map(({ source, label }) => {
				const layerEnds = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
				const events = snapshot.traceEvents.filter((event) => event.source === source)
					.sort((a, b) => (Number(a.queuedAt) || a.startedAt) - (Number(b.queuedAt) || b.startedAt))
					.slice(-80);
				const items = events.map((event) => {
					const lifecycleStart = Number(event.queuedAt) || event.startedAt;
					const lifecycleEnd = event.pending ? snapshot.at : Number(event.endedAt) || event.startedAt;
					let layer = layerEnds.findIndex((end) => end <= lifecycleStart);
					if (layer < 0) layer = layerEnds.indexOf(Math.min(...layerEnds));
					layerEnds[layer] = lifecycleEnd;
					const top = 3 + layer * 10;
					const issue = requestFlowIssue(event, snapshot.at);
					const typeLabel = REQUEST_FLOW_TYPE_META[event.type]?.label || event.type;
					const priorityLabel = requestFlowPriorityLabel(event.priority);
					const status = event.controlOnly ? '未发出' : event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '完成';
					const detail = [
						`${formatRequestFlowTimestamp(event.startedAt)} · ${event.method} ${event.path}`,
						`${requestFlowSourceLabel(event.source)} / ${typeLabel} / ${status}`,
						requestFlowTimingLabel(event, snapshot.at),
						priorityLabel && `${priorityLabel}优先级`,
						event.attempt > 1 && `第 ${event.attempt} 次尝试`,
						event.callSite && `发起点 ${event.callSite}`,
					].filter(Boolean).join(' · ');
					const wireStart = Math.max(windowStart, event.startedAt);
					const wireEnd = Math.min(snapshot.at, Math.max(event.startedAt, lifecycleEnd));
					const wire = event.controlOnly ? '' : `<i class="ldp-request-flow-trace-wire" data-request-flow-type="${event.type}" data-pending="${event.pending}" data-ldp-tooltip-label="${escAttr(detail)}" style="--ldp-request-flow-left:${position(wireStart).toFixed(3)}%;--ldp-request-flow-width:${width(wireStart, wireEnd).toFixed(3)}%;--ldp-request-flow-top:${top}px"></i>`;
					const queueEnd = Math.min(snapshot.at, Number(event.permittedAt) || event.startedAt);
					const queue = lifecycleStart < queueEnd - .5 && queueEnd >= windowStart
						? `<i class="ldp-request-flow-trace-queue" data-ldp-tooltip-label="${escAttr(`${formatRequestFlowTimestamp(lifecycleStart)} → ${formatRequestFlowTimestamp(queueEnd)} · ${requestFlowWaitReasonLabel(event.waitReason)}排队 ${formatRequestFlowDuration(event.permitWait)}`)}" style="--ldp-request-flow-left:${position(Math.max(windowStart, lifecycleStart)).toFixed(3)}%;--ldp-request-flow-width:${width(Math.max(windowStart, lifecycleStart), queueEnd).toFixed(3)}%;--ldp-request-flow-top:${top}px"></i>`
						: '';
					const permit = queue && !event.controlOnly
						? `<i class="ldp-request-flow-trace-permit" data-ldp-tooltip-label="${escAttr(`${formatRequestFlowTimestamp(queueEnd)} · 调度放行`)}" style="--ldp-request-flow-left:${position(queueEnd).toFixed(3)}%;--ldp-request-flow-top:${top}px"></i>`
						: '';
					const anomalyAt = Math.min(snapshot.at, Math.max(windowStart, lifecycleEnd));
					const anomalyPosition = Math.max(.6, Math.min(99.4, position(anomalyAt)));
					const anomaly = issue
						? `<i class="ldp-request-flow-trace-anomaly" data-level="${issue.level}" data-ldp-tooltip-label="${escAttr(`${formatRequestFlowTimestamp(anomalyAt)} · ${issue.kind} · ${event.path} · ${issue.detail}`)}" style="--ldp-request-flow-left:${anomalyPosition.toFixed(3)}%;--ldp-request-flow-top:${top}px"></i>`
						: '';
					return queue + permit + wire + anomaly;
				}).join('');
				return `<div class="ldp-request-flow-trace-lane" data-request-flow-source="${source}">
					<span class="ldp-request-flow-trace-label">${label}</span>
					<span class="ldp-request-flow-trace-track">${items}</span>
				</div>`;
			}).join('');
		};
		let requestFlowTimer = 0;
		const updateRequestFlowHtml = (element, html) => {
			if (!element || element.matches(':hover') || element.innerHTML === html) return;
			const scrollTop = element.scrollTop;
			element.innerHTML = html;
			if (scrollTop) element.scrollTop = scrollTop;
		};
		const syncRequestFlowControls = () => {
			if (!requestFlowChart || !requestFlowSection) return;
			const snapshot = requestFlowSnapshot();
			let schedulerState = null;
			try { schedulerState = currentReaderRequestScheduler().snapshot(); } catch (error) {}
			const recent429 = snapshot.recent60.filter((event) => event.status === 429).length;
			const metricValues = {
				rate10: `${snapshot.recent10.length}次 · ${(snapshot.recent10.length / 10).toFixed(1)}/秒`,
				rate60: `${snapshot.recent60.length}次 · ${(snapshot.recent60.length / 60).toFixed(1)}/秒`,
				peak: `${snapshot.peak100}次/100ms`,
				transfer: formatRequestFlowBytes(snapshot.transfer),
				issues: `${snapshot.recentIssues.length}次${recent429 ? ` · 429 ${recent429}` : ''}`,
			};
			requestFlowMetrics.forEach((element) => {
				element.textContent = metricValues[element.dataset.requestFlowMetric] || '—';
			});
			const queued10 = snapshot.recent10.filter((event) => event.permitWait > .5).length;
			const globalUsage = schedulerState
				? `全局 ${schedulerState.globalInstances} 实例 · 执行 ${schedulerState.globalActive} · 排队 ${schedulerState.globalQueued} · 共享上限 ${schedulerState.globalMaxConcurrent} 路 · 间隔 ${schedulerState.globalMinInterval}ms${schedulerState.coordinationMode === 'best-effort' ? ' · 协调降级' : ''}`
				: `本页阅读器 ${snapshot.sourceCounts.reader} · 宿主 ${snapshot.sourceCounts.host}`;
			const readerWindowUsage = schedulerState
				? ` · 窗口 ${schedulerState.shortWindowCount}/${schedulerState.shortWindowBudget} · ${schedulerState.longWindowCount}/${schedulerState.longWindowBudget}`
				: '';
			requestFlowWindow.textContent = `近 10 秒 ${snapshot.recent10.length} 请求 · 排队 ${queued10} · 异常 ${snapshot.recentIssues.filter((entry) => entry.event.startedAt >= snapshot.at - REQUEST_FLOW_TRACE_WINDOW_MS).length} · ${globalUsage}${readerWindowUsage}`;
			updateRequestFlowHtml(requestFlowChart, requestFlowTraceMarkup(snapshot));
			const visibleTypes = snapshot.typeStats.slice(0, 8);
			const typeLegend = visibleTypes.length
				? visibleTypes.map((stat) => `<span data-request-flow-type="${stat.type}"><i class="ldp-request-flow-dot"></i>${esc(REQUEST_FLOW_TYPE_META[stat.type]?.label || stat.type)}</span>`).join('')
				: '<span data-request-flow-type="other"><i class="ldp-request-flow-dot"></i>等待请求</span>';
			updateRequestFlowHtml(requestFlowLegend, `<span><i class="ldp-request-flow-queue-key"></i>排队</span><span><i class="ldp-request-flow-warning-key"></i>慢/久候</span><span><i class="ldp-request-flow-danger-key"></i>错误/限流</span>${typeLegend}`);
			const bottleneck = requestFlowBottleneckInfo(snapshot, schedulerState);
			requestFlowBottleneck.dataset.level = bottleneck.level;
			requestFlowBottleneckState.textContent = bottleneck.state;
			requestFlowBottleneckDetail.textContent = bottleneck.detail;
			if (requestFlowAnomalyWindow) {
				requestFlowAnomalyWindow.textContent = snapshot.recentIssues.length
					? `最近 60 秒 ${snapshot.recentIssues.length} 个 · 红色为失败/限流`
					: '最近 60 秒未发现异常';
			}
			if (requestFlowAnomalies) {
				const latestIssues = snapshot.recentIssues.slice(-8).reverse();
				updateRequestFlowHtml(requestFlowAnomalies, latestIssues.length
					? latestIssues.map(({ event, issue }) => {
							const typeLabel = REQUEST_FLOW_TYPE_META[event.type]?.label || event.type;
							const caller = event.callSite || `${event.transport} 发起`;
							const detail = `${requestFlowSourceLabel(event.source)} / ${typeLabel} · ${requestFlowTimingLabel(event, snapshot.at)} · ${issue.detail} · 发起点 ${caller}`;
							return `<div class="ldp-request-flow-anomaly-row" data-level="${issue.level}" data-request-flow-type="${event.type}" data-ldp-tooltip-label="${escAttr(`${event.method} ${event.path} · ${detail}`)}">
								<span class="ldp-request-flow-anomaly-time">${esc(formatRequestFlowTimestamp(event.endedAt || event.startedAt))}</span>
								<span class="ldp-request-flow-anomaly-kind">${esc(issue.kind)}</span>
								<span class="ldp-request-flow-anomaly-detail"><strong>${esc(`${event.method} ${event.path}`)}</strong><small>${esc(detail)}</small></span>
							</div>`;
						}).join('')
					: '<div class="ldp-request-flow-empty">最近 60 秒没有 HTTP 错误、网络中止、超时、慢响应或超过 1 秒的排队。</div>');
			}
			updateRequestFlowHtml(requestFlowTypes, snapshot.typeStats.length
				? snapshot.typeStats.map((stat) => {
						const p95 = stat.p95 ? formatRequestFlowDuration(stat.p95) : '—';
						return `<div class="ldp-request-flow-type-row" data-request-flow-type="${stat.type}">
							<span class="ldp-request-flow-type-name"><i class="ldp-request-flow-dot"></i><strong>${esc(REQUEST_FLOW_TYPE_META[stat.type]?.label || stat.type)}</strong></span>
							<span>${stat.count} 次</span><span>${p95}</span><span>${stat.issues || '—'}</span>
						</div>`;
					}).join('')
				: '<div class="ldp-request-flow-empty">还没有可统计的请求。</div>');
			const recentLog = snapshot.session.slice(-60).reverse();
			updateRequestFlowHtml(requestFlowLog, recentLog.length
				? recentLog.map((event) => {
						const status = event.controlOnly ? '未发出' : event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '—';
						const typeLabel = REQUEST_FLOW_TYPE_META[event.type]?.label || event.type;
						const priorityLabel = requestFlowPriorityLabel(event.priority);
						const issue = requestFlowIssue(event, snapshot.at);
						const caller = event.callSite || `${event.transport} 发起`;
						const title = `${event.method} ${event.path}${priorityLabel ? ` · ${priorityLabel}优先级` : ''}${event.attempt > 1 ? ` · 第 ${event.attempt} 次尝试` : ''} · ${requestFlowTimingLabel(event, snapshot.at)} · 发起点 ${caller}`;
						return `<div class="ldp-request-flow-log-row"${issue ? ` data-level="${issue.level}"` : ''} data-request-flow-type="${event.type}" data-ldp-tooltip-label="${escAttr(title)}">
							<span>${esc(formatRequestFlowTimestamp(event.startedAt))}</span>
							<span class="ldp-request-flow-source">${requestFlowSourceLabel(event.source)}</span>
							<span class="ldp-request-flow-type-name"><i class="ldp-request-flow-dot"></i>${esc(typeLabel)}</span>
							<span class="ldp-request-flow-status${event.status >= 400 || event.error ? ' is-error' : ''}">${status}</span>
							<span class="ldp-request-flow-timing">${esc(requestFlowTimingLabel(event, snapshot.at))}</span>
							<span class="ldp-request-flow-path">${esc(`${event.path} ← ${caller}`)}</span>
						</div>`;
					}).join('')
				: '<div class="ldp-request-flow-empty">打开帖子或操作宿主页面后,请求会按时间出现在这里。</div>');
			const latestLimit = snapshot.latestLimit;
			if (latestLimit && latestLimit.status === 429) {
				const typeLabel = REQUEST_FLOW_TYPE_META[latestLimit.type]?.label || '请求';
				const details = [
					latestLimit.rateLimitCode && `错误码 ${latestLimit.rateLimitCode}`,
					latestLimit.retryAfter && `Retry-After ${latestLimit.retryAfter} 秒`,
				].filter(Boolean).join(',');
				requestFlowObserved.textContent = `本页最近观测:${typeLabel}收到 429${details ? `(${details})` : ''}。`;
			} else if (latestLimit?.serverLimit) {
				requestFlowObserved.textContent = `服务器最近返回:上限 ${latestLimit.serverLimit}${latestLimit.serverRemaining ? `,剩余 ${latestLimit.serverRemaining}` : ''}${latestLimit.serverReset ? `,重置 ${latestLimit.serverReset}` : ''}。`;
			} else if (schedulerState?.learnedRateLimit) {
				requestFlowObserved.textContent = `所有实例已共享历史限流与有效策略:${schedulerState.shortWindowBudget} 次/10 秒、${schedulerState.longWindowBudget} 次/分钟、并发 ${schedulerState.globalMaxConcurrent} 路。`;
			} else if (schedulerState) {
				requestFlowObserved.textContent = `当前未收到 429 或服务器上限头;所有实例共同使用 ${schedulerState.shortWindowBudget} 次/10 秒、${schedulerState.longWindowBudget} 次/分钟、并发 ${schedulerState.globalMaxConcurrent} 路。`;
			}
		};
		const stopRequestFlowUpdates = () => {
			if (requestFlowTimer) clearInterval(requestFlowTimer);
			requestFlowTimer = 0;
		};
		const startRequestFlowUpdates = () => {
			stopRequestFlowUpdates();
			if (document.visibilityState !== 'visible') return;
			syncRequestFlowControls();
			requestFlowTimer = setInterval(syncRequestFlowControls, 1000);
		};
		const RESOURCE_MONITOR_RETENTION_MS = 10 * 60 * 1000;
		const RESOURCE_MONITOR_MAX_EVENTS = 1200;
		const resourceMonitorState = readerShell.resourceMonitorState || {
			timer: 0,
			longTaskObserver: null,
			longTasksAvailable: false,
			samples: [],
		};
		const RESOURCE_MONITOR_OBSERVER_KEYS = [
			'longTaskObserver', 'longFrameObserver', 'resourceObserver', 'mutationObserver',
			'readerMutationObserver', 'hostMutationObserver',
		];
		const disconnectResourceMonitorObservers = (keys = RESOURCE_MONITOR_OBSERVER_KEYS) => keys.forEach((key) => {
			if (isFunction(resourceMonitorState[key]?.disconnect)) {
				resourceMonitorState[key].disconnect();
			}
			resourceMonitorState[key] = null;
		});
		if (!Array.isArray(resourceMonitorState.samples)) resourceMonitorState.samples = [];
		if (!Array.isArray(resourceMonitorState.events)) resourceMonitorState.events = [];
		if (!Array.isArray(resourceMonitorState.visibilityTimeline)) resourceMonitorState.visibilityTimeline = [];
		if (!Array.isArray(resourceMonitorState.memorySamples)) resourceMonitorState.memorySamples = [];
		if (!resourceMonitorState.memory || typeof resourceMonitorState.memory !== 'object') {
			resourceMonitorState.memory = { bytes: null, status: 'waiting', nextAt: 0, pending: false };
		}
		if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer);
		disconnectResourceMonitorObservers();
		resourceMonitorState.timer = 0;
		const previousResourceMonitorVisibility = resourceMonitorState.visibilityTimeline[resourceMonitorState.visibilityTimeline.length - 1];
		if (previousResourceMonitorVisibility && previousResourceMonitorVisibility.state !== 'stopped') {
			resourceMonitorState.visibilityTimeline.push({ at: requestFlowNow(), state: 'stopped' });
		}
		resourceMonitorState.collecting = false;
		resourceMonitorState.eventSequence = Number(resourceMonitorState.eventSequence) || 0;
		resourceMonitorState.lastPruneAt = Number(resourceMonitorState.lastPruneAt) || 0;
		readerShell.resourceMonitorState = resourceMonitorState;
		const resourceMonitorLongTasksSupported = isFunction(window.PerformanceObserver) &&
			(!Array.isArray(window.PerformanceObserver.supportedEntryTypes) ||
				window.PerformanceObserver.supportedEntryTypes.includes('longtask'));
		const resourceMonitorLongFramesSupported = isFunction(window.PerformanceObserver) &&
			[...window.PerformanceObserver.supportedEntryTypes || []].includes('long-animation-frame');
		const resourceMonitorNow = () => requestFlowNow();
		const resourceMonitorTimestamp = (at) => {
			const date = new Date(Number(at) || 0);
			const clock = date.toLocaleTimeString('zh-CN', { hour12: false });
			return `${clock}.${String(date.getMilliseconds()).padStart(3, '0')}`;
		};
		const resourceMonitorDuration = (value) => {
			const duration = Math.max(0, Number(value) || 0);
			return `${duration < 100 ? duration.toFixed(1) : Math.round(duration)}ms`;
		};
		const resourceMonitorPrune = (at = resourceMonitorNow()) => {
			const cutoff = at - RESOURCE_MONITOR_RETENTION_MS;
			resourceMonitorState.samples = resourceMonitorState.samples
				.filter((entry) => Number(entry.at) >= cutoff)
				.slice(-600);
			resourceMonitorState.memorySamples = resourceMonitorState.memorySamples
				.filter((entry) => Number(entry.at) >= cutoff)
				.slice(-60);
			resourceMonitorState.events = resourceMonitorState.events
				.filter((entry) => Number(entry.at) >= cutoff)
				.slice(-RESOURCE_MONITOR_MAX_EVENTS);
			resourceMonitorState.visibilityTimeline = resourceMonitorState.visibilityTimeline
				.filter((entry, index, entries) => Number(entry.at) >= cutoff || index === entries.length - 1)
				.slice(-240);
		};
		const resourceMonitorPushEvent = (event) => {
			const at = Number(event?.at);
			if (!Number.isFinite(at)) return;
			resourceMonitorState.events.push(Object.assign({
				id: ++resourceMonitorState.eventSequence,
				visibility: 'unknown',
				scope: 'shared',
				kind: 'event',
				duration: 0,
				detail: '',
				basis: '',
			}, event, { at }));
			if (resourceMonitorState.events.length > RESOURCE_MONITOR_MAX_EVENTS) {
				resourceMonitorState.events.splice(0, resourceMonitorState.events.length - RESOURCE_MONITOR_MAX_EVENTS);
			}
			const now = resourceMonitorNow();
			if (now - resourceMonitorState.lastPruneAt >= 1000) {
				resourceMonitorState.lastPruneAt = now;
				resourceMonitorPrune(now);
			}
		};
		const resourceMonitorVisibilityAt = (at) => {
			for (let index = resourceMonitorState.visibilityTimeline.length - 1; index >= 0; index--) {
				const entry = resourceMonitorState.visibilityTimeline[index];
				if (Number(entry.at) > at) continue;
				return ['visible', 'hidden'].includes(entry.state) ? entry.state : 'unknown';
			}
			return 'unknown';
		};
		const resourceMonitorPushTimedEvent = (at, event) => resourceMonitorPushEvent(Object.assign({
			at,
			visibility: resourceMonitorVisibilityAt(at),
		}, event));
		const recordResourceMonitorVisibility = (reason = 'visibilitychange', at = resourceMonitorNow()) => {
			if (!resourceMonitorState.collecting) return;
			const state = document.visibilityState === 'visible' ? 'visible' : 'hidden';
			const previous = resourceMonitorState.visibilityTimeline[resourceMonitorState.visibilityTimeline.length - 1];
			if (previous && previous.state === state) return;
			resourceMonitorState.visibilityTimeline.push({ at, state });
			resourceMonitorPushEvent({
				at,
				visibility: state,
				scope: 'shared',
				kind: 'visibility',
				detail: reason === 'start' ? `开始取证 · 当前${state === 'visible' ? '前台' : '后台'}` : `页面切换为${state === 'visible' ? '前台' : '后台'}`,
				basis: 'Page Visibility',
			});
		};
		const resourceMonitorRequestEvents = (at = resourceMonitorNow()) => {
			const cutoff = at - RESOURCE_MONITOR_RETENTION_MS;
			pruneRequestFlowEvents(at);
			return REQUEST_FLOW_EVENTS.filter((event) => !event.controlOnly &&
				Number(event.startedAt) >= cutoff && Number(event.startedAt) <= at).map((event) => {
				const visibility = resourceMonitorVisibilityAt(Number(event.startedAt));
				const scope = event.source === 'reader' ? 'reader' : event.source === 'host' ? 'host' : 'shared';
				const duration = Math.max(0, event.pending ? at - event.startedAt : Number(event.duration) || 0);
				const bytes = Math.max(0, Number(event.size) || 0);
				const status = event.pending ? '进行中' : event.status > 0 ? String(event.status) : event.error ? 'ERR' : '完成';
				const initiator = event.resourceTimed
					? String(event.transport || 'resource')
					: REQUEST_FLOW_TYPE_META[event.type]?.label || event.type;
				const request = event.resourceTimed ? event.path : `${event.method} ${event.path}`;
				const basis = event.resourceTimed
					? `PerformanceResourceTiming${event.source === 'reader' ? ' + reader 元数据' : event.source === 'host' ? ' + 未标记请求' : ''}`
					: event.source === 'reader' ? '显式 reader 元数据' : event.source === 'host' ? '未标记 fetch/XHR' : 'Resource Timing';
				return {
					id: `request-${event.id}`,
					at: Number(event.startedAt),
					visibility,
					scope,
					kind: 'resource',
					duration,
					bytes,
					detail: `${initiator} · ${request} · ${status} · ${resourceMonitorDuration(duration)}${bytes ? ` · ${formatRequestFlowBytes(bytes)}` : event.resourceTimed ? ' · 传输量受限或缓存命中' : ''}`,
					basis,
				};
			});
		};
		const resourceMonitorResourceDropEvents = (at = resourceMonitorNow()) => REQUEST_FLOW_RESOURCE_DROPS
			.filter((entry) => entry.at >= at - RESOURCE_MONITOR_RETENTION_MS && entry.at <= at)
			.map((entry, index) => ({
				id: `resource-drop-${entry.at}-${index}`,
				at: entry.at,
				visibility: resourceMonitorVisibilityAt(entry.at),
				scope: 'shared',
				kind: 'dropped',
				detail: `Resource Timing 有 ${entry.count} 条浏览器记录被丢弃;该区间统计不完整`,
				basis: 'PerformanceObserver droppedEntriesCount',
			}));
		const resourceMonitorEvidenceEvents = (requestEvents, resourceDropEvents) => resourceMonitorState.events.concat(
			requestEvents.filter((event) => ['visible', 'hidden'].includes(event.visibility)),
			resourceDropEvents.filter((event) => ['visible', 'hidden'].includes(event.visibility))
		);
		const resourceMonitorIsReaderRoot = (node) => {
			if (!node || node.nodeType !== 1) return false;
			if (node === READER_PORTAL_HOST) return true;
			return !!node.closest('.ldp-reader-portal-host,.ldp-overlay,.ldp-native-boost-menu,.ldp-lightbox,.ldp-user-card-fallback');
		};
		const resourceMonitorElementCount = (nodes, excludeReaderRoots = false) => [...nodes || []].reduce((sum, node) => {
			if (!node || node.nodeType !== 1 || (excludeReaderRoots && resourceMonitorIsReaderRoot(node))) return sum;
			return sum + 1 + (isFunction(node.getElementsByTagName) ? node.getElementsByTagName('*').length : 0);
		}, 0);
		const resourceMonitorHostDomCount = () => {
			const total = document.getElementsByTagName('*').length;
			const readerRoots = document.querySelectorAll('html > .ldp-reader-portal-host,body > .ldp-native-boost-menu');
			return Math.max(0, total - resourceMonitorElementCount(readerRoots));
		};
		const resourceMonitorRecordMutations = (scope, records) => {
			let added = 0;
			let removed = 0;
			records.forEach((record) => {
				added += resourceMonitorElementCount(record.addedNodes, scope === 'host');
				removed += resourceMonitorElementCount(record.removedNodes, scope === 'host');
			});
			if (!added && !removed) return;
			const at = resourceMonitorNow();
			resourceMonitorPushTimedEvent(at, {
				scope,
				kind: 'dom',
				added,
				removed,
				detail: `DOM +${added} / −${removed} 元素 · ${records.length} 个变更记录`,
				basis: 'MutationObserver 回调',
			});
		};
		const resourceMonitorRecordDroppedEntries = (entryType, options) => {
			const dropped = Math.max(0, Number(options?.droppedEntriesCount) || 0);
			if (!dropped) return;
			const at = resourceMonitorNow();
			resourceMonitorPushTimedEvent(at, {
				scope: 'shared',
				kind: 'dropped',
				detail: `${entryType} 有 ${dropped} 条浏览器记录被丢弃;该区间统计不完整`,
				basis: 'PerformanceObserver droppedEntriesCount',
			});
		};
		const resourceMonitorScriptScope = (script) => {
			const rawSource = String(script?.sourceURL || '');
			let source = rawSource;
			try { source = decodeURIComponent(rawSource); } catch (error) {}
			if (/Awesome LinuxDo Reader|更流畅的 LinuxDo 阅读器|katex@0\.16\.22|pinyin-pro@3\.18\.2|hls\.js@1\.6\.16/i.test(source)) return 'reader';
			try {
				const url = new URL(rawSource, location.href);
				if ((url.origin === BASE || /(?:^|\.)linux\.do$/i.test(url.hostname) || /(?:^|\.)ldstatic\.com$/i.test(url.hostname)) &&
					/\/(?:assets|plugins|theme-javascripts)\/|\.(?:m?js)(?:$|[?#])/i.test(url.pathname)) return 'host';
			} catch (error) {}
			return 'shared';
		};
		const resourceMonitorScriptLabel = (script) => {
			const functionName = String(script?.sourceFunctionName || '').trim();
			if (functionName) return functionName.slice(0, 80);
			const source = String(script?.sourceURL || '');
			if (!source) return '匿名脚本';
			try {
				const url = new URL(source, location.href);
				return `${url.host}${url.pathname}`.slice(0, 100);
			} catch (error) {
				return source.split(/[?#]/, 1)[0].slice(0, 100);
			}
		};
		const requestResourceMonitorMemoryMeasurement = (at) => {
			const memory = resourceMonitorState.memory;
			const measure = window.performance?.measureUserAgentSpecificMemory;
			if (!isFunction(measure)) {
				memory.status = 'unsupported';
				return;
			}
			if (window.crossOriginIsolated !== true) {
				memory.status = 'not-isolated';
				return;
			}
			if (memory.pending || at < Number(memory.nextAt || 0)) return;
			memory.pending = true;
			memory.status = 'measuring';
			memory.nextAt = at + 30000 + Math.random() * 30000;
			let measurement;
			try { measurement = measure.call(window.performance); } catch (error) { measurement = Promise.reject(error); }
			Promise.resolve(measurement).then((result) => {
				const bytes = Number(result?.bytes);
				const measuredAt = resourceMonitorNow();
				memory.pending = false;
				if (!Number.isFinite(bytes)) {
					memory.status = 'error';
					return;
				}
				memory.bytes = bytes;
				memory.status = 'measured';
				resourceMonitorState.memorySamples.push({ at: measuredAt, heapUsed: bytes });
				resourceMonitorPushTimedEvent(measuredAt, {
					scope: 'shared',
					kind: 'memory',
					detail: `页面内存估计 ${formatRequestFlowBytes(bytes)}`,
					basis: 'measureUserAgentSpecificMemory',
				});
			}).catch(() => {
				memory.pending = false;
				memory.status = 'error';
			});
		};
		const resourceMonitorStats = (samples, key) => {
			const values = samples.map((sample) => sample[key]).filter(Number.isFinite);
			if (!values.length) return { count: 0, average: 0, peak: 0, delta: 0 };
			return {
				count: values.length,
				average: values.reduce((sum, value) => sum + value, 0) / values.length,
				peak: Math.max(...values),
				delta: values[values.length - 1] - values[0],
			};
		};
		const resourceMonitorSignedBytes = (value) => {
			const numeric = Number(value) || 0;
			if (!numeric) return '0 B';
			return `${numeric > 0 ? '+' : '-'}${formatRequestFlowBytes(Math.abs(numeric))}`;
		};
		const resourceMonitorSignedCount = (value) => {
			const numeric = Math.round(Number(value) || 0);
			return numeric > 0 ? `+${numeric}` : String(numeric);
		};
		const resourceMonitorTrendLabel = (stats, formatter) => {
			if (stats.count < 2) return '→ —';
			const arrow = stats.delta > 0 ? '↑' : stats.delta < 0 ? '↓' : '→';
			return `${arrow} ${formatter(stats.delta)}`;
		};
		const resourceMonitorChartPoints = (samples, key) => {
			const entries = samples.filter((sample) => Number.isFinite(sample[key]));
			if (!entries.length) return '';
			if (entries.length === 1) return '0,17 240,17';
			const stride = Math.max(1, Math.ceil(entries.length / 120));
			const plotted = entries.filter((_entry, index) => index % stride === 0 || index === entries.length - 1);
			const values = plotted.map((entry) => entry[key]);
			const minimum = Math.min(...values);
			const maximum = Math.max(...values);
			const range = maximum - minimum || 1;
			return plotted.map((entry, index) => {
				const x = index / Math.max(1, plotted.length - 1) * 240;
				const y = 31 - (entry[key] - minimum) / range * 28;
				return `${x.toFixed(1)},${y.toFixed(1)}`;
			}).join(' ');
		};
		const resourceMonitorMemoryLabel = () => {
			const memory = resourceMonitorState.memory;
			if (memory.status === 'measured' && Number.isFinite(memory.bytes)) return formatRequestFlowBytes(memory.bytes);
			if (memory.status === 'measuring') return '正在测量';
			if (memory.status === 'not-isolated') return '不可用(未隔离)';
			if (memory.status === 'unsupported') return '浏览器未提供';
			if (memory.status === 'error') return '测量失败';
			return '等待测量';
		};
		const resourceMonitorScopeSnapshot = (at, requestEvents) => {
			const createCell = () => ({ requests: 0, requestDuration: 0, bytes: 0, scripts: 0, longTasks: 0, longTaskDuration: 0, domChanges: 0 });
			const scopes = {
				reader: { visible: createCell(), hidden: createCell() },
				host: { visible: createCell(), hidden: createCell() },
				shared: { visible: createCell(), hidden: createCell() },
			};
			const cutoff = at - 60000;
			requestEvents.forEach((event) => {
				if (event.at < cutoff || !['visible', 'hidden'].includes(event.visibility)) return;
				const cell = scopes[event.scope][event.visibility];
				cell.requests++;
				cell.requestDuration += event.duration;
				cell.bytes += event.bytes;
			});
			resourceMonitorState.events.forEach((event) => {
				if (event.at < cutoff || event.at > at || !['visible', 'hidden'].includes(event.visibility)) return;
				const scope = Object.hasOwn(scopes, event.scope) ? event.scope : 'shared';
				const cell = scopes[scope][event.visibility];
				if (event.kind === 'script') cell.scripts += Math.max(0, Number(event.duration) || 0);
				if (event.kind === 'longtask') {
					cell.longTasks++;
					cell.longTaskDuration += Math.max(0, Number(event.duration) || 0);
				}
				if (event.kind === 'dom') cell.domChanges += Math.max(0, Number(event.added) || 0) + Math.max(0, Number(event.removed) || 0);
			});
			return scopes;
		};
		const resourceMonitorScopeCellLabel = (cell, scope) => {
			const parts = [];
			if (cell.requests) {
				const requestCopy = `请求 ${cell.requests} / ${resourceMonitorDuration(cell.requestDuration)}`;
				parts.push(cell.bytes ? `${requestCopy} / ${formatRequestFlowBytes(cell.bytes)}` : requestCopy);
			}
			if (cell.scripts) parts.push(`已归因脚本 ${resourceMonitorDuration(cell.scripts)}`);
			if (scope === 'shared' && cell.longTasks) parts.push(`长任务 ${cell.longTasks} / ${resourceMonitorDuration(cell.longTaskDuration)}`);
			if (cell.domChanges) parts.push(`DOM 变更 ${cell.domChanges}`);
			return parts.length ? parts.join(' · ') : '—';
		};
		const syncResourceMonitorScopeControls = (sample, at, requestEvents, evidenceEvents) => {
			const scopes = resourceMonitorScopeSnapshot(at, requestEvents);
			const scopeValues = {
				reader: `${sample.dom} DOM · ${sample.retainedFloors} 楼`,
				host: `${sample.hostDom} DOM`,
				shared: resourceMonitorMemoryLabel(),
				readerBasis: `reader 元数据 · Resource Timing · Reader DOM${resourceMonitorLongFramesSupported ? ' · LoAF 已确认脚本' : ''}`,
				hostBasis: `未标记 fetch/XHR · Resource Timing · Document DOM${resourceMonitorLongFramesSupported ? ' · 同源 LoAF 脚本' : ''}`,
				sharedBasis: `Long Tasks · Resource Timing · Page Visibility${resourceMonitorLongFramesSupported ? ' · 未归因 LoAF' : ''}`,
			};
			resourceMonitorScopeRows.forEach((row) => {
				const scope = row.dataset.resourceMonitorScope;
				row.querySelector('[data-resource-monitor-scope-current]').textContent = scopeValues[scope] || '—';
				row.querySelector('[data-resource-monitor-scope-visible]').textContent = resourceMonitorScopeCellLabel(scopes[scope].visible, scope);
				row.querySelector('[data-resource-monitor-scope-hidden]').textContent = resourceMonitorScopeCellLabel(scopes[scope].hidden, scope);
				row.querySelector('[data-resource-monitor-scope-basis]').textContent = scopeValues[`${scope}Basis`] || '—';
			});
			const recentEvents = evidenceEvents.filter((event) => event.at >= at - 60000);
			const visibleCount = recentEvents.filter((event) => event.visibility === 'visible').length;
			const hiddenCount = recentEvents.filter((event) => event.visibility === 'hidden').length;
			const unknownCount = recentEvents.length - visibleCount - hiddenCount;
			resourceMonitorEvidenceWindow.textContent = `${document.visibilityState === 'visible' ? '当前前台' : '当前后台'} · 前 ${visibleCount} / 后 ${hiddenCount}${unknownCount ? ` / 未归档 ${unknownCount}` : ''} 个原始事件`;
		};
		const resourceMonitorScopeLabel = (scope) => RESOURCE_MONITOR_SCOPE_LABELS[scope] || '未归因';
		const syncResourceMonitorEventLog = (at, evidenceEvents) => {
			if (!resourceMonitorEventLog) return;
			const events = evidenceEvents
				.filter((event) => event.at >= at - RESOURCE_MONITOR_RETENTION_MS && event.at <= at)
				.sort((left, right) => right.at - left.at || String(right.id).localeCompare(String(left.id)))
				.slice(0, 48);
			resourceMonitorEventLog.innerHTML = events.length
				? events.map((event) => {
						const visibilityLabel = event.visibility === 'visible' ? '前台' : event.visibility === 'hidden' ? '后台' : '未知';
						return `<div class="ldp-resource-monitor-event-row" role="listitem">
							<time datetime="${escAttr(new Date(event.at).toISOString())}">${esc(resourceMonitorTimestamp(event.at))}</time>
							<span class="ldp-resource-monitor-event-state" data-visibility="${escAttr(event.visibility)}">${visibilityLabel}</span>
							<span class="ldp-resource-monitor-event-scope">${esc(resourceMonitorScopeLabel(event.scope))}</span>
							<span class="ldp-resource-monitor-event-detail">${esc(event.detail)}</span>
							<span class="ldp-resource-monitor-event-basis">${esc(event.basis)}</span>
						</div>`;
					}).join('')
				: '<div class="ldp-resource-monitor-event-empty">等待 Performance Timeline、Resource Timing、DOM 或可见性事件。</div>';
		};
		const resourceMonitorHealthInfo = (samples, current, at) => {
			const baselineSamples = samples.filter((sample) => sample.at >= at - 12000);
			if (baselineSamples.length < 10) {
				return {
					level: 'normal',
					state: '建立基线',
					detail: `最近 12 秒取得 ${baselineSamples.length} 个真实快照;满 10 个后开始判断 DOM 增长、共享长任务和请求排队。`,
				};
			}
			const recent = samples.filter((sample) => sample.at >= at - 60000);
			const first = recent[0] || current;
			const domGrowth = current.dom - first.dom;
			const retainedFloorGrowth = current.retainedFloors - first.retainedFloors;
			const warnings = [];
			let level = 'normal';
			if (current.longTaskDuration >= 2000) {
				level = 'danger';
				warnings.push(`最近 10 秒长任务累计 ${Math.round(current.longTaskDuration)}ms`);
			} else if (current.longTaskDuration >= 500) {
				level = 'warning';
				warnings.push(`最近 10 秒长任务累计 ${Math.round(current.longTaskDuration)}ms`);
			}
			if (current.queuedRequests >= 10) {
				level = 'danger';
				warnings.push(`当前有 ${current.queuedRequests} 条阅读器请求排队`);
			} else if (current.queuedRequests >= 3) {
				if (level === 'normal') level = 'warning';
				warnings.push(`当前有 ${current.queuedRequests} 条阅读器请求排队`);
			}
			if (domGrowth >= 1000 && retainedFloorGrowth >= 50) {
				if (level === 'normal') level = 'warning';
				warnings.push(`近一分钟 DOM 增加 ${domGrowth} 个,保留楼层增加 ${retainedFloorGrowth} 个`);
			}
			if (warnings.length) {
				return {
					level,
					state: level === 'danger' ? '资源压力高' : '需要关注',
					detail: `${warnings.join(';')}。继续观察趋势,回落后状态会自动恢复。`,
				};
			}
			return {
				level: 'normal',
				state: '采样正常',
				detail: '最近一分钟未发现阅读器 DOM 快速膨胀、页面共享长任务或阅读器请求积压;内存估计不参与自动判定。',
			};
		};
		const syncResourceMonitorControls = () => {
			if (!resourceMonitorSection || !resourceMonitorMetrics.length) return;
			const at = resourceMonitorNow();
			const activeContext = readerShell.activeContext;
			requestResourceMonitorMemoryMeasurement(at);
			resourceMonitorPrune(at);
			const requestEvents = resourceMonitorRequestEvents(at);
			const recentLongTasks = resourceMonitorState.events.filter((entry) =>
				entry.kind === 'longtask' && entry.at >= at - 10000);
			const longTaskDuration = recentLongTasks.reduce((sum, entry) => sum + entry.duration, 0);
			let schedulerState = null;
			try { schedulerState = currentReaderRequestScheduler().snapshot(); } catch (error) {}
			const recentReaderRequests = requestEvents
				.filter((event) => event.scope === 'reader' && event.at >= at - 60000);
			const readerTransfer = recentReaderRequests.reduce((sum, event) => sum + event.bytes, 0);
			const sample = {
				at,
				visibility: document.visibilityState === 'visible' ? 'visible' : 'hidden',
				longTaskCount: resourceMonitorState.longTasksAvailable ? recentLongTasks.length : null,
				longTaskDuration: resourceMonitorState.longTasksAvailable ? longTaskDuration : null,
				dom: overlay.getElementsByTagName('*').length + 1,
				hostDom: resourceMonitorHostDomCount(),
				mountedFloors: activeContext?.streamMountedNodes?.size || 0,
				retainedFloors: activeContext?.streamNodeMap?.size || 0,
				nested: activeContext?.subReplyState?.size || 0,
				media: body.querySelectorAll('img,video,audio,iframe').length,
				activeRequests: schedulerState?.active || 0,
				queuedRequests: schedulerState?.queued || 0,
				readerRequestCount: recentReaderRequests.length,
				readerTransfer,
			};
			const lastSample = resourceMonitorState.samples[resourceMonitorState.samples.length - 1];
			if (!lastSample || at - lastSample.at >= 800) {
				if (lastSample && at - lastSample.at > 1800) {
					const crossedHidden = lastSample.visibility === 'hidden' || resourceMonitorState.visibilityTimeline.some((entry) =>
						entry.at > lastSample.at && entry.at <= at && entry.state === 'hidden');
					const crossedStopped = resourceMonitorState.visibilityTimeline.some((entry) =>
						entry.at > lastSample.at && entry.at <= at && entry.state === 'stopped');
					resourceMonitorPushEvent({
						at,
						visibility: crossedStopped ? 'unknown' : crossedHidden ? 'hidden' : resourceMonitorVisibilityAt(at),
						scope: 'shared',
						kind: 'gap',
						duration: at - lastSample.at,
						detail: `快照空档 ${resourceMonitorDuration(at - lastSample.at)}${crossedStopped ? '(包含取证暂停)' : crossedHidden ? '(包含后台节流或冻结)' : ''};未补造中间样本`,
						basis: 'High Resolution Time',
					});
				}
				resourceMonitorState.samples.push(sample);
			}
			resourceMonitorPrune(at);
			if (document.visibilityState !== 'visible') return;
			const evidenceEvents = resourceMonitorEvidenceEvents(
				requestEvents,
				resourceMonitorResourceDropEvents(at)
			);
			const samples = resourceMonitorState.samples;
			const stats = RESOURCE_MONITOR_STAT_KEYS.reduce((result, key) => {
				result[key] = resourceMonitorStats(key === 'heapUsed' ? resourceMonitorState.memorySamples : samples, key);
				return result;
			}, {});
			const metricValues = {
				heap: resourceMonitorMemoryLabel(),
				longTasks: resourceMonitorState.longTasksAvailable
					? `${recentLongTasks.length} 次 · ${resourceMonitorDuration(longTaskDuration)}`
					: '浏览器未提供',
				dom: `${sample.dom} 个`,
				floors: `${sample.mountedFloors} / ${sample.retainedFloors} 个`,
				nested: `${sample.nested} 组`,
				media: `${sample.media} 个`,
				requests: schedulerState ? `${schedulerState.active} / ${schedulerState.queued} 条` : '—',
				network: `${recentReaderRequests.length} 次 · ${formatRequestFlowBytes(readerTransfer)}`,
				heapAverage: stats.heapUsed.count ? formatRequestFlowBytes(stats.heapUsed.average) : '—',
				longTasksAverage: stats.longTaskDuration.count
					? `${stats.longTaskCount.average.toFixed(1)} / ${Math.round(stats.longTaskDuration.average)}ms`
					: '—',
				domAverage: `${Math.round(stats.dom.average)} 个`,
				floorsAverage: `${Math.round(stats.mountedFloors.average)} / ${Math.round(stats.retainedFloors.average)}`,
				nestedAverage: `${Math.round(stats.nested.average)} 组`,
				mediaAverage: `${Math.round(stats.media.average)} 个`,
				requestsAverage: `${stats.activeRequests.average.toFixed(1)} / ${stats.queuedRequests.average.toFixed(1)}`,
				networkAverage: `${Math.round(stats.readerRequestCount.average)} / ${formatRequestFlowBytes(stats.readerTransfer.average)}`,
				heapPeak: stats.heapUsed.count ? formatRequestFlowBytes(stats.heapUsed.peak) : '—',
				longTasksPeak: stats.longTaskDuration.count
					? `${Math.round(stats.longTaskCount.peak)} / ${Math.round(stats.longTaskDuration.peak)}ms`
					: '—',
				domPeak: `${Math.round(stats.dom.peak)} 个`,
				floorsPeak: `${Math.round(stats.mountedFloors.peak)} / ${Math.round(stats.retainedFloors.peak)}`,
				nestedPeak: `${Math.round(stats.nested.peak)} 组`,
				mediaPeak: `${Math.round(stats.media.peak)} 个`,
				requestsPeak: `${Math.round(stats.activeRequests.peak)} / ${Math.round(stats.queuedRequests.peak)}`,
				networkPeak: `${Math.round(stats.readerRequestCount.peak)} / ${formatRequestFlowBytes(stats.readerTransfer.peak)}`,
				heapTrend: resourceMonitorTrendLabel(stats.heapUsed, resourceMonitorSignedBytes),
				longTasksTrend: resourceMonitorTrendLabel(stats.longTaskDuration, (value) => `${resourceMonitorSignedCount(value)}ms`),
				domTrend: resourceMonitorTrendLabel(stats.dom, resourceMonitorSignedCount),
				floorsTrend: resourceMonitorTrendLabel(stats.retainedFloors, resourceMonitorSignedCount),
				nestedTrend: resourceMonitorTrendLabel(stats.nested, resourceMonitorSignedCount),
				mediaTrend: resourceMonitorTrendLabel(stats.media, resourceMonitorSignedCount),
				requestsTrend: resourceMonitorTrendLabel(stats.queuedRequests, resourceMonitorSignedCount),
				networkTrend: resourceMonitorTrendLabel(stats.readerTransfer, resourceMonitorSignedBytes),
			};
			resourceMonitorValueColumns.forEach(([elements, dataKey, suffix]) => elements.forEach((element) => {
				element.textContent = metricValues[`${element.dataset[dataKey]}${suffix}`] || '—';
			}));
			const chartCurrentValues = {
				heapUsed: metricValues.heap,
				dom: metricValues.dom,
				retainedFloors: `${sample.retainedFloors} 个`,
			};
			resourceMonitorChartRows.forEach((row) => {
				const key = row.dataset.resourceMonitorChart;
				const chartSamples = key === 'heapUsed' ? resourceMonitorState.memorySamples : samples;
				row.querySelector('polyline').setAttribute('points', resourceMonitorChartPoints(chartSamples, key));
				row.querySelector('[data-resource-monitor-chart-current]').textContent = chartCurrentValues[key] || '—';
			});
			const coveredMs = samples.length > 1 ? samples[samples.length - 1].at - samples[0].at : 0;
			const maximumGap = samples.slice(1).reduce((maximum, entry, index) =>
				Math.max(maximum, entry.at - samples[index].at), 0);
			const visibleSamples = samples.filter((entry) => entry.visibility === 'visible').length;
			const hiddenSamples = samples.filter((entry) => entry.visibility === 'hidden').length;
			const coveredLabel = coveredMs >= 60000 ? `${(coveredMs / 60000).toFixed(1)} 分钟` : `${Math.round(coveredMs / 1000)} 秒`;
			resourceMonitorTrendWindow.textContent = `${coveredLabel} · 前 ${visibleSamples} / 后 ${hiddenSamples}${maximumGap > 1800 ? ` · 最大空档 ${formatRequestFlowDuration(maximumGap)}` : ''}`;
			syncResourceMonitorScopeControls(sample, at, requestEvents, evidenceEvents);
			syncResourceMonitorEventLog(at, evidenceEvents);
			const health = resourceMonitorHealthInfo(samples, sample, at);
			resourceMonitorHealth.dataset.level = health.level;
			resourceMonitorHealthState.textContent = health.state;
			resourceMonitorHealthDetail.textContent = health.detail;
			resourceMonitorUpdated.textContent = `最近快照 ${resourceMonitorTimestamp(at)} · ${sample.visibility === 'visible' ? '前台' : '后台'} · 仅内存保留`;
		};
		const stopResourceMonitorUpdates = () => {
			if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer);
			resourceMonitorState.timer = 0;
			disconnectResourceMonitorObservers();
			if (resourceMonitorState.collecting) {
				const at = resourceMonitorNow();
				resourceMonitorPushTimedEvent(at, {
					scope: 'shared',
					kind: 'capture',
					detail: '资源取证暂停',
					basis: '监控面板生命周期',
				});
				resourceMonitorState.visibilityTimeline.push({ at, state: 'stopped' });
			}
			resourceMonitorState.collecting = false;
		};
		const observeResourceMonitorPerformance = (supported, key, type, callback) => {
			if (!supported) return false;
			try {
				resourceMonitorState[key] = new window.PerformanceObserver(callback);
				resourceMonitorState[key].observe({ type });
				return true;
			} catch (error) {
				resourceMonitorState[key] = null;
				return false;
			}
		};
		const startResourceMonitorUpdates = () => {
			if (resourceMonitorState.collecting) {
				syncResourceMonitorControls();
				return;
			}
			if (resourceMonitorState.timer) clearInterval(resourceMonitorState.timer);
			resourceMonitorState.timer = 0;
			resourceMonitorState.collecting = true;
			resourceMonitorState.longTasksAvailable = false;
			recordResourceMonitorVisibility('start');
			resourceMonitorState.longTasksAvailable = observeResourceMonitorPerformance(
				resourceMonitorLongTasksSupported, 'longTaskObserver', 'longtask', (list, ...callbackArgs) => {
						resourceMonitorRecordDroppedEntries('Long Tasks', callbackArgs[1]);
						list.getEntries().forEach((entry) => {
							const startedAt = window.performance.timeOrigin + Number(entry.startTime || 0);
							const duration = Math.max(0, Number(entry.duration) || 0);
							resourceMonitorPushTimedEvent(startedAt, {
								scope: 'shared',
								kind: 'longtask',
								duration,
								detail: `页面共享主线程长任务 ${resourceMonitorDuration(duration)}`,
								basis: 'PerformanceLongTaskTiming · 1ms',
							});
						});
					});
			observeResourceMonitorPerformance(
				resourceMonitorLongFramesSupported, 'longFrameObserver', 'long-animation-frame', (list, ...callbackArgs) => {
						resourceMonitorRecordDroppedEntries('Long Animation Frames', callbackArgs[1]);
						list.getEntries().forEach((entry) => {
							const startedAt = window.performance.timeOrigin + Number(entry.startTime || 0);
							const frameDuration = Math.max(0, Number(entry.duration) || 0);
							const blockingDuration = Math.max(0, Number(entry.blockingDuration) || 0);
							resourceMonitorPushTimedEvent(startedAt, {
								scope: 'shared',
								kind: 'frame',
								duration: frameDuration,
								detail: `长动画帧 ${resourceMonitorDuration(frameDuration)} · 阻塞 ${resourceMonitorDuration(blockingDuration)}`,
								basis: 'Long Animation Frames',
							});
							const groups = new Map();
							[...entry.scripts || []].forEach((script) => {
								const duration = Math.max(0, Number(script.duration) || 0);
								if (!duration) return;
								const scope = resourceMonitorScriptScope(script);
								const group = groups.get(scope) || { duration: 0, forced: 0, count: 0, labels: [] };
								group.duration += duration;
								group.forced += Math.max(0, Number(script.forcedStyleAndLayoutDuration) || 0);
								group.count++;
								if (group.labels.length < 2) group.labels.push(resourceMonitorScriptLabel(script));
								groups.set(scope, group);
							});
							groups.forEach((group, scope) => {
								resourceMonitorPushTimedEvent(startedAt, {
									scope,
									kind: 'script',
									duration: group.duration,
									detail: `长帧内已归因脚本 ${resourceMonitorDuration(group.duration)}${group.forced ? ` · 强制布局 ${resourceMonitorDuration(group.forced)}` : ''} · ${group.labels.join(' / ')}`,
									basis: `PerformanceScriptTiming · ${group.count} 段`,
								});
							});
						});
					});
			if (isFunction(window.MutationObserver)) {
				try {
					resourceMonitorState.mutationObserver = new window.MutationObserver((records) => {
						resourceMonitorRecordMutations('reader', records.filter((record) => body.contains(record.target)));
						resourceMonitorRecordMutations('host', records);
					});
					resourceMonitorState.mutationObserver.observe(PAGE_ROOT, { childList: true, subtree: true });
				} catch (error) {
					disconnectResourceMonitorObservers(['mutationObserver']);
				}
			}
			syncResourceMonitorControls();
			resourceMonitorState.timer = setInterval(syncResourceMonitorControls, 1000);
		};
		const syncJumpHighlightControls = () => {
			const config = jumpHighlightDraft;
			JUMP_HIGHLIGHT_SETTING_FIELDS.forEach((field) => {
				const control = jumpHighlightControls[field.key];
				control.input.value = String(config[field.key]);
				control.output.textContent = field.format(config[field.key]);
				if (field.type !== 'color') syncRangeVisual(control.input);
			});
			const changed = settingsDraftChangeCount('flash') > 0;
			jumpHighlightStatus.textContent = changed
				? '闪烁效果正在实时预览;点击“确认应用”后保存。'
				: '闪烁效果当前配置已应用。';
			jumpHighlightStatus.classList.toggle('balanced', !changed);
			jumpHighlightApplyBtn.disabled = !changed;
			applyJumpHighlightSettings(config);
			syncSettingsDraftIndicators();
		};
		let loadingAnimationPreviewRandomKey = '';
		const syncLoadingAnimationControls = (reroll = false) => {
			const preference = normalizeReaderLoadingAnimation(PREFS.loadingAnimation);
			if (preference === 'random') {
				if (reroll || !loadingAnimationPreviewRandomKey) {
					const candidates = READER_LOADING_ANIMATIONS.filter((animation) =>
						animation.key !== loadingAnimationPreviewRandomKey);
					const next = candidates[Math.floor(Math.random() * candidates.length)] || READER_LOADING_ANIMATIONS[0];
					loadingAnimationPreviewRandomKey = next.key;
				}
			} else {
				loadingAnimationPreviewRandomKey = '';
			}
			const animation = resolveReaderLoadingAnimation(preference, loadingAnimationPreviewRandomKey);
			loadingAnimationSelect.value = preference;
			loadingAnimationPreview.replaceChildren();
			loadingAnimationPreview.insertAdjacentHTML('afterbegin', readerLoadingVisualHTML(animation));
			const animationNumber = READER_LOADING_ANIMATIONS.findIndex((candidate) => candidate.key === animation.key) + 1;
			loadingAnimationPreviewLabel.textContent = `${String(animationNumber).padStart(2, '0')} / ${READER_LOADING_ANIMATIONS.length} · ${animation.label}${preference === 'random' ? ' · 随机预览' : ' · 固定使用'}`;
			loadingAnimationReroll.hidden = preference !== 'random';
		};
		const boostCopyDraftFromControls = () => ({
			boostCopyMode: boostCopyModeSelect.value,
			boostCopyPrefix: boostCopyPrefixInput.value,
			boostCopyCounterMarker: boostCopyCounterMarkerInput.value,
			boostCopyCounterStep: boostCopyCounterStepInput.value,
			boostCopyFixedSuffix: boostCopyFixedSuffixInput.value,
		});
		const syncBoostCopyPreview = (prefs = boostCopyDraftFromControls()) => {
			const firstResult = applyBoostCopyRule('原 Boost', prefs);
			const config = boostCopyConfigFromPrefs(prefs);
			boostCopyPreview.textContent = config.mode === 'counter'
				? `${firstResult} → ${applyBoostCopyRule(firstResult, prefs)}`
				: firstResult;
		};
		const syncOtherControls = () => {
			const historyButtonsAlwaysVisible = PREFS.historyButtonsAlwaysVisible === true;
			const historyEdgeTriggerPercent = normalizeHistoryEdgeTriggerPercent(PREFS.historyEdgeTriggerPercent);
			historyButtonsAlwaysVisibleInput.checked = historyButtonsAlwaysVisible;
			historyEdgeTriggerRange.value = String(historyEdgeTriggerPercent);
			historyEdgeTriggerRange.disabled = historyButtonsAlwaysVisible;
			historyEdgeTriggerValue.value = `${historyEdgeTriggerPercent}%`;
			historyEdgeTriggerValue.textContent = `${historyEdgeTriggerPercent}%`;
			syncRangeVisual(historyEdgeTriggerRange);
			overlay._ldpSyncHistoryButtonVisibilityPreference?.();
			historySortModeSelect.value = PREFS.historySortMode;
			openTopicsAtFirstPostInput.checked = PREFS.openTopicsAtFirstPost === true;
			expandNestedRepliesByDefaultInput.checked = PREFS.expandNestedRepliesByDefault === true;
			expandLeafNestedRepliesInput.checked = PREFS.expandLeafNestedReplies === true;
			nestedReplyDisplayWarning.hidden = !(PREFS.expandNestedRepliesByDefault && PREFS.expandLeafNestedReplies);
			const boostCopy = boostCopyConfigFromPrefs(PREFS);
			boostCopyModeSelect.value = boostCopy.mode;
			boostCopyPrefixInput.value = boostCopy.prefix;
			boostCopyCounterMarkerInput.value = boostCopy.counterMarker;
			boostCopyCounterStepInput.value = String(boostCopy.counterStep);
			boostCopyFixedSuffixInput.value = boostCopy.fixedSuffix;
			const counterMode = boostCopy.mode === 'counter';
			boostCopyCounterRows.forEach((row) => { row.hidden = !counterMode; });
			boostCopyTextRows.forEach((row) => { row.hidden = counterMode; });
			syncBoostCopyPreview(PREFS);
		};
		let cacheControlsSyncToken = 0;
		const syncCacheControls = async () => {
			const token = ++cacheControlsSyncToken;
			flushPersistentCacheWrites();
			await flushTopicSnapshotWrites();
			const responseStatsRequest = storedResponseCacheStats();
			const stats = await Promise.all([...cacheSizeEls].map(async (el) => ({
				el,
				stat: await cacheTypeStats(el.dataset.cacheSize, responseStatsRequest),
			})));
			if (token !== cacheControlsSyncToken) return;
			stats.forEach(({ el, stat }) => {
				const key = el.dataset.cacheSize;
				el.textContent = formatCacheTypeStats(key, stat);
			});
			cacheRetentionEls.forEach((el) => {
				el.textContent = cacheRetentionLabel(el.dataset.cacheRetention);
			});
			cacheClearBtn.disabled = ![...cacheSelects].some((input) => input.checked);
		};
		settingHelpTooltip.id = 'ldp-setting-help-tooltip';
		let activeSettingHelpTarget = null;
		const hideSettingHelp = () => {
			if (activeSettingHelpTarget && activeSettingHelpTarget.getAttribute('aria-describedby') === settingHelpTooltip.id) {
				activeSettingHelpTarget.removeAttribute('aria-describedby');
			}
			activeSettingHelpTarget = null;
			settingHelpTooltip.classList.remove('is-visible');
			settingHelpTooltip.hidden = true;
		};
		const showSettingHelp = (target) => {
			const copy = target && target.dataset.settingHelp;
			if (!copy) return;
			if (activeSettingHelpTarget && activeSettingHelpTarget !== target) hideSettingHelp();
			activeSettingHelpTarget = target;
			target.setAttribute('aria-describedby', settingHelpTooltip.id);
			settingHelpTooltip.textContent = copy;
			settingHelpTooltip.hidden = false;
			settingHelpTooltip.classList.remove('is-visible');
			const targetRect = target.getBoundingClientRect();
			const tooltipRect = settingHelpTooltip.getBoundingClientRect();
			const margin = 12;
			const gap = 8;
			const bounds = settingsSurfaceBounds();
			const minimumLeft = bounds.left + margin;
			const minimumTop = bounds.top + margin;
			const maximumLeft = Math.max(minimumLeft, bounds.right - tooltipRect.width - margin);
			const left = Math.min(maximumLeft, Math.max(
				minimumLeft,
				targetRect.left + (targetRect.width - tooltipRect.width) / 2,
			));
			let top = targetRect.top - tooltipRect.height - gap;
			if (top < minimumTop) top = targetRect.bottom + gap;
			top = Math.min(
				Math.max(minimumTop, top),
				Math.max(minimumTop, bounds.bottom - tooltipRect.height - margin),
			);
			settingHelpTooltip.style.left = `${Math.round(left - bounds.left)}px`;
			settingHelpTooltip.style.top = `${Math.round(top - bounds.top)}px`;
			settingHelpTooltip.classList.add('is-visible');
		};
		let userInfoLoadId = 0;
		let userInfoLastRefreshAt = 0;
		let userInfoLastRefreshTimer = 0;
		const stopUserInfoLastRefreshTicker = () => {
			if (userInfoLastRefreshTimer) clearInterval(userInfoLastRefreshTimer);
			userInfoLastRefreshTimer = 0;
		};
		const renderUserInfoLastRefresh = () => {
			if (!userInfoLastRefresh) return;
			if (!userInfoLastRefreshAt) {
				userInfoLastRefresh.textContent = '最后刷新 · 暂无';
				delete userInfoLastRefresh.dataset.ldpTooltipLabel;
				refreshTooltips(userInfoLastRefresh);
				return;
			}
			const elapsed = Math.max(0, Date.now() - userInfoLastRefreshAt);
			const seconds = Math.floor(elapsed / 1000);
			const relative = seconds < 5
				? '刚刚'
				: seconds < 60
					? `${seconds} 秒前`
					: seconds < 3600
						? `${Math.floor(seconds / 60)} 分钟前`
						: seconds < 86400
							? `${Math.floor(seconds / 3600)} 小时前`
							: `${Math.floor(seconds / 86400)} 天前`;
			const beijingTime = new Date(userInfoLastRefreshAt).toLocaleString('zh-CN', {
				timeZone: 'Asia/Shanghai',
				year: 'numeric',
				month: '2-digit',
				day: '2-digit',
				hour: '2-digit',
				minute: '2-digit',
				second: '2-digit',
				hour12: false,
			}).replaceAll('/', '-').replace(',', '');
			userInfoLastRefresh.textContent = `最后刷新 · ${relative}`;
			userInfoLastRefresh.dataset.ldpTooltipLabel = `北京时间 ${beijingTime}`;
			refreshTooltips(userInfoLastRefresh);
		};
		const syncUserInfoLastRefresh = (value) => {
			userInfoLastRefreshAt = Number(value || 0);
			renderUserInfoLastRefresh();
			stopUserInfoLastRefreshTicker();
			if (userInfoLastRefreshAt && userInfoContent?.closest('.ldp-settings-section')?.hidden === false) {
				userInfoLastRefreshTimer = setInterval(renderUserInfoLastRefresh, 1000);
			}
		};
		const syncUserInfoTitleRefresh = (refreshing) => {
			if (!userInfoTitleRefresh) return;
			userInfoTitleRefresh.disabled = refreshing;
			userInfoTitleRefresh.classList.toggle('is-refreshing', refreshing);
			setLabel(userInfoTitleRefresh, refreshing ? '正在刷新用户信息' : '刷新用户信息');
		};
		const loadUserInfoSettings = async (force = false) => {
			if (!userInfoContent) return;
			const loadId = ++userInfoLoadId;
			if (force) syncUserInfoTitleRefresh(true);
			syncCurrentUserFromHost();
			if (loadId !== userInfoLoadId) return;
			const username = String(ME_USERNAME || ME_CURRENT_USER?.username || '').trim();
			if (!username) {
				userInfoContent.innerHTML = '<div class="ldp-user-info-error">当前未登录 LINUX DO,无法读取用户资料。</div>';
				syncUserInfoTitleRefresh(false);
				return;
			}
			const cachedUser = getCachedUserCard(username);
			const cachedConnect = cachedUser?._ldp_connect_trust || null;
			syncUserInfoLastRefresh(Math.max(
				Number(cachedUser && (cachedUser._ldp_user_info_checked_at || cachedUser._ldp_summary_checked_at) || 0),
				Number(cachedConnect?.cachedAt || 0),
			));
			if (cachedUser) {
				renderSettingsUserInfo(
					userInfoContent,
					Object.assign({}, cachedUser, ME_CURRENT_USER || {}, { username }),
					cachedConnect?.data,
					null,
					{ refreshing: force, cachedAt: cachedUser._ldp_user_info_checked_at || cachedUser._ldp_summary_checked_at }
				);
			} else {
				userInfoContent.innerHTML = '<div class="ldp-user-info-loading">正在加载用户信息…</div>';
			}
			const [profileResult, connectResult] = await Promise.allSettled([
				fetchSettingsUserInfo(username, force),
				fetchConnectTrustData(force, cachedConnect),
			]);
			if (loadId !== userInfoLoadId) return;
			const profile = profileResult.status === 'fulfilled' ? profileResult.value : cachedUser;
			const user = Object.assign({}, cachedUser || {}, ME_CURRENT_USER || {}, profile || {}, { username });
			const connectData = connectResult.status === 'fulfilled'
				? connectResult.value
				: cachedConnect?.data || null;
			const connectError = connectResult.status === 'rejected' ? connectResult.reason : null;
			if (connectResult.status === 'fulfilled') {
				user._ldp_connect_trust = {
					data: connectData,
					cachedAt: Number(CONNECT_TRUST_CACHE?.cachedAt || Date.now()),
				};
			}
			if (profileResult.status === 'fulfilled' || connectResult.status === 'fulfilled') {
				setCachedUserCard(username, user, 0);
				syncUserInfoLastRefresh(Math.max(
					Number(user._ldp_user_info_checked_at || user._ldp_summary_checked_at || 0),
					Number(user._ldp_connect_trust?.cachedAt || 0),
					Date.now(),
				));
			}
			renderSettingsUserInfo(userInfoContent, user, connectData, connectError, {
				cachedAt: user._ldp_user_info_checked_at || user._ldp_summary_checked_at,
			});
			syncUserInfoTitleRefresh(false);
		};
		const settingsDraftConfigs = {
			font: {
				label: '字体设置', fields: FONT_PROFILE_VALUE_KEYS,
				draft: currentFontDraft, saved: currentFontProfile,
				reset: () => { fontDraft = currentFontProfile(); },
				patch: () => ({ fontProfile: { ...currentFontDraft() } }),
				apply: applyFontScales, sync: syncFontControls, beforeSave: closeFontFamilyMenu,
			},
			layout: {
				label: '布局设置', fields: LAYOUT_REGION_KEYS,
				draft: currentLayoutDraft, saved: currentLayoutRatios,
				reset: () => { layoutDraft = currentLayoutRatios(); },
				patch: () => ({ layoutProfile: { ...currentLayoutDraft() } }),
				apply: applyLayoutRatios, sync: syncLayoutControls,
				changed: (draft, saved, field) => Math.abs(draft[field] - saved[field]) >= 0.01,
				valid: () => layoutRatioTotal(currentLayoutDraft()) === 100,
			},
			appearance: {
				label: '外观设置', fields: APPEARANCE_PROFILE_VALUE_KEYS,
				draft: currentAppearanceDraft, saved: currentAppearanceProfile,
				reset: () => { appearanceDraft = currentAppearanceProfile(); },
				patch: () => ({ appearanceProfile: { ...currentAppearanceDraft() } }),
				apply: applyAppearanceColors, sync: syncAppearanceControls,
			},
			flash: {
				label: '闪烁动效', fields: JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) => field.key),
				draft: () => jumpHighlightDraft, saved: () => jumpHighlightConfigFromPrefs(PREFS),
				reset: () => { jumpHighlightDraft = jumpHighlightConfigFromPrefs(PREFS); },
				patch: () => jumpHighlightPrefsPatch(jumpHighlightDraft),
				apply: applyJumpHighlightSettings, sync: syncJumpHighlightControls,
			},
		};
		const settingsDraftChangeCount = (panel) => {
			const config = settingsDraftConfigs[panel];
			if (!config) return 0;
			const draft = config.draft();
			const saved = config.saved();
			return config.fields.reduce((count, field) =>
				count + (config.changed ? config.changed(draft, saved, field) : draft[field] !== saved[field]), 0);
		};
		const resetSettingsDrafts = (except = '') => {
			Object.entries(settingsDraftConfigs).forEach(([panel, config]) => {
				if (panel === except) return;
				config.reset();
				config.apply();
			});
			syncSettingsDraftIndicators();
		};
		const settingsPanelLifecycle = {
			user: [() => { settingsPanel.scrollTop = 0; void loadUserInfoSettings(false); }],
			image: [null, syncImageScaleControls],
			font: [null, syncFontControls],
			layout: [null, syncLayoutControls],
			appearance: [null, syncAppearanceControls],
			window: [null, syncReaderWindowControls],
			flash: [null, () => {
				syncJumpHighlightControls();
				syncLoadingAnimationControls();
			}],
			performance: [null, syncPerformanceControls],
			other: [null, syncOtherControls],
			cache: [null, syncCacheControls],
		};
		const showSettingsPanel = (name) => {
			closeFontFamilyMenu();
			hideSettingHelp();
			settingsRangeSaveReminderCategory = '';
			settingsTabs.forEach((tab) => tab.classList.toggle('active', tab.dataset.settingsPanel === name));
			settingsSections.forEach((section) => { section.hidden = section.dataset.settingsPanel !== name; });
			if (name !== 'user') stopUserInfoLastRefreshTicker();
			if (name === 'resource-monitor') startResourceMonitorUpdates();
			else stopResourceMonitorUpdates();
			if (name === 'request-flow') startRequestFlowUpdates();
			else stopRequestFlowUpdates();
			const [beforeRouting, afterRouting] = settingsPanelLifecycle[name] || [];
			if (beforeRouting) beforeRouting();
			if (afterRouting) afterRouting();
			syncSettingsDraftIndicators();
		};
		const syncSettingsNavScrollHints = () => {
			if (!settingsNav || !settingsNavScrollUp || !settingsNavScrollDown) return;
			const maxScroll = Math.max(0, settingsNav.scrollHeight - settingsNav.clientHeight);
			settingsNavScrollUp.hidden = maxScroll < 2 || settingsNav.scrollTop < 2;
			settingsNavScrollDown.hidden = maxScroll < 2 || settingsNav.scrollTop >= maxScroll - 2;
		};
		const scrollSettingsNav = (direction) => {
			if (!settingsNav) return;
			const distance = Math.max(120, Math.round(settingsNav.clientHeight * .7));
			const reduceMotion = prefersReducedMotion();
			settingsNav.scrollBy({ top: direction * distance, behavior: reduceMotion ? 'auto' : 'smooth' });
		};
		let settingsRangeDragRow = null;
		let settingsColorPickRow = null;
		let settingsWindowDrag = null;
		let settingsWindowDragFrame = 0;
		let settingsRangeSaveReminderCategory = '';
		const settingsDraftSummary = () => Object.entries(settingsDraftConfigs)
			.map(([panel, config]) => ({ panel, label: config.label, count: settingsDraftChangeCount(panel) }))
			.filter((entry) => entry.count > 0);
		function syncSettingsDraftIndicators() {
			const counts = Object.fromEntries(settingsDraftSummary().map((entry) => [entry.panel, entry.count]));
			settingsTabs.forEach((tab) => {
				const count = counts[tab.dataset.settingsPanel] || 0;
				const badge = tab.querySelector('.ldp-settings-tab-draft-count');
				const label = SETTINGS_PANEL_META.find(([id]) => id === tab.dataset.settingsPanel)?.[2] || '设置';
				tab.classList.toggle('has-draft', count > 0);
				setLabel(tab, count > 0 ? `${label},${count} 项未保存` : label);
				if (!badge) return;
				badge.hidden = count <= 0;
				badge.textContent = count > 99 ? '99+' : String(count);
			});
		}
		const remindUnsavedRangeSetting = (range) => {
			const category = range.closest('.ldp-settings-section')?.dataset.settingsPanel || '';
			if (!settingsDraftChangeCount(category)) {
				if (settingsRangeSaveReminderCategory === category) settingsRangeSaveReminderCategory = '';
				return;
			}
			if (settingsRangeSaveReminderCategory === category) return;
			settingsRangeSaveReminderCategory = category;
			showSelectionToast('修改已实时预览,但还未保存;请点击本页底部的“确认应用”。');
		};
		const stopSettingsRangeDrag = () => {
			settingsPopover.classList.remove('ldp-range-dragging');
			if (settingsRangeDragRow) settingsRangeDragRow.classList.remove('ldp-range-drag-active');
			settingsRangeDragRow = null;
		};
		const stopSettingsColorPick = () => {
			settingsPopover.classList.remove('ldp-color-picking');
			if (settingsColorPickRow) settingsColorPickRow.classList.remove('ldp-color-pick-active');
			settingsColorPickRow = null;
		};
		const moveSettingsWindowTo = (left, top, width, height) => {
			const margin = READER_SETTINGS_SURFACE_MARGIN;
			const bounds = settingsSurfaceBounds();
			const minimumLeft = bounds.left + margin;
			const minimumTop = bounds.top + margin;
			const maximumLeft = Math.max(minimumLeft, bounds.right - width - margin);
			const maximumTop = Math.max(minimumTop, bounds.bottom - height - margin);
			const position = {
				left: Math.round(Math.min(maximumLeft, Math.max(minimumLeft, left))),
				top: Math.round(Math.min(maximumTop, Math.max(minimumTop, top))),
			};
			settingsPopover.style.left = `${position.left - bounds.left}px`;
			settingsPopover.style.top = `${position.top - bounds.top}px`;
			settingsPopover.style.transform = 'none';
			return position;
		};
		const keepSettingsWindowVisible = () => {
			if (settingsPopover.style.transform !== 'none') return;
			const rect = settingsPopover.getBoundingClientRect();
			moveSettingsWindowTo(rect.left, rect.top, rect.width, rect.height);
		};
		const syncSettingsSurfaceGeometry = ({ closeTransients = true } = {}) => {
			if (closeTransients) {
				closeFontFamilyMenu();
				closeColorPickerPopover();
				hideSettingHelp();
			}
			syncReaderSurfaceDensity();
			if (!settingsPopover.hidden) keepSettingsWindowVisible();
		};
		if (!readerShell.syncSettingsSurfaceGeometry) {
			readerShell.syncSettingsSurfaceGeometry = syncSettingsSurfaceGeometry;
		}
		const renderSettingsWindowDrag = () => {
			settingsWindowDragFrame = 0;
			if (!settingsWindowDrag) return;
			const drag = settingsWindowDrag;
			const margin = READER_SETTINGS_SURFACE_MARGIN;
			const bounds = settingsSurfaceBounds();
			const minimumLeft = bounds.left + margin;
			const minimumTop = bounds.top + margin;
			const maximumLeft = Math.max(minimumLeft, bounds.right - drag.width - margin);
			const maximumTop = Math.max(minimumTop, bounds.bottom - drag.height - margin);
			drag.previewLeft = Math.min(maximumLeft, Math.max(
				minimumLeft,
				drag.startLeft + drag.clientX - drag.startX,
			));
			drag.previewTop = Math.min(maximumTop, Math.max(
				minimumTop,
				drag.startTop + drag.clientY - drag.startY,
			));
			settingsPopover.style.transform = `translate3d(${drag.previewLeft - drag.startLeft}px,${drag.previewTop - drag.startTop}px,0)`;
		};
		const stopSettingsWindowDrag = (event) => {
			if (!settingsWindowDrag || (event && event.pointerId !== settingsWindowDrag.pointerId)) return;
			if (settingsWindowDragFrame) {
				cancelAnimationFrame(settingsWindowDragFrame);
				settingsWindowDragFrame = 0;
			}
			renderSettingsWindowDrag();
			const drag = settingsWindowDrag;
			settingsWindowDrag = null;
			moveSettingsWindowTo(
				drag.previewLeft ?? drag.startLeft,
				drag.previewTop ?? drag.startTop,
				drag.width,
				drag.height,
			);
			settingsPopover.classList.remove('ldp-settings-window-dragging');
			try {
				if (drag.handle.hasPointerCapture(drag.pointerId)) drag.handle.releasePointerCapture(drag.pointerId);
			} catch (e) {}
		};
		const closeSettings = () => {
			closeFontFamilyMenu();
			stopSettingsRangeDrag();
			stopSettingsColorPick();
			stopSettingsWindowDrag();
			stopResourceMonitorUpdates();
			stopRequestFlowUpdates();
			stopUserInfoLastRefreshTicker();
			hideSettingHelp();
			settingsRangeSaveReminderCategory = '';
			fontFamilyManualEditing.clear();
			settingsPopover.hidden = true;
			setExpanded(settingsBtn, false);
			resetSettingsDrafts();
		};
		const saveSettingsDraft = (panel) => {
			const config = settingsDraftConfigs[panel];
			if (!config || !settingsDraftChangeCount(panel)) return true;
			if (config.valid && !config.valid()) return false;
			config.beforeSave?.();
			setPrefs(config.patch());
			settingsRangeSaveReminderCategory = '';
			config.reset();
			config.apply();
			config.sync();
			return true;
		};
		const saveFontSettingsDraft = () => saveSettingsDraft('font');
		const saveLayoutSettingsDraft = () => saveSettingsDraft('layout');
		const saveAppearanceSettingsDraft = () => saveSettingsDraft('appearance');
		const saveJumpHighlightSettingsDraft = () => saveSettingsDraft('flash');
		const saveAllSettingsDrafts = () => {
			return ['layout', 'font', 'appearance', 'flash'].every(saveSettingsDraft);
		};
		const requestCloseSettings = () => {
			const summary = settingsDraftSummary();
			if (summary.length) {
				const total = summary.reduce((count, entry) => count + entry.count, 0);
				openReaderConfirmDialog(readerShell.activeContext, {
					title: `${total} 项设置尚未保存`,
					message: '草稿已保留在各设置页。可以保存全部修改后关闭,也可以明确放弃这些草稿。',
					details: summary.map((entry) => ({ label: entry.label, value: `${entry.count} 项` })),
					note: '选择“继续编辑”会回到当前设置页,草稿不会丢失。',
					icon: 'check',
					tone: 'primary',
					cancelLabel: '继续编辑',
					secondaryLabel: '放弃并关闭',
					confirmLabel: '保存全部并关闭',
					onSecondary: closeSettings,
					onConfirm: () => {
						if (!saveAllSettingsDrafts()) {
							showSettingsPanel('layout');
							showSelectionToast('布局比例还没有分配到 100%,请完成后再保存。');
							return;
						}
						closeSettings();
						showSelectionToast(`已保存 ${total} 项设置`);
					},
				});
				return false;
			}
			closeSettings();
			return true;
		};
		const closeReaderFloatingPanels = () => {
			closeFontFamilyMenu();
			if (!settingsPopover.hidden) requestCloseSettings();
			closeNotifications();
			closeHistory();
			closeBookmarks();
		};
		const readerSurfaceController = readerShell.surfaceController || {
			closeFloatingPanels: closeReaderFloatingPanels,
			closeNotifications,
			closeHistory,
			closeBookmarks,
			closeFontFamilyMenu,
			requestCloseSettings,
			syncOpenSettingsControls,
		};
		readerShell.surfaceController = readerSurfaceController;
		if (!reusingShell) {
			applyReaderVisualSettings();
			applyJumpHighlightSettings();
			syncOtherControls();
			settingsTabs.forEach((tab) => {
			onShell(tab, 'click', () => showSettingsPanel(tab.dataset.settingsPanel || 'image'));
			});
			onShell(loadingAnimationSelect, 'change', () => {
				setPref('loadingAnimation', normalizeReaderLoadingAnimation(loadingAnimationSelect.value));
				loadingAnimationPreviewRandomKey = '';
				syncLoadingAnimationControls();
			});
			onShell(loadingAnimationSelect, 'wheel', (event) => event.stopPropagation(), { passive: true });
			onShell(loadingAnimationReroll, 'click', () => syncLoadingAnimationControls(true));
			if (settingsNav) onShell(settingsNav, 'scroll', syncSettingsNavScrollHints, { passive: true });
			if (settingsNavScrollUp) onShell(settingsNavScrollUp, 'click', () => scrollSettingsNav(-1));
			if (settingsNavScrollDown) onShell(settingsNavScrollDown, 'click', () => scrollSettingsNav(1));
			if (userInfoContent) onShell(userInfoContent, 'click', (event) => {
				const viewTab = event.target.closest('[data-user-info-view]');
				if (viewTab) {
					const view = viewTab.dataset.userInfoView === 'profile' ? 'profile' : 'connect';
					userInfoContent.dataset.userInfoView = view;
					syncTabSelection(userInfoContent.querySelectorAll('[data-user-info-view]'), 'userInfoView', view);
					userInfoContent.querySelectorAll('[data-user-info-panel]').forEach((panel) => {
						panel.hidden = panel.dataset.userInfoPanel !== view;
					});
				}
			});
			if (userInfoTitleRefresh) onShell(userInfoTitleRefresh, 'click', () => void loadUserInfoSettings(true));
			onShell(settingsPopover, 'pointerdown', (event) => {
				const row = event.target.closest('.ldp-settings-intro');
				if (!row) return;
				if (event.button !== 0) return;
				if (event.target.closest('.ldp-user-info-title-refresh')) return;
				if (settingsSurfaceIsCompact()) return;
				const rect = settingsPopover.getBoundingClientRect();
				closeFontFamilyMenu();
				stopSettingsRangeDrag();
				stopSettingsColorPick();
				hideSettingHelp();
				const position = moveSettingsWindowTo(rect.left, rect.top, rect.width, rect.height);
				settingsWindowDrag = {
					pointerId: event.pointerId,
					handle: row,
					startX: event.clientX,
					startY: event.clientY,
					clientX: event.clientX,
					clientY: event.clientY,
					startLeft: position.left,
					startTop: position.top,
					width: rect.width,
					height: rect.height,
				};
				settingsPopover.classList.add('ldp-settings-window-dragging');
				try { row.setPointerCapture(event.pointerId); } catch (e) {}
				event.preventDefault();
			});
			onShell(settingsPopover, 'pointermove', (event) => {
				if (!settingsWindowDrag || event.pointerId !== settingsWindowDrag.pointerId) return;
				const coalescedEvents = isFunction(event.getCoalescedEvents) ? event.getCoalescedEvents() : [];
				const latestEvent = coalescedEvents[coalescedEvents.length - 1] || event;
				settingsWindowDrag.clientX = latestEvent.clientX;
				settingsWindowDrag.clientY = latestEvent.clientY;
				if (!settingsWindowDragFrame) settingsWindowDragFrame = requestAnimationFrame(renderSettingsWindowDrag);
				event.preventDefault();
			});
			const stopSettingsPointerInteractions = (event) => {
				stopSettingsWindowDrag(event);
				stopSettingsRangeDrag();
			};
			['pointerup', 'pointercancel'].forEach((type) =>
				onShell(settingsPopover, type, stopSettingsPointerInteractions));
			onShell(settingsPopover, 'lostpointercapture', stopSettingsPointerInteractions, true);
			onShell(configExportBtn, 'click', () => {
			try {
				downloadReaderConfigExport();
				showSelectionToast('设置配置已导出');
			} catch (error) {
				showSelectionToast('导出失败,请重试');
			}
			});
			onShell(configImportBtn, 'click', () => {
			configFileInput.value = '';
			configFileInput.click();
			});
			onShell(configFileInput, 'change', async () => {
			const file = configFileInput.files?.[0];
			if (!file) return;
			let importedPrefs;
			try {
				importedPrefs = readerPrefsFromConfigExport(JSON.parse(await file.text()));
			} catch (error) {
				showSelectionToast('配置文件无效,请选择由阅读器导出的 JSON 文件');
				return;
			}
			openReaderConfirmDialog(readerShell.activeContext, {
				title: '导入这份设置配置?',
				message: `将使用“${file.name}”覆盖当前阅读器设置,并刷新页面。`,
				note: '浏览历史和缓存不会改变;浮窗尺寸与位置会自动限制在当前屏幕范围内。',
				confirmLabel: '导入并刷新',
				onConfirm: () => replacePrefsAndReload(importedPrefs, '导入失败,浏览器未能保存设置'),
			});
			});
			onShell(configResetBtn, 'click', () => {
			openReaderConfirmDialog(readerShell.activeContext, {
				title: '恢复全部默认设置?',
				message: '图片、字体、布局、浮窗、外观、闪烁、动效、性能和其他功能设置都会恢复默认,并刷新页面。',
				note: '浏览历史和缓存不会被删除。',
				confirmLabel: '恢复并刷新',
				onConfirm: () => replacePrefsAndReload(DEFAULT_PREFS, '恢复失败,浏览器未能保存设置'),
			});
			});
			cacheSelects.forEach((input) => onShell(input, 'change', () => {
			cacheClearBtn.disabled = ![...cacheSelects].some((candidate) => candidate.checked);
			}));
			onShell(cacheClearBtn, 'click', async () => {
			const selected = [...cacheSelects].filter((input) => input.checked).map((input) => input.value);
			if (!selected.length) return;
			cacheClearBtn.disabled = true;
			await clearSelectedCaches(selected);
			cacheSelects.forEach((input) => { input.checked = false; });
			await syncCacheControls();
			});
			performancePresetButtons.forEach((button) => {
			onShell(button, 'click', () => {
				const name = button.dataset.performancePreset;
				if (name === 'custom') {
					const firstInput = performanceInputs[0];
					if (firstInput) firstInput.focus();
					return;
				}
				setPerformancePreset(name);
				performanceSettingsChanged = true;
				syncPerformanceControls();
			});
			});
			performanceInputs.forEach((input) => {
			onShell(input, 'change', () => {
				setPerformanceValue(input.dataset.performanceKey, input.value);
				performanceSettingsChanged = true;
				syncPerformanceControls();
			});
			});
			onShell(performanceResetBtn, 'click', () => {
			setPerformancePreset('balanced');
			performanceSettingsChanged = true;
			syncPerformanceControls();
			});
			const previewJumpHighlightControls = () => {
			const config = Object.fromEntries(JUMP_HIGHLIGHT_SETTING_FIELDS.map((field) =>
				[field.key, jumpHighlightControls[field.key].input.value]));
			jumpHighlightDraft = jumpHighlightConfigFromPrefs(Object.assign({}, PREFS, jumpHighlightPrefsPatch(config)));
			syncJumpHighlightControls();
			};
			jumpHighlightInputs.forEach((input) => onShell(input, 'input', previewJumpHighlightControls));
			onShell(jumpHighlightApplyBtn, 'click', saveJumpHighlightSettingsDraft);
			[
				[historyButtonsAlwaysVisibleInput, (checked) => setPref('historyButtonsAlwaysVisible', checked), syncOtherControls],
				[openTopicsAtFirstPostInput, (checked) => setPref('openTopicsAtFirstPost', checked), syncOtherControls],
				[lightboxOriginalByDefaultInput, (checked) => setPref('lightboxOriginalByDefault', checked)],
				[lightboxCommentsExpandedInput, (checked) => setPref('lightboxCommentsExpandedByDefault', checked)],
				[lightboxDescriptionExpandedInput, (checked) => setPref('lightboxDescriptionExpanded', checked)],
				[fontRenderingEnabledInput, (checked) => setPref('fontRenderingEnabled', checked)],
				[fontRenderingOnHostInput, (checked) => setPref('fontRenderingOnHost', checked)],
				[readerWindowPinInput, (checked) => readerWindow.setPinned(checked), syncReaderWindowControls],
				[readerWindowLockInput, (checked) => readerWindow.setLocked(checked), syncReaderWindowControls],
			].forEach(([input, save, sync]) => onShell(input, 'change', () => {
				save(input.checked);
				if (sync) sync();
			}));
			onShell(historyEdgeTriggerRange, 'input', () => {
			setPref('historyEdgeTriggerPercent', normalizeHistoryEdgeTriggerPercent(historyEdgeTriggerRange.value));
			syncOtherControls();
			});
			onShell(historySortModeSelect, 'change', () => {
			setPref('historySortMode', historySortModeSelect.value);
			syncOtherControls();
			syncHistorySortToggle();
			overlay._ldpRefreshReaderHistoryNavigation?.();
			});
			const saveNestedReplyDisplayPreference = (changedInput) => {
			let expandAll = expandNestedRepliesByDefaultInput.checked;
			let expandLeaf = expandLeafNestedRepliesInput.checked;
			if (changedInput === expandNestedRepliesByDefaultInput && !expandAll) expandLeaf = true;
			else if (changedInput === expandLeafNestedRepliesInput && !expandLeaf) expandAll = true;
			setPrefs({
				expandNestedRepliesByDefault: expandAll,
				expandLeafNestedReplies: expandLeaf,
			});
			applyNestedReplyDisplayPreference(readerShell.activeContext);
			syncOtherControls();
			};
			[expandNestedRepliesByDefaultInput, expandLeafNestedRepliesInput].forEach((input) =>
				onShell(input, 'change', () => saveNestedReplyDisplayPreference(input)));
			const saveBoostCopyControls = () => {
			setPrefs(boostCopyDraftFromControls());
			syncOtherControls();
			};
			onShell(boostCopyModeSelect, 'change', saveBoostCopyControls);
			[boostCopyPrefixInput, boostCopyCounterMarkerInput, boostCopyCounterStepInput, boostCopyFixedSuffixInput]
				.forEach((input) => {
					onShell(input, 'input', () => syncBoostCopyPreview());
					onShell(input, 'change', saveBoostCopyControls);
				});
			onShell(notificationPanel.markAll, 'click', async () => {
			if (notificationPanel.markAll.disabled || notificationPanel.markAll.dataset.ldpRequestBusy === '1') return;
			setReaderControlBusy(notificationPanel.markAll, true);
			const previousHtml = notificationPanel.markAll.innerHTML;
			notificationPanel.markAll.innerHTML = `${icon('loader')}<span>处理中</span>`;
			try {
				const groupKey = Object.hasOwn(NOTIFICATION_GROUPS, notificationState.group)
					? notificationState.group
					: 'all';
				const group = NOTIFICATION_GROUPS[groupKey];
				const dismissParams = group.types.length
					? { dismiss_types: group.types.join(',') }
					: undefined;
				await apiSend(`${BASE}/notifications/mark-read`, 'PUT', dismissParams);
				syncCurrentUserNotificationsDismissed(groupKey);
				const markedCount = markCachedNotificationsRead(groupKey);
				notificationPanel.list.querySelectorAll('.ldp-notification-item.unread')
					.forEach((item) => {
						item.classList.remove('unread');
						const readState = item.querySelector('.ldp-notification-read-state');
						if (readState) readState.textContent = '已读';
					});
				showSelectionToast(groupKey === 'all'
					? '消息已全部标为已读'
					: `${group.label}消息已全部标为已读(${markedCount} 条)`);
			} catch (error) {
				alert(`标记已读失败:${error.message}`);
			} finally {
				setReaderControlBusy(notificationPanel.markAll, false);
				notificationPanel.markAll.innerHTML = previousHtml;
				notificationPanel.markAll.disabled = cachedUnreadNotificationCount(notificationState.group) <= 0;
			}
			});
			const bindCollectionPanelToggle = (controller, peers, openPanel) => {
				onShell(controller.toggle, 'click', (event) => {
					consumeEvent(event);
					const opening = controller.popover.hidden;
					peers.forEach((peer) => peer.close());
					if (!requestCloseSettings()) return;
					if (!opening) return controller.close();
					controller.open();
					openPanel();
				});
			};
			bindCollectionPanelToggle(notificationCollection, [historyCollection, bookmarksCollection], () => {
				positionNotificationsPopover();
				syncCurrentUserFromHost();
				readerShell.startNotificationLiveEvents?.();
				notificationState.page = 0;
				loadNotificationPage(notificationState.group, 0, { force: true });
			});
			bindCollectionPanelToggle(historyCollection, [notificationCollection, bookmarksCollection], renderHistoryPage);
			bindCollectionPanelToggle(bookmarksCollection, [notificationCollection, historyCollection], () => {
				positionBookmarksPopover();
				loadBookmarksPage();
			});
			notificationTabs.forEach((tab) => {
			onShell(tab, 'click', () => {
				notificationState.group = tab.dataset.notificationGroup || 'all';
				notificationState.page = 0;
				loadNotificationPage(notificationState.group, notificationState.page);
			});
			});
			const collectionSearchCancels = {};
			const bindCollectionNavigation = (key, panel, state, search, page, delay = 0) => {
				collectionSearchCancels[key] = bindReaderPanelSearch(panel.search, panel.searchClear, (query) => {
					state.query = query;
					state.page = 0;
					search(query);
				}, delay);
				bindReaderPanelPager(panel.pagePrev, panel.pageNext, state, page);
			};
			bindCollectionNavigation('notification', notificationPanel, notificationState,
				() => loadNotificationPage(notificationState.group, 0),
				() => loadNotificationPage(notificationState.group, notificationState.page));
			onShell(historyPanel.sortToggle, 'click', () => {
			setPref('historySortMode', PREFS.historySortMode === 'recent-viewed' ? 'first-viewed' : 'recent-viewed');
			historyState.page = 0;
			historySelection.clear();
			syncOtherControls();
			renderHistoryPage();
			overlay._ldpRefreshReaderHistoryNavigation?.();
			refreshTooltips(historyPanel.sortToggle);
			});
			bindReaderPanelMultiMode(
				historyPanel.popover, historyState, historySelection,
				historyPanel.multi, historyPanel.multiDone, historyPanel.selectScope, renderHistoryPage
			);
			onShell(historyPanel.selectToggle, 'click', () => {
			toggleReaderPanelSelection(historySelection, historyScopeIds);
			renderHistoryPage();
			});
			bindCollectionNavigation('history', historyPanel, historyState, () => {
				historySelection.clear();
				renderHistoryPage();
			}, renderHistoryPage);
			onShell(historyPanel.deleteSelected, 'click', () => {
			const selectedCount = historySelection.size;
			if (!selectedCount) return;
			openReaderConfirmDialog(readerShell.activeContext, {
				title: '删除所选浏览历史?',
				message: `将删除选中的 ${selectedCount} 条阅读器浏览历史。`,
				note: '不会清除浏览器自身的访问历史。',
				icon: 'trash',
				confirmLabel: `删除 ${selectedCount} 条`,
				onConfirm: () => {
					writeTopicHistory(readTopicHistory().filter((entry) => !historySelection.has(Number(entry.topicId))));
					historySelection.clear();
					renderHistoryPage();
					showSelectionToast(`已删除 ${selectedCount} 条浏览历史`);
				},
			});
			});
			onShell(historyPanel.clear, 'click', () => {
			const historyCount = readTopicHistory().length;
			if (!historyCount) return;
			openReaderConfirmDialog(readerShell.activeContext, {
				title: '清空浏览历史?',
				message: `将删除全部 ${historyCount} 条阅读器浏览历史。`,
				note: '此操作无法撤销,但不会清除浏览器自身的访问历史。',
				icon: 'trash',
				confirmLabel: '清空全部',
				onConfirm: () => {
					writeTopicHistory([]);
					historySelection.clear();
					historyPanel.search.value = '';
					historyPanel.searchClear.hidden = true;
					historyState.query = '';
					historyState.page = 0;
					renderHistoryPage();
					showSelectionToast('浏览历史已清空');
				},
			});
			});
			bookmarksTabs.forEach((tab) => {
			onShell(tab, 'click', () => {
				const requestedType = tab.dataset.bookmarkType;
				const nextType = ['Topic', 'Post', 'Reaction'].includes(requestedType) ? requestedType : 'Topic';
				if (nextType === bookmarksState.type) return;
				bookmarksState.type = nextType;
				bookmarksState.page = 0;
				bookmarksState.multi = false;
				clearBookmarksSelectionScope();
				if (nextType !== 'Reaction' || bookmarksState.reactionLoadedAt <= 0) renderBookmarksPage();
				loadBookmarksPage();
			});
			});
			let bookmarkTabDrag = null;
			let suppressBookmarkTabClick = false;
			const finishBookmarkTabDrag = (event) => {
				if (!bookmarkTabDrag || event.pointerId !== bookmarkTabDrag.pointerId) return;
				const { tab, moved } = bookmarkTabDrag;
				bookmarkTabDrag = null;
				tab.classList.remove('ldp-bookmark-tab-dragging');
				if (!moved) return;
				suppressBookmarkTabClick = true;
				setPref('bookmarkTabOrder', Array.from(bookmarksTabList.children, (item) => item.dataset.bookmarkType));
			};
			onShell(bookmarksTabList, 'click', (event) => {
				if (!suppressBookmarkTabClick) return;
				suppressBookmarkTabClick = false;
				event.preventDefault();
				event.stopImmediatePropagation();
			}, true);
			onShell(bookmarksTabList, 'pointerdown', (event) => {
				if (event.pointerType !== 'mouse' || event.button !== 0) return;
				const tab = event.target.closest('.ldp-bookmark-tab');
				if (!tab) return;
				bookmarkTabDrag = {
					tab,
					pointerId: event.pointerId,
					startX: event.clientX,
					startY: event.clientY,
					moved: false,
				};
				try { tab.setPointerCapture(event.pointerId); } catch (e) {}
			});
			onShell(bookmarksTabList, 'pointermove', (event) => {
				if (!bookmarkTabDrag || event.pointerId !== bookmarkTabDrag.pointerId) return;
				if (!bookmarkTabDrag.moved && Math.hypot(
					event.clientX - bookmarkTabDrag.startX,
					event.clientY - bookmarkTabDrag.startY
				) < 5) return;
				bookmarkTabDrag.moved = true;
				bookmarkTabDrag.tab.classList.add('ldp-bookmark-tab-dragging');
				event.preventDefault();
				const siblings = [...bookmarksTabList.children].filter((item) => item !== bookmarkTabDrag.tab);
				const next = siblings.find((item) => {
					const rect = item.getBoundingClientRect();
					return event.clientX < rect.left + rect.width / 2;
				});
				bookmarksTabList.insertBefore(bookmarkTabDrag.tab, next || null);
			});
			onShell(bookmarksTabList, 'pointerup', finishBookmarkTabDrag);
			onShell(bookmarksTabList, 'pointercancel', finishBookmarkTabDrag);
			onShell(bookmarksReactionFilters, 'click', (e) => {
			const button = e.target.closest('[data-reaction-filter]');
			if (!button) return;
			bookmarksState.reactionFilter = String(button.dataset.reactionFilter || '');
			bookmarksState.page = 0;
			renderBookmarksPage();
			});
			bindReaderPanelMultiMode(
				bookmarksPanel.popover, bookmarksState, bookmarksSelection,
				bookmarksPanel.multi, bookmarksPanel.multiDone, bookmarksPanel.selectScope, renderBookmarksPage,
				() => { bookmarksState.allIds = null; }
			);
			onShell(bookmarksPanel.selectToggle, 'click', () => {
			let scopeIds = bookmarksState.entries.map((entry) => Number(entry.id)).filter((id) => id > 0);
			if (bookmarksState.scope === 'all') {
				if (!bookmarksState.allIds) {
					bookmarksState.allIds = bookmarkTypeEntries()
						.map((entry) => Number(entry.id))
						.filter((bookmarkId) => bookmarkId > 0);
				}
				scopeIds = bookmarksState.allIds || [];
			}
			toggleReaderPanelSelection(bookmarksSelection, scopeIds);
			renderBookmarksPage();
			});
			bindCollectionNavigation('bookmarks', bookmarksPanel, bookmarksState, () => {
				clearBookmarksSelectionScope();
				if (bookmarksState.type === 'Reaction' && bookmarksState.reactionLoadedAt > 0) renderBookmarksPage();
				else loadBookmarksPage();
			}, renderBookmarksPage, 250);
			readerShell.cancelBookmarksSearch = collectionSearchCancels.bookmarks;
			onShell(bookmarksPanel.deleteSelected, 'click', async () => {
			const bookmarkIds = [...bookmarksSelection];
			if (!bookmarkIds.length || bookmarksPanel.deleteSelected.dataset.ldpRequestBusy === '1' ||
				!window.confirm(`确定取消选中的 ${bookmarkIds.length} 条收藏吗?`)) return;
			setReaderControlBusy(bookmarksPanel.deleteSelected, true);
			const visibleRemoved = bookmarksState.entries.filter((entry) => bookmarksSelection.has(Number(entry.id)));
			const selectedRemoved = bookmarksState.allEntries.filter((entry) => bookmarksSelection.has(Number(entry.id)));
			const removedAllMatches = Array.isArray(bookmarksState.allIds) && bookmarksState.allIds.length > 0 &&
				bookmarksState.allIds.every((bookmarkId) => bookmarksSelection.has(bookmarkId));
			try {
				const bookmarkModule = lookupDiscourseModule('discourse/models/bookmark');
				const Bookmark = bookmarkModule?.default;
				if (!Bookmark || !isFunction(Bookmark.bulkOperation)) {
					throw new Error('Discourse 收藏批处理接口尚未就绪');
				}
				await Bookmark.bulkOperation(
					bookmarkIds.map((id) => ({ id })),
					{ type: 'delete' }
				);
				await clearStoredResponseCache({ tags: ['bookmarks'] });
				selectedRemoved.forEach(applyRemovedBookmarkState);
				const clearedPage = bookmarksState.entries.length > 0 && visibleRemoved.length === bookmarksState.entries.length;
				if (removedAllMatches) bookmarksState.page = 0;
				else if (clearedPage && bookmarksState.page > 0) bookmarksState.page -= 1;
				clearBookmarksSelectionScope();
				await loadBookmarksPage();
				showSelectionToast(`已取消 ${bookmarkIds.length} 条收藏`);
			} catch (error) {
				alert(`批量取消收藏失败:${error.message}`);
			} finally {
				setReaderControlBusy(bookmarksPanel.deleteSelected, false);
				if (!bookmarksPanel.popover.hidden) renderBookmarksPage();
			}
			});
			onShell(notificationsPopover, 'click', (e) => e.stopPropagation());
			bindFloatingSurfaceWheel(notificationsPopover);
			bindReaderPanelSelectionList(historyPanel.list, '.ldp-history-select-input', '[data-history-topic-id]',
				'historyTopicId', historySelection, renderHistoryPage);
			onShell(historyPanel.popover, 'click', (e) => {
			e.stopPropagation();
			const item = e.target.closest('[data-history-topic-id]');
			if (!item) return;
			if (e.target.closest('.ldp-history-select')) return;
			e.preventDefault();
			const targetTopicId = Number(item.dataset.historyTopicId);
			const targetPostNumber = Math.max(1, Number(item.dataset.historyPostNumber) || 1);
			if (e.target.closest('.ldp-history-delete')) {
				writeTopicHistory(readTopicHistory().filter((entry) => Number(entry.topicId) !== targetTopicId));
				historySelection.delete(targetTopicId);
				renderHistoryPage();
				showSelectionToast('已删除这条浏览历史');
				return;
			}
			if (targetTopicId > 0) openModal(targetTopicId, { targetPostNumber });
			});
			bindFloatingSurfaceWheel(historyPanel.popover);
			bindReaderPanelSelectionList(bookmarksPanel.list, '.ldp-bookmark-select-input', '[data-bookmark-id]',
				'bookmarkId', bookmarksSelection, renderBookmarksPage);
			onShell(bookmarksPanel.popover, 'click', async (e) => {
			e.stopPropagation();
			const reactionItem = e.target.closest('[data-reaction-record]');
			if (reactionItem) {
				e.preventDefault();
				const targetTopicId = Number(reactionItem.dataset.reactionTopicId);
				const targetPostNumber = Math.max(1, Number(reactionItem.dataset.reactionPostNumber) || 1);
				if (targetTopicId > 0) {
					closeBookmarks();
					openModal(targetTopicId, { targetPostNumber });
				}
				return;
			}
			const item = e.target.closest('[data-bookmark-id]');
			if (!item || e.target.closest('.ldp-bookmark-select')) return;
			e.preventDefault();
			const bookmarkId = Number(item.dataset.bookmarkId);
			const entry = bookmarksState.entries.find((candidate) => Number(candidate.id) === bookmarkId);
			if (!entry) return;
			if (e.target.closest('.ldp-bookmark-delete')) {
				const deleteBtn = e.target.closest('.ldp-bookmark-delete');
				if (deleteBtn.dataset.ldpRequestBusy === '1') return;
				setReaderControlBusy(deleteBtn, true);
				try {
					await deleteDiscourseBookmark(bookmarkId);
					await clearStoredResponseCache({ tags: ['bookmarks'] });
					applyRemovedBookmarkState(entry);
					bookmarksSelection.delete(bookmarkId);
					bookmarksState.allIds = null;
					if (bookmarksState.entries.length === 1 && bookmarksState.page > 0) bookmarksState.page -= 1;
					await loadBookmarksPage();
					showSelectionToast('已取消这条收藏');
				} catch (error) {
					alert(`取消收藏失败:${error.message}`);
					setReaderControlBusy(deleteBtn, false);
				}
				return;
			}
			const target = bookmarkTarget(entry);
			if (target.topicId > 0) {
				closeBookmarks();
				openModal(target.topicId, { targetPostNumber: target.postNumber });
			}
			});
			bindFloatingSurfaceWheel(bookmarksPanel.popover);
			themeButtons.forEach((button) => onShell(button, 'click', (event) => {
			consumeEvent(event);
			transitionReaderTheme(button.dataset.readerThemeMode);
			}));
			onShell(settingsBtn, 'click', (e) => {
			consumeEvent(e);
			const nextHidden = !settingsPopover.hidden;
			closeNotifications();
			closeHistory();
			closeBookmarks();
			if (nextHidden) {
				closeFontFamilyMenu();
				requestCloseSettings();
				return;
			}
			settingsPopover.hidden = false;
			fontFamilyManualEditing.clear();
			syncSettingsSurfaceGeometry({ closeTransients: false });
			setExpanded(settingsBtn, true);
			settingsRangeSaveReminderCategory = '';
			showSettingsPanel('user');
			requestAnimationFrame(syncSettingsNavScrollHints);
			});
			onShell(settingsCloseBtn, 'click', (event) => {
			event.preventDefault();
			requestCloseSettings();
			});
			onShell(settingsPopover, 'click', (e) => e.stopPropagation());
			bindFloatingSurfaceWheel(settingsPopover);
			onShell(settingsPopover, 'pointerover', (event) => {
			const target = event.target.closest('[data-setting-help]');
			if (!target || (event.relatedTarget && target.contains(event.relatedTarget))) return;
			showSettingHelp(target);
			});
			const scheduleSettingHelpHide = () => {
				requestAnimationFrame(() => {
					if (activeSettingHelpTarget
						&& !activeSettingHelpTarget.matches(':hover')
						&& !activeSettingHelpTarget.matches(':focus-within')) hideSettingHelp();
				});
			};
			onShell(settingsPopover, 'pointerout', (event) => {
			if (!activeSettingHelpTarget) return;
			if (event.relatedTarget && activeSettingHelpTarget.contains(event.relatedTarget)) return;
			scheduleSettingHelpHide();
			});
			onShell(settingsPopover, 'focusin', (event) => {
			const target = event.target.closest('[data-setting-help]');
			if (target) showSettingHelp(target);
			});
			onShell(settingsPopover, 'focusout', scheduleSettingHelpHide);
			onShell(settingsPanel, 'scroll', () => {
				hideSettingHelp();
				closeFontFamilyMenu();
				closeColorPickerPopover();
			}, { passive: true });
			onShell(settingsPopover, 'pointerdown', (event) => {
			if (!fontFamilyMenu.hidden && !event.target.closest('.ldp-font-family-picker')) closeFontFamilyMenu();
			const color = event.target.closest('input[type="color"]');
			if (color && !color.disabled && event.button === 0) {
				hideSettingHelp();
				stopSettingsRangeDrag();
				stopSettingsColorPick();
				settingsColorPickRow = color.closest('.ldp-setting-row');
				if (settingsColorPickRow) settingsColorPickRow.classList.add('ldp-color-pick-active');
				settingsPopover.classList.add('ldp-color-picking');
				return;
			}
			stopSettingsColorPick();
			const range = event.target.closest('input[type="range"]');
			if (!range || range.disabled || event.button !== 0) return;
			hideSettingHelp();
			stopSettingsRangeDrag();
			settingsRangeDragRow = range.closest('.ldp-setting-row');
			if (settingsRangeDragRow) settingsRangeDragRow.classList.add('ldp-range-drag-active');
			settingsPopover.classList.add('ldp-range-dragging');
			try { range.setPointerCapture(event.pointerId); } catch (e) {}
			});
			onShell(settingsPopover, 'change', (event) => {
			if (event.target.matches('input[type="color"]')) stopSettingsColorPick();
			if (event.target.matches('input[type="range"]')) remindUnsavedRangeSetting(event.target);
			});
			onShell(settingsPopover, 'focusout', (event) => {
			if (event.target.matches('input[type="color"]')) stopSettingsColorPick();
			});
			onShell(modal, 'click', closeReaderFloatingPanels);
			const saveCurrentImageProfile = (values) => {
			setPrefs({ imageProfile: values });
			};
			onShell(imageScaleRange, 'input', () => {
			const next = normalizeImageScalePercent(imageScaleRange.value, currentImageScalePercent());
			saveCurrentImageProfile({
				...currentImageProfile(),
				preset: 'custom',
				custom: next,
			});
			syncImageScaleControls();
			applyImageScale();
			});
			onShell(imageScaleReset, 'click', () => {
			saveCurrentImageProfile({ ...IMAGE_PROFILE_DEFAULT });
			syncImageScaleControls();
			applyImageScale();
			});
			fontScopeTabs.forEach((tab) => {
			onShell(tab, 'click', () => {
				const scope = tab.dataset.fontScopeTab;
				if (!FONT_SCOPE_KEYS.includes(scope) || scope === fontSettingsScope) return;
				closeFontFamilyMenu();
				fontSettingsScope = scope;
				syncFontControls();
			});
			});
			fontFamilyTriggers.forEach((trigger) => {
			onShell(trigger, 'click', (event) => {
				consumeEvent(event);
				hideSettingHelp();
				const scope = trigger.dataset.fontScope;
				if (!fontFamilyMenu.hidden && activeFontFamilyScope === scope) closeFontFamilyMenu(true);
				else openFontFamilyMenu(scope);
			});
			onShell(trigger, 'keydown', (event) => {
				if (event.key !== 'ArrowDown') return;
				event.preventDefault();
				openFontFamilyMenu(trigger.dataset.fontScope);
			});
			});
			onShell(fontFamilySearch, 'input', applyFontFamilySearch);
			onShell(fontFamilyOptions, 'click', (event) => {
			const option = event.target.closest('.ldp-font-family-option');
			if (!option) return;
			const config = fontScopeConfig();
			const values = { ...fontValuesForScope() };
			if (option.dataset.fontKind === 'preset') {
				values[config.family] = normalizeFontFamily(option.dataset.fontValue, values[config.family]);
				values[config.customFamily] = '';
			} else {
				values[config.family] = 'custom';
				values[config.customFamily] = normalizeCustomFontFamily(option.dataset.fontValue, values[config.customFamily]);
			}
			fontFamilyManualEditing.delete(activeFontFamilyScope);
			if (activeFontFamilyScope === 'host') setPrefs({
				hostFontFamily: values.hostFontFamily,
				hostFontCustomFamily: values.hostFontCustomFamily,
			});
			else setCurrentFontDraft(values);
			closeFontFamilyMenu(true);
			syncFontControls();
			if (activeFontFamilyScope !== 'host') applyFontScales();
			});
			onShell(fontFamilyManualButton, 'click', () => {
			const scope = activeFontFamilyScope;
			const config = fontScopeConfig(scope);
			const values = { ...fontValuesForScope(scope), [config.family]: 'custom' };
			fontFamilyManualEditing.add(scope);
			if (scope === 'host') setPref(config.family, 'custom');
			else setCurrentFontDraft(values);
			closeFontFamilyMenu();
			syncFontControls();
			if (scope !== 'host') applyFontScales();
			const input = [...fontFamilyCustomInputs].find((candidate) => candidate.dataset.fontScope === scope);
			input.focus();
			input.select();
			});
			onShell(fontFamilyMenu, 'keydown', (event) => {
			handleSearchableMenuKeyDown(event, fontFamilySearch, fontFamilyOptions,
				'.ldp-font-family-option:not([hidden])', closeFontFamilyMenu);
			});
			onShell(fontFamilyMenu, 'click', (event) => event.stopPropagation());
			onShell(fontFamilyMenu, 'pointerdown', (event) => event.stopPropagation());
			bindFloatingSurfaceWheel(fontFamilyMenu);
			fontFamilyCustomInputs.forEach((input) => onShell(input, 'input', () => {
			const scope = input.dataset.fontScope;
			const config = fontScopeConfig(scope);
			const values = { ...fontValuesForScope(scope) };
			values[config.family] = 'custom';
			values[config.customFamily] = normalizeCustomFontFamily(input.value, values[config.customFamily]);
			if (scope === 'host') setPrefs({
				hostFontFamily: values.hostFontFamily,
				hostFontCustomFamily: values.hostFontCustomFamily,
			});
			else setCurrentFontDraft(values);
			syncFontControls();
			if (scope !== 'host') applyFontScales();
			}));
			[
				[fontWeightSelects, 'change', 'weight', normalizeFontWeight],
				[fontColorInputs, 'input', 'color', normalizeHexColor],
			].forEach(([inputs, eventName, configField, normalizeValue]) => inputs.forEach((input) => {
				onShell(input, eventName, () => {
					const scope = input.dataset.fontScope;
					const config = fontScopeConfig(scope);
					const values = { ...fontValuesForScope(scope) };
					const field = config[configField];
					values[field] = normalizeValue(input.value, values[field]);
					if (scope === 'host') setPref(field, values[field]);
					else setCurrentFontDraft(values);
					syncFontControls();
					if (scope !== 'host') applyFontScales();
				});
			}));
			fontScaleSettings.forEach(({ input, key, normalize }) => onShell(input, 'input', () => {
				const values = { ...currentFontDraft() };
				values[key] = normalize(input.value, values[key]);
				setCurrentFontDraft(values);
				syncFontControls();
				applyFontScales();
			}));
			hostEmbeddedSizeInputs.forEach((input) => onShell(input, 'input', () => {
			const config = HOST_EMBED_SIZE_SETTINGS[input.dataset.hostEmbedSize];
			if (!config) return;
			setPref(config.pref, normalizeHostEmbeddedSizeScale(input.value));
			syncFontControls();
			}));
			hostEmbeddedSizeResetButtons.forEach((button) => onShell(button, 'click', () => {
			const config = HOST_EMBED_SIZE_SETTINGS[button.dataset.hostEmbedSizeReset];
			if (!config) return;
			setPref(config.pref, config.defaultScale);
			syncFontControls();
			}));
			fontFieldResetButtons.forEach((button) => onShell(button, 'click', () => {
			const host = button.dataset.hostFontReset != null;
			const profileField = button.dataset.fontFieldReset;
			const scope = host ? 'host' : FONT_SCOPE_KEYS.find((key) => Object.values(fontScopeConfig(key)).includes(profileField));
			if (!scope) return;
			const config = fontScopeConfig(scope);
			const field = host ? config[button.dataset.hostFontReset] : profileField;
			const defaults = host ? HOST_FONT_DEFAULTS : currentFontDefaults();
			if (!Object.hasOwn(defaults, field)) return;
			const patch = { [field]: defaults[field] };
			if (field === config.family) {
				patch[config.customFamily] = defaults[config.customFamily];
				fontFamilyManualEditing.delete(scope);
			}
			if (host) setPrefs(patch);
			else setCurrentFontDraft({ ...currentFontDraft(), ...patch });
			syncFontControls();
			if (!host) applyFontScales();
			}));
			onShell(fontResetBtn, 'click', () => {
			closeFontFamilyMenu();
			fontFamilyManualEditing.clear();
			setPrefs(HOST_FONT_DEFAULTS);
			setCurrentFontDraft({ ...currentFontDefaults() });
			syncFontControls();
			applyFontScales();
			});
			onShell(fontApplyBtn, 'click', saveFontSettingsDraft);
			layoutRatioInputs.forEach((input) => {
			onShell(input, 'input', () => {
				const name = input.dataset.layoutRegion;
				if (!LAYOUT_REGION_KEYS.includes(name)) return;
				const ratios = { ...currentLayoutDraft() };
				const desired = Number(input.value);
				const maximum = 100 - LAYOUT_REGION_KEYS.reduce((sum, other) =>
					sum + (other === name ? 0 : LAYOUT_MIN_RATIOS[other]), 0);
				ratios[name] = roundLayoutRatio(Math.min(
					maximum,
					Math.max(LAYOUT_MIN_RATIOS[name], Number.isFinite(desired) ? desired : ratios[name])
				));
				setCurrentLayoutDraft(rebalanceLayoutRatiosAfterChange(ratios, name));
				applyLayoutRatios();
				syncLayoutControls();
			});
			});
			onShell(layoutResetBtn, 'click', () => {
			setCurrentLayoutDraft({ ...currentLayoutDefaults() });
			applyLayoutRatios();
			syncLayoutControls();
			});
			onShell(layoutApplyBtn, 'click', saveLayoutSettingsDraft);
			const applyReaderWindowInputs = () => {
			readerWindow.setGeometry(
				readerWindowWidthInput.value,
				readerWindowHeightInput.value,
				readerWindowXInput.value,
				readerWindowYInput.value
			);
			syncReaderWindowControls();
			};
			onShell(readerWindowWidthInput, 'change', applyReaderWindowInputs);
			onShell(readerWindowHeightInput, 'change', applyReaderWindowInputs);
			onShell(readerWindowXInput, 'change', applyReaderWindowInputs);
			onShell(readerWindowYInput, 'change', applyReaderWindowInputs);
			onShell(readerWindowResetBtn, 'click', () => {
			readerWindow.reset();
			syncReaderWindowControls();
			});
			const setAppearanceDraftValue = (key, value) => {
				setCurrentAppearanceDraft({ ...currentAppearanceDraft(), [key]: value });
			};
			const setAppearanceDraftValues = (values) => {
				setCurrentAppearanceDraft({ ...currentAppearanceDraft(), ...values });
			};
			appearanceGroupToggleInputs.forEach((input) => onShell(input, 'change', () => {
				const key = input.dataset.appearanceGroupToggle;
				if (!APPEARANCE_PROFILE_SETTING_KEYS.includes(key)) return;
				setAppearanceDraftValue(key, input.checked);
				applyAppearanceColors();
				syncAppearanceControls();
			}));
			appearanceControlSettings.forEach(({ input, key, normalize: normalizeValue, apply: applyPreview }) => {
				onShell(input, 'input', () => {
					if (input.type === 'color') {
						const sourceColor = normalizeHexColor(input.value, currentAppearanceDefaults()[key]);
						setAppearanceDraftValues({
							[key]: sourceColor,
							[appearanceDarkColorKey(key)]: sourceColor,
						});
					} else {
						setAppearanceDraftValue(key, normalizeValue(input.value));
					}
					applyPreview();
					syncAppearanceControls();
				});
			});
			colorResetButtons.forEach((button) => onShell(button, 'click', () => {
			const key = button.dataset.colorReset;
			const defaults = currentAppearanceDefaults();
			const valueKeys = appearanceSettingValueFields(key);
			if (valueKeys.some((valueKey) => defaults[valueKey] == null)) return;
			setAppearanceDraftValues(Object.fromEntries(valueKeys.map((valueKey) => [valueKey, defaults[valueKey]])));
			applyAppearanceColors();
			syncAppearanceControls();
			}));
			onShell(appearanceApplyBtn, 'click', saveAppearanceSettingsDraft);
			readerShell.cancelShellFrames = () => {
				if (colorPickerCommitFrame) cancelAnimationFrame(colorPickerCommitFrame);
				colorPickerCommitFrame = 0;
				pendingColorPickerCommit = null;
				if (settingsWindowDragFrame) cancelAnimationFrame(settingsWindowDragFrame);
				settingsWindowDragFrame = 0;
				settingsWindowDrag = null;
				Object.values(collectionSearchCancels).forEach((cancel) => cancel());
			};
		}

		const contextScope = createLifecycleScope();
		const onContext = (target, type, listener, options) => contextScope.listen(target, type, listener, options);
		let ctx = null;
		const tracker = createReadTracker(topicId, body, postRequestScheduler, (postNumbers) => {
			markReaderPostsRead(postNumbers, ctx);
		});
		ctx = {
			topicId, op: null, topicData: null, loader, modal, commentsEl, commentsHeader, countEl, emptyEl, scrollRoot: body, performance,
			readerSession,
			isPostVoting: !!(cachedTopicPreview?.is_post_voting),
			directRepliesByParent: new Map(), replyParentByPost: new Map(), tracker, totalPosts: 0, repliesIO: null, postRequestScheduler,
			subReplyState: new Map(), // 楼中楼原始数据 + 已渲染数量的状态表
			confirmedReadPostNumbers: new Set(),
			streamNodeMap: new Map(),
			streamItems: [], streamItemMap: new Map(), streamPrefix: [0],
			onlyOp: false, streamLayoutItems: [], streamLayoutDirty: true,
			streamPrefixDirty: true, streamPrefixDirtyFrom: 0,
			streamNeedsRepair: true, streamPendingReindexFrom: null,
			streamWindowStart: 0, streamWindowEnd: 0, streamMountedItemCount: 0,
			streamViewportTopIndex: 0, streamViewportBottomIndex: 0,
			viewportPersistenceEnabled: false,
			streamScrollDirection: 0, streamScrollVelocity: 0, streamLastScrollAt: 0,
			streamProgrammaticTarget: null, streamViewportAnchor: null,
			streamViewportAnchorRestoreDepth: 0, streamViewportAnchorRestorePending: false,
			streamWindowResetPending: false, streamInternalScrollTop: NaN, streamInternalScrollDelta: 0,
			readerNavigationSequence: 0,
			streamListOffset: NaN,
			streamMountedNodes: new Set(), streamHydratedNodes: new Set(),
			pendingPostHydrations: new Set(), pendingPostLatexRenders: new Set(),
			streamTopSpacer: createStreamSpacer('top'), streamBottomSpacer: createStreamSpacer('bottom'),
			streamResizeObserver: null, queueViewportTracker: null, streamWindowFrame: 0, floorPreviewSyncFrame: 0,
			latestReplyAt: '', latestReplyTimestamp: 0,
			topicBookmarked: !!(cachedTopicPreview?.bookmarked),
			topicNotificationLevel: topicNotificationLevel(cachedTopicPreview),
			topicSharedIssueBusy: false,
			topicSharedIssueHostVisible: false,
			initialTargetPositionPending: targetPostNumber > 1,
			initialTargetHydrationBoundary: targetPostNumber > 1 ? targetPostNumber : 0,
			initialTargetPrecedingContentDeferred: targetPostNumber > 1,
			initialTargetHeadSpacerHeight: 0,
			cancelInitialTargetPosition: null, releaseInitialPrecedingContent: null, readerActionDialog: null,
			nativeComposerSession: null, toggleTopicBookmark: null, readerWindow, readerWorkspace,
		};
		ctx.queueViewportTracker = queueEntry
			? createReaderQueueViewportTracker(ctx, queueEntry)
			: { observe() {}, unobserve() {}, sync() {}, stop() {} };
		readerShell.activeContext = ctx;
		if (!isFunction(readerShell.refreshBookmarksPanel)) {
			readerShell.refreshBookmarksPanel = (confirmedReaction = null) => {
				clearBookmarksSelectionScope();
				const hadCompleteReactionSnapshot = bookmarksState.reactionLoadedAt > 0;
				const confirmedLike = confirmedReaction && typeof confirmedReaction.likeActed === 'boolean';
				const confirmedCustomReaction = confirmedReaction && !confirmedLike &&
					rememberConfirmedGivenReaction(confirmedReaction.post, confirmedReaction.topic);
				if (confirmedLike || confirmedCustomReaction) {
					if (confirmedCustomReaction) {
						bookmarksState.reactionEntries = mergeGivenReactionOverrides(bookmarksState.reactionEntries);
					}
					if (hadCompleteReactionSnapshot) {
						if (confirmedLike) {
							bookmarksState.reactionEntries = applyConfirmedGivenLike(
								bookmarksState.reactionEntries,
								confirmedReaction.post,
								confirmedReaction.topic,
								confirmedReaction.likeActed
							);
						}
						bookmarksState.reactionLoadedAt = Date.now();
						setGivenReactionSnapshot(bookmarksState.reactionUsername, bookmarksState.reactionEntries);
					}
					if (!bookmarksPanel.popover.hidden && bookmarksState.type === 'Reaction') {
						if (hadCompleteReactionSnapshot) {
							bookmarksState.page = 0;
							renderBookmarksPage();
						} else loadBookmarksPage();
					}
					return;
				}
				if (!bookmarksPanel.popover.hidden) loadBookmarksPage();
			};
		}
		ctx.refreshBookmarksPanel = (options) => readerShell.refreshBookmarksPanel(options);
		ctx.nativeComposerWindow = createNativeComposerWindowController(readerSession);
		ctx.renderTopicShell = renderTopicShell;
		ctx.repliesIO = createRepliesIO(ctx);
		ctx.streamResizeObserver = createStreamResizeObserver(ctx);
		const timeline = ctx.timeline = bindTopicTimeline(timelineEl, ctx);
		const loadTopicEditorOptions = () => {
			const site = lookupDiscourse('service:site');
			const categories = [...discourseModelValue(site, 'categories') || []]
				.filter((category) => category && +discourseModelValue(category, 'id') > 0 &&
					discourseModelValue(category, 'name'))
				.map((category) => ({
					id: +discourseModelValue(category, 'id'),
					name: String(discourseModelValue(category, 'name') || ''),
					slug: String(discourseModelValue(category, 'slug') || ''),
					color: String(discourseModelValue(category, 'color') || ''),
					parent_category_id: +discourseModelValue(category, 'parent_category_id') || null,
				}));
			if (!categories.length) return Promise.reject(new Error('宿主分类数据尚未就绪,请稍后重试'));
			return Promise.resolve({ categories });
		};
		const openTopicEditor = () => {
			const topic = ctx.topicData;
			if (!topic || !topic.details || topic.details.can_edit !== true) return;
			if (topicEditorState) topicEditorState.close(true);
			const topicEditTrigger = readerElement('.ldp-topic-edit-trigger');
			const layer = readerElement('.ldp-topic-edit-layer');
			const form = readerElement('.ldp-topic-edit-form');
			const titleInput = form.querySelector('input[name="title"]');
			const categoryTrigger = form.querySelector('.ldp-topic-edit-category-trigger');
			const categoryValue = form.querySelector('.ldp-topic-edit-category-value');
			const categoryMenu = form.querySelector('.ldp-topic-edit-category-menu');
			const categorySearch = form.querySelector('.ldp-topic-edit-category-search input');
			const categoryOptions = form.querySelector('.ldp-topic-edit-category-options');
			const labelInput = form.querySelector('.ldp-topic-edit-label-input');
			const labelValues = form.querySelector('.ldp-topic-edit-label-values');
			const labelOptions = form.querySelector('.ldp-topic-edit-label-options');
			const saveButton = form.querySelector('.ldp-topic-edit-save');
			const cancelButton = form.querySelector('.ldp-topic-edit-cancel');
			const closeButton = layer.querySelector('.ldp-topic-edit-close');
			const status = form.querySelector('.ldp-topic-edit-status');
			const originalTitle = String(topic.title || '').trim();
			const originalCategoryId = Number(topic.category_id || topic.categoryId) || 0;
			const originalLabelNames = [...new Set(
				(Array.isArray(topic.tags) ? topic.tags : []).map(topicNavText).map((name) => name.trim()).filter(Boolean),
			)];
			const labelsByKey = new Map();
			const selectedLabelKeys = new Set();
			const categoriesById = new Map();
			let selectedCategoryId = originalCategoryId;
			let optionsLoaded = false;
			let busy = false;
			let closed = false;
			let labelSearchSequence = 0;
			let labelSearchDebounce = null;
			let pendingLabelSearch = { query: '', sequence: 0 };
			const editorScope = createLifecycleScope();

			const labelKey = (name) => String(name || '').trim().toLocaleLowerCase();
			originalLabelNames.forEach((name) => {
				const key = labelKey(name);
				labelsByKey.set(key, { id: null, name });
				selectedLabelKeys.add(key);
			});
			const selectedLabels = () => [...selectedLabelKeys]
				.map((key) => labelsByKey.get(key)).filter(Boolean);
			const renderSelectedLabels = () => {
				labelValues.innerHTML = selectedLabels().map((label) => `
					<span class="ldp-topic-edit-label-chip">
						<span>${esc(label.name)}</span>
						<button class="ldp-topic-edit-label-remove" type="button" data-label-key="${escAttr(labelKey(label.name))}" aria-label="移除 label ${escAttr(label.name)}">${icon('x')}</button>
					</span>`).join('');
			};
			const renderLabelOptions = (labels) => {
				labelOptions.innerHTML = (labels || [])
					.map((label) => `<option value="${escAttr(label.name)}"></option>`).join('');
			};
			const performLabelSearch = async (query, sequence) => {
				const tagUtils = lookupDiscourse('service:tag-utils');
				const siteSettings = lookupDiscourse('service:site-settings');
				if (!tagUtils || !isFunction(tagUtils.searchTags) || !siteSettings) {
					throw new Error('Discourse 标签搜索服务尚未就绪');
				}
				const selected = selectedLabels();
				const data = {
					q: query,
					limit: Math.max(1, Number(discourseModelValue(siteSettings, 'max_tag_search_results')) || 20),
					categoryId: selectedCategoryId || undefined,
					filterForInput: true,
				};
				const selectedIds = selected.map((label) => Number(label.id)).filter((id) => id > 0);
				const selectedNames = selected.filter((label) => !(Number(label.id) > 0)).map((label) => label.name);
				if (selectedIds.length) data.selected_tag_ids = selectedIds.slice(0, 100);
				if (selectedNames.length) data.selected_tags = selectedNames.slice(0, 100);
				const results = await tagUtils.searchTags('/tags/filter/search', data, (json) => {
					const incoming = Array.isArray(json?.results) ? json.results : [];
					return isFunction(tagUtils.sortSearchResults)
						? tagUtils.sortSearchResults(incoming)
						: incoming;
				});
				if (closed || sequence !== labelSearchSequence) return;
				const labels = (Array.isArray(results) ? results : []).map((label) => ({
					id: Number(label?.id) || null,
					name: String(label && (label.name || label.text) || '').trim(),
				})).filter((label) => label.name);
				labels.forEach((label) => labelsByKey.set(labelKey(label.name), label));
				renderLabelOptions(labels);
			};
			const runScheduledLabelSearch = () => {
				const { query, sequence } = pendingLabelSearch;
				void performLabelSearch(query, sequence).catch((error) => {
					if (!closed && sequence === labelSearchSequence) {
						status.textContent = error?.message ? error.message : '标签搜索失败,请重试';
					}
				});
			};
			const scheduleLabelSearch = () => {
				if (closed || !optionsLoaded) return;
				const query = String(labelInput.value || '').trim();
				const sequence = ++labelSearchSequence;
				pendingLabelSearch = { query, sequence };
				const runloopModule = lookupDiscourseModule('@ember/runloop');
				const discourseDebounce = runloopModule?.debounce;
				if (!isFunction(discourseDebounce)) {
					status.textContent = 'Discourse 标签搜索服务尚未就绪';
					return;
				}
				labelSearchDebounce = discourseDebounce(labelInput, runScheduledLabelSearch, 300);
			};
			const syncCategorySelection = () => {
				const selected = categoriesById.get(selectedCategoryId);
				const parent = selected && categoriesById.get(Number(selected.parent_category_id));
				categoryValue.textContent = !selected
					? '请选择类别'
					: parent ? `${parent.name} / ${selected.name}` : String(selected.name || '请选择类别');
				categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => {
					option.setAttribute('aria-selected', +option.dataset.categoryId === selectedCategoryId ? 'true' : 'false');
				});
			};
			const closeCategoryMenu = (restoreFocus = false) => {
				categoryMenu.hidden = true;
				setExpanded(categoryTrigger, false);
				if (restoreFocus && !categoryTrigger.disabled) categoryTrigger.focus();
			};
			const openCategoryMenu = () => {
				if (busy || !optionsLoaded || categoryTrigger.disabled) return;
				categoryMenu.hidden = false;
				setExpanded(categoryTrigger, true);
				categorySearch.value = '';
				categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => {
					option.hidden = false;
				});
				categorySearch.focus();
				categoryOptions.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' });
			};
			const renderCategoryOptions = () => {
				const fragment = document.createDocumentFragment();
				categoriesById.forEach((category) => {
					const parent = categoriesById.get(Number(category.parent_category_id));
					const option = makeElement('button');
					const color = String(category.color || '').replace(/^#/, '');
					option.className = 'ldp-topic-edit-category-option ldp-picker-option';
					option.type = 'button';
					option.dataset.categoryId = String(category.id);
					option.dataset.searchText = `${parent ? `${parent.name} ` : ''}${category.name}`.toLocaleLowerCase();
					option.setAttribute('role', 'option');
					option.setAttribute('aria-selected', +category.id === selectedCategoryId ? 'true' : 'false');
					option.innerHTML = `
						<span class="ldp-topic-edit-category-dot"></span>
						<span class="ldp-topic-edit-category-copy ldp-picker-option-copy">
							<strong>${esc(String(category.name || '未命名类别'))}</strong>
							${parent ? `<small>${esc(String(parent.name))}</small>` : '<small>一级类别</small>'}
						</span>`;
					if (/^[0-9a-f]{6}$/i.test(color)) {
						option.querySelector('.ldp-topic-edit-category-dot').style.setProperty('--ldp-category-color', `#${color}`);
					}
					fragment.appendChild(option);
				});
				categoryOptions.replaceChildren(fragment);
				syncCategorySelection();
			};
			const setBusy = (nextBusy) => {
				busy = nextBusy;
				titleInput.disabled = nextBusy;
				categoryTrigger.disabled = nextBusy || !optionsLoaded;
				categorySearch.disabled = nextBusy || !optionsLoaded;
				labelInput.disabled = nextBusy || !optionsLoaded;
				saveButton.disabled = nextBusy || !optionsLoaded;
				cancelButton.disabled = nextBusy;
				closeButton.disabled = nextBusy;
				if (nextBusy) closeCategoryMenu();
			};
			const commitLabelInput = (showError = true) => {
				const raw = String(labelInput.value || '').replace(/[,,]\s*$/, '').trim();
				if (!raw) {
					labelInput.value = '';
					return true;
				}
				const key = labelKey(raw);
				const label = labelsByKey.get(key);
				if (!label) {
					if (showError) status.textContent = `没有找到 label“${raw}”`;
					return false;
				}
				selectedLabelKeys.add(key);
				labelInput.value = '';
				status.textContent = '';
				renderSelectedLabels();
				scheduleLabelSearch();
				return true;
			};
			const onLabelKeyDown = (event) => {
				if (event.key !== 'Enter' && event.key !== ',' && event.key !== ',') return;
				event.preventDefault();
				commitLabelInput();
			};
			const onLabelInput = () => scheduleLabelSearch();
			const onLabelChange = () => commitLabelInput(false);
			const onLabelRemove = (event) => {
				const button = event.target.closest('.ldp-topic-edit-label-remove');
				if (!button || busy) return;
				selectedLabelKeys.delete(button.dataset.labelKey || '');
				renderSelectedLabels();
				scheduleLabelSearch();
				labelInput.focus();
			};
			const onCategoryToggle = () => {
				if (categoryMenu.hidden) openCategoryMenu();
				else closeCategoryMenu(true);
			};
			const onCategoryTriggerKeyDown = (event) => {
				if (event.key !== 'ArrowDown') return;
				event.preventDefault();
				openCategoryMenu();
			};
			const onCategoryOptionClick = (event) => {
				const option = event.target.closest('.ldp-topic-edit-category-option');
				if (!option || busy) return;
				selectedCategoryId = Number(option.dataset.categoryId) || 0;
				syncCategorySelection();
				closeCategoryMenu(true);
				scheduleLabelSearch();
			};
			const onCategorySearchInput = () => {
				const query = String(categorySearch.value || '').trim().toLocaleLowerCase();
				categoryOptions.querySelectorAll('.ldp-topic-edit-category-option').forEach((option) => {
					option.hidden = !!query && !String(option.dataset.searchText || '').includes(query);
				});
			};
			const onCategoryMenuKeyDown = (event) => {
				handleSearchableMenuKeyDown(event, categorySearch, categoryOptions,
					'.ldp-topic-edit-category-option:not([hidden])', closeCategoryMenu);
			};
			const onLayerClick = (event) => {
				if (event.target === layer) {
					close();
					return;
				}
				if (!event.target.closest('.ldp-topic-edit-category')) closeCategoryMenu();
			};
			const close = (force = false) => {
				if (closed || (busy && !force)) return;
				closed = true;
				labelSearchSequence++;
				const runloopModule = lookupDiscourseModule('@ember/runloop');
				const cancelRunloop = runloopModule?.cancel;
				if (labelSearchDebounce && isFunction(cancelRunloop)) cancelRunloop(labelSearchDebounce);
				labelSearchDebounce = null;
				closeCategoryMenu();
				layer.hidden = true;
				setExpanded(topicEditTrigger, false);
				editorScope.destroy();
				if (topicEditorState === state) topicEditorState = null;
				if (!force && !topicEditTrigger.hidden) topicEditTrigger.focus();
			};
			const onCancel = () => close();
			const onSubmit = async (event) => {
				event.preventDefault();
				if (busy || !optionsLoaded || !commitLabelInput()) return;
				const nextTitle = String(titleInput.value || '').trim();
				const nextCategoryId = selectedCategoryId;
				if (!nextTitle) {
					status.textContent = '标题不能为空';
					titleInput.focus();
					return;
				}
				const category = categoriesById.get(nextCategoryId);
				if (!category) {
					status.textContent = '请选择类别';
					categoryTrigger.focus();
					return;
				}
				if (!ctx.topicData || !ctx.topicData.details || ctx.topicData.details.can_edit !== true) {
					status.textContent = '当前账号没有编辑该帖子的权限';
					return;
				}
				setBusy(true);
				status.textContent = '正在保存…';
				const nextLabels = selectedLabels();
				const updateProps = {
					title: nextTitle,
					category_id: nextCategoryId,
					tags: nextLabels.map((label) => Object.assign(
						{ name: label.name },
						+label.id > 0 ? { id: +label.id } : {},
					)),
				};
				try {
					const topicModule = lookupDiscourseModule('discourse/models/topic');
					const Topic = topicModule?.default;
					if (!Topic || !isFunction(Topic.create) || !isFunction(Topic.update)) {
						throw new Error('Discourse 主题编辑接口尚未就绪');
					}
					const topicModel = Topic.create({
						...ctx.topicData,
						id: Number(topicId),
						slug: String(ctx.topicData.slug || ''),
					});
					await Topic.update(topicModel, updateProps, { fastEdit: true });
					await clearStoredResponseCache({ tags: [`topic:${topicId}`] });
					let freshTopic = null;
					try {
						freshTopic = await loader.refreshTopicData({ priority: POST_REQUEST_PRIORITY.visible });
					} catch (refreshError) {}
					const updatedTopic = Object.assign({}, ctx.topicData || {}, {
						title: discourseModelValue(topicModel, 'title'),
						fancy_title: discourseModelValue(topicModel, 'fancy_title'),
						tags: discourseModelValue(topicModel, 'tags'),
					}, freshTopic || {});
					updatedTopic.title = String(updatedTopic.title || nextTitle);
					updatedTopic.fancy_title = String(
						(freshTopic?.fancy_title) || updatedTopic.fancy_title || updatedTopic.title,
					);
					updatedTopic.category_id = nextCategoryId;
					updatedTopic.category_name = cleanTopicCategoryName(category.name);
					updatedTopic.category_level = topicCategoryLevel(category.name);
					updatedTopic.category_slug = String(category.slug || '');
					updatedTopic.tags = nextLabels.map((label) => label.name);
					topicNavMetadataOverride = {
						categoryName: updatedTopic.category_name,
						categoryLevel: updatedTopic.category_level,
						categorySlug: updatedTopic.category_slug,
						categoryId: nextCategoryId,
						categoryIconHtml: Number(sourceTopicNavMetadata.categoryId) === nextCategoryId
							? sourceTopicNavMetadata.categoryIconHtml : '',
						tags: updatedTopic.tags.slice(),
						tagIcons: (Array.isArray(sourceTopicNavMetadata.tagIcons) ? sourceTopicNavMetadata.tagIcons : [])
							.filter((item) => updatedTopic.tags.includes(topicNavText(item?.name))),
					};
					sourceTopicNavMetadata = mergeTopicNavMetadata(topicNavMetadataOverride, cachedTopicNavMetadata(updatedTopic));
					ctx.topicData = updatedTopic;
					setCachedTopicData(topicId, updatedTopic);
					rememberTopic(updatedTopic, 0, readerQueueEntry(topicId)?.seenPostNumbers);
					busy = false;
					close(true);
					renderTopicShell(updatedTopic);
					syncTopicActionControls(ctx);
					showSelectionToast('帖子信息已更新');
				} catch (error) {
					status.textContent = error?.message ? error.message : '保存失败,请重试';
					setBusy(false);
				}
			};
			const state = {
				close,
				closeTransient: () => {
					if (categoryMenu.hidden) return false;
					closeCategoryMenu(true);
					return true;
				},
			};
			topicEditorState = state;
			titleInput.value = originalTitle;
			categoriesById.set(originalCategoryId, {
				id: originalCategoryId,
				name: cleanTopicCategoryName(topic.category_name || sourceTopicNavMetadata.categoryName || '当前类别'),
				slug: String(topic.category_slug || sourceTopicNavMetadata.categorySlug || ''),
			});
			categoryOptions.replaceChildren();
			syncCategorySelection();
			labelOptions.innerHTML = '';
			labelInput.value = '';
			status.textContent = '正在加载类别和 label…';
			renderSelectedLabels();
			setBusy(false);
			layer.hidden = false;
			setExpanded(topicEditTrigger, true);
			editorScope.listen(labelInput, 'keydown', onLabelKeyDown);
			editorScope.listen(labelInput, 'input', onLabelInput);
			editorScope.listen(labelInput, 'change', onLabelChange);
			editorScope.listen(labelValues, 'click', onLabelRemove);
			editorScope.listen(categoryTrigger, 'click', onCategoryToggle);
			editorScope.listen(categoryTrigger, 'keydown', onCategoryTriggerKeyDown);
			editorScope.listen(categoryOptions, 'click', onCategoryOptionClick);
			editorScope.listen(categorySearch, 'input', onCategorySearchInput);
			editorScope.listen(categoryMenu, 'keydown', onCategoryMenuKeyDown);
			editorScope.listen(layer, 'click', onLayerClick);
			editorScope.listen(form, 'submit', onSubmit);
			editorScope.listen(cancelButton, 'click', onCancel);
			editorScope.listen(closeButton, 'click', onCancel);
			titleInput.focus();
			titleInput.select();
			void loadTopicEditorOptions().then(({ categories }) => {
				if (closed || topicEditorState !== state) return;
				const loadedCategoryIds = new Set(categories.map((category) => +category.id));
				const availableCategories = categories.slice();
				if (originalCategoryId && !loadedCategoryIds.has(originalCategoryId)) {
					availableCategories.unshift({
						id: originalCategoryId,
						name: cleanTopicCategoryName(topic.category_name || sourceTopicNavMetadata.categoryName || '当前类别'),
						slug: String(topic.category_slug || sourceTopicNavMetadata.categorySlug || ''),
					});
				}
				categoriesById.clear();
				availableCategories.forEach((category) => categoriesById.set(+category.id, category));
				renderCategoryOptions();
				optionsLoaded = true;
				status.textContent = '';
				renderSelectedLabels();
				setBusy(false);
				scheduleLabelSearch();
			}).catch((error) => {
				if (closed || topicEditorState !== state) return;
				status.textContent = `类别和 label 加载失败:${error?.message ? error.message : '请重试'}`;
			});
		};
		onContext(readerElement('.ldp-topic-edit-trigger'), 'click', openTopicEditor);
		overlay._ldpCaptureHistoryViewport = () => {
			timeline.sync();
			const viewport = embeddedReaderViewportState(ctx) || {};
			const timelinePostNumber = Number(
				timelineEl.querySelector('.ldp-topic-timeline-track')?.getAttribute('aria-valuenow')
			);
			const timelineViewport = embeddedReaderPostViewportState(ctx, timelinePostNumber);
			return normalizeReaderHistoryViewport({
				postNumber: Number(timelineViewport?.postNumber) || Number(viewport.postNumber) || 1,
				postOffset: timelineViewport ? timelineViewport.postOffset : viewport.postOffset,
				scrollTop: Number.isFinite(Number(viewport.scrollTop)) ? Number(viewport.scrollTop) : body.scrollTop,
			});
		};
		overlay._ldpCaptureHistoryPostNumber = () => overlay._ldpCaptureHistoryViewport()?.postNumber || 1;
		const rememberCurrentTopicPosition = () => {
			const currentTopic = ctx.topicData;
			if (!currentTopic) return;
			const currentPostNumber = Number(overlay._ldpCaptureHistoryPostNumber?.()) || targetPostNumber || 1;
			rememberTopic(currentTopic, currentPostNumber, readerQueueEntry(topicId)?.seenPostNumbers);
		};
		const onWindowResize = () => {
			syncReaderBackgroundMode();
			applyReaderVisualSettings();
			readerSurfaceController.syncOpenSettingsControls(true);
			readerShell.syncSettingsSurfaceGeometry?.();
			if (ctx.nativeComposerWindow) ctx.nativeComposerWindow.syncLayer();
			positionHeaderPopovers();
			overlay._ldpSyncReaderQueuePosition?.();
			ctx.streamListOffset = NaN;
			ctx.streamNeedsRepair = true;
			scheduleStreamWindowSync(ctx);
		};
		onContext(window, 'resize', onWindowResize);
		onContext(overlay, 'ldp-reader-window-change', onWindowResize);

		let loading = false, done = false, pendingRetry = false, loadHalted = false, loadHaltError = null;
		let loadingPrev = false, loadingNext = false;
		let latestReplyRefreshRunning = false, latestReplyLastRefreshAt = 0;
		let latestReplyMessageBus = null, latestReplyEventTimer = 0;
		let latestReplyMessageSubscriptions = [];
		const livePostRefreshTimers = new Map();
		let pendingLiveReplyCount = 0, liveUpdateTargetPostNumber = 0, liveUpdateDismissed = false;
		let liveUpdateJumpRunning = false;
		let gapLoadTimer = 0, gapLoadDueAt = 0, pendingGapDirection = 0, pendingGapBatches = 1;
		let previousGapRetryCount = 0;
		let previousGapRetryAt = 0;
		let previousGapFailureUntil = 0, nextGapFailureUntil = 0;
		let onBodyScroll = null, onBodyTouchStart = null, onReaderScrollKeyDown = null;
		let onVisibilityChange = null;
		let scrollWorkFrame = 0, pendingGoingUp = false, filterPumpTimer = 0, onlyOpProgressTimer = 0;
		let onlyOpBatchStartedAt = 0, onlyOpRetryAt = 0, onlyOpRetryStreak = 0;
		let onlyOpLastProgressAt = 0, onlyOpLastScanned = 0;
		let destroyActions = null, stopTopicPresence = null, cancelInitialLatestRefresh = null, disposed = false, closePromise = null;
		let cancelInitialNeighborPrefetch = null;
		let failureRetryTimer = 0;
		let nativeAppEvents = null;
		let handleNativeComposerPostRefresh = null, nativeComposerRefreshTimer = 0;
		let nativeComposerHostIsolation = null, suspendedNativeComposerHostTopicBus = null;
		let onOnlyOpToggle = null, onTitleJump = null, onTitleJumpKeyDown = null, onTimelineTopClick = null, onOpenNativePost = null;
		let onReaderRefreshClick = null, onReaderCloseClick = null, onOverlayClick = null, onModalWheel = null;
		let onNativeComposerCloseClick = null, onNativeComposerSubmitClick = null, onNativeComposerSubmitKeyDown = null;
		const clearFailureRetryTimer = () => {
			if (failureRetryTimer) clearTimeout(failureRetryTimer);
			failureRetryTimer = 0;
		};
		const stopOnlyOpProgress = () => {
			if (onlyOpProgressTimer) clearInterval(onlyOpProgressTimer);
			onlyOpProgressTimer = 0;
		};
		const syncOnlyOpProgress = () => {
			if (!onlyOpProgress || !onlyOpProgressValue) return;
			if (!ctx.onlyOp) {
				stopOnlyOpProgress();
				onlyOpProgress.hidden = true;
				return;
			}
			const total = Math.max(1, +(loader.streamLength || ctx.totalPosts || 1));
			const scanned = Math.min(total, Math.max(0, +(loader.scanCursor || 0)));
			const skipped = Math.min(scanned, Math.max(0, +(loader.scanSkippedCount || 0)));
			const found = streamLayoutItems(ctx).length;
			const now = Date.now();
			if (!onlyOpLastProgressAt || scanned !== onlyOpLastScanned) {
				onlyOpLastProgressAt = now;
				onlyOpLastScanned = scanned;
				if (scanned > 0) onlyOpRetryStreak = 0;
			}
			const percent = done ? 100 : Math.min(99.5, scanned / total * 100);
			const batchFlow = done ? '' : ` · 批${Math.max(1, Number(loader.scanBatchSize) || 1)}`;
			const skippedFlow = skipped ? ` · 缺失${skipped}` : '';
			let requestState = null;
			try { requestState = postRequestScheduler.snapshot(); } catch (error) {}
			let state = done ? skipped ? 'partial' : 'done' : 'running';
			let flow = '';
			if (!done && loadHalted) {
				const status = Number(loadHaltError?.status || 0);
				state = 'error';
				flow = ` · 已暂停${status ? ` HTTP ${status}` : ''}`;
			} else if (!done && requestState && requestState.coolingDown) {
				state = 'cooldown';
				flow = ` · 429冷却 ${Math.max(1, Math.ceil(requestState.cooldownRemaining / 1000))}秒`;
			} else if (!done && requestState && ['10s', '60s'].includes(requestState.blockingReason)) {
				state = 'window';
				const label = requestState.blockingReason === '10s' ? '10秒窗' : '60秒窗';
				flow = ` · ${label} ${Math.max(1, Math.ceil(requestState.nextPermitDelay / 1000))}秒`;
			} else if (!done && pendingRetry && onlyOpRetryAt > now) {
				state = 'retry';
				flow = ` · 重试${onlyOpRetryStreak || 1} ${Math.max(1, Math.ceil((onlyOpRetryAt - now) / 1000))}秒`;
			} else if (!done && loading && onlyOpBatchStartedAt) {
				const waitingSeconds = Math.max(1, Math.ceil((now - onlyOpBatchStartedAt) / 1000));
				const globallyBusy = requestState && (
					Number(requestState.globalActive || 0) > 0 || Number(requestState.globalQueued || 0) > 0
				);
				state = waitingSeconds * 1000 >= POST_REQUEST_TIMEOUT ? 'waiting' : 'running';
				flow = globallyBusy
					? ` · 请求处理中 ${waitingSeconds}秒`
					: ` · 缓存协调中 ${waitingSeconds}秒`;
			} else if (!done && now - onlyOpLastProgressAt >= 2000) {
				state = 'waiting';
				flow = ` · 调度恢复中 ${Math.max(2, Math.ceil((now - onlyOpLastProgressAt) / 1000))}秒`;
			} else if (!done && requestState) {
				const globalQueue = Math.max(requestState.queued, Number(requestState.globalQueued) || 0);
				flow = ` · 全局执行${requestState.globalActive} 排队${globalQueue}`;
			}
			onlyOpProgress.hidden = false;
			onlyOpProgress.dataset.state = state;
			onlyOpProgress.style.setProperty('--ldp-only-op-progress', `${percent.toFixed(1)}%`);
			const phase = done ? skipped ? '部分完成' : '完成' : state === 'error' ? '暂停' : state === 'retry' ? '等待' : '扫描';
			onlyOpProgressValue.textContent = `${phase} ${scanned}/${total} · 楼主${found}${skippedFlow}${batchFlow}${flow}`;
			setLabel(onlyOpProgress, `只看楼主${done ? skipped ? '扫描结束但有缺失' : '扫描完成' : '压测中'},已处理 ${scanned}/${total} 层,找到 ${found} 条楼主内容${skipped ? `,其中 ${skipped} 层未能取回` : ''}${batchFlow}${flow}`);
			onlyOpProgress.setAttribute('data-ldp-tooltip-label', onlyOpProgress.getAttribute('aria-label'));
		};
		const startOnlyOpProgress = () => {
			stopOnlyOpProgress();
			onlyOpLastProgressAt = Date.now();
			onlyOpLastScanned = Math.max(0, +(loader.scanCursor || 0));
			syncOnlyOpProgress();
			if (ctx.onlyOp && !done) {
				onlyOpProgressTimer = setInterval(() => {
					syncOnlyOpProgress();
					const retryDue = pendingRetry && onlyOpRetryAt > 0 && onlyOpRetryAt <= Date.now();
					if (ctx.onlyOp && !done && !loading && !loadHalted && !filterPumpTimer &&
							(!pendingRetry || retryDue) && Date.now() - onlyOpLastProgressAt >= 2000) {
						pump({ maxBatches: 1, forceOnlyOpScan: true });
					}
				}, 250);
			}
		};
		const prevLoadMisses = new Map();
		const nextLoadMisses = new Map();
		const gapMissBlocked = (misses, postNumber) => {
			const blockedUntil = Number(misses.get(+postNumber) || 0);
			if (!blockedUntil) return false;
			if (blockedUntil > Date.now()) return true;
			misses.delete(+postNumber);
			return false;
		};
		const sessionAcceptsWork = () => readerSession.isCurrent('opening', 'active');
		ctx.releaseInitialPrecedingContent = () => {
			if (!ctx.initialTargetPrecedingContentDeferred) return;
			ctx.initialTargetPrecedingContentDeferred = false;
			ctx.streamMountedNodes.forEach((node) => {
				if (+node.dataset.postNumber >= +(ctx.initialTargetHydrationBoundary || 0)) return;
				if (node.dataset.childReplyTotal && ctx.repliesIO && ctx.repliesIO.observe) {
					ctx.repliesIO.observe(node);
				}
			});
			scheduleStreamWindowSync(ctx);
		};

		const stopLatestReplyUpdates = () => {
			if (latestReplyEventTimer) clearTimeout(latestReplyEventTimer);
			livePostRefreshTimers.forEach((timer) => clearTimeout(timer));
			livePostRefreshTimers.clear();
			latestReplyEventTimer = 0;
			if (latestReplyMessageBus) {
				latestReplyMessageSubscriptions.forEach(({ channel, handler }) => {
					try { latestReplyMessageBus.unsubscribe(channel, handler); } catch (e) {}
				});
			}
			latestReplyMessageBus = null;
			latestReplyMessageSubscriptions = [];
		};
		const scheduleFullTopicRefresh = () => {
			if (latestReplyEventTimer || !sessionAcceptsWork() ||
					document.visibilityState !== 'visible') return;
			latestReplyEventTimer = setTimeout(() => {
				latestReplyEventTimer = 0;
				refreshLatestReplyState({ force: true });
			}, 350);
		};
		const targetedLivePostMessageTypes = new Set([
			'acted', 'created', 'rebaked', 'recovered', 'revised',
		]);
		const refreshLivePostFromMessage = (payload) => {
			const postId = Number(payload?.id || 0);
			if (!postId || livePostRefreshTimers.has(postId) || !sessionAcceptsWork() ||
					document.visibilityState !== 'visible') return;
			const messageType = String(payload.type || '');
			if (messageType === 'read') return;
			if (!targetedLivePostMessageTypes.has(messageType)) {
				scheduleFullTopicRefresh();
				return;
			}
			const timer = setTimeout(async () => {
				livePostRefreshTimers.delete(postId);
				if (!sessionAcceptsWork() || !isFunction(ctx.refreshPostById)) return;
				const wasKnown = readerPostCopies(postId, ctx).length > 0;
				const wasNearBottom = body.scrollHeight - body.scrollTop - body.clientHeight <= 120;
				const previousHighestPostNumber = Math.max(0, +(ctx.totalPosts || 0));
				try {
					const post = await ctx.refreshPostById(postId);
					if (!post || !sessionAcceptsWork()) return;
					const postNumber = Math.max(0, Number(post.post_number) || 0);
					const newlyAdded = messageType === 'created' && !wasKnown && postNumber > previousHighestPostNumber;
					if (postNumber) {
						ctx.totalPosts = Math.max(previousHighestPostNumber, postNumber);
						if (ctx.topicData) {
							ctx.topicData.highest_post_number = Math.max(
								Number(ctx.topicData.highest_post_number) || 0,
								postNumber
							);
							if (post.created_at) ctx.topicData.last_posted_at = post.created_at;
						}
						rememberLatestReplyTime(ctx, post);
						updateCommentsHeader(ctx);
						ctx.timeline.update();
					}
					if (!newlyAdded) return;
					if (wasNearBottom) {
						clearLiveUpdateButton();
						const previousScrollTop = body.scrollTop;
						body.scrollTop = body.scrollHeight;
						markStreamInternalScroll(ctx, previousScrollTop);
					} else {
						liveUpdateDismissed = false;
						pendingLiveReplyCount++;
						if (!liveUpdateTargetPostNumber || postNumber < liveUpdateTargetPostNumber) {
							liveUpdateTargetPostNumber = postNumber;
						}
						syncLiveUpdateButton();
					}
				} catch (error) {
					scheduleFullTopicRefresh();
				}
			}, 120);
			livePostRefreshTimers.set(postId, timer);
		};
		const startLatestReplyUpdates = () => {
			if (latestReplyMessageSubscriptions.length || !ctx.topicData || !sessionAcceptsWork() ||
					document.visibilityState !== 'visible') return;
			const messageBus = lookupDiscourse('service:message-bus');
			if (isFunction(messageBus?.subscribe) && isFunction(messageBus.unsubscribe)) {
				const topicHandler = (message) => {
					const payload = message?.payload && typeof message.payload === 'object'
						? message.payload
						: message;
					const messageTopicId = Number(message?.topic_id || payload?.topic_id || 0);
					if (messageTopicId && messageTopicId !== +topicId) return;
					refreshLivePostFromMessage(payload);
				};
				const subscriptions = [
					{ channel: `/topic/${topicId}`, handler: topicHandler },
				];
				try {
					subscriptions.forEach(({ channel, handler }) => messageBus.subscribe(channel, handler));
					latestReplyMessageBus = messageBus;
					latestReplyMessageSubscriptions = subscriptions;
					return;
				} catch (e) {
					subscriptions.forEach(({ channel, handler }) => {
						try { messageBus.unsubscribe(channel, handler); } catch (unsubscribeError) {}
					});
				}
			}
		};
		overlay._ldpStopLatestReplyUpdates = stopLatestReplyUpdates;

		const syncLiveUpdateButton = () => {
			liveUpdateBtn.hidden = pendingLiveReplyCount <= 0 || liveUpdateDismissed;
			liveUpdateJumpBtn.disabled = liveUpdateJumpRunning;
			liveUpdateJumpBtn.setAttribute('aria-busy', String(liveUpdateJumpRunning));
			const label = liveUpdateBtn.querySelector('span');
			if (label) label.textContent = `有 ${pendingLiveReplyCount} 条新回复`;
		};
		const clearLiveUpdateButton = () => {
			pendingLiveReplyCount = 0;
			liveUpdateTargetPostNumber = 0;
			liveUpdateDismissed = false;
			syncLiveUpdateButton();
		};
		const onLiveUpdateJump = async () => {
			if (!sessionAcceptsWork() || liveUpdateJumpRunning) return;
			const targetPostNumber = liveUpdateTargetPostNumber;
			const pendingCount = pendingLiveReplyCount;
			clearLiveUpdateButton();
			if (!targetPostNumber) return;
			liveUpdateJumpRunning = true;
			syncLiveUpdateButton();
			let jumped = false;
			try {
				jumped = await ctx.timeline.jumpTo(targetPostNumber);
				if (!jumped && sessionAcceptsWork()) {
					const targetItem = ctx.streamItemMap.get(targetPostNumber);
					if (ctx.onlyOp && !streamItemMatchesFilter(ctx, targetItem) && isFunction(onOnlyOpToggle)) {
						await onOnlyOpToggle();
					} else {
						ctx.streamWindowResetPending = true;
						syncStreamWindow(ctx);
					}
					await waitForReaderJumpLayout();
					if (sessionAcceptsWork()) jumped = await ctx.timeline.jumpTo(targetPostNumber);
				}
			} catch {
				jumped = false;
			} finally {
				liveUpdateJumpRunning = false;
				if (!jumped && sessionAcceptsWork()) {
					pendingLiveReplyCount += pendingCount;
					if (!liveUpdateTargetPostNumber || targetPostNumber < liveUpdateTargetPostNumber) {
						liveUpdateTargetPostNumber = targetPostNumber;
					}
					liveUpdateDismissed = false;
					showSelectionToast(`暂时无法定位到楼层 #${targetPostNumber},可重试`);
				}
				syncLiveUpdateButton();
			}
		};
		const onLiveUpdateDismiss = (event) => {
			consumeEvent(event);
			liveUpdateDismissed = true;
			syncLiveUpdateButton();
		};
		onContext(liveUpdateJumpBtn, 'click', onLiveUpdateJump);
		onContext(liveUpdateDismissBtn, 'click', onLiveUpdateDismiss);

		const refreshLatestReplyState = async (options = {}) => {
			const force = options.force === true;
			if (latestReplyRefreshRunning || !sessionAcceptsWork() || document.visibilityState !== 'visible') return null;
			const now = Date.now();
			if (!force && now - latestReplyLastRefreshAt < 10000) return null;
			latestReplyLastRefreshAt = now;
			latestReplyRefreshRunning = true;
			try {
				const refreshPriority = Number.isFinite(options.priority)
					? options.priority
					: POST_REQUEST_PRIORITY.background;
				const previousHighestPostNumber = Math.max(
					0,
					Number.isFinite(+options.afterPostNumber) ? +options.afterPostNumber : +(ctx.totalPosts || 0),
				);
				const wasNearBottom = body.scrollHeight - body.scrollTop - body.clientHeight <= 120;
				const freshTopic = await loader.refreshTopicData({ priority: refreshPriority });
				if (!freshTopic || !sessionAcceptsWork()) return null;
				const freshHighestPostNumber = Math.max(0, +(freshTopic.highest_post_number || 0), +(freshTopic.posts_count || 0));
				const postsByNumber = new Map();
				postsFromPayload(freshTopic).forEach((post) => {
					const postNumber = +(post?.post_number || 0);
					if (!postNumber) return;
					if (ctx.streamNodeMap.has(postNumber) || postNumber > previousHighestPostNumber) {
						postsByNumber.set(postNumber, post);
					}
				});
				let fetchAfter = previousHighestPostNumber;
				let pageGuard = 0;
				while (fetchAfter > 0 && fetchAfter < freshHighestPostNumber && pageGuard++ < 10) {
					const page = await loader.loadAfterPost(fetchAfter, {
						priority: refreshPriority,
					});
					if (!sessionAcceptsWork()) return null;
					const newer = page.filter((post) => +(post?.post_number || 0) > fetchAfter);
					if (!newer.length) break;
					newer.forEach((post) => postsByNumber.set(+post.post_number, post));
					const nextAfter = newer.reduce((latest, post) => Math.max(latest, +(post.post_number || 0)), fetchAfter);
					if (nextAfter <= fetchAfter) break;
					fetchAfter = nextAfter;
				}
				const freshPosts = ingestLivePosts([...postsByNumber.values()], ctx);
				ctx.topicData = freshTopic;
				ctx.topicSharedIssueHostVisible = nativeTopicSharedIssueVisible(freshTopic);
				ctx.isPostVoting = !!freshTopic.is_post_voting;
				ctx.topicBookmarked = !!freshTopic.bookmarked;
				ctx.topicNotificationLevel = topicNotificationLevel(freshTopic);
				ctx.totalPosts = freshHighestPostNumber;
				rememberLatestReplyTime(ctx, { created_at: freshTopic.last_posted_at }, { force: true });
				updateCommentsHeader(ctx);
				syncTopicActionControls(ctx);
				renderTopicShell(freshTopic);
				syncAllPostActionControls(ctx);
				ctx.timeline.update();
				if (freshPosts.length) {
					if (!liveUpdateTargetPostNumber) {
						liveUpdateTargetPostNumber = +(freshPosts[0]?.post_number || 0);
					}
					if (wasNearBottom) {
						clearLiveUpdateButton();
						const previousScrollTop = body.scrollTop;
						body.scrollTop = body.scrollHeight;
						markStreamInternalScroll(ctx, previousScrollTop);
					} else {
						liveUpdateDismissed = false;
						pendingLiveReplyCount += freshPosts.length;
						syncLiveUpdateButton();
					}
				}
				return {
					posts: [...postsByNumber.values()],
					topic: freshTopic,
				};
			} catch (e) {
				// Keep the last known timestamp when a background refresh fails.
				return null;
			} finally {
				latestReplyRefreshRunning = false;
			}
		};
		ctx.refreshPostByNumber = async (postNumber) => {
			const posts = await loader.loadPost(+postNumber, {
				priority: POST_REQUEST_PRIORITY.target,
				forceRefresh: true,
			});
			if (!sessionAcceptsWork()) return null;
			loader.persistFetchedPosts(posts);
			ingestLivePosts(posts, ctx, { refreshContent: true });
			return posts.find((post) => +(post?.post_number || 0) === +postNumber) || posts[0] || null;
		};
		ctx.refreshPostById = async (postId) => {
			const targetPostId = Number(postId) || 0;
			if (!targetPostId) return null;
			const post = await fetchJSON(`${BASE}/posts/${targetPostId}.json`, {
				scheduler: postRequestScheduler,
				priority: POST_REQUEST_PRIORITY.visible,
				cacheMode: 'refresh',
				allowStaleOnError: false,
				fetchOptions: { cache: 'no-store' },
				key: `post:${targetPostId}:live-update`,
			});
			if (!sessionAcceptsWork() || !post || +post.id !== targetPostId || !post.post_number) return null;
			loader.persistFetchedPosts([post]);
			ingestLivePosts([post], ctx, { refreshContent: true });
			return post;
		};
		ctx.refreshNativeComposerSession = async (session) => {
			if (!session || !sessionAcceptsWork()) return false;
			if (session.action === 'edit') {
				const updated = await ctx.refreshPostByNumber(session.postNumber);
				if (!updated) return false;
				const target = readerPostCopies(updated.id, ctx)[0];
				if (target) keepJumpHighlightUntilSettled(target);
				return true;
			}
			if (session.action !== 'reply') return false;
			const parentPostNumber = +(session.parentPostNumber || 0);
			const highestPostNumber = +(session.highestPostNumber || 0);
			const retryDelays = [0, 700, 1400];
			for (let attempt = 0; attempt < retryDelays.length && sessionAcceptsWork(); attempt++) {
				if (retryDelays[attempt]) {
					await waitForDelay(retryDelays[attempt]);
					if (!sessionAcceptsWork()) return false;
				}
				const result = await refreshLatestReplyState({
					force: true,
					afterPostNumber: highestPostNumber,
					priority: POST_REQUEST_PRIORITY.visible,
				});
				const candidates = new Map();
				(result?.posts || []).forEach((post) => candidates.set(+post.post_number, post));
				ctx.streamItems.forEach((item) => {
					const post = item?.node && item.node._ldpPostData;
					if (post && +post.post_number > highestPostNumber) candidates.set(+post.post_number, post);
				});
				const created = [...candidates.values()]
					.filter((post) => +post.post_number > highestPostNumber)
					.filter((post) => !ME_USERNAME || post.username === ME_USERNAME)
					.filter((post) => parentPostNumber <= 1
						? +(post.reply_to_post_number || 0) <= 1
						: +(post.reply_to_post_number || 0) === parentPostNumber)
					.sort((a, b) => +b.post_number - +a.post_number)[0];
				if (created) {
					if (isFunction(ctx.loader?.persistFetchedPosts)) {
						ctx.loader.persistFetchedPosts([created]);
					}
					clearLiveUpdateButton();
					return focusLiveNestedReply(created, ctx);
				}
			}
			return false;
		};
		ctx.bindNativeComposerRefresh = () => {
			if (handleNativeComposerPostRefresh) return true;
			nativeAppEvents = lookupDiscourse('service:app-events');
			if (!nativeAppEvents || !isFunction(nativeAppEvents.on)) return false;
			handleNativeComposerPostRefresh = () => {
				const session = ctx.nativeComposerSession;
				if (!session || session.refreshing || !sessionAcceptsWork()) return;
				session.refreshing = true;
				nativeComposerRefreshTimer = setTimeout(async () => {
					nativeComposerRefreshTimer = 0;
					try {
						await ctx.refreshNativeComposerSession(session);
					} finally {
						if (ctx.nativeComposerSession === session) ctx.nativeComposerSession = null;
					}
				}, 0);
			};
			nativeAppEvents.on('composer:created-post', handleNativeComposerPostRefresh);
			nativeAppEvents.on('post:created', handleNativeComposerPostRefresh);
			return true;
		};
		ctx.bindNativeComposerRefresh();
		onVisibilityChange = () => {
			if (!sessionAcceptsWork()) return;
			const visible = document.visibilityState === 'visible';
			ctx.timeline.setActive(visible);
			recordResourceMonitorVisibility();
			if (!visible) {
				stopLatestReplyUpdates();
				stopRequestFlowUpdates();
				suspendReaderMedia(overlay);
				ctx.streamHydratedNodes.forEach((node) => suspendReaderMedia(node));
				return;
			}
			ctx.streamMountedNodes.forEach((node) => activateReaderMedia(node));
			startLatestReplyUpdates();
			if (!settingsPopover.hidden && !resourceMonitorSection.hidden) startResourceMonitorUpdates();
			if (!settingsPopover.hidden && !requestFlowSection.hidden) startRequestFlowUpdates();
			ctx.timeline.update();
			ctx.timeline.refreshRelativeTimes();
			refreshLatestReplyState({ force: true });
		};
		onContext(document, 'visibilitychange', onVisibilityChange);

		const readerEscapeExitWindow = 1500;
		let readerEscapeExitDeadline = 0;
		let readerEscapeExitTimer = 0;
		let readerEscapePointerX = Number.NaN;
		let readerEscapePointerY = Number.NaN;
		const disarmReaderEscapeExit = () => {
			readerEscapeExitDeadline = 0;
			if (readerEscapeExitTimer) clearTimeout(readerEscapeExitTimer);
			readerEscapeExitTimer = 0;
			readerPortalQuery('.ldp-selection-toast[data-ldp-reader-exit-hint]')?.remove();
		};
		const armReaderEscapeExit = () => {
			disarmReaderEscapeExit();
			readerEscapeExitDeadline = Date.now() + readerEscapeExitWindow;
			showSelectionToast('再按一次 Esc 退出阅读器');
			const toast = readerPortalQuery('.ldp-selection-toast');
			if (toast) toast.dataset.ldpReaderExitHint = '1';
			readerEscapeExitTimer = setTimeout(disarmReaderEscapeExit, readerEscapeExitWindow);
		};
		const nativeComposerCloseWindow = 1500;
		let nativeComposerCloseAction = '';
		let nativeComposerCloseDeadline = 0;
		let nativeComposerCloseTimer = 0;
		const disarmNativeComposerClose = () => {
			nativeComposerCloseAction = '';
			nativeComposerCloseDeadline = 0;
			if (nativeComposerCloseTimer) clearTimeout(nativeComposerCloseTimer);
			nativeComposerCloseTimer = 0;
			readerPortalQuery('.ldp-selection-toast[data-ldp-composer-close-hint]')?.remove();
		};
		const nativeComposerCloseConfirmed = (action) =>
			nativeComposerCloseAction === action && nativeComposerCloseDeadline > Date.now();
		const armNativeComposerClose = (action, message) => {
			disarmNativeComposerClose();
			nativeComposerCloseAction = action;
			nativeComposerCloseDeadline = Date.now() + nativeComposerCloseWindow;
			showSelectionToast(message);
			const toast = readerPortalQuery('.ldp-selection-toast');
			if (toast) toast.dataset.ldpComposerCloseHint = '1';
			nativeComposerCloseTimer = setTimeout(disarmNativeComposerClose, nativeComposerCloseWindow);
		};
		const releaseReaderNativeComposerHostIsolation = (isolation = nativeComposerHostIsolation) => {
			if (!isolation || isolation.released) return;
			isolation.released = true;
			if (isolation.appEvents && isolation.appEvents.trigger === isolation.trigger) {
				try { isolation.appEvents.trigger = isolation.originalTrigger; } catch (error) {}
			}
			(isolation.routeGuards || []).forEach(({ owner, originalRouteTo, guardedRouteTo }) => {
				if (owner.routeTo !== guardedRouteTo) return;
				try { owner.routeTo = originalRouteTo; } catch (error) {}
			});
			if (nativeComposerHostIsolation === isolation) nativeComposerHostIsolation = null;
		};
		const resumeReaderNativeComposerHostTopicBus = () => {
			const suspended = suspendedNativeComposerHostTopicBus;
			suspendedNativeComposerHostTopicBus = null;
			if (!suspended || !suspended.topicController ||
					!isFunction(suspended.topicController.subscribe)) return;
			const currentModel = discourseModelValue(suspended.topicController, 'model');
			const currentTopicId = +(discourseModelValue(currentModel, 'id') || 0);
			if (currentTopicId !== suspended.hostTopicId) return;
			try { suspended.topicController.subscribe(); } catch (error) {}
		};
		const isolateReaderNativeComposerFromHost = () => {
			const session = ctx.nativeComposerSession;
			if (!session || session.action !== 'reply') return null;
			releaseReaderNativeComposerHostIsolation();
			const appEvents = session.composer && (
				session.composer.appEvents || lookupDiscourse('service:app-events')
			);
			if (!appEvents || !isFunction(appEvents.trigger)) return null;
			const originalTrigger = appEvents.trigger;
			const readerRefreshEvents = new Set([
				'composer:created-post',
				'post:created',
			]);
			const suppressedEvents = new Set([
				...readerRefreshEvents,
				'post:highlight',
			]);
			const isolation = {
				appEvents,
				originalTrigger,
				trigger: null,
				routeGuards: [],
				released: false,
			};
			isolation.trigger = function (eventName, ...args) {
				const name = String(eventName || '');
				if (readerRefreshEvents.has(name) && isFunction(handleNativeComposerPostRefresh)) {
					handleNativeComposerPostRefresh();
				}
				if (suppressedEvents.has(name)) return this;
				return originalTrigger.call(this, eventName, ...args);
			};
			try {
				appEvents.trigger = isolation.trigger;
			} catch (error) {
				return null;
			}
			const discourseUrlModule = lookupDiscourseModule('discourse/lib/url');
			const routeOwner = discourseUrlModule?.default;
			if (isFunction(routeOwner?.routeTo)) {
				const originalRouteTo = routeOwner.routeTo;
				const guardedRouteTo = function (url, ...args) {
					const route = extractTopicRouteFromUrl(String(url || ''));
					if (route && +route.topicId === +ctx.topicId && +route.postNumber > 0) return;
					return originalRouteTo.call(this, url, ...args);
				};
				try {
					routeOwner.routeTo = guardedRouteTo;
					if (routeOwner.routeTo === guardedRouteTo) {
						isolation.routeGuards.push({ owner: routeOwner, originalRouteTo, guardedRouteTo });
					}
				} catch (error) {}
			}
			const topicController = lookupDiscourse('controller:topic');
			const hostModel = discourseModelValue(topicController, 'model');
			const hostTopicId = +(discourseModelValue(hostModel, 'id') || 0);
			const alreadySuspended = suspendedNativeComposerHostTopicBus &&
				suspendedNativeComposerHostTopicBus.topicController === topicController &&
				suspendedNativeComposerHostTopicBus.hostTopicId === hostTopicId;
			if (!alreadySuspended && hostTopicId === +ctx.topicId && topicController &&
					isFunction(topicController.unsubscribe)) {
				resumeReaderNativeComposerHostTopicBus();
				try {
					topicController.unsubscribe();
					suspendedNativeComposerHostTopicBus = { topicController, hostTopicId };
				} catch (error) {}
			}
			nativeComposerHostIsolation = isolation;
			return isolation;
		};
		const nativeReaderReplyComposerButton = () => {
			const session = ctx.nativeComposerSession;
			if (!session || session.action !== 'reply' ||
					discourseModelValue(session.composer, 'model') !== session.model) return null;
			const composer = document.querySelector('#reply-control');
			if (!nativeComposerVisible(composer) || !composer.matches('.composer-action-reply')) return null;
			return composer.querySelector('.save-or-cancel button.create');
		};
		const submitReaderNativeReplyWithoutHostRefresh = (button) => {
			const session = ctx.nativeComposerSession;
			const composer = session?.composer;
			if (!button || button.disabled || !composer || !isFunction(composer.save)) return false;
			const isolation = isolateReaderNativeComposerFromHost();
			try {
				const saveResult = composer.save(true, { jump: false });
				Promise.resolve(saveResult).then(
					() => releaseReaderNativeComposerHostIsolation(isolation),
					() => releaseReaderNativeComposerHostIsolation(isolation),
				);
			} catch (error) {
				releaseReaderNativeComposerHostIsolation(isolation);
				console.warn('[LINUX DO reader] native composer reply submit failed', error);
			}
			return true;
		};
		onNativeComposerSubmitClick = (event) => {
			const button = event.target.closest?.('#reply-control .save-or-cancel button.create');
			if (!button || button !== nativeReaderReplyComposerButton()) return;
			event.preventDefault();
			event.stopImmediatePropagation();
			submitReaderNativeReplyWithoutHostRefresh(button);
		};
		onNativeComposerSubmitKeyDown = (event) => {
			if (event.isComposing || !(event.ctrlKey || event.metaKey) ||
					(event.key !== 'Enter' && event.code !== 'Enter')) return;
			const button = nativeReaderReplyComposerButton();
			if (!button) return;
			event.preventDefault();
			event.stopImmediatePropagation();
			submitReaderNativeReplyWithoutHostRefresh(button);
		};
		onNativeComposerCloseClick = (event) => {
			const button = event.target.closest?.('#reply-control .toggle-save-and-close, #reply-control .discard-button');
			if (!button || !nativeComposerVisible(document.querySelector('#reply-control'))) return;
			const action = button.classList.contains('discard-button') ? 'discard' : 'save-close';
			if (nativeComposerCloseConfirmed(action)) {
				disarmNativeComposerClose();
				if (action === 'discard') {
					event.preventDefault();
					event.stopImmediatePropagation();
					discardReaderNativeComposer(ctx).catch((error) => {
						console.warn('[LINUX DO reader] native composer discard failed', error);
					});
				}
				return;
			}
			event.preventDefault();
			event.stopImmediatePropagation();
			armNativeComposerClose(action, action === 'discard'
				? '再点一次舍弃回复'
				: '再点一次关闭回复窗口');
		};
		const onReaderEscapePointerMove = (event) => {
			readerEscapePointerX = event.clientX;
			readerEscapePointerY = event.clientY;
		};
		const visibleExpandedDefaultPosts = () => {
			const rootRect = ctx.scrollRoot.getBoundingClientRect();
			return [...ctx.commentsEl.querySelectorAll(
				':scope > .ldp-post.ldp-reply-collapsible:not(.ldp-nested-collapsed)',
			)].map((post) => ({ post, rect: post.getBoundingClientRect() }))
				.filter(({ post }) => isEscCollapsibleNestedReply(post))
				.filter(({ rect }) => rect.bottom > rootRect.top && rect.top < rootRect.bottom &&
					rect.right > rootRect.left && rect.left < rootRect.right)
				.sort((a, b) => a.rect.top - b.rect.top);
		};
		const pickExpandedDefaultPost = () => {
			if (ctx.onlyOp) return null;
			const candidates = visibleExpandedDefaultPosts();
			const pointed = candidates.find(({ rect }) =>
				Number.isFinite(readerEscapePointerX) && Number.isFinite(readerEscapePointerY) &&
				readerEscapePointerX >= rect.left && readerEscapePointerX <= rect.right &&
				readerEscapePointerY >= rect.top && readerEscapePointerY <= rect.bottom);
			return (pointed || candidates[0] || {}).post || null;
		};
		const onReaderEscapeExitInteraction = (event) => {
			disarmReaderEscapeExit();
			if (event.type !== 'pointerdown' || event.button !== 0 || !readerWorkspace.isEmbedded() ||
				eventPathIncludes(event, READER_PORTAL_HOST)) return;
			readerSurfaceController.closeFloatingPanels();
		};
		let runtimeStopped = false;
		let shellStopped = false;
		const runReaderCleanup = (label, cleanup) => {
			try { cleanup(); } catch (error) { console.warn(`[LDP] ${label} cleanup failed`, error); }
		};
		const stopReaderRuntime = (options = {}) => {
			const preserveShell = options.preserveShell === true;
			if (!runtimeStopped) {
				runtimeStopped = true;
				if (queueEntry && isFunction(overlay._ldpCaptureHistoryViewport)) {
					saveReaderQueueViewport(queueEntry.topicId, overlay._ldpCaptureHistoryViewport());
				}
				if (isFunction(overlay._ldpCloseCodePreview)) overlay._ldpCloseCodePreview(false, true);
				if (preserveShell) {
					const parkedCommentsHeader = parkReaderCommentsHeader(overlay, ctx.commentsHeader);
					if (parkedCommentsHeader) readerShell.commentsHeader = parkedCommentsHeader;
				}
				stopLatestReplyUpdates();
				stopOnlyOpProgress();
				stopRateLimitNotice();
				latestReplyRefreshRunning = false;
				runReaderCleanup('topic nav metadata', stopTopicNavMetadataHydration);
				if (nativeComposerRefreshTimer) clearTimeout(nativeComposerRefreshTimer);
				nativeComposerRefreshTimer = 0;
				releaseReaderNativeComposerHostIsolation();
				resumeReaderNativeComposerHostTopicBus();
				if (nativeAppEvents && handleNativeComposerPostRefresh && isFunction(nativeAppEvents.off)) {
					nativeAppEvents.off('composer:created-post', handleNativeComposerPostRefresh);
					nativeAppEvents.off('post:created', handleNativeComposerPostRefresh);
				}
				[
					['loader', () => loader.destroy()],
					['request scheduler', () => {
						if (ACTIVE_READER_REQUEST_SCHEDULER === postRequestScheduler) ACTIVE_READER_REQUEST_SCHEDULER = null;
						postRequestScheduler.destroy();
					}],
					['user card', hideFallbackUserCard],
					['action dialog', () => ctx.readerActionDialog?.close()],
					['topic editor', () => topicEditorState?.close(true)],
					['composer window', () => ctx.nativeComposerWindow.destroy()],
					['post actions', () => destroyActions && destroyActions()],
					['read tracker', () => tracker.stop()],
					['queue viewport tracker', () => ctx.queueViewportTracker.stop()],
					['nested replies', () => ctx.repliesIO.disconnect()],
					['media players', () => suspendReaderMedia(overlay)],
					['detached media players', () => ctx.streamHydratedNodes.forEach((node) => suspendReaderMedia(node))],
					['pending content hydration', () => [...ctx.pendingPostHydrations]
						.forEach((node) => cancelPostContentHydration(node, ctx))],
					['pending latex render', () => [...ctx.pendingPostLatexRenders]
						.forEach((node) => cancelPostLatexRender(node, ctx))],
					['jump highlight', clearActiveJumpHighlight],
					['stream viewport anchor', () => clearStreamViewportAnchor(ctx)],
					['avatar placeholders', () => releasePersistentAvatarImages(overlay)],
					['resize observer', () => ctx.streamResizeObserver.disconnect()],
					['timeline', () => ctx.timeline.destroy()],
					['topic presence', () => stopTopicPresence && stopTopicPresence()],
				].forEach(([label, cleanup]) => runReaderCleanup(label, cleanup));
				if (ctx.streamWindowFrame) cancelAnimationFrame(ctx.streamWindowFrame);
				if (ctx.floorPreviewSyncFrame) cancelAnimationFrame(ctx.floorPreviewSyncFrame);
				if (scrollWorkFrame) cancelAnimationFrame(scrollWorkFrame);
				ctx.streamWindowFrame = 0;
				ctx.floorPreviewSyncFrame = 0;
				scrollWorkFrame = 0;
				[cancelInitialLatestRefresh, cancelInitialNeighborPrefetch].forEach((cancel) => {
					if (cancel) cancel();
				});
				[gapLoadTimer, filterPumpTimer].forEach((timer) => {
					if (timer) clearTimeout(timer);
				});
				gapLoadTimer = 0;
				filterPumpTimer = 0;
				gapLoadDueAt = 0;
				pendingGapDirection = 0;
				pendingGapBatches = 1;
				contextScope.destroy();
				disarmNativeComposerClose();
				disarmReaderEscapeExit();
				if (readerShell.activeContext === ctx) readerShell.activeContext = null;
				[ctx.streamItems, ctx.streamLayoutItems].forEach((items) => { items.length = 0; });
				[ctx.streamItemMap, ctx.streamMountedNodes, ctx.streamHydratedNodes, ctx.pendingPostHydrations,
					ctx.pendingPostLatexRenders, ctx.streamNodeMap, ctx.subReplyState, ctx.directRepliesByParent,
					ctx.confirmedReadPostNumbers,
					ctx.replyParentByPost].forEach((collection) => collection.clear());
			}
			if (!preserveShell && !shellStopped) {
				shellStopped = true;
				overlay.dataset.readerShellDestroyed = '1';
				runReaderCleanup('avatar observers', () => {
					releasePersistentAvatarImages(notificationPanel.list);
					releasePersistentAvatarImages(historyPanel.list);
					releasePersistentAvatarImages(bookmarksPanel.list);
				});
				runReaderCleanup('resource monitor view', stopResourceMonitorUpdates);
				runReaderCleanup('request flow view', stopRequestFlowUpdates);
				if (readerShell.cancelShellFrames) readerShell.cancelShellFrames();
				runReaderCleanup('notification live events', () => readerShell.stopNotificationLiveEvents?.());
				runReaderCleanup('shell events', () => shellScope.destroy());
				runReaderCleanup('reader window', () => readerShell.readerWindow.destroy());
				runReaderCleanup('reader workspace', () => readerShell.readerWorkspace.destroy());
				runReaderCleanup('reader queue surface', () => overlay._ldpDestroyReaderQueueSurface?.());
			}
		};

		let readerSwitchTarget = null;
		let readerSwitchFrame = 0;
		let readerSwitchX = 0;
		let readerSwitchY = 0;
		const popupCanSwitchTopic = () => !readerWindow.isPinned() && readerWorkspace.isFloating() &&
			window.innerWidth > READER_WINDOW_COMPACT_WIDTH;
		const readerTopicTargetAt = (clientX, clientY) => {
			const overlayDisplay = overlay.style.getPropertyValue('display');
			const overlayDisplayPriority = overlay.style.getPropertyPriority('display');
			let source;
			try {
				overlay.style.setProperty('display', 'none', 'important');
				source = document.elementFromPoint(clientX, clientY);
			} finally {
				if (overlayDisplay) overlay.style.setProperty('display', overlayDisplay, overlayDisplayPriority);
				else overlay.style.removeProperty('display');
			}
			const link = source?.closest('a[href]');
			const route = link && !link.classList.contains('ldp-open') && !shouldBypassReaderUrl(link.href)
				? extractTopicRouteFromUrl(link.getAttribute('href') || link.href)
				: null;
			return route ? { link, route } : null;
		};
		const setReaderSwitchTarget = (link) => {
			if (readerSwitchTarget === link) return;
			readerSwitchTarget?.classList.remove('ldp-reader-switch-target');
			readerSwitchTarget = link || null;
			readerSwitchTarget?.classList.add('ldp-reader-switch-target');
			overlay.classList.toggle('ldp-reader-switch-ready', !!readerSwitchTarget);
		};
		const stopReaderSwitchTarget = () => {
			if (readerSwitchFrame) cancelAnimationFrame(readerSwitchFrame);
			readerSwitchFrame = 0;
			setReaderSwitchTarget(null);
		};
		const syncReaderSwitchTarget = () => {
			readerSwitchFrame = 0;
			const target = popupCanSwitchTopic() ? readerTopicTargetAt(readerSwitchX, readerSwitchY) : null;
			setReaderSwitchTarget(target?.link);
		};
		const onReaderSwitchPointerMove = (event) => {
			if (event.target !== overlay || event.pointerType === 'touch' || !popupCanSwitchTopic()) {
				stopReaderSwitchTarget();
				return;
			}
			readerSwitchX = event.clientX;
			readerSwitchY = event.clientY;
			if (!readerSwitchFrame) readerSwitchFrame = requestAnimationFrame(syncReaderSwitchTarget);
		};

		let prepareTopicSwitchPromise = null;
		const saveReaderNativeComposerDraftSafely = () => {
			let draftSavePromise;
			try {
				draftSavePromise = saveAndCloseReaderNativeComposer(ctx);
			} catch (error) {
				console.warn('[LINUX DO reader] native composer draft save setup failed', error);
				draftSavePromise = Promise.resolve(false);
			}
			return Promise.resolve(draftSavePromise).catch((error) => {
				console.warn('[LINUX DO reader] native composer draft save cleanup failed', error);
				return false;
			});
		};
		const prepareTopicSwitch = () => {
			if (prepareTopicSwitchPromise) return prepareTopicSwitchPromise;
			if (disposed) return Promise.resolve(false);
			clearFailureRetryTimer();
			stopReaderSwitchTarget();
			readerSession.state = 'switching';
			const draftSavePromise = saveReaderNativeComposerDraftSafely();
			rememberCurrentTopicPosition();
			stopReaderRuntime({ preserveShell: true });
			disposeHistoryNavigation();
			overlay._ldpCaptureHistoryViewport = () => null;
			overlay._ldpCaptureHistoryPostNumber = () => 1;
			prepareTopicSwitchPromise = draftSavePromise;
			return prepareTopicSwitchPromise;
		};

		const close = (closeOptions = {}) => {
			if (closePromise) return closePromise;
			if (disposed) return Promise.resolve();
			clearFailureRetryTimer();
			stopReaderSwitchTarget();
			disposed = true;
			readerSession.state = 'closing';
			const draftSavePromise = saveReaderNativeComposerDraftSafely();
			rememberCurrentTopicPosition();
			stopReaderRuntime();
			disposeHistoryNavigation();
			removeOwnedNode(overlay);
			if (CURRENT_OVERLAY === overlay) CURRENT_OVERLAY = null;
			if (!closeOptions.suppressNavigation) autoDequeueReaderQueueEntry(topicId);
			queueMicrotask(releaseReaderPortalIfIdle);
			if (ACTIVE_READER_SESSION_ID === readerSessionId) ACTIVE_READER_SESSION_ID = 0;
			PAGE_ROOT.classList.remove('ldp-reader-open');
			setPageScrollLocked(false);
			readerSession.state = 'closed';
			if (!closeOptions.preserveDocumentTitle) restoreHostDocumentTitle();
			if (!closeOptions.suppressNavigation) {
				AUTO_OPEN_SUPPRESSED_TOPIC_ID = String(topicId);
				PAGE_ROOT.classList.remove('ldp-route-takeover');
				syncNativeReaderTrigger();
			}
			closePromise = draftSavePromise;
			return closePromise;
		};
		overlay._ldpDispose = () => close({ suppressNavigation: true, preserveDocumentTitle: true });
		overlay._ldpPrepareTopicSwitch = prepareTopicSwitch;
		function onEsc(e) {
			if (e.key !== 'Escape') {
				disarmReaderEscapeExit();
				return;
			}
			if (isFunction(overlay._ldpCloseCodePreview)) {
				e.preventDefault();
				e.stopImmediatePropagation();
				disarmReaderEscapeExit();
				overlay._ldpCloseCodePreview();
				return;
			}
			if (e.repeat) {
				e.preventDefault();
				e.stopImmediatePropagation();
				return;
			}
			const exitConfirmed = readerEscapeExitDeadline > Date.now();
			disarmReaderEscapeExit();
			const boostMenu = document.body?.querySelector(':scope > .ldp-native-boost-menu');
			const boostEmojiPicker = [...document.querySelectorAll('.emoji-picker')].find(nativeComposerVisible);
			if (boostMenu && boostEmojiPicker && isFunction(boostMenu._ldpCloseEmojiPicker)) {
				e.preventDefault();
				e.stopImmediatePropagation();
				boostMenu._ldpCloseEmojiPicker();
				return;
			}
			const nativeFloatingSurface = visibleNativeFloatingSurface();
			if (nativeFloatingSurface) return;
			if (isFunction(boostMenu?._ldpClose)) {
				e.preventDefault();
				e.stopImmediatePropagation();
				boostMenu._ldpClose();
				return;
			}
			const nativeComposer = document.querySelector('#reply-control');
			if (nativeComposerVisible(nativeComposer)) {
				e.preventDefault();
				e.stopImmediatePropagation();
				if (nativeComposerCloseConfirmed('escape')) {
					disarmNativeComposerClose();
					discardReaderNativeComposer(ctx).catch((error) => {
						console.warn('[LINUX DO reader] native composer discard failed', error);
					});
				} else {
					armNativeComposerClose('escape', '再按一次 Esc 舍弃回复');
				}
				return;
			}
			if (topicEditorState) {
				consumeEvent(e);
				if (!topicEditorState.closeTransient || !topicEditorState.closeTransient()) topicEditorState.close();
				return;
			}
			if (ctx.readerActionDialog) {
				consumeEvent(e);
				ctx.readerActionDialog.close();
				return;
			}
			if (isFunction(ctx.closeTransientActions) && ctx.closeTransientActions()) {
				consumeEvent(e);
				return;
			}
			if (fallbackUserCardOpen()) {
				consumeEvent(e);
				if (isFunction(ctx.closeUserCardLayer)) ctx.closeUserCardLayer();
				else if (isFunction(ctx.closeUserCard)) ctx.closeUserCard();
				else hideFallbackUserCard();
				return;
			}
			if (ctx.floorPreview?.isOpen() && ctx.floorPreview.isVisibleInViewport()) {
				consumeEvent(e);
				ctx.floorPreview.hide();
				if (pickExpandedDefaultPost()) {
					showSelectionToast('再按 Esc 收起楼层:优先鼠标所在位置,否则从上往下');
				}
				return;
			}
			const collapsePost = pickExpandedDefaultPost();
			if (collapsePost) {
				consumeEvent(e);
				setNestedReplyCollapsed(collapsePost, true);
				const nextPost = pickExpandedDefaultPost();
				showSelectionToast(nextPost
					? `已收起 #${collapsePost.dataset.postNumber || ''} · Esc 继续收起 #${nextPost.dataset.postNumber || ''}`
					: `已收起楼层 #${collapsePost.dataset.postNumber || ''}`);
				return;
			}
			const visibleSurface = [
				[notificationsPopover, readerSurfaceController.closeNotifications],
				[historyPanel.popover, readerSurfaceController.closeHistory],
				[bookmarksPanel.popover, readerSurfaceController.closeBookmarks],
				[fontFamilyMenu, () => readerSurfaceController.closeFontFamilyMenu(true)],
				[settingsPopover, readerSurfaceController.requestCloseSettings],
			].find(([surface]) => !surface.hidden);
			if (visibleSurface) {
				consumeEvent(e);
				visibleSurface[1]();
				return;
			}
			if (readerPortalQuery('.ldp-lightbox')) return;
			e.preventDefault();
			e.stopImmediatePropagation();
			if (exitConfirmed) close();
			else armReaderEscapeExit();
		}
		const readerRefreshBtn = readerElement('.ldp-reader-refresh');
		readerRefreshBtn.disabled = readerSession.state !== 'active';
		onReaderRefreshClick = async () => {
			if (readerRefreshBtn.disabled || readerSession.state !== 'active') return;
			readerRefreshBtn.disabled = true;
			readerRefreshBtn.classList.add('is-refreshing');
			try {
				const viewport = embeddedReaderViewportState(ctx) || {};
				const refreshPostNumber = Math.max(
					1,
					Number(viewport.postNumber) || Number(overlay._ldpCaptureHistoryPostNumber?.()) || 1,
				);
				const refreshOnlyOp = ctx.onlyOp === true;
				const topicCacheSnapshot = ctx.topicData;
				if (isFunction(overlay._ldpPrepareTopicSwitch)) {
					await overlay._ldpPrepareTopicSwitch();
				}
				await clearCurrentTopicCaches(topicId, topicCacheSnapshot);
				await openModal(topicId, {
					targetPostNumber: refreshPostNumber,
					restoreOnlyOp: refreshOnlyOp,
					restoreViewport: {
						postNumber: refreshPostNumber,
						postOffset: Number(viewport.postOffset) || 0,
						scrollTop: Number(viewport.scrollTop) || 0,
					},
					topicNavMetadata: sourceTopicNavMetadata,
					historyNavigation,
				});
			} finally {
				readerRefreshBtn.classList.remove('is-refreshing');
				if (readerRefreshBtn.isConnected && readerSession.state === 'active') readerRefreshBtn.disabled = false;
			}
		};
		onContext(readerRefreshBtn, 'click', onReaderRefreshClick);
		onReaderCloseClick = () => close();
		onContext(readerElement('.ldp-close'), 'click', onReaderCloseClick);
		onContext(overlay, 'pointermove', onReaderSwitchPointerMove);
		onOverlayClick = (e) => {
			if (e.target !== overlay || !popupCanSwitchTopic()) return;
			const target = readerTopicTargetAt(e.clientX, e.clientY);
			if (target) {
				stopReaderSwitchTarget();
				AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
				hideNativeReaderTrigger();
				openModal(target.route.topicId, {
					targetPostNumber: topicUrlReaderStartPostNumber(target.route),
					topicNavMetadata: readTopicNavMetadata(target.link),
				});
				return;
			}
			close();
		};
		onContext(overlay, 'click', onOverlayClick);
		onModalWheel = (e) => {
			const floatingSurface = e.target.closest?.('.ldp-reaction-picker,.ldp-reader-action-layer:not(.ldp-topic-edit-layer)');
			if (floatingSurface) {
				containFloatingSurfaceWheel(floatingSurface, e);
				return;
			}
			if (notificationsPopover.contains(e.target) || historyPanel.popover.contains(e.target) || bookmarksPanel.popover.contains(e.target)) return;
			const topicEditLayer = e.target.closest?.('.ldp-topic-edit-layer');
			if (topicEditLayer) {
				e.preventDefault();
				const categoryMenu = e.target.closest('.ldp-topic-edit-category-menu');
				const categoryOptions = categoryMenu?.querySelector('.ldp-topic-edit-category-options');
				if (categoryOptions && !categoryMenu.hidden) {
					const categoryDelta = e.deltaMode === 1
						? e.deltaY * 40
						: e.deltaMode === 2 ? e.deltaY * categoryOptions.clientHeight : e.deltaY;
					categoryOptions.scrollTop += categoryDelta;
				}
				return;
			}
			if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
			const delta = e.deltaMode === 1
				? e.deltaY * 40
				: e.deltaMode === 2 ? e.deltaY * body.clientHeight : e.deltaY;
			if (delta < 0) {
				ctx.streamScrollDirection = -1;
				ctx.streamLastScrollAt = Date.now();
				ctx.streamScrollVelocity = Math.max(+(ctx.streamScrollVelocity || 0), Math.abs(delta) / 16);
				captureStreamViewportAnchor(ctx);
				clearInitialTargetHeadSpacer(ctx);
				ctx.releaseInitialPrecedingContent();
				scheduleStreamWindowSync(ctx);
				scheduleGapLoad(true);
			} else if (delta > 0) {
				ctx.streamScrollDirection = 1;
				captureStreamViewportAnchor(ctx);
			}
			if (body.contains(e.target)) return;
			e.preventDefault();
			body.scrollTop += delta;
		};
		onContext(modal, 'wheel', onModalWheel, { passive: false });
		onContext(document, 'pointerdown', onReaderEscapeExitInteraction, true);
		onContext(document, 'scroll', onReaderEscapeExitInteraction, true);
		onContext(document, 'click', onNativeComposerSubmitClick, true);
		onContext(document, 'keydown', onNativeComposerSubmitKeyDown, true);
		onContext(document, 'click', onNativeComposerCloseClick, true);
		onContext(document, 'keydown', onEsc, true);
		onContext(overlay, 'pointermove', onReaderEscapePointerMove);

		const sentinelVisible = () => body.scrollTop + body.clientHeight >= body.scrollHeight - 300;

		const pump = async (options = {}) => {
			if (loading || loadHalted || !sessionAcceptsWork()) return;
			loading = true;
			const maxBatches = options.maxBatches ?? 1;
			const keepFilling = options.keepFilling === true;
			const pumpOnlyOp = ctx.onlyOp;
			const forceOnlyOpScan = options.forceOnlyOpScan === true && pumpOnlyOp;
			if (pumpOnlyOp) onlyOpBatchStartedAt = Date.now();
			let batches = 0;
			let immediateRetries = 0;
			let retryDelay = 180;
			// 只要还没加载完,进入循环前先亮出底部提示
			if (!done) {
				endTip.classList.remove('show');
				loadingTip.classList.add('show');
			}
			try {
				while (!done && batches < maxBatches &&
					(forceOnlyOpScan || sentinelVisible() || pendingRetry || (keepFilling && batches > 0))) {
					pendingRetry = false;
					const { posts, done: isDone, retry, fatal, error } = await loader.next({
						onlyOp: pumpOnlyOp,
						onlyOpUsername: pumpOnlyOp ? ctx.op : '',
						persistFetchedPosts: options.persistFetchedPosts === true,
						onSource: (source, counts) => syncLoadingState(source, counts),
					});
					if (!sessionAcceptsWork()) return;
					if (posts.length) syncLoadingState('render');
					attachPostBatch(posts, ctx);
					if (ctx.onlyOp) syncOnlyOpProgress();
					if (fatal) {
						loadHalted = true;
						loadHaltError = error || null;
						pendingRetry = false;
						break;
					}
					if (retry) {
						pendingRetry = true;
						loadHaltError = error || null;
						const status = Number(error?.status || 0);
						const endpointRetryDelay = Math.max(0, Number(error?.retryAt || 0) - Date.now());
						if (pumpOnlyOp) onlyOpRetryStreak++;
						retryDelay = status === 429 || error && error.name === 'AbortError'
							? Math.max(250, endpointRetryDelay)
							: status === 408 || status >= 500
								? 1000
								: 400;
						if (shouldStopReaderFallback(error) || immediateRetries >= 1) break;
						immediateRetries++;
						await waitForDelay(400);
						continue;
					}
					loadHaltError = null;
					onlyOpRetryStreak = 0;
					onlyOpRetryAt = 0;
					retryDelay = 180;
					batches++;
					if (ctx.onlyOp === pumpOnlyOp) done = isDone;
				}
			} catch (error) {
				if (sessionAcceptsWork()) {
					loadHaltError = error || new Error('请求流异常停止');
					const transient = !!error && (
						error.name === 'AbortError' ||
						error.name === 'TypeError' ||
						Number(error.status || 0) === 408 ||
						Number(error.status || 0) === 429 ||
						Number(error.status || 0) >= 500
					);
					if (transient) {
						pendingRetry = true;
						if (pumpOnlyOp) onlyOpRetryStreak++;
						const endpointRetryDelay = Math.max(0, Number(error?.retryAt || 0) - Date.now());
						retryDelay = Math.min(
							endpointRetryDelay > 0 ? READER_ENDPOINT_429_MAX_BLOCK_MS : 15000,
							Math.max(
								endpointRetryDelay,
								1000 * Math.pow(2, Math.min(4, Math.max(0, onlyOpRetryStreak - 1)))
							)
						);
					} else {
						loadHalted = pumpOnlyOp;
						pendingRetry = !pumpOnlyOp;
						retryDelay = 1000;
						console.warn(
							`[LINUX DO reader] request pump ${pumpOnlyOp ? 'stopped' : 'will retry'} after unexpected error`,
							error
						);
					}
				}
			} finally {
				loading = false;
				if (pumpOnlyOp) onlyOpBatchStartedAt = 0;
				if (sessionAcceptsWork()) {
					// 本轮抓取结束(无论成功、失败或无更多内容)都收起提示
					loadingTip.classList.remove('show');
					endTip.classList.toggle('show', done);
					if (ctx.onlyOp) syncOnlyOpProgress();
					if ((ctx.onlyOp || pendingRetry) && !done && !loadHalted && !filterPumpTimer) {
						if (ctx.onlyOp) onlyOpRetryAt = Date.now() + retryDelay;
						filterPumpTimer = setTimeout(() => {
							filterPumpTimer = 0;
							onlyOpRetryAt = 0;
							if (sessionAcceptsWork()) {
								pump({ maxBatches: 1, forceOnlyOpScan: ctx.onlyOp });
							}
						}, retryDelay);
					} else if (done || loadHalted || !ctx.onlyOp) {
						onlyOpRetryAt = 0;
					}
				}
			}
		};

		const hasStreamGap = (node, backward) => {
			if (ctx.isPostVoting) return false;
			const num = +(node && node.dataset.postNumber);
			const misses = backward ? prevLoadMisses : nextLoadMisses;
			if (!num || (backward && num <= 1) || gapMissBlocked(misses, num)) return false;
			const item = ctx.streamItemMap.get(num);
			const lastPostNumber = Math.max(1, ctx.totalPosts || 1);
			if (ctx.onlyOp) {
				const layoutItems = streamLayoutItems(ctx);
				const boundary = layoutItems[backward ? 0 : layoutItems.length - 1];
				const loaded = ctx.streamItems[backward ? 0 : ctx.streamItems.length - 1];
				return item && item === boundary && loaded &&
					(backward ? loaded.postNumber > 1 : loaded.postNumber < lastPostNumber);
			}
			const adjacentIndex = item ? item.index + (backward ? -1 : 1) : -1;
			const adjacent = adjacentIndex >= 0 && adjacentIndex < ctx.streamItems.length
				? ctx.streamItems[adjacentIndex]
				: null;
			if (!adjacent) return backward || num < lastPostNumber;
			return backward ? num - adjacent.postNumber > 1 : adjacent.postNumber - num > 1;
		};

		const visibleGapPost = (backward) => {
			const items = streamLayoutItems(ctx);
			const lookahead = scrollLoadPressure(ctx).lookahead;
			const start = Math.max(ctx.streamWindowStart,
				ctx.streamViewportTopIndex - (backward ? lookahead : 1));
			const end = Math.min(ctx.streamWindowEnd,
				ctx.streamViewportBottomIndex + (backward ? 2 : lookahead + 1));
			for (let index = start; index < end; index++) {
				const node = items[index]?.node;
				if (!node || !node.isConnected || !hasStreamGap(node, backward)) continue;
				return node;
			}
			return null;
		};

		const loadStreamGap = async (backward, preferredAnchor = null, options = {}) => {
			const loadingGap = backward ? loadingPrev : loadingNext;
			const failureUntil = backward ? previousGapFailureUntil : nextGapFailureUntil;
			if (loadingGap || !sessionAcceptsWork() || Date.now() < failureUntil) return false;
			const hasGap = (node) => hasStreamGap(node, backward);
			const findAnchor = () => visibleGapPost(backward);
			let anchor = preferredAnchor && (!backward || preferredAnchor.isConnected) && hasGap(preferredAnchor)
				? preferredAnchor
				: findAnchor();
			if (backward && !anchor && ctx.streamScrollDirection < 0 && !ctx.streamItemMap.has(1)) {
				ctx.streamNeedsRepair = true;
				syncStreamWindow(ctx);
				anchor = findAnchor();
			}
			if (!anchor) return false;
			if (backward) captureStreamViewportAnchor(ctx);
			const maxBatches = Math.max(1, Math.min(2, +(options.maxBatches || 1)));
			let anchorNum = +anchor.dataset.postNumber;
			let loadedCount = 0;
			let retryDelay = 0;
			if (backward) loadingPrev = true;
			else loadingNext = true;
			try {
				for (let batch = 0; batch < maxBatches; batch++) {
					const directionChanged = backward ? ctx.streamScrollDirection > 0 : ctx.streamScrollDirection < 0;
					if (batch > 0 && (directionChanged || scrollLoadPressure(ctx).batches < 2)) break;
					const posts = await loader[backward ? 'loadBeforePost' : 'loadAfterPost'](anchorNum, {
						persistFetchedPosts: options.persistFetchedPosts === true,
					});
					if (backward) previousGapFailureUntil = 0;
					else nextGapFailureUntil = 0;
					if (!sessionAcceptsWork()) return false;
					if (backward && ctx.streamScrollDirection >= 0) return false;
					const fresh = posts.filter((p) => p && !ctx.streamNodeMap.has(+p.post_number));
					if (!fresh.length) {
						(backward ? prevLoadMisses : nextLoadMisses).set(anchorNum, Date.now() + 5000);
						if (backward) {
							retryDelay = 5100;
							previousGapRetryAt = Date.now() + retryDelay;
						}
						break;
					}
					const sorted = fresh.slice().sort((a, b) => (a.post_number || 0) - (b.post_number || 0));
					if (backward) {
						ctx.initialTargetHeadSpacerHeight = 0;
						attachPostBatch(sorted, ctx, {
							syncStreamNow: true,
							preserveViewportAnchor: true,
						});
						restoreStreamViewportAnchor(ctx);
						previousGapRetryCount = 0;
						previousGapRetryAt = 0;
					} else attachPostBatch(sorted, ctx);
					loadedCount += sorted.length;
					const edgePost = sorted[backward ? 0 : sorted.length - 1];
					const nextAnchorNum = +(edgePost?.post_number || 0);
					const nextAnchor = ctx.streamNodeMap.get(nextAnchorNum);
					const advanced = backward ? nextAnchorNum < anchorNum : nextAnchorNum > anchorNum;
					if (!nextAnchorNum || !advanced || !nextAnchor || !hasGap(nextAnchor)) break;
					anchorNum = nextAnchorNum;
				}
				return loadedCount > 0;
			} catch (e) {
				if (backward) {
					previousGapFailureUntil = Date.now() + 3000;
					retryDelay = 3100;
					previousGapRetryAt = Date.now() + retryDelay;
				} else nextGapFailureUntil = Date.now() + 3000;
			} finally {
				if (backward) loadingPrev = false;
				else loadingNext = false;
				if (backward && retryDelay && ctx.streamScrollDirection < 0 && !ctx.streamItemMap.has(1) &&
						previousGapRetryCount < 2) {
					previousGapRetryCount++;
					scheduleGapLoad(true, retryDelay);
				}
			}
			return false;
		};
		const loadPreviousGap = (preferredAnchor = null, options = {}) =>
			loadStreamGap(true, preferredAnchor, options);
		const loadNextGap = (preferredAnchor = null, options = {}) =>
			loadStreamGap(false, preferredAnchor, options);

		const scheduleGapLoad = (goingUp, minimumDelay = 0) => {
			const direction = goingUp ? -1 : 1;
			const pressure = scrollLoadPressure(ctx);
			const directionChanged = !!pendingGapDirection && pendingGapDirection !== direction;
			pendingGapDirection = direction;
			pendingGapBatches = Math.max(directionChanged ? 1 : pendingGapBatches, pressure.batches);
			const previousRetryDelay = direction < 0 ? Math.max(0, previousGapRetryAt - Date.now()) : 0;
			const dueAt = Date.now() + Math.max(previousRetryDelay, Math.max(0, +minimumDelay || 0));
			if (gapLoadTimer && !directionChanged && dueAt >= gapLoadDueAt) return;
			if (gapLoadTimer) clearTimeout(gapLoadTimer);
			gapLoadDueAt = dueAt;
			gapLoadTimer = setTimeout(() => {
				gapLoadTimer = 0;
				gapLoadDueAt = 0;
				if (!sessionAcceptsWork()) return;
				const nextDirection = pendingGapDirection;
				const maxBatches = pendingGapBatches;
				pendingGapDirection = 0;
				pendingGapBatches = 1;
				if (nextDirection < 0) loadPreviousGap(null, { maxBatches });
				else if (nextDirection > 0) {
					if (loading || sentinelVisible()) return;
					loadNextGap(null, { maxBatches });
				}
			}, Math.max(0, dueAt - Date.now()));
		};

		ctx.loadPost = async (postNumber, options = {}) => {
			if (!sessionAcceptsWork()) return false;
			postNumber = +postNumber;
			if (!postNumber) return false;
			const around = options.scope === 'around';
			const shouldMount = options.mount !== false;
			const ensureAround = options.ensureAround === true && postNumber > 1;
			const targetAlreadyKnown = ctx.streamNodeMap.has(postNumber);
			if (targetAlreadyKnown && (!around || !ensureAround)) {
				return shouldMount ? !!getStreamPostNode(ctx, postNumber, { mount: true }) : true;
			}
			let posts = [];
			try {
				posts = await loader.loadPost(postNumber, {
					onSource: options.onSource,
					priority: Number.isFinite(options.priority) ? options.priority
						: around ? POST_REQUEST_PRIORITY.target : undefined,
					scope: around ? 'around' : 'single',
					...(around ? {} : { forceRefresh: options.forceRefresh === true }),
				});
			} catch (error) {
				if (!around || !targetAlreadyKnown) throw error;
			}
			if (!sessionAcceptsWork()) return false;
			const fresh = around
				? posts.slice().sort((a, b) => (a.post_number || 0) - (b.post_number || 0))
				: posts.filter((p) => p && !ctx.streamNodeMap.has(+p.post_number));
			attachPostBatch(fresh, ctx);
			return shouldMount
				? !!getStreamPostNode(ctx, postNumber, { mount: true })
				: ctx.streamNodeMap.has(postNumber);
		};
		const nearestAvailablePost = (postNumber) => {
			const target = +postNumber || 0;
			if (!target) return null;
			return loader.cachedPosts.reduce((nearest, candidate) => {
				const current = +(candidate?.post_number || 0);
				if (!current) return nearest;
				if (!nearest) return candidate;
				const nearestNumber = +(nearest.post_number || 0);
				const currentDistance = Math.abs(current - target);
				const nearestDistance = Math.abs(nearestNumber - target);
				if (currentDistance < nearestDistance) return candidate;
				if (currentDistance === nearestDistance && current > target && nearestNumber < target) return candidate;
				return nearest;
			}, null);
		};

		const syncOnlyOpButton = () => {
			onlyOpBtn.classList.toggle('active', ctx.onlyOp);
			setPressed(onlyOpBtn, ctx.onlyOp);
			setLabel(onlyOpBtn, ctx.onlyOp ? '显示全部楼层' : '只看楼主');
		};
		onOnlyOpToggle = async () => {
			if (!sessionAcceptsWork() || onlyOpBtn.disabled || !ctx.op) return;
			clearStreamViewportAnchor(ctx);
			ctx.onlyOp = !ctx.onlyOp;
			pendingRetry = false;
			done = ctx.onlyOp ? loader.scanDone : loader.loadDone;
			if (!ctx.onlyOp && filterPumpTimer) {
				clearTimeout(filterPumpTimer);
				filterPumpTimer = 0;
			}
			if (!ctx.onlyOp) {
				onlyOpBatchStartedAt = 0;
				onlyOpRetryAt = 0;
				onlyOpRetryStreak = 0;
			}
			syncOnlyOpButton();
			invalidateStreamLayout(ctx);
			if (ctx.onlyOp) startOnlyOpProgress();
			else syncOnlyOpProgress();
			ctx.streamWindowResetPending = true;
			ctx.streamMountedItemCount = -1;
			const previousScrollTop = body.scrollTop;
			body.scrollTop = 0;
			markStreamInternalScroll(ctx, previousScrollTop);
			if (ctx.onlyOp && !ctx.streamItemMap.has(1)) {
				await ctx.loadPost(1, { priority: POST_REQUEST_PRIORITY.target });
				if (!sessionAcceptsWork()) return;
				invalidateStreamLayout(ctx);
			}
			flushStreamWindow(ctx);
			ctx.timeline.update();
			if (ctx.onlyOp) pump({ maxBatches: 1, forceOnlyOpScan: true });
		};
		onContext(onlyOpBtn, 'click', onOnlyOpToggle);

		try {
			setReaderOpenPhase('load-topic');
			const topic = await initialTopicRequest;
			if (!sessionAcceptsWork()) return;
			setReaderOpenPhase('initialize-topic');
			ctx.topicData = topic;
			ctx.topicSharedIssueHostVisible = !loader.initializedFromCache && nativeTopicSharedIssueVisible(topic);
			postsFromPayload(topic).forEach((post) => rememberPostData(ctx, post));
			ctx.isPostVoting = !!topic.is_post_voting;
			ctx.topicBookmarked = !!topic.bookmarked;
			ctx.topicNotificationLevel = topicNotificationLevel(topic);
			ctx.latestReplyAt = String(topic.last_posted_at || '');
			ctx.latestReplyTimestamp = Date.parse(ctx.latestReplyAt) || 0;
			ctx.op = topic._opUsername;
			onlyOpBtn.disabled = !ctx.op;
			syncOnlyOpButton();
			ctx.totalPosts = Math.max(0, topic.highest_post_number || 0, topic.posts_count || 0);
			renderTopicShell(topic);
			updateCommentsHeader(ctx);
			syncTopicActionControls(ctx);
			syncCurrentUserFromHost();
			if (sessionAcceptsWork()) {
				readerShell.startNotificationLiveEvents?.();
				syncTopicActionControls(ctx);
				syncAllPostActionControls(ctx);
				readerElements('.ldp-post').forEach((postNode) => {
					renderPostSpecialContent(postNode, ctx);
					if (postNode.querySelector(':scope > .ldp-boost-list:not(.ldp-boost-pending)')) {
						syncRenderedBoost(postNode, postNode._ldpPostData || {}, ctx);
					}
				});
			}
			if (sessionAcceptsWork()) {
				if (loader.initializedFromCache) {
					cancelInitialLatestRefresh = scheduleIdleWork(() => {
						cancelInitialLatestRefresh = null;
						refreshLatestReplyState();
					}, 1400);
				}
				startLatestReplyUpdates();
			}
			const titleBtn = readerElement('.ldp-title-jump');
			let cancelInitialTargetJump = false;
			ctx.cancelInitialTargetPosition = () => {
				cancelInitialTargetJump = true;
				ctx.initialTargetPositionPending = false;
				clearInitialTargetHeadSpacer(ctx);
				ctx.releaseInitialPrecedingContent();
			};
			onTitleJump = async () => {
				if (!sessionAcceptsWork()) return;
				await ctx.timeline.jumpToStart();
			};
			onContext(titleBtn, 'click', onTitleJump);
			onTitleJumpKeyDown = (event) => {
				if (event.key !== 'Enter' && event.key !== ' ') return;
				event.preventDefault();
				titleBtn.click();
			};
			onContext(titleBtn, 'keydown', onTitleJumpKeyDown);
			onTimelineTopClick = () => titleBtn.click();
			onContext(timelineEl.querySelector('.ldp-topic-timeline-top'), 'click', onTimelineTopClick);

			const openBtn = readerElement('.ldp-open');
			openBtn.href = topicPageUrl(topic.id, 0, true); openBtn.hidden = false;
			setLabel(openBtn, '打开原帖');
			onOpenNativePost = (event) => {
				if (event.type === 'click' && event.button && event.button !== 0) return;
				if (event.type === 'auxclick' && event.button !== 1) return;
				if (!openNativeTopicTab(openBtn.href)) return;
				consumeEvent(event);
			};
			onContext(openBtn, 'click', onOpenNativePost);
			onContext(openBtn, 'auxclick', onOpenNativePost);
			ctx.topicBookmarked = !!topic.bookmarked;
			syncTopicActionControls(ctx);
			ctx.toggleTopicBookmark = () => toggleReaderBookmark(topic, ctx, 'Topic');
			stopTopicPresence = bindReaderTopicPresence(ctx);

			destroyActions = bindActions(modal, ctx);
			tracker.start();

			let lastScrollTop = body.scrollTop;
			let lastScrollAt = Date.now();
			ctx.streamInternalScrollTop = NaN;
			ctx.streamInternalScrollDelta = 0;
			onBodyScroll = () => {
				if (!sessionAcceptsWork()) return;
				const now = Date.now();
				const nextScrollTop = body.scrollTop;
				const rawDelta = nextScrollTop - lastScrollTop;
				const elapsed = Math.max(16, now - lastScrollAt);
				lastScrollTop = nextScrollTop;
				lastScrollAt = now;
				const expectedInternalScrollTop = ctx.streamInternalScrollTop;
				const internalScrollDelta = Number.isFinite(ctx.streamInternalScrollDelta)
					? ctx.streamInternalScrollDelta
					: 0;
				ctx.streamInternalScrollTop = NaN;
				ctx.streamInternalScrollDelta = 0;
				// Anchor compensation and a wheel step can share one scroll event.
				// Remove only the compensation so windowing still sees the user's residual movement.
				let delta = rawDelta - internalScrollDelta;
				if (Math.abs(delta) < 1) delta = 0;
				const hasInternalScroll = Math.abs(internalScrollDelta) >= 0.01 ||
					(Number.isFinite(expectedInternalScrollTop) &&
						Math.abs(nextScrollTop - expectedInternalScrollTop) < 1);
				if (hasInternalScroll && !delta) {
					scheduleStreamWindowSync(ctx);
					return;
				}
				if (ctx.initialTargetPositionPending) {
					scheduleStreamWindowSync(ctx);
					return;
				}
				if (delta && readerSession.state === 'active') ctx.viewportPersistenceEnabled = true;
				const deltaDirection = delta < 0 ? -1 : delta > 0 ? 1 : 0;
				if (deltaDirection) {
					ctx.streamScrollDirection = deltaDirection;
					captureStreamViewportAnchor(ctx);
					if (deltaDirection < 0) {
						clearInitialTargetHeadSpacer(ctx);
						ctx.releaseInitialPrecedingContent();
					} else {
						previousGapRetryCount = 0;
					}
				}
				const followsUserIntent = !deltaDirection || deltaDirection === ctx.streamScrollDirection;
				pendingGoingUp = ctx.streamScrollDirection < 0;
				if (followsUserIntent) {
					const instantVelocity = Math.abs(delta) / elapsed;
					ctx.streamScrollVelocity = ctx.streamScrollVelocity * 0.55 + instantVelocity * 0.45;
				}
				ctx.streamLastScrollAt = now;
				if (pendingLiveReplyCount > 0 && body.scrollHeight - nextScrollTop - body.clientHeight <= 80) {
					clearLiveUpdateButton();
				}
				scheduleStreamWindowSync(ctx);
				if (scrollWorkFrame) return;
				scrollWorkFrame = requestAnimationFrame(() => {
					scrollWorkFrame = 0;
					if (!sessionAcceptsWork()) return;
					if (body.scrollTop <= 10 && !ctx.streamItemMap.has(1)) {
						pendingGapDirection = 0;
						pendingGapBatches = 1;
						if (gapLoadTimer) {
							clearTimeout(gapLoadTimer);
							gapLoadTimer = 0;
							gapLoadDueAt = 0;
						}
						loadPreviousGap(null, {
							maxBatches: scrollLoadPressure(ctx).batches,
						});
					} else {
						scheduleGapLoad(pendingGoingUp);
					}
					if (sentinelVisible()) {
						const pressure = scrollLoadPressure(ctx);
						pump({
							maxBatches: pressure.batches,
							keepFilling: pressure.batches > 1,
						});
					}
				});
			};
			onContext(body, 'scroll', onBodyScroll, { passive: true });
			onBodyTouchStart = () => {
				if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
				ctx.streamScrollDirection = 0;
				captureStreamViewportAnchor(ctx);
				ctx.releaseInitialPrecedingContent();
			};
			onContext(body, 'touchstart', onBodyTouchStart, { passive: true });
			onReaderScrollKeyDown = (event) => {
				if (['ArrowUp', 'PageUp', 'Home'].includes(event.key) || (event.key === ' ' && event.shiftKey)) {
					if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
					ctx.streamScrollDirection = -1;
					captureStreamViewportAnchor(ctx);
					clearInitialTargetHeadSpacer(ctx);
					ctx.releaseInitialPrecedingContent();
					scheduleGapLoad(true);
				} else if (['ArrowDown', 'PageDown', 'End'].includes(event.key) || (event.key === ' ' && !event.shiftKey)) {
					if (ctx.timeline?.releaseJump) ctx.timeline.releaseJump();
					ctx.streamScrollDirection = 1;
					captureStreamViewportAnchor(ctx);
				}
			};
			onContext(overlay, 'keydown', onReaderScrollKeyDown);

			setReaderOpenPhase('load-target-post');
			let targetLoaded = false;
			const syncTargetLoadingSource = (source) => syncLoadingState(source, { target: true });
			targetLoaded = await ctx.loadPost(Math.max(1, targetPostNumber), {
				priority: POST_REQUEST_PRIORITY.target,
				onSource: syncTargetLoadingSource,
			});
			if (targetPostNumber > 1 && !targetLoaded) {
				targetLoaded = await ctx.loadPost(targetPostNumber, {
					onSource: syncTargetLoadingSource,
					scope: 'around',
				});
			}
			if (targetPostNumber > 1 && !targetLoaded && !cancelInitialTargetJump) {
				targetLoaded = await ctx.loadPost(targetPostNumber, {
					priority: POST_REQUEST_PRIORITY.target,
					forceRefresh: true,
					onSource: syncTargetLoadingSource,
				});
			}
			if (targetPostNumber > 1 && !targetLoaded && !cancelInitialTargetJump) {
				const fallbackPost = nearestAvailablePost(targetPostNumber);
				const fallbackPostNumber = +(fallbackPost?.post_number || 0);
				if (fallbackPostNumber) {
					unavailableTargetPostNumber = targetPostNumber;
					attachPostBatch([fallbackPost], ctx);
					targetPostNumber = fallbackPostNumber;
					targetLoaded = !!getStreamPostNode(ctx, targetPostNumber, { mount: true });
					overlay.dataset.targetPostNumber = String(targetPostNumber);
					readerShell.targetPostNumber = targetPostNumber;
					ctx.initialTargetHydrationBoundary = targetPostNumber;
					if (restoreViewport && +restoreViewport.postNumber === unavailableTargetPostNumber) {
						restoreViewport = { ...restoreViewport, postNumber: targetPostNumber, postOffset: 0 };
					}
				}
				if (!targetLoaded) {
					throw new Error(`目标楼层 #${targetPostNumber} 加载失败,请重试`);
				}
			}
			if (targetPostNumber > 1 && targetLoaded && !cancelInitialTargetJump) {
				setReaderOpenPhase('load-target-neighborhood');
				await ctx.loadPost(targetPostNumber, {
					ensureAround: true,
					priority: POST_REQUEST_PRIORITY.visible,
					onSource: syncTargetLoadingSource,
					scope: 'around',
				});
				if (!sessionAcceptsWork()) return;
			}
			if (targetPostNumber <= 1 && !targetLoaded) {
				await pump({ maxBatches: 1, persistFetchedPosts: true });
			}
			// The batch path normally mounts on the next animation frame. Flush the
			// first window before removing the mask so opening never exposes a blank frame.
			setReaderOpenPhase('mount-initial-stream');
			syncLoadingState('render');
			if (targetPostNumber > 1 && targetLoaded && !cancelInitialTargetJump) {
				ctx.releaseInitialPrecedingContent();
				hydrateStreamNavigationNeighborhood(ctx, targetPostNumber);
			}
			flushStreamWindow(ctx);
			let initialQuoteVisualTarget = null;
			const resolveInitialQuoteVisualTarget = initialQuoteHighlight && targetPostNumber
				? () => {
					initialQuoteVisualTarget = applyReaderQuoteHighlight(
						targetPostNumber,
						ctx,
						initialQuoteHighlight.text,
						{ source: initialQuoteHighlight.source },
					);
					return initialQuoteVisualTarget;
				}
				: null;
			const initialStartPostNumber = Math.max(1, +targetPostNumber || 1);
			if (targetPostNumber > 1) {
				if (targetLoaded && !cancelInitialTargetJump) {
					const alignInitialTargetBottom = !initialQuoteHighlight && +targetPostNumber === +(ctx.totalPosts || 0);
					const initialTargetScrollOptions = {
						...PINNED_STREAM_SCROLL_OPTIONS,
						resolveVisualTarget: resolveInitialQuoteVisualTarget,
						alignNearest: !!initialQuoteHighlight,
						alignBottom: alignInitialTargetBottom,
						alignTop: !alignInitialTargetBottom,
					};
					if (!scrollToPost(targetPostNumber, ctx, initialTargetScrollOptions)) {
						throw new Error(`目标楼层 #${targetPostNumber} 定位失败,请重试`);
					}
					captureStreamViewportAnchor(ctx);
				}
				overlay.classList.remove('ldp-initial-target-layout');
				ctx.initialTargetPositionPending = false;
			} else {
				ctx.initialTargetPositionPending = false;
				if (!scrollToPost(initialStartPostNumber, ctx, {
					...PINNED_STREAM_SCROLL_OPTIONS,
					resolveVisualTarget: resolveInitialQuoteVisualTarget,
					alignNearest: !!initialQuoteHighlight,
					alignTop: true,
					highlight: !!initialQuoteHighlight,
				})) {
					throw new Error(`目标楼层 #${initialStartPostNumber} 定位失败,请重试`);
				}
			}
			if (targetLoaded && (targetPostNumber > 1 || ctx.totalPosts > initialStartPostNumber)) {
				cancelInitialNeighborPrefetch = scheduleIdleWork(async () => {
					cancelInitialNeighborPrefetch = null;
					if (!sessionAcceptsWork()) return;
					const startNode = ctx.streamNodeMap.get(initialStartPostNumber);
					if (!startNode || !startNode.isConnected) return;
					await loadNextGap(startNode);
				}, 1400);
			}
			if (!sessionAcceptsWork()) return;
			if (restoreOnlyOp && !ctx.onlyOp && ctx.op) await onOnlyOpToggle();
			if (!sessionAcceptsWork()) return;
			if (restoreViewport) {
				const restoredPostNumber = Math.max(1, Number(restoreViewport.postNumber) || targetPostNumber || 1);
				const restoredPost = getStreamPostNode(ctx, restoredPostNumber, { mount: true });
				const restoredPostOffset = stableReaderViewportOffset(restoredPost, ctx, restoreViewport.postOffset);
				const restored = !!restoredPost && scrollToPost(restoredPostNumber, ctx, {
					...PINNED_STREAM_SCROLL_OPTIONS,
					visualNode: restoredPost,
					alignOffset: restoredPostOffset,
					highlight: false,
				});
				if (!restored) {
					throw new Error(`历史位置 #${restoredPostNumber} 恢复失败,请重试`);
				}
				flushStreamWindow(ctx);
				captureStreamViewportAnchor(ctx);
			}
			if (!sessionAcceptsWork()) return;
			if (settleTargetJump) {
				await waitForReaderInitialLayout(overlay);
				if (!sessionAcceptsWork()) return;
				ctx.timeline.sync();
				const settledTargetPostNumber = restoreViewport
					? Math.max(1, Number(restoreViewport.postNumber) || targetPostNumber || 1)
					: targetPostNumber;
				let settledTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected
					? initialQuoteVisualTarget
					: getStreamPostNode(ctx, settledTargetPostNumber);
				if (settledTargetPostNumber > 0 && !readerTargetNodeIsVisible(settledTarget, ctx)) {
					const retryPost = getStreamPostNode(ctx, settledTargetPostNumber, { mount: true });
					const retryVisualTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected
						? initialQuoteVisualTarget
						: retryPost;
					const retryRestoreOffset = restoreViewport
						? stableReaderViewportOffset(retryVisualTarget, ctx, restoreViewport.postOffset)
						: NaN;
					const retryAlignBottom = !restoreViewport && !initialQuoteHighlight &&
						+settledTargetPostNumber === +(ctx.totalPosts || 0);
					if (retryPost && scrollToPost(settledTargetPostNumber, ctx, {
						...PINNED_STREAM_SCROLL_OPTIONS,
						visualNode: retryVisualTarget,
						resolveVisualTarget: !restoreViewport ? resolveInitialQuoteVisualTarget : null,
						alignOffset: retryRestoreOffset,
						alignNearest: !restoreViewport && !!initialQuoteHighlight,
						alignBottom: retryAlignBottom,
						alignTop: !restoreViewport && !initialQuoteHighlight && !retryAlignBottom,
						highlight: false,
					})) {
						captureStreamViewportAnchor(ctx);
						await waitForReaderJumpLayout();
						if (!sessionAcceptsWork()) return;
						ctx.timeline.sync();
						settledTarget = !restoreViewport && initialQuoteVisualTarget && initialQuoteVisualTarget.isConnected
							? initialQuoteVisualTarget
							: getStreamPostNode(ctx, settledTargetPostNumber);
					}
				}
				if (settledTargetPostNumber > 0 && !readerTargetNodeIsVisible(settledTarget, ctx)) {
					throw new Error(`目标楼层 #${settledTargetPostNumber} 布局稳定校验失败,请重试`);
				}
			}
			setReaderOpenPhase('activate-reader');
			readerSession.state = 'active';
			if (previousQueueTopicId > 0 && previousQueueTopicId !== Number(topicId)) {
				autoDequeueReaderQueueEntry(previousQueueTopicId);
			}
			readerRefreshBtn.disabled = false;
			if (initialQuoteSourceTarget) await returnToReaderQuoteSource(initialQuoteSourceTarget, ctx);
			ctx.timeline.update();
			removeOwnedNode(maskEl);
			if (unavailableTargetPostNumber) {
				showSelectionToast(`楼层 #${unavailableTargetPostNumber} 已不可用,已跳到相邻楼层 #${targetPostNumber}`);
			}
			if (sessionAcceptsWork()) rememberTopic(topic, targetPostNumber || 1, queueEntry?.seenPostNumbers);
		} catch (err) {
			if (disposed || !readerSession.isCurrent() || !overlay.isConnected) return;
			const failedPhase = readerOpenPhase;
			console.error('[LDP] reader open failed', {
				phase: failedPhase,
				topicId: String(topicId || ''),
				targetPostNumber,
				sessionId: readerSessionId,
			}, err);
			readerSession.state = 'failed';
			overlay.classList.remove('ldp-initial-target-layout');
			ctx.initialTargetPositionPending = false;
			const endpointRateLimitedFailure = Number(err?.status || 0) === 429 && err && err.rateLimitScope === 'endpoint';
			const rateLimitedFailure = Number(err?.status || 0) === 429 && !endpointRateLimitedFailure;
			stopReaderRuntime({ preserveShell: true });
			removeOwnedNode(maskEl);
			const errorEl = makeElement('div');
			errorEl.className = 'ldp-error';
			errorEl.setAttribute('role', 'alert');
			errorEl.innerHTML = `
				<div class="ldp-error-message"></div>
				<div class="ldp-error-actions">
					<button class="ldp-error-retry" type="button">重新加载</button>
					${rateLimitedFailure ? `<a class="ldp-error-challenge" href="${BASE}/" target="_blank" rel="noopener" aria-label="在独立浮窗打开 LINUX DO 官网完成 Cloudflare 验证">打开 Cloudflare 验证</a>` : ''}
					<button class="ldp-error-close" type="button">关闭阅读器</button>
				</div>`;
			errorEl.querySelector('.ldp-error-message').textContent = rateLimitedFailure
				? '请求过于频繁,阅读器已暂停加载。倒计时结束后将自动打开过盾浮窗,验证通过后浮窗会自动关闭。'
				: endpointRateLimitedFailure
					? '当前请求地址暂时返回 429,已只暂停该地址;其他阅读请求不受影响,请稍后重试。'
				: `加载失败(${failedPhase}):${err?.message ? err.message : '未知错误'}`;
			body.replaceChildren(errorEl);
			const retryButton = errorEl.querySelector('.ldp-error-retry');
			const errorChallenge = errorEl.querySelector('.ldp-error-challenge');
			let retryOpening = false;
			let challengeAutoOpened = false;
			const syncRetryState = () => {
				clearFailureRetryTimer();
				if (!overlay.isConnected || readerSession.state !== 'failed') return;
				const remaining = rateLimitCooldownRemaining();
				retryButton.disabled = retryOpening || remaining > 0;
				retryButton.textContent = retryOpening
					? '正在重新加载…'
					: remaining > 0
						? `限流冷却中(${Math.ceil(remaining / 1000)} 秒)`
						: '重新加载';
				if (remaining > 0) {
					failureRetryTimer = setTimeout(syncRetryState, Math.min(1000, remaining + 20));
				} else if (rateLimitedFailure && !challengeAutoOpened && errorChallenge) {
					challengeAutoOpened = true;
					openCloudflareChallengePopup(errorChallenge.href);
				}
			};
			errorEl.addEventListener('click', (event) => {
				const target = event.target instanceof Element ? event.target : null;
				if (target?.closest('.ldp-error-close')) {
					onReaderCloseClick();
					return;
				}
				if (!target?.closest('.ldp-error-retry')) return;
				if (retryButton.disabled || retryOpening) return;
				retryOpening = true;
				syncRetryState();
				Promise.resolve(openModal(topicId, {
					targetPostNumber,
					topicNavMetadata: sourceTopicNavMetadata,
					restoreViewport,
					historyNavigation,
					settleTargetJump,
				})).catch((retryError) => {
					console.error('[LDP] reader retry failed', retryError);
					retryOpening = false;
					syncRetryState();
				});
			});
			syncRetryState();
		}
	}

	/* ============ 14. 接管帖子路由 ============ */
	function shouldBypassReaderUrl(url) {
		let u;
		try { u = new URL(url, location.href); } catch (e) { return false; }
		return u.searchParams.has(NATIVE_BYPASS_PARAM);
	}

	function consumeNativeBypassTabSignal() {
		try {
			const bypass = sessionStorage.getItem(NATIVE_BYPASS_TAB_KEY) === '1';
			if (bypass) sessionStorage.removeItem(NATIVE_BYPASS_TAB_KEY);
			return bypass;
		} catch (e) {
			return false;
		}
	}

	function openNativeTopicTab(url) {
		let tab = null;
		try { tab = window.open('about:blank', '_blank'); } catch (e) {}
		if (!tab) return false;
		try { tab.sessionStorage.setItem(NATIVE_BYPASS_TAB_KEY, '1'); } catch (e) {}
		try { tab.opener = null; } catch (e) {}
		try { tab.location.replace(url); } catch (e) { tab.location.href = url; }
		return true;
	}

	let cloudflareChallengePopup = null;
	let cloudflareChallengePopupTimer = 0;
	let cloudflareChallengePopupState = null;
	let cloudflareChallengeLeaseHeartbeatAt = 0;
	const cloudflareChallengeInstanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;

	function readCloudflareChallengeLease() {
		try {
			const lease = JSON.parse(localStorage.getItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY) || 'null');
			return lease && typeof lease === 'object' ? lease : null;
		} catch (e) {
			return null;
		}
	}

	function writeCloudflareChallengeLease(state, ttl = LDP_CLOUDFLARE_CHALLENGE_LEASE_TTL) {
		const at = Date.now();
		if (state === 'active') cloudflareChallengeLeaseHeartbeatAt = at;
		const lease = {
			owner: cloudflareChallengeInstanceId,
			state,
			updatedAt: at,
			expiresAt: at + ttl,
		};
		try { localStorage.setItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY, JSON.stringify(lease)); } catch (e) {}
		return lease;
	}

	function releaseCloudflareChallengeLease() {
		const lease = readCloudflareChallengeLease();
		if (!lease || lease.owner !== cloudflareChallengeInstanceId) return;
		try { localStorage.removeItem(LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY); } catch (e) {}
	}

	function onCloudflareChallengeLeaseChange(event) {
		if (event.key === LDP_CLOUDFLARE_CHALLENGE_RESULT_KEY && event.newValue) {
			let result = null;
			try { result = JSON.parse(event.newValue); } catch (e) {}
			if (!result || result.owner !== cloudflareChallengeInstanceId) return;
			try { localStorage.removeItem(LDP_CLOUDFLARE_CHALLENGE_RESULT_KEY); } catch (e) {}
			const state = cloudflareChallengePopupState;
			if (state && state.owner === result.owner) verifyCloudflareChallengePopupState(state, result);
			return;
		}
		if (event.key !== LDP_CLOUDFLARE_CHALLENGE_LEASE_KEY || !event.newValue) return;
		let lease = null;
		try { lease = JSON.parse(event.newValue); } catch (e) {}
		if (!lease || lease.owner === cloudflareChallengeInstanceId || lease.state !== 'passed') return;
		const resetRate = resetReaderRateAfterChallenge();
		retryPendingReaderReadTracking();
		if (resetRate) showSelectionToast('验证已在另一窗口通过,已重置请求流限速');
	}

	window.addEventListener('storage', onCloudflareChallengeLeaseChange);

	function stopCloudflareChallengePopupMonitor() {
		if (cloudflareChallengePopupTimer) clearInterval(cloudflareChallengePopupTimer);
		cloudflareChallengePopupTimer = 0;
	}

	function cloudflareChallengePageInfo(popup) {
		try {
			const popupDocument = popup?.document;
			if (!popupDocument) return { accessible: false, challenge: false, ready: false, status: 0 };
			const href = String(popup.location?.href || '');
			const title = String(popupDocument.title || '');
			const challenge = !!(
				popup._cf_chl_opt ||
				popupDocument.querySelector(
					'iframe[src*="challenges.cloudflare.com"],script[src*="/cdn-cgi/challenge-platform/"],#challenge-running'
				) ||
				/^(?:Just a moment|请稍候)/i.test(title)
			);
			const navigation = popup.performance?.getEntriesByType
				? popup.performance.getEntriesByType('navigation')[0]
				: null;
			return {
				accessible: true,
				challenge,
				ready: popupDocument.readyState === 'complete' && href !== 'about:blank',
				status: Math.max(0, Number(navigation?.responseStatus) || 0),
			};
		} catch (e) {
			return { accessible: false, challenge: false, ready: false, status: 0 };
		}
	}

	function cloudflareChallengePassed(popup) {
		try {
			const popupDocument = popup?.document;
			if (!popupDocument) return false;
			return !!popupDocument.querySelector('meta[name="discourse-base-uri"],#main-outlet');
		} catch (e) {
			return false;
		}
	}

	function retryPendingReaderReadTracking() {
		const tracker = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell?.activeContext?.tracker;
		if (isFunction(tracker?.retryPending)) tracker.retryPending();
	}

	function completeCloudflareChallengePopup(message) {
		const resetRate = resetReaderRateAfterChallenge();
		retryPendingReaderReadTracking();
		writeCloudflareChallengeLease('passed', 10 * 1000);
		try { cloudflareChallengePopup.close(); } catch (e) {}
		cloudflareChallengePopup = null;
		cloudflareChallengePopupState = null;
		stopCloudflareChallengePopupMonitor();
		try { localStorage.removeItem(LDP_CLOUDFLARE_CHALLENGE_RESULT_KEY); } catch (e) {}
		if (message) showSelectionToast(message);
		else if (resetRate) showSelectionToast('验证已通过,已重置请求流限速');
	}

	function verifyCloudflareChallengePopupState(state, result = {}) {
		if (!state || state.verificationStarted || state.verificationFailed) return;
		state.verificationStarted = true;
		const status = Math.max(0, Number(result.status) || 0);
		if (result.challenged !== true && status >= 200 && status < 300) {
			if (state.onFormSuccess) state.onFormSuccess();
			completeCloudflareChallengePopup('已收到后端 2xx,楼层已标记为已读');
			return;
		}
		if (result.challenged !== true || !state.verify) {
			state.verificationFailed = true;
			releaseCloudflareChallengeLease();
			showSelectionToast('验证请求未完成,浮窗已保留;楼层仍保持未读');
			return;
		}
		Promise.resolve(state.verify()).then((verified) => {
			if (cloudflareChallengePopupState !== state) return;
			if (verified === true) {
				completeCloudflareChallengePopup('验证通过且后端已返回 2xx,楼层已标记为已读');
				return;
			}
			state.verificationFailed = true;
			releaseCloudflareChallengeLease();
			showSelectionToast('验证后接口仍未返回 2xx;浮窗已保留,本次不会重复触发');
		}).catch((error) => {
			console.warn('[LDP] verify read tracking challenge failed', error);
			if (cloudflareChallengePopupState !== state) return;
			state.verificationFailed = true;
			releaseCloudflareChallengeLease();
			showSelectionToast('验证后接口仍未返回 2xx;浮窗已保留,本次不会重复触发');
		});
	}

	async function submitCloudflareChallengePost(popup, url, params, state) {
		const token = await discourseCsrfToken();
		if (!popup || popup.closed || cloudflareChallengePopup !== popup || cloudflareChallengePopupState !== state) {
			return false;
		}
		const target = 'ldp-cloudflare-challenge';
		try { popup.name = target; } catch (e) {}
		const form = makeElement('form');
		form.method = 'POST';
		const challengeUrl = new URL(url, location.href);
		challengeUrl.searchParams.set(LDP_CLOUDFLARE_CHALLENGE_RETURN_PARAM, state.owner);
		form.action = challengeUrl.href;
		form.target = target;
		form.hidden = true;
		const appendField = (name, value) => {
			const input = makeElement('input');
			input.type = 'hidden';
			input.name = name;
			input.value = String(value);
			form.appendChild(input);
		};
		appendField('authenticity_token', token);
		Object.entries(params || {}).forEach(([name, value]) => {
			if (Array.isArray(value)) value.forEach((item) => appendField(`${name}[]`, item));
			else if (value != null) appendField(name, value);
		});
		document.body.appendChild(form);
		state.formSubmitted = true;
		state.submittedAt = Date.now();
		try {
			form.submit();
		} finally {
			form.remove();
		}
		return true;
	}

	function openCloudflareChallengePopup(url, options = {}) {
		if (cloudflareChallengePopupState?.formParams &&
			!cloudflareChallengePopupState.verificationFailed) {
			try { cloudflareChallengePopup.focus(); } catch (e) {}
			return true;
		}
		if (cloudflareChallengePopup && !cloudflareChallengePopup.closed) {
			try { cloudflareChallengePopup.focus(); } catch (e) {}
			return true;
		}
		const existingLease = readCloudflareChallengeLease();
		if (existingLease && existingLease.expiresAt > Date.now() && existingLease.owner !== cloudflareChallengeInstanceId) {
			if (existingLease.state === 'passed') {
				const resetRate = resetReaderRateAfterChallenge();
				if (resetRate) showSelectionToast('验证已通过,已重置请求流限速');
			} else {
				showSelectionToast('已有一个过盾窗口正在处理,请在该窗口完成验证');
			}
			return true;
		}
		writeCloudflareChallengeLease('active');
		const availableScreen = window.screen;
		const width = Math.min(760, Math.max(420, availableScreen.availWidth || 760));
		const height = Math.min(720, Math.max(520, availableScreen.availHeight || 720));
		const left = Math.max(0, Math.round((availableScreen.availLeft || 0) + ((availableScreen.availWidth || width) - width) / 2));
		const top = Math.max(0, Math.round((availableScreen.availTop || 0) + ((availableScreen.availHeight || height) - height) / 2));
		const features = `popup=yes,width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`;
		let popup = null;
		try {
			popup = window.open(options.formParams ? 'about:blank' : url, 'ldp-cloudflare-challenge', features);
		} catch (e) {}
		if (!popup) {
			releaseCloudflareChallengeLease();
			showSelectionToast('验证浮窗被浏览器拦截,请允许本站弹出式窗口');
			return false;
		}
		cloudflareChallengePopup = popup;
		const popupState = {
			owner: cloudflareChallengeInstanceId,
			formParams: options.formParams || null,
			formSubmitted: false,
			submittedAt: 0,
			detachedAt: 0,
			sawChallenge: false,
			verificationStarted: false,
			verificationFailed: false,
			verify: isFunction(options.verify) ? options.verify : null,
			onFormSuccess: isFunction(options.onFormSuccess) ? options.onFormSuccess : null,
		};
		cloudflareChallengePopupState = popupState;
		try { popup.focus(); } catch (e) {}
		if (popupState.formParams) {
			showSelectionToast('请在浮窗完成验证;后端返回 2xx 前楼层仍保持未读');
			void submitCloudflareChallengePost(popup, url, popupState.formParams, popupState).catch((error) => {
				console.warn('[LDP] open read tracking challenge failed', error);
				popupState.verificationFailed = true;
				releaseCloudflareChallengeLease();
				showSelectionToast('无法提交验证请求,楼层仍保持未读');
			});
		}
		stopCloudflareChallengePopupMonitor();
		cloudflareChallengePopupTimer = setInterval(() => {
			if (!cloudflareChallengePopup || cloudflareChallengePopup.closed) {
				const state = cloudflareChallengePopupState;
				if (cloudflareChallengePopup && state && state.formParams && state.formSubmitted &&
					!state.verificationStarted && !state.verificationFailed) {
					if (!state.detachedAt) state.detachedAt = Date.now();
					if (Date.now() - state.detachedAt < 2 * 60 * 1000) {
						if (Date.now() - cloudflareChallengeLeaseHeartbeatAt >= 5000) {
							writeCloudflareChallengeLease('active', 2 * 60 * 1000);
						}
						return;
					}
				}
				cloudflareChallengePopup = null;
				cloudflareChallengePopupState = null;
				stopCloudflareChallengePopupMonitor();
				releaseCloudflareChallengeLease();
				return;
			}
			if (Date.now() - cloudflareChallengeLeaseHeartbeatAt >= 5000) {
				writeCloudflareChallengeLease('active');
			}
			const state = cloudflareChallengePopupState;
			const pageInfo = cloudflareChallengePageInfo(cloudflareChallengePopup);
			if (pageInfo.challenge) {
				if (state) state.sawChallenge = true;
				return;
			}
			if (!state || state.verificationFailed || !pageInfo.ready) return;
			if (!state.formParams) {
				if (!state.sawChallenge || !cloudflareChallengePassed(cloudflareChallengePopup)) return;
				completeCloudflareChallengePopup();
				return;
			}
			if (!state.formSubmitted || Date.now() - state.submittedAt < 500 || state.verificationStarted) return;
			if (!state.sawChallenge && !(pageInfo.status >= 200 && pageInfo.status < 300)) return;
			verifyCloudflareChallengePopupState(state, {
				status: pageInfo.status,
				challenged: state.sawChallenge,
			});
		}, 250);
		return true;
	}

	function stripBypassParamFromUrl() {
		try {
			const u = new URL(location.href);
			if (!u.searchParams.has(NATIVE_BYPASS_PARAM)) return;
			u.searchParams.delete(NATIVE_BYPASS_PARAM);
			history.replaceState(history.state, '', u.pathname + u.search + u.hash);
		} catch (e) {}
	}

	function extractTopicRouteFromUrl(url) {
		let u;
		try { u = new URL(url, location.href); } catch (e) { return null; }
		if (/^https?:$/i.test(u.protocol) && u.origin !== BASE) return null;
		const parts = u.pathname.split('/').filter(Boolean);
		if (parts[0] !== 't') return null;
		const segments = parts.slice(1);
		if (/^\d+$/.test(segments[0] || '')) {
			const topicId = segments[0];
			const postNumber = /^\d+$/.test(segments[1] || '') ? segments[1] : '';
			return { topicId, postNumber: postNumber ? +postNumber : 0 };
		}
		const topicId = segments[1];
		const postNumber = /^\d+$/.test(segments[2] || '') ? segments[2] : '';
		return /^\d+$/.test(topicId || '') ? { topicId, postNumber: postNumber ? +postNumber : 0 } : null;
	}

	function topicUrlReaderStartPostNumber(route) {
		if (!route) return 0;
		return PREFS.openTopicsAtFirstPost ? 1 : (+route.postNumber || 0);
	}

	function runWhenBodyReady(fn) {
		if (document.body) fn();
		else document.addEventListener('DOMContentLoaded', fn, { once: true });
	}

	function hideNativeReaderTrigger() {
		PAGE_ROOT.classList.remove('ldp-native-reader-trigger-visible');
		readerPortalQueryAll('.ldp-native-reader-trigger-item').forEach((item) => {
			item.hidden = true;
		});
		const button = readerPortalQuery('.ldp-native-reader-trigger');
		if (button && !button.closest('.ldp-native-reader-trigger-item')) button.hidden = true;
	}

	function nativeReaderTriggerAvatarHost() {
		return document.querySelector('.d-header-icons .current-user:has(img.avatar)');
	}

	function syncNativeReaderTrigger() {
		if (!document.body) return;
		const route = extractTopicRouteFromUrl(location.href);
		let target = route ? { ...route, source: 'route', title: '' } : null;
		if (!target) {
			const firstHistoryTopic = orderedTopicHistory()[0];
			const historyTopicId = Number(firstHistoryTopic?.topicId);
			if (historyTopicId) {
				target = {
					topicId: historyTopicId,
					postNumber: Math.max(1, Number(firstHistoryTopic.postNumber) || 1),
					source: 'history',
					title: String(firstHistoryTopic.title || ''),
				};
			}
		}
		const topicId = target && String(target.topicId);
		if (CURRENT_OVERLAY) {
			hideNativeReaderTrigger();
			return;
		}
		const avatarHost = nativeReaderTriggerAvatarHost();
		if (!avatarHost) {
			hideNativeReaderTrigger();
			return;
		}
		PAGE_ROOT.classList.add('ldp-native-reader-trigger-visible');
		let item = document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]');
		if (!item) {
			item = makeElement('div');
			item.className = 'ldp-native-reader-trigger-item';
			item.dataset.ldpPortal = '1';
		}
		if (item.parentElement !== avatarHost) avatarHost.appendChild(item);
		readerPortalQueryAll('.ldp-native-reader-trigger-item').forEach((candidate) => {
			if (candidate !== item) candidate.hidden = true;
		});
		let button = item.querySelector('.ldp-native-reader-trigger');
		if (!button) {
			button = makeElement('button');
			button.className = 'ldp-native-reader-trigger';
			button.type = 'button';
			button.innerHTML = icon('maximize2');
			button.addEventListener('click', async (event) => {
				consumeEvent(event);
				const initialTopicId = Number(button.dataset.topicId);
				const initialPostNumber = Number(button.dataset.postNumber) || 0;
				const initialSource = button.dataset.triggerSource;
				if (!initialTopicId) return;
				AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
				hideNativeReaderTrigger();
				if (PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active') &&
						!await closeChromeSidePanelForReader()) {
					return stopDirectTopicTakeover(syncNativeReaderTrigger, String(initialTopicId));
				}
				const currentRoute = extractTopicRouteFromUrl(location.href);
				if (initialSource === 'route') {
					if (!currentRoute || Number(currentRoute.topicId) !== initialTopicId) {
						syncNativeReaderTrigger();
						return;
					}
					PAGE_ROOT.classList.add('ldp-route-takeover');
				} else if (currentRoute) {
					syncNativeReaderTrigger();
					return;
				}
				openModal(initialTopicId, {
					targetPostNumber: initialSource === 'route'
						? topicUrlReaderStartPostNumber(currentRoute)
						: initialPostNumber,
					topicNavMetadata: readTopicNavMetadata(null, initialTopicId),
				});
			});
			item.appendChild(button);
		}
		item.style.removeProperty('left');
		item.style.removeProperty('top');
		delete item.dataset.ldpPlacement;
		item.hidden = false;
		button.hidden = false;
		button.dataset.topicId = topicId || '';
		button.dataset.triggerSource = target ? target.source : 'empty-history';
		const startPostNumber = target
			? target.source === 'route' ? topicUrlReaderStartPostNumber(target) : target.postNumber
			: 0;
		button.dataset.postNumber = String(startPostNumber || '');
		const sidePanelActive = PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active');
		const label = !target
			? '暂无浏览历史'
			: target.source === 'history'
			? target.title ? `打开历史首项:${target.title}` : '打开历史首项'
			: sidePanelActive
			? '切换到浮窗阅读器'
			: PREFS.openTopicsAtFirstPost
				? '从 #1 打开浮窗阅读器'
				: target.postNumber ? `从 #${target.postNumber} 打开浮窗阅读器` : '打开浮窗阅读器';
		setLabel(button, label);
	}

	function installNativeReaderTriggerObserver() {
		if (!document.body || document.body._ldpNativeReaderTriggerObserver) return;
		const relevantSelector = '.d-header,.d-header-wrap,.d-header-icons,.current-user,' +
			'.ldp-native-reader-trigger-item[data-ldp-portal="1"]';
		const relevantContainerSelector = '.discourse-root,.d-header-wrap,header';
		const mutationNodeIsRelevant = (node) => {
			if (!node || node.nodeType !== Node.ELEMENT_NODE) return false;
			if (node.matches(relevantSelector)) return true;
			return node.matches(relevantContainerSelector) && !!node.querySelector(relevantSelector);
		};
		const mutationIsRelevant = (mutation) => {
			const target = mutation.target;
			if (target && target.nodeType === Node.ELEMENT_NODE && target.closest(relevantSelector)) return true;
			return [...mutation.addedNodes, ...mutation.removedNodes].some(mutationNodeIsRelevant);
		};
		let syncFrame = 0;
		const observer = new MutationObserver((mutations) => {
			if (!mutations.some(mutationIsRelevant) || syncFrame) return;
			syncFrame = requestAnimationFrame(() => {
				syncFrame = 0;
				const avatarHost = nativeReaderTriggerAvatarHost();
				const item = document.querySelector('.ldp-native-reader-trigger-item[data-ldp-portal="1"]');
				if (avatarHost && (!item || item.parentElement !== avatarHost)) syncNativeReaderTrigger();
			});
		});
		observer.observe(document.body, { childList: true, subtree: true });
		document.body._ldpNativeReaderTriggerObserver = observer;
	}

	function stopDirectTopicTakeover(triggerAction, suppressedTopicId = AUTO_OPEN_SUPPRESSED_TOPIC_ID) {
		PAGE_ROOT.classList.remove('ldp-route-takeover');
		AUTO_OPEN_SUPPRESSED_TOPIC_ID = suppressedTopicId;
		triggerAction();
		return false;
	}

	async function takeoverDirectTopicRoute() {
		if (shouldBypassReaderUrl(location.href)) {
			return stopDirectTopicTakeover(hideNativeReaderTrigger);
		}
		const route = extractTopicRouteFromUrl(location.href);
		if (!route) {
			return stopDirectTopicTakeover(syncNativeReaderTrigger, '');
		}
		const topicId = String(route.topicId);
		const activeReaderWorkspace = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell?.readerWorkspace;
		if (AUTO_OPEN_SUPPRESSED_TOPIC_ID === topicId && activeReaderWorkspace?.isEmbedded()) {
			return stopDirectTopicTakeover(syncNativeReaderTrigger);
		}
		if (PAGE_ROOT.classList.contains('ldp-reader-sidepanel-active') &&
				!await closeChromeSidePanelForReader()) {
			return stopDirectTopicTakeover(syncNativeReaderTrigger, topicId);
		}
		const currentRoute = extractTopicRouteFromUrl(location.href);
		if (!currentRoute || String(currentRoute.topicId) !== topicId) {
			return stopDirectTopicTakeover(hideNativeReaderTrigger);
		}
		if (AUTO_OPEN_SUPPRESSED_TOPIC_ID && AUTO_OPEN_SUPPRESSED_TOPIC_ID !== topicId) {
			AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
		}
		if (AUTO_OPEN_SUPPRESSED_TOPIC_ID === topicId) {
			return stopDirectTopicTakeover(syncNativeReaderTrigger);
		}
		hideNativeReaderTrigger();
		if (CURRENT_OVERLAY && CURRENT_OVERLAY.dataset.topicId === topicId) return true;
		PAGE_ROOT.classList.add('ldp-route-takeover');
		openModal(currentRoute.topicId, {
			targetPostNumber: topicUrlReaderStartPostNumber(currentRoute),
			topicNavMetadata: readTopicNavMetadata(null, currentRoute.topicId),
		});
		return true;
	}

	function installDiscourseRouteChangeHandler() {
		if (DISCOURSE_ROUTE_HANDLER_INSTALLED) return true;
		const pluginApiModule = lookupDiscourseModule('discourse/lib/plugin-api');
		if (!pluginApiModule || !isFunction(pluginApiModule.withPluginApi)) return false;
		try {
			pluginApiModule.withPluginApi((api) => {
				api.onPageChange(() => {
					setTimeout(() => {
						takeoverDirectTopicRoute().catch((error) => {
							console.warn('[LDP] route signal takeover failed', error);
						});
					}, 0);
				});
			});
		} catch (error) {
			return false;
		}
		DISCOURSE_ROUTE_HANDLER_INSTALLED = true;
		return true;
	}

	function captureHostTopicPointerAnchor(event, topicId) {
		if (event.type !== 'click' || event.detail <= 0) return null;
		const card = eventPathClosest(event, 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item');
		if (!card || card.closest('.ldp-overlay')) return null;
		const rect = card.getBoundingClientRect();
		if (rect.width <= 0 || rect.height <= 0) return null;
		return {
			card,
			topicId: String(topicId || ''),
			clientY: event.clientY,
			offsetY: Math.max(0, Math.min(rect.height, event.clientY - rect.top)),
		};
	}

	function restoreHostTopicPointerAnchor(anchor) {
		if (!anchor) return false;
		let card = anchor.card?.isConnected ? anchor.card : null;
		if (!card && anchor.topicId) {
			const cardSelector = 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item';
			card = [...document.querySelectorAll(cardSelector)].find((candidate) => {
				if (candidate.closest('.ldp-overlay')) return false;
				const directId = candidate.dataset.topicId || candidate.getAttribute('data-topic-id');
				const link = candidate.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],a[href*="/t/"]');
				return String(directId || extractTopicRouteFromUrl(link?.href)?.topicId || '') === anchor.topicId;
			}) || null;
		}
		if (!card) return false;
		document.querySelectorAll('[data-ldp-reader-active-topic]').forEach((candidate) => {
			if (candidate !== card) candidate.removeAttribute('data-ldp-reader-active-topic');
		});
		card.dataset.ldpReaderActiveTopic = 'true';
		const rect = card.getBoundingClientRect();
		const delta = rect.top + Number(anchor.offsetY || 0) - Number(anchor.clientY || 0);
		if (Math.abs(delta) >= .5) window.scrollBy({ top: delta, behavior: 'auto' });
		return true;
	}

	function handleTopicLinkTrigger(e) {
		if (e.type === 'click' && e.button && e.button !== 0) return false;
		const queueButton = eventPathClosest(e, '.ldp-reader-queue-add[data-reader-queue-topic-id]');
		if (queueButton) {
			consumeEvent(e);
			const topicId = Number(queueButton.dataset.readerQueueTopicId);
			const existing = readerQueueEntry(topicId);
			if (existing) {
				if (removeReaderQueueEntry(topicId)) showSelectionToast('已从阅读队列移除');
			} else {
				const entry = ensureReaderQueueEntry(topicId, readerQueueMetadataFromLink(queueButton, topicId));
				if (entry) showSelectionToast(`已加入阅读队列:${entry.title}`);
			}
			return true;
		}
		let a = eventPathClosest(e, 'a[href]');
		if (!a && PAGE_ROOT.classList.contains('ldp-reader-workspace')) {
			const card = eventPathClosest(e, 'tr.topic-list-item,.topic-list-item,.latest-topic-list-item');
			const interactive = eventPathClosest(e, 'button,input,select,textarea,[role="button"],[contenteditable="true"]');
			if (card && !card.closest('.ldp-overlay') && !interactive) {
				a = card.querySelector('a.raw-topic-link[href*="/t/"],a.title[href*="/t/"],.link-top-line a[href*="/t/"],a[href*="/t/"]');
			}
		}
		if (!a) return false;
		if (a.classList.contains('ldp-rate-limit-challenge') || a.classList.contains('ldp-error-challenge')) {
			consumeEvent(e);
			openCloudflareChallengePopup(a.href);
			return true;
		}
		const notificationId = markClickedNotificationRead(a.closest('.ldp-notification-item'));
		if (notificationId > 0) {
			void apiSend(`${BASE}/notifications/mark-read`, 'PUT', { id: notificationId }, null, {
				surviveReaderSwitch: true,
			}).catch((error) => {
				console.warn('[LDP] mark notification read failed', error);
			});
		}
		const activeReaderWorkspace = CURRENT_OVERLAY && CURRENT_OVERLAY._ldpReaderShell?.readerWorkspace;
		if (activeReaderWorkspace?.isEmbedded() && READER_PORTAL_HOST?.contains(a) &&
			!a.classList.contains('ldp-history-link') &&
			!a.classList.contains('ldp-rate-limit-challenge') &&
			!a.classList.contains('ldp-error-challenge') &&
			openEmbeddedReaderLinkInHost(e, a, activeReaderWorkspace)) return true;
		if (a.classList.contains('ldp-open')) return false;
		if (shouldBypassReaderUrl(a.href)) return false;
		const route = extractTopicRouteFromUrl(a.getAttribute('href') || a.href);
		if (!route) return false;
		const readerQueueMetadata = readerQueueMetadataFromLink(a, route.topicId);
		readerQueueMetadata.urlPostNumber = Math.max(0, Number(route.postNumber) || 0);
		const hostTopicPointerAnchor = captureHostTopicPointerAnchor(e, route.topicId);
		if (hostTopicPointerAnchor && isFunction(a.blur)) a.blur();
		consumeEvent(e);
		AUTO_OPEN_SUPPRESSED_TOPIC_ID = '';
		hideNativeReaderTrigger();
		openModal(route.topicId, {
			targetPostNumber: a.dataset.ldpPreserveTargetPost === '1'
				? +route.postNumber || 0
				: topicUrlReaderStartPostNumber(route),
			topicNavMetadata: readTopicNavMetadata(a),
			readerQueueMetadata,
			hostTopicPointerAnchor,
		});
		return true;
	}

	document.addEventListener('keydown', (event) => {
		if (event.key !== 'F5') return false;
		const readerShell = CURRENT_OVERLAY?._ldpReaderShell;
		const readerWorkspace = readerShell?.readerWorkspace;
		if (!readerWorkspace || !readerWorkspace.isEmbedded()) return false;
		event.preventDefault();
		event.stopImmediatePropagation();
		if (!refreshDiscourseHostRoute()) {
			showSelectionToast('LINUX DO 宿主刷新暂不可用,请稍后重试');
		}
		return true;
	}, true);
	document.addEventListener('click', handleTopicLinkTrigger, true);
	runWhenBodyReady(async () => {
		installRequestFlowInstrumentation();
		if (!installDiscourseRouteChangeHandler()) {
			window.addEventListener('load', () => {
				if (!installDiscourseRouteChangeHandler()) {
					console.warn('[LDP] Discourse page change signal unavailable');
				}
			}, { once: true });
		}
		ensureReaderStyleMounted();
		installNativeReaderTriggerObserver();
		installNativeTopicQueueButtons();
		let directTopicTakenOver = false;
		try {
			directTopicTakenOver = await takeoverDirectTopicRoute();
		} catch (error) {
			console.warn('[LDP] direct topic takeover failed', error);
		}
		if (directTopicTakenOver) return;
		try {
			await restoreEmbeddedReaderAfterReload();
		} catch (error) {
			console.warn('[LDP] embedded reader reload restore failed', error);
		}
		syncNativeReaderTrigger();
	});
})();