User:TonySt/CVPI dev.js

// <nowiki>
/**
 *
 *    █████████     █████   █████    ███████████     █████
 *   ███▒▒▒▒▒███   ▒▒███   ▒▒███    ▒▒███▒▒▒▒▒███   ▒▒███ 
 *  ███     ▒▒▒     ▒███    ▒███     ▒███    ▒███    ▒███ 
 * ▒███             ▒███    ▒███     ▒██████████     ▒███ 
 * ▒███             ▒▒███   ███      ▒███▒▒▒▒▒▒      ▒███ 
 * ▒▒███     ███     ▒▒▒█████▒       ▒███            ▒███ 
 *  ▒▒█████████        ▒▒███         █████           █████
 *   ▒▒▒▒▒▒▒▒▒          ▒▒▒         ▒▒▒▒▒           ▒▒▒▒▒ 
 * 
 *                      Interceptor
 *                        v0.9.21
 *                      User:TonySt
 */

(function () {
'use strict';

const CVPI_STYLE = `
@media screen and (prefers-color-scheme: dark) {
    html.skin-theme-clientpref-os {
        color-scheme:light dark;
        --color-base: #eaecf0;
        --color-base--hover: #f8f9fa;
        --color-emphasized: #f8f9fa;
        --color-neutral: #c8ccd1;
        --color-subtle: #a2a9b1;
        --color-disabled: #54595d;
        --color-disabled-emphasized: #72777d;
        --color-inverted: #101418;
        --color-progressive: #88a3e8;
        --color-progressive--hover: #a6bbf5;
        --color-progressive--active: #b6d4fb;
        --color-destructive: #fd7865;
        --color-destructive--hover: #fea898;
        --color-destructive--active: #ffc8bd;
        --color-visited: #a799cd;
        --color-visited--hover: #c5b9dd;
        --color-visited--active: #d9d0e9;
        --color-destructive--visited: #c99391;
        --color-destructive--visited--hover: #dcb5b3;
        --color-destructive--visited--active: #e8cecd;
        --color-error: #fd7865;
        --color-error--hover: #fea898;
        --color-error--active: #ffc8bd;
        --color-warning: #ca982e;
        --color-success: #2cb491;
        --color-notice: #a2a9b1;
        --color-content-added: #80cdb3;
        --color-content-removed: #fd7865;
        --color-base--subtle: #a2a9b1;
        --box-shadow-color-base: #72777d;
        --box-shadow-color-progressive--focus: #6485d1;
        --box-shadow-color-progressive-selected: #88a3e8;
        --box-shadow-color-progressive-selected--hover: #a6bbf5;
        --box-shadow-color-progressive-selected--active: #b6d4fb;
        --box-shadow-color-destructive--focus: #6485d1;
        --box-shadow-color-inverted: #000;
        --box-shadow-color-alpha-base: rgba(0,0,0,0.87);
        --mix-blend-mode-blend: screen;
        --background-color-base: #101418;
        --background-color-neutral: #27292d;
        --background-color-neutral-subtle: #202122;
        --background-color-interactive: #27292d;
        --background-color-interactive--hover: #404244;
        --background-color-interactive--active: #54595d;
        --background-color-interactive-subtle: #202122;
        --background-color-interactive-subtle--hover: #27292d;
        --background-color-interactive-subtle--active: #404244;
        --background-color-disabled: #404244;
        --background-color-disabled-subtle: #27292d;
        --background-color-inverted: #f8f9fa;
        --background-color-progressive--focus: #6485d1;
        --background-color-progressive-subtle: #1b223d;
        --background-color-progressive-subtle--hover: #233566;
        --background-color-progressive-subtle--active: #3056a9;
        --background-color-destructive--focus: #6485d1;
        --background-color-destructive-subtle: #3c1a13;
        --background-color-destructive-subtle--hover: #612419;
        --background-color-destructive-subtle--active: #9f3526;
        --background-color-error-subtle: #3c1a13;
        --background-color-error-subtle--hover: #612419;
        --background-color-error-subtle--active: #9f3526;
        --background-color-warning-subtle: #2d2212;
        --background-color-success-subtle: #132821;
        --background-color-notice-subtle: #27292d;
        --background-color-content-added: #233566;
        --background-color-content-removed: #453217;
        --background-color-target-text: #572c19;
        --background-color-backdrop-light: rgba(0,0,0,0.65);
        --background-color-backdrop-dark: rgba(255,255,255,0.65);
        --border-color-base: #72777d;
        --border-color-emphasized: #eaecf0;
        --border-color-subtle: #54595d;
        --border-color-muted: #404244;
        --border-color-interactive--hover: #a2a9b1;
        --border-color-interactive--active: #c8ccd1;
        --border-color-disabled: #54595d;
        --border-color-inverted: #101418;
        --border-color-progressive--hover: #88a3e8;
        --border-color-progressive--active: #a6bbf5;
        --border-color-progressive--focus: #6485d1;
        --border-color-destructive--hover: #fd7865;
        --border-color-destructive--active: #fea898;
        --border-color-destructive--focus: #6485d1;
        --border-color-error--hover: #fd7865;
        --border-color-error--active: #fea898;
        --border-color-warning--hover: #ca982e;
        --border-color-warning--active: #edb537;
        --border-color-content-added: #233566;
        --border-color-content-removed: #987027
    }
}

@media screen {
    :root,.skin-invert,.notheme {
        --color-base: #202122;
        --color-base-fixed: #202122;
        --color-base--hover: #404244;
        --color-emphasized: #101418;
        --color-neutral: #404244;
        --color-subtle: #54595d;
        --color-placeholder: #72777d;
        --color-disabled: #a2a9b1;
        --color-disabled-emphasized: #a2a9b1;
        --color-inverted: #fff;
        --color-inverted-fixed: #fff;
        --color-progressive: #36c;
        --color-progressive--hover: #3056a9;
        --color-progressive--active: #233566;
        --color-progressive--focus: #36c;
        --color-destructive: #bf3c2c;
        --color-destructive--hover: #9f3526;
        --color-destructive--active: #612419;
        --color-destructive--focus: #36c;
        --color-visited: #6a60b0;
        --color-visited--hover: #534fa3;
        --color-visited--active: #353262;
        --color-destructive--visited: #9f5555;
        --color-destructive--visited--hover: #854848;
        --color-destructive--visited--active: #512e2e;
        --color-error: #bf3c2c;
        --color-error--hover: #9f3526;
        --color-error--active: #612419;
        --color-warning: #886425;
        --color-success: #177860;
        --color-notice: #404244;
        --color-icon-error: #f54739;
        --color-icon-warning: #ab7f2a;
        --color-icon-success: #099979;
        --color-icon-notice: #72777d;
        --color-content-added: #006400;
        --color-content-removed: #8b0000;
        --filter-invert-icon: 0;
        --filter-invert-primary-button-icon: 1;
        --box-shadow-color-base: #a2a9b1;
        --box-shadow-color-progressive--active: #233566;
        --box-shadow-color-progressive--focus: #36c;
        --box-shadow-color-progressive-selected: #36c;
        --box-shadow-color-progressive-selected--hover: #3056a9;
        --box-shadow-color-progressive-selected--active: #233566;
        --box-shadow-color-destructive--focus: #36c;
        --box-shadow-color-inverted: #fff;
        --box-shadow-color-alpha-base: rgba(0,0,0,0.06);
        --box-shadow-color-transparent: transparent;
        --mix-blend-mode-base: normal;
        --mix-blend-mode-blend: multiply;
        --background-color-base: #fff;
        --background-color-base-fixed: #fff;
        --background-color-neutral: #eaecf0;
        --background-color-neutral-subtle: #f8f9fa;
        --background-color-interactive: #eaecf0;
        --background-color-interactive--hover: #dadde3;
        --background-color-interactive--active: #c8ccd1;
        --background-color-interactive-subtle: #f8f9fa;
        --background-color-interactive-subtle--hover: #eaecf0;
        --background-color-interactive-subtle--active: #dadde3;
        --background-color-disabled: #dadde3;
        --background-color-disabled-subtle: #eaecf0;
        --background-color-inverted: #101418;
        --background-color-progressive: #36c;
        --background-color-progressive--hover: #3056a9;
        --background-color-progressive--active: #233566;
        --background-color-progressive--focus: #36c;
        --background-color-progressive-subtle: #e8eeff;
        --background-color-progressive-subtle--hover: #d9e2ff;
        --background-color-progressive-subtle--active: #b6d4fb;
        --background-color-destructive: #bf3c2c;
        --background-color-destructive--hover: #9f3526;
        --background-color-destructive--active: #612419;
        --background-color-destructive--focus: #36c;
        --background-color-destructive-subtle: #ffe9e5;
        --background-color-destructive-subtle--hover: #ffdad3;
        --background-color-destructive-subtle--active: #ffc8bd;
        --background-color-error: #f54739;
        --background-color-error--hover: #d74032;
        --background-color-error--active: #bf3c2c;
        --background-color-error-subtle: #ffe9e5;
        --background-color-error-subtle--hover: #ffdad3;
        --background-color-error-subtle--active: #ffc8bd;
        --background-color-warning-subtle: #fdf2d5;
        --background-color-success-subtle: #dff2eb;
        --background-color-notice-subtle: #eaecf0;
        --background-color-content-added: #a3d3ff;
        --background-color-content-removed: #ffe49c;
        --background-color-target-text: #ffead4;
        --background-color-transparent: transparent;
        --background-color-backdrop-light: rgba(255,255,255,0.65);
        --background-color-backdrop-dark: rgba(0,0,0,0.65);
        --background-color-button-quiet--hover: rgba(0,24,73,0.027);
        --background-color-button-quiet--active: rgba(0,24,73,0.082);
        --background-color-input-binary--checked: #36c;
        --background-color-tab-list-item-framed--hover: rgba(255,255,255,0.3);
        --background-color-tab-list-item-framed--active: rgba(255,255,255,0.65);
        --opacity-icon-base: 0.87;
        --opacity-icon-base--hover: 0.74;
        --opacity-icon-base--selected: 1;
        --opacity-icon-base--disabled: 0.51;
        --opacity-icon-placeholder: 0.51;
        --opacity-icon-subtle: 0.67;
        --border-color-base: #a2a9b1;
        --border-color-emphasized: #202122;
        --border-color-subtle: #c8ccd1;
        --border-color-muted: #dadde3;
        --border-color-interactive: #72777d;
        --border-color-interactive--hover: #27292d;
        --border-color-interactive--active: #202122;
        --border-color-disabled: #c8ccd1;
        --border-color-inverted: #fff;
        --border-color-inverted-fixed: #fff;
        --border-color-progressive: #6485d1;
        --border-color-progressive--hover: #3056a9;
        --border-color-progressive--active: #233566;
        --border-color-progressive--focus: #36c;
        --border-color-destructive: #f54739;
        --border-color-destructive--hover: #9f3526;
        --border-color-destructive--active: #612419;
        --border-color-destructive--focus: #36c;
        --border-color-error: #f54739;
        --border-color-error--hover: #9f3526;
        --border-color-error--active: #612419;
        --border-color-warning: #ab7f2a;
        --border-color-warning--hover: #735421;
        --border-color-warning--active: #453217;
        --border-color-success: #099979;
        --border-color-notice: #72777d;
        --border-color-content-added: #a3d3ff;
        --border-color-content-removed: #ffe49c;
        --border-color-transparent: transparent;
        --border-color-divider: #a2a9b1;
        --outline-color-progressive--focus: #36c;
        --color-link: var(--color-progressive);
        --color-link--hover: var(--color-progressive--hover);
        --color-link--active: var(--color-progressive--active);
        --color-link--focus: var(--color-progressive--focus);
        --color-link--visited: var(--color-visited);
        --color-link--visited--hover: var(--color-visited--hover);
        --color-link--visited--active: var(--color-visited--active);
        --color-link-red: var(--color-destructive);
        --color-link-red--hover: var(--color-destructive--hover);
        --color-link-red--active: var(--color-destructive--active);
        --color-link-red--focus: var(--color-destructive--focus);
        --color-link-red--visited: var(--color-destructive--visited);
        --color-link-red--visited--hover: var(--color-destructive--visited--hover);
        --color-link-red--visited--active: var(--color-destructive--visited--active);
        --accent-color-base: #36c;
        --border-color-input--hover: var(--border-color-interactive);
        --border-color-input-binary: var(--border-color-interactive);
        --border-color-input-binary--hover: var(--border-color-progressive--hover);
        --border-color-input-binary--active: var(--border-color-progressive--active);
        --border-color-input-binary--focus: var(--border-color-progressive--focus);
        --border-color-input-binary--checked: var(--border-color-progressive);
        --color-base--subtle: #54595d
    }
}
/* mio theme elevation shamelessly stolen from https://m3.material.io/components/toolbars/overview */
:root {
	--mio-shadow-color-fore: color-mix(
		in srgb,
		var(--color-base, #000),
		transparent 30%
	);
	--mio-shadow-color-aft: color-mix(
		in srgb,
		var(--color-base, #000),
		transparent 15%
	);
	/* not using the above fore-aft variables here because i don't like the look of the white shadow on dark mode */
	--mio-theme-elevation-1: 0px 1px 2px 0px rgb(0 0 0 / 30%), 0px 1px 3px 1px rgb(0 0 0 / 15%);
	--mio-theme-elevation-2: 0px 1px 2px 0px rgb(0 0 0 / 30%), 0px 2px 6px 2px rgb(0 0 0 / 15%);
	--mio-theme-elevation-3: 0px 1px 3px 0px rgb(0 0 0 / 30%), 0px 4px 8px 3px rgb(0 0 0 / 15%);

	/* CVPI Colors */
	--cvpi-color-ins-bg: rgba(0, 255, 0);
	--cvpi-color-ins-outline: var(--border-color-content-added);
	--cvpi-color-del-bg: rgba(255, 0, 0);
	--cvpi-color-del-outline: var(--border-color-content-removed);
	--cvpi-color-vandalism-bg-start: #bf3c2c;
	--cvpi-color-vandalism-bg-end: #dd3c2c;
	--cvpi-color-vandalism: #fff;
}

.cvpi-colorblind {
	--cvpi-color-ins-bg: rgba(30, 126, 229, 0.8);
	--cvpi-color-ins-outline: rgba(30, 126, 229, 1);
	--cvpi-color-del-bg: rgba(255, 193, 7, 0.8);
	--cvpi-color-del-outline: rgba(255, 193, 7, 1);
	--cvpi-color-vandalism-bg-start: rgb(232, 174, 0);
	--cvpi-color-vandalism-bg-end: rgb(255, 193, 7);
	--cvpi-color-vandalism: #000;
}

/* ── Root layout ─────────────────────────────── */
.cvpi-only-mobile {
	display: none !important;
}

.cvpi-only-desktop {
	display: flex !important;
}

.cvpi-mobile-layout .cvpi-only-mobile {
	display: flex !important;
}

.cvpi-mobile-layout .cvpi-only-desktop {
	display: none !important;
}

.cvpi-wrapper {
	position: fixed;
	inset: 0;
	color: var(--color-base, #000);
	font-family:
		-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
	font-size: 0.9rem;
	z-index: 1000;
	display: grid;
	padding: 2px;
	box-sizing: border-box;
	overflow: hidden;
	overscroll-behavior: none;
	grid-template-columns: auto 20% 1fr 1fr auto auto;
	grid-template-rows: auto min-content auto auto 1fr auto;
	grid-template-areas:
		"menu   menu    menu    menu    menu    menu"
		"hist   title   title   title   cvpi-toolbar--top talk"
		"hist   details details details cvpi-toolbar--top talk"
		"hist   comments comments comments comments talk"
		"hist   diff    diff    diff    diff    talk"
		"footer footer  footer  footer  footer  footer";
}

.cvpi-wrapper button {
	border-radius:1.5em;
}

.cvpi-wrapper *,
.cvpi-wrapper *::before,
.cvpi-wrapper *::after {
	box-sizing: border-box;
}

.cvpi-wrapper > * {
	min-width: 0;
}

.cvpi-background-blur {
	position: fixed;
	inset: 0;
	background: color-mix(
		in srgb,
		var(--background-color-base, #fff),
		transparent 10%
	);
	backdrop-filter: blur(0.9em);
	z-index: 999;
}

.cvpi-progress-notification {
	position: fixed;
	font-size: 0.75rem;
	bottom: 7.5rem; /* Top edge of the 4rem tall toolbar (which is at 3.5rem bottom) */
	left: 50%;
	transform: translate(-50%, 100%); /* Initial state: pushed down behind the toolbar */
	background: var(--background-color-base, #fff);
	color: var(--color-base, #000);
	padding: 0.6rem 1rem;
	border-radius: 1.5rem 1.5rem 0 0;
	font-weight: 500;
	box-shadow: var(--mio-theme-elevation-2);
	border: 1px solid var(--border-color-muted);
	border-bottom: none;
	z-index: 99; /* Behind toolbar (which is 100) */
	transition: transform 0.3s ease-out, opacity 0.3s ease !important;
	pointer-events: none;
	opacity: 0;
	min-width: 10dvw;
	max-width: 40em;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
	text-align: center;
}

.cvpi-progress-notification--visible {
	transform: translate(-50%, 0); /* Slide UP into view to rest on top of the toolbar */
	opacity: 1;
}

/* =========================================
   Floating Toolbar
   ========================================= */
menu {
	padding-inline-start: 0;
}

.cvpi-toolbar {
	display: flex;
	position:absolute;
	bottom: 3.5rem;
	align-items: stretch;
	justify-content: space-evenly;
	justify-self: center;
	height: 4rem;
	list-style: none;
	margin: 0;
	padding: 0 1em;
	width: 95dvw;
	max-width: 40em;
	z-index: 100;
}

.cvpi-toolbar.cvpi-toolbar--actions {
	display:grid;
	grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto minmax(0, 1fr) minmax(0, 1fr);
}

.cvpi-mobile-layout .cvpi-toolbar {
	padding: 0 0.5em;
}

/* Background and blur layer for the toolbar */
.cvpi-toolbar::before {
	content: "";
	position: absolute;
	inset: 0;
	background-color: color-mix(in srgb, var(--background-color-base), transparent 10%);
	backdrop-filter: blur(2px);
	-webkit-backdrop-filter: blur(2px);
	border-radius: 24px;
	box-shadow: var(--mio-theme-elevation-1);
	border: 1px solid var(--border-color-muted);
	z-index: -1;
	pointer-events: none;
}

.cvpi-toolbar--top {
	position:relative;
	grid-area: cvpi-toolbar--top;
	justify-self:end;
	align-self:start;
	bottom: auto; /* Override the bottom: 3.5rem from .cvpi-toolbar */
	width: auto;
	max-width: none;
	margin-top:0.25em;
	margin-right:0.25em;
}

.cvpi-toolbar--top .cvpi-toolbar__toolbarPanel {
	right: 0;
	left: auto !important;
}

.cvpi-toolbar__action-item {
	display: flex;
	justify-content: center;
	position: relative;
}

[data-action-id="cvpi-toolbar-vandalism"] {
	margin: 0 1em;
	height: 110%;
	top: -5%;
}

.cvpi-action-btn {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	width:100%;
	max-width: 6em;
	transition: background-color 0.2s ease;
	cursor: pointer;
	border-radius: 1.5em;
	padding: 0 0.5em;
}

.cvpi-toolbar--actions .cvpi-action-btn {
	line-height: 0.75em;
}

.cvpi-toolbar-btn--vandalism {
	min-width:6em;
	background-image: linear-gradient(316deg, var(--cvpi-color-vandalism-bg-start), var(--cvpi-color-vandalism-bg-end));
	color: var(--cvpi-color-vandalism);
}

.cvpi-toolbar-btn--vandalism > span,
.cvpi-toolbar-btn--vandalism > span.cvpi-action-btn__label {
	color: var(--cvpi-color-vandalism);
}

.cvpi-action-btn--disabled {
	opacity: 0.5;
	cursor: not-allowed;
	pointer-events: none;
	transition: none !important;
}

.cvpi-action-btn--disabled.cvpi-toolbar-btn--vandalism {
	opacity: 1;
	background-color: color-mix(in srgb, var(--background-color-base), var(--cvpi-color-vandalism-bg-start) 60%);
	background-image: none;
}

.cvpi-action-btn--disabled:active {
	background-color: transparent !important;
}

@media (hover: hover) {
	.cvpi-action-btn--disabled:hover {
		background-color: transparent !important;
	}

	.cvpi-action-btn:hover:not(.cvpi-toolbar-btn--vandalism) {
		background-color: var(--background-color-interactive-subtle--hover);
	}

	.cvpi-action-btn.cvpi-toolbar-btn--vandalism:hover {
		border: 2px solid var(--color-emphasized);
		background-color: #e24633;
	}
}

.cvpi-action-btn:active {
	background-color: var(--background-color-interactive-subtle--active);
}

.cvpi-toolbar__action-item--left-end > .cvpi-action-btn {
	border-top-left-radius: 24px;
	border-bottom-left-radius: 24px;
}

.cvpi-toolbar__action-item--right-end > .cvpi-action-btn {
	border-top-right-radius: 24px;
	border-bottom-right-radius: 24px;
}

.cvpi-action-btn__symbol {
	position: relative;
	margin-bottom: 2px;
	color: var(--color-emphasized);
	user-select: none;
	display: flex;
	align-items: center;
	justify-content: center;
}

.cvpi-action-btn__badge {
	position: absolute;
	top: -1em;
	right: -1em;
	background-color: var(--background-color-progressive--focus, #6485d1);
	color: var(--color-inverted, #fff);
	border-radius: 50%;
	min-width: 1.2rem;
	height: 1.2rem;
	font-size: 0.7rem;
	font-family:sans-serif;
	font-weight: bold;
	display: flex;
	align-items: center;
	justify-content: center;
	padding: 0 4px;
	box-shadow: 0 0 0 2px var(--background-color-base, #fff);
	z-index: 1;
	pointer-events: none;
}

.cvpi-action-btn__symbol:not(.material-symbols-outlined) {
	font-weight: bold;
}

.cvpi-menu-item {
	display: grid;
	grid-template-columns: 24px 1fr auto;
	align-items: center;
	padding: 8px 16px;
	gap: 12px;
	width: 100%;
	transition: background-color 0.15s ease;
	cursor: pointer;
}

@media (hover: hover) {
	.cvpi-menu-item:hover {
		background-color: var(--background-color-interactive-subtle--hover);
	}
}

.cvpi-menu-item:active {
	background-color: var(--background-color-interactive-subtle--active);
}

.cvpi-menu-item--disabled {
	opacity: 0.5;
	cursor: not-allowed;
	pointer-events: none;
}

.cvpi-menu-item__icon {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 24px;
	height: 24px;
	color: var(--color-emphasized);
}

.cvpi-menu-item__label {
	font-size: 13px;
	font-weight: 500;
	color: var(--color-base);
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}

.cvpi-menu-item__action {
	display: flex;
	align-items: center;
	justify-self: end;
}

.cvpi-menu-item .cvpi-action-btn__symbol {
	margin: 0;
	font-size: 20px;
}

.cvpi-action-btn__label {
	font-size: 0.75em;
	font-weight: 500;
	color: var(--color-subtle);
	user-select: none;
	text-align: center;
	white-space: nowrap;
	overflow: hidden;
	text-overflow: ellipsis;
}

.cvpi-hide-labels .cvpi-action-btn__label {
	display: none;
}

/* Submenu: Inline */
.cvpi-toolbar__inline-toolbar {
	display: none;
	list-style: none;
	padding: 0;
	margin: 0;
	background-color: var(--background-color-neutral-subtle);
}

.cvpi-toolbar__inline-toolbar[data-state="cvpi-toolbar--open"] {
	display: flex;
}

@media (hover: hover) {
	.cvpi-toolbar__inline-toolbar .cvpi-action-btn:hover {
		background-color: var(--background-color-interactive--hover);
	}
}

.cvpi-toolbar__inline-toolbar .cvpi-action-btn:active {
	background-color: var(--background-color-interactive--active);
}

/* Submenu: Vertical */
.cvpi-toolbar__vertical-menu {
	display: flex;
	list-style: none;
	padding: 8px 0;
	margin: 0;
	min-width: 160px;
	min-height: 15em;
	max-height: calc(100dvh - 15em);
	overflow-y: scroll;
	flex-direction: column;
}

.cvpi-toolbar__vertical-menu-item {
	display: block;
	width: 100%;
}

/* Stepper Component */
.cvpi-stepper-wrapper {
	display: flex;
	align-items: center;
	gap: 8px;
}

.cvpi-stepper-btn {
	display: flex;
	align-items: center;
	justify-content: center;
	width: 24px;
	height: 24px;
	border-radius: 4px;
	background-color: var(--background-color-interactive-subtle);
	cursor: pointer;
	user-select: none;
	transition: background-color 0.15s ease;
}

@media (hover: hover) {
	.cvpi-stepper-btn:hover {
		background-color: var(--background-color-interactive-subtle--hover);
	}
}

.cvpi-stepper-btn:active {
	background-color: var(--background-color-interactive-subtle--active);
}

.cvpi-stepper-value {
	min-width: 40px;
	text-align: center;
	font-family: monospace;
	font-weight: bold;
}

/* Toggle Switch Component */
.cvpi-switch {
	position: relative;
	display: inline-block;
	width: 36px;
	height: 20px;
}

.cvpi-switch input {
	opacity: 0;
	width: 0;
	height: 0;
}

.cvpi-switch-slider {
	position: absolute;
	cursor: pointer;
	top: 0;
	left: 0;
	right: 0;
	bottom: 0;
	background-color: var(--border-color-base);
	transition: .2s;
	border-radius: 20px;
}

.cvpi-switch-slider:before {
	position: absolute;
	content: "";
	height: 14px;
	width: 14px;
	left: 3px;
	bottom: 3px;
	background-color: var(--color-inverted);
	transition: .2s;
	border-radius: 50%;
}

input:checked + .cvpi-switch-slider {
	background-color: var(--color-success);
}

input:checked + .cvpi-switch-slider:before {
	transform: translateX(16px);
}

.cvpi-history {
	display: block;
	grid-area: hist;
	overflow-y: auto;
}
.cvpi-sidebar-pane--talk {
	grid-area: talk;
	overflow-y: auto;
}

.cvpi-meta__title {
	grid-area: title;
	font-weight: 700;
	font-size: 1.5em;
	display: -webkit-box;
	line-clamp: 3;
	-webkit-line-clamp: 3;
	-webkit-box-orient: vertical;
	overflow: hidden;
	padding: 6px 12px;
}
.cvpi-meta__details {
	grid-area: details;
	display: flex;
	gap: 0.8em;
	align-items: center;
	font-size: 0.9em;
	padding: 6px 12px;
}
.cvpi-meta__size {
	font-weight: bold;
	font-family: monospace;
}
.cvpi-meta__size--increase {
	color: color-mix(
		in srgb,
		var(--cvpi-color-ins-bg),
		black 30%
	);
}
.cvpi-meta__size--decrease {
	color: color-mix(
		in srgb,
		var(--cvpi-color-del-bg),
		black 30%
	);
}
.cvpi-meta__user {
	font-weight: 400;
	color: var(--color-subtle, #53595d);
}
.cvpi-meta__user a {
	font-weight: 600;
	color: var(--color-progressive, #36c);
}
.cvpi-meta__warn {
	font-weight: bold;
	margin-left: 4px;
}
.cvpi-meta__reason {
	color: var(--color-subtle, #53595d);
	font-style: italic;
	border-radius: 0.2em;
	padding: 0.2em;
}
.cvpi-meta__reason__MLRS {
	background-color: rgba(0, 255, 0, 0.4);
	outline: 1px solid green;
}
.cvpi-meta__reason__editfilter {
	background-color: rgba(0, 255, 0, 0.4);
	outline: 1px solid blue;
}

.cvpi-meta__summary {
	grid-area: comments;
	word-break: break-word;
	overflow-y: auto;
	padding: 4px 12px;
	max-height:20dvh;
}

a.cvpi-comment {
	text-decoration: underline dotted var(--color-subtle);
	text-decoration-thickness: 1px;
}

a.cvpi-meta__revcount {
	color:var(--color-subtle);
}

.cvpi-comment {
	color:var(--color-subtle);
	font-style: italic;
	display:inline;
	padding:0 0.2em;
	border-radius:0.2em;
}

.cvpi-diff {
	grid-area: diff;
	font-family: "IBM Plex Mono", monospace;
	overflow: auto;
	padding: 0;
	box-shadow: 0 0 0.15rem var(--box-shadow-color-base);
}
.cvpi-footer {
	grid-area: footer;
	display: grid;
	grid-template-columns: 1fr auto 1fr;
	width: 100%;
	font-size: 0.85em;
	color: var(--color-subtle, #53595d);
	padding: 0.5em;
	background-color: var(--background-color-base);
	align-items: center;
}
.cvpi-footer-cell--status-area {
	justify-self: start;
}
.cvpi-footer__center {
	justify-self: center;
}
.cvpi-footer-timestamp {
	justify-self: end;
}
.cvpi-footer a {
	color: var(--color-subtle, #53595d);
	text-decoration: underline;
}

/* ── Placeholder ─────────────────────────────── */
.cvpi-placeholder {
	grid-area: diff;
	display: flex;
	align-items: center;
	justify-content: center;
	color: var(--color-subtle, #53595d);
	height: 100%;
	text-align: center;
}

/* Specific overrides for integrated (non-overlay) views */
.cvpi-history,
.cvpi-sidebar-pane--talk,
.cvpi-toolbarPanel--talk {
	width: 15rem;
	min-width: 15rem;
	font-size: 0.75rem;
}
.cvpi-sidebar-pane--history,
.cvpi-sidebar-pane--talk {
	display: block;
}

/* Update-in-place Layout toggles */
.cvpi-sidebar-pane {
	display: block;
}
.cvpi-toolbarPanel {
	display: none;
}
.cvpi-mobile-layout .cvpi-sidebar-pane {
	display: none !important;
}
.cvpi-mobile-layout .cvpi-toolbarPanel {
	display: block;
}

/* ── Blur and Focus Logic ─────────────────────── */
.cvpi-wrapper > * {
	transition:
		filter 0.25s ease,
		opacity 0.2s ease;
}

/* ── Nav specific ────────────────────────────── */
.cvpi-next--alert .cvpi-action-btn__badge {
	background: var(--background-color-destructive, #bf3c2c) !important;
	color: #fff !important;
}

.cvpi-badge--warning-high {
	background: #d33 !important;
	color: #fff !important;
}

.cvpi-badge--warning-mid {
	background: #f80 !important;
	color: #fff !important;
}

/* ── Diff ────────────────────────────────────── */
.cvpi-diff table.diff {
	width: 100%;
	table-layout: fixed;
	border-collapse: collapse;
	margin: 0;
	word-break: break-word;
	font-size: 0.9em;
}
.cvpi-diff col.diff-marker,
.cvpi-diff td.diff-marker {
	width: 1px;
	overflow: hidden;
}
.cvpi-diff tr.cvpi-diff-bottompadding {
	/*
	 * so the user can scroll to see what's
	 * behind the toolbar.
	 * 
	 * toolbarHeight + toolbarBottomOffset
	 */
	height: calc(4rem + 3.5rem);
}
.cvpi-diff ins {
	text-decoration: none;
	background: color-mix(
		in srgb,
		var(--cvpi-color-ins-bg),
		transparent 50%
	);
	outline: 0.2em solid var(--cvpi-color-ins-outline);
}
.cvpi-diff del {
	text-decoration: none;
		background: color-mix(
		in srgb,
		var(--cvpi-color-del-bg),
		transparent 50%
	);
	outline: 0.2em solid var(--cvpi-color-del-outline);
}
.cvpi-diff .diffchange {
	border-radius: 0.2em;
	padding: 0 0.2em;
	margin: 0 0.2em;
}
.cvpi-diff .diff-addedline {
	background: color-mix(in srgb, var(--cvpi-color-ins-bg), transparent 90%);
}
.cvpi-diff .diff-deletedline {
	background: color-mix(in srgb, var(--cvpi-color-del-bg), transparent 90%);
}
.cvpi-diff .diff-changedline {
	background: rgba(128, 128, 128, 0.1);
}
.cvpi-diff--overridden {
	opacity: 0.7;
	font-style: italic;
}
.cvpi-diff a {
	text-decoration: underline;
	color: var(--color-notice, #404244);
}

/* ── Scroll arrow ────────────────────────────── */
.cvpi-arrow {
	grid-area: diff;
	align-self: start;
	justify-self: center;
	z-index: 1;
	display: none;
	cursor: pointer;
	user-select: none;
	background: var(--background-color-neutral-subtle, #eaecf0);
	color: var(--color-progressive, #36c);
	padding: 0.25em 1em;
	opacity: 0.85;
	font-size: 1.5em;
	border-radius: 0 0 1em 1em;
	box-shadow: var(--mio-theme-elevation-1);
}

/* ── Modal ───────────────────────────────────── */
.cvpi-modal {
	position: fixed;
	inset: 0;
	background: rgba(0, 0, 0, 0.1);
	z-index: 2000;
	display: flex;
	align-items: center;
	justify-content: center;
	backdrop-filter: blur(0.1em);
	-webkit-backdrop-filter: blur(0.1em);
}

.cvpi-modal-box {
	width: 90%;
	max-width: 400px;
	padding: 16px;
	background: var(--background-color-base, #fff);
	border-radius: 4px;
	box-shadow: var(--mio-theme-elevation-3);
}

.cvpi-modal-box h3 {
	margin: 0 0 12px;
	border-bottom: 1px solid var(--border-color-divider);
	padding-bottom: 8px;
}

.cvpi-modal-choices-container {
	display: grid;
	grid-template-columns: repeat(3, 1fr);
	grid-auto-rows: 1fr;
	gap: 0.5em;
}

.cvpi-modal-choices-container > button { 
	box-shadow: var(--mio-theme-elevation-1);
}

.cvpi-modal-input {
	width: 100%;
	box-sizing: border-box;
	padding: 8px;
	border: 1px solid var(--border-color-input-binary, #72777d);
	border-radius: 2px;
}

.cvpi-modal-actions {
	display: flex;
	justify-content: flex-end;
	gap: 8px;
	margin-top: 16px;
}

/* ── Overlay lists ───────────────────────────── */
.cvpi-history > button,
.cvpi-talk > button {
	width: 100%;
	margin-bottom: 5px;
}
.cvpi-history__list,
.cvpi-talk__list {
	list-style: none;
	padding: 0;
	margin: 0;
}
.cvpi-history__list li,
.cvpi-talk__empty {
	padding: 4px;
	border-radius: 0.5em;
	background: var(--background-color-neutral-subtle, #eaecf0);
	border: 1px solid var(--border-color-muted, #dadde3);
	margin: 0.5em;
}
@media (hover: hover) {
	.cvpi-history__list li:hover {
		background-color: var(--background-color-interactive--hover, #dadde3);
	}
}
.cvpi-history__list li:active {
	background-color: var(--background-color-interactive--active, #c8ccd1);
}
.cvpi-history__list li.cvpi-history__item--active {
	border-color: var(--border-color-emphasized, #202122);
}

.cvpi-talk__list {
	background: var(--background-color-neutral-subtle, #eaecf0);
	border: 1px solid var(--border-color-subtle, #c8ccd1);
	border-radius: 0.8rem;
	overflow: hidden;
	margin: 0.5em;
}

/* ── History view ───────────────────────────── */
.cvpi-history__list a {
	display: grid;
	grid-template: "ts user" "comments comments" / 1fr 1fr;
	text-decoration: none;
	color: inherit;
	row-gap: 4px;
}
.cvpi-history__ts {
	grid-area: ts;
	color: var(--color-subtle, #53595d);
}
.cvpi-history__user {
	grid-area: user;
	color: var(--color-subtle, #53595d);
	justify-self: right;
	text-align: right;
}
.cvpi-history__user--current {
	color: var(--color-destructive, #bf3c2c);
	font-weight: bold;
}
.cvpi-history__comments {
	grid-area: comments;
	background: var(--background-color-backdrop-light, rgba(255, 255, 255, 0.65));
	outline: 1px solid var(--border-color-muted, #dadde3);
	border-radius: 0.5em;
	padding: 4px 6px;
	min-height: 1.5rem;
	word-break: break-word;
	box-sizing: border-box;
}

/* ── Talk view ──────────────────────────────── */
.cvpi-talk__month {
	background: color-mix(in srgb, var(--color-subtle, #6e6e73), transparent 95%);
	font-weight: 700;
	text-transform: uppercase;
	letter-spacing: 0.05em;
	color: var(--color-subtle, #6e6e73);
	padding: 0.5em;
	text-align: center;
}
.cvpi-talk__item {
	cursor: pointer;
	display: grid;
	grid-template: "ts ts" "lvl name" "lvl issuer";
	gap: 4px;
	align-items: center;
	justify-content: space-evenly;
	padding: 8px 4px;
	border-bottom: 1px solid var(--border-color-divider, #a2a9b1);
}
.cvpi-talk__item:last-child {
	border-bottom: none;
}
@media (hover: hover) {
	.cvpi-talk__item:hover {
		background-color: var(--background-color-interactive--hover, #dadde3);
	}
}
.cvpi-talk__item:active {
	background-color: var(--background-color-interactive--active, #c8ccd1);
}

.cvpi-talk__item--stale {
	opacity: 0.5;
}
.cvpi-talk__item.cvpi-talk__item--final {
	border: 2px solid var(--border-color-destructive, #f54739);
	border-radius: 4px;
	margin: 4px;
	background: rgba(245, 71, 57, 0.05);
}
.cvpi-talk__ts {
	grid-area: ts;
	border-bottom: 1px solid var(--border-color-divider, #a2a9b1);
	text-align: center;
	padding-bottom: 2px;
}
.cvpi-talk__level {
	grid-area: lvl;
	justify-self: center;
	color: var(--color-notice, #404244);
	font-size: 1rem;
}
.cvpi-talk__name {
	grid-area: name;
}
.cvpi-talk__issuer {
	grid-area: issuer;
}
.cvpi-talk__empty {
	padding: 10px;
	text-align: center;
}

/* Submenu: ToolbarPanel */
.cvpi-toolbar__toolbarPanel {
	display: none;
	position: absolute;
	width: max-content;
	background-color: color-mix(in srgb, var(--background-color-base), transparent 10%);
	backdrop-filter: blur(4px);
	-webkit-backdrop-filter: blur(4px);
	border-radius: 2em;
	box-shadow: var(--mio-theme-elevation-1);
	list-style: none;
	padding: 0;
	z-index: 10;
	border: 1px solid var(--border-color-muted);
	overflow: hidden;
}

.cvpi-toolbar--actions >.cvpi-toolbar__toolbarPanel {
	max-width: 100%;
}

.cvpi-mobile-layout .cvpi-toolbar--actions >.cvpi-toolbar__toolbarPanel[data-active-action="cvpi-toolbar-dashboard"] {
	max-width: 95dvw;
	left: 50% !important;
	transform: translateX(-50%);
}

.cvpi-mobile-layout .cvpi-toolbar--actions >.cvpi-toolbar__toolbarPanel .cvpi-rollback-dashboard {
	padding: 1.5em 0.5em;
}

.cvpi-mobile-layout .cvpi-toolbar--actions > .cvpi-toolbar__toolbarPanel .cvpi-rollback-dashboard .cvpi-rollback-dashboard__grid {
	gap: 0.25em;
}

.cvpi-toolbar__toolbarPanel[data-placement="top"] {
	bottom: 100%;
	margin-bottom: 8px;
}

.cvpi-toolbar__toolbarPanel[data-placement="bottom"] {
	top: 100%;
	margin-top: 8px;
}

/*

border: 10px solid;
border-image: repeating-linear-gradient(
	45deg,
	#fcd116,
	#fcd116 10px,
	#000 10px,
	#000 20px
	) 10;
*/

.cvpi-toolbar__toolbarPanel[data-state="cvpi-toolbar--open"] {
	display: flex;
}

/* ── ToolbarPanel Adjustments ──────────────── */
.cvpi-toolbarPanel--history,
.cvpi-toolbarPanel--talk {
	max-height: 60dvh;
	overflow-y: auto;
	width: 15rem;
	max-width: 95dvw;
	background-color: transparent;
}

.cvpi-toolbarPanel--history a.cvpi-btn--wide,
.cvpi-toolbarPanel--talk a.cvpi-btn--wide {
	margin-bottom: 8px;
	border-radius: 2em 2em 0 0;
}

#cvpi-btn-vandalism {
	box-shadow: var(--mio-theme-elevation-3);
}

/* ── Rollback dashboard ────────────────────────── */
.cvpi-rollback-dashboard {
	display: flex;
	flex-direction: column;
	gap: 0.75em;
	max-width: 50em;
	margin: 0 auto;
	padding: 1.5em;
	max-height: calc(100dvh - 15rem);
	overflow: auto;
}
.cvpi-rollback-dashboard button {
	box-shadow: var(--mio-theme-elevation-1);
}
.cvpi-rollback-dashboard__grid {
	display: grid;
	grid-template-columns: 1fr 1fr 1fr;
	gap: 1.25em;
	flex-shrink: 0;
	align-items: start;
}

.cvpi-rollback-dashboard__grid--single button.cvpi-btn--arm {
	background-color: var(--background-color-interactive-subtle);
}

.cvpi-rollback-group {
	display: grid;
	gap: 0.25em;
}
.cvpi-rollback-group--multi {
	grid-template: "a a" "1 2" "3 4" / 1fr 1fr;
}
.cvpi-rollback-group .cvpi-btn--auto {
	grid-area: a;
	min-height: 3em;
}
.cvpi-rollback-group--single {
	grid-template-columns: 1fr 1fr;
	align-content: end;
}
.cvpi-rollback-group--single > button:first-child {
	grid-column: 1 / -1;
}
.cvpi-rollback-group__title {
	text-align: center;
	font-weight: bold;
	padding-bottom: 5px;
	grid-column: 1 / -1;
}
.cvpi-rollback-footer {
	display: flex;
	gap: 5px;
	flex-wrap: wrap;
	flex-shrink: 0;
}
.cvpi-rollback-footer .mw-ui-button {
	flex: 1;
}

.cvpi-btn--wide {
	width: 100%;
}

.cvpi-rollback-group button.mw-ui-button {
	min-height: 3.5em;
}

/* Auto button text toggling for mobile */
.cvpi-rollback-group:not(.cvpi-rollback-group--active) .cvpi-btn-text-expanded {
	display: none;
}
.cvpi-rollback-group.cvpi-rollback-group--active .cvpi-btn-text-collapsed {
	display: none;
}

/* ── Desktop Overrides ── */
/* Make hidden buttons visible */
.cvpi-wrapper:not(.cvpi-mobile-layout) .cvpi-rollback-group .cvpi-hidden {
	visibility: visible !important;
}

/* Force expanded text on auto buttons */
.cvpi-wrapper:not(.cvpi-mobile-layout) .cvpi-btn-text-collapsed {
	display: none !important;
}
.cvpi-wrapper:not(.cvpi-mobile-layout) .cvpi-btn-text-expanded {
	display: inline !important;
}

/* ── User monitoring ─────────────────────────── */
[class*="cvpi-user--monitored--"] a
{
	background: #fff9c4;
	padding: 0 2px;
	border-radius: 2px;
}

/* ── Utility ─────────────────────────────────── */
.cvpi-hidden {
	visibility: hidden !important;
}
.mw-notification-area {
	z-index: 2000 !important;
}
.mw-ui-button:focus {
	outline: none !important;
	box-shadow: none !important;
}

/* ── CVPI run page border rotation ───────────── */
@property --cvpi-run-page-gradient-angle {
	syntax: '<angle>';
	initial-value: 135deg;
	inherits: false;
}

.cvpi-run-border {
	background-image: linear-gradient(var(--cvpi-run-page-gradient-angle), #86C, #6ED) !important;
	animation: rotate-gradient 14s linear infinite;
}

@keyframes rotate-gradient {
	0% {
		--cvpi-run-page-gradient-angle: 135deg;
	}
	100% {
		--cvpi-run-page-gradient-angle: 495deg;
	}
}
`;

// ── core/config.js ──────────────────────────
const CVPI_CHANGELOG = {
	"0.9.21": [
		"Admins can now block editors via the dashboard"
	],
	"0.9.20": [
		"Users who previously tripped certain edit filters will show up in your feed temporarily",
		"The reason you're being shown an edit is shown at the very bottom of the screen",
		"Fixed bug related to viewing many consecutive revisions by the same editor",
		"If you're an admin, it no longer automatically reports users to AIV"
	],
	"0.9.19": [
		"Bug fix: 'warn only' didn't add users to monitored list"
	],
	"0.9.18": [
		"All edit summaries shown up top now, instead of only the latest",
		"Edit summaries can be clicked to open their individual diffs",
		"Revision count moved from footer to edit summaries area",
		"View your device stats in the menu",
		"Bug fix: vandalism button didn't immediately update after manual report"
	],
	"0.9.17": [
		"Styling tweaks for prompts",
		"Styling change for disabled vandalism button",
		"Code organization changes",
		"Handle sandbox link in other warners' signature",
		"Add non-English communication warning on talk"
	],
	"0.9.16": [
		"Colorblind mode",
		"COI single-issue template",
		"Fix for old blocks not showing as expired",
		"Partial blocks no longer prevent reporting",
		"Predefined manual AIV report comments",
		"Better matching for prior uw talk templates",
		"Style changes"
	],
	"0.9.15": [
		"Fixed toolbar overflow on narrow screens",
		"Different warnings in dashboard per namespace",
		"Prevent autosubscribe to AIV when reporting"
	],
	"0.9.14": [
		"Bigger dashboard buttons",
		"Wider dashboard layout on mobile",
		"Diffs are now shown slightly faster",
		"Lots of internal stuff",
		"No longer includes vandal's username in edit summary of AIV if reported manually",
		"No longer includes vandal's name in edit summary of rollbacks if reported to AIV already",
		"Birds are dinosaurs"
	],
	"0.9.13": [
		"Redesign",
		"In dashboard on mobile, tap the warning type to unlock it, then tap it again to use auto level",
		"(Note: 'vandalism' button in toolbar is always unlocked)",
		"New icons",
		"Opening user talk page on 'other' rollbacks is optional now",
		"Better logic for showing talk page warnings",
		"Better state management for buttons",
		"Bug fix for keyboard commands in modal prompts",
		"Bug fix for 4im templates not being recognized",
		"UI elements are now updated instead of replaced (better performance)",
		"Texas is about twice the size of Germany"
	],
	"0.9.12": [
		"Reduced API calls by almost half",
		"Edits now appear more quickly",
		"Removed 'draft' namespace (and its talk) since it's mostly noise",
		"Improved tracking for edit/page staleness",
		"More accurate 'analyzing' number",
		"No more empty diffs from self-undoing editors (we just ignore those edits now)",
		"A herd of giraffes is called a tower"
	],
	"0.9.11": [
		"<b>MLRS</b>: Users are monitored if multiple other rollbackers roll them back",
		"Restore visual feedback for blanked talk page warnings",
		"Revert menu is now called dashboard",
		"Reason for change being shown is now given",
		"Better handling of situations where Cluebot causes a revision to go stale",
		"Fixed a bug where page history wouldn't scroll properly on mobile",
		"There are about 25 blimps"
	],
	"0.9.10": [
		"Status bar",
		"Clock in your preferred time zone",
		"Improved logic behind just-monitored users",
		"Better handling of connection loss",
		"Slightly bigger gap between revert types",
		"20% fewer calories"
	],
	"0.9.0": [
		"New design",
		"Mobile: <b>swipe left</b> for previous, <b>swipe right</b> for next",
		"Logic improvements",
		"Fancy changelog"
	]
};

const CVPI_CONFIG = Object.freeze({
	VERSION: "0.9.21",
	API: {
		RC_LOOKBACK_MS: 30_000,           // Time window for recent changes to fetch in each tick
		RV_LIMIT: 50,                     // Number of revisions to look back at for history/comparison
		MONITOR_PAST_ROLLBACKS_HOURS: 1,  // How far back to seed the monitored list from user's own contributions
		BATCH_SIZE: 50,                   // Maximum number of items per API batch (e.g., ORES, revision IDs)
		/**
		 * source: https://en.wikipedia.org/wiki/Template:Namespaces
		 * 
		 * ID  Namespace
		 * 0   Article
		 * 1   Article talk
		 * 2   User
		 * 3   User talk
		 * 4   Wikipedia
		 * 5   Wikipedia talk
		 * 6   File
		 * 7   File talk
		 * 8   MediaWiki
		 * 9   MediaWiki talk
		 * 10  Template
		 * 11  Template talk
		 * 12  Help
		 * 13  Help talk
		 * 14  Category
		 * 15  Category talk
		 * 100 Portal
		 * 101 Portal talk
		 * 118 Draft
		 * 119 Draft talk
		 * 126 MOS
		 * 127 MOS talk
		 * 828 Module
		 * 829 Module talk
		 */
		NAMESPACE_FILTER: "0|1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|100|101|126|127|828|829",
		URGENT_EDIT_FILTER_LOG_IDS: "3|380|384|1294|1344",
		LESS_URGENT_EDIT_FILTER_LOG_IDS: "231|614|644|716|981|1297",
	},
	QUEUE: {
		MAX_SIZE: 50,               // Maximum number of 'ready' edits to keep in the queue
		TOO_OLD_FOR_US_TO_CARE_ABOUT_MS: 300_000, // Age at which we stop tracking/fetching an edit
	},
	LOGIC: {
		REFRESH_RATE_MS: 2001,               // Frequency of the main tick loop
		MLRS_INTERVAL_MS: 20_000,            // Frequency of MLRS discovery check
		MLRS_LOOKBACK_MS: 300_000,           // Total lookback for MLRS discovery check
		EDIT_FILTER_LOG_INTERVAL_MS: 30_000, // Frequency of Edit Filter Log discovery check
		EDIT_FILTER_LOG_LOOKBACK_MS: 300_000, // Total lookback for Edit Filter Log discovery check
		DEFAULT_SCORE_THRESHOLD: 0.15,       // Initial ORES threshold
		PROMOTE_DEBOUNCE_MS: 300,            // Batching window for pre-fetching diffs
		STALE_WARNING_METHOD: 'previous_seven_days', // Method for determining stale warnings: 'current_month' | 'previous_seven_days'
	},
	UI: {
		HISTORY_LIMIT: 15,     // Number of entries to show in the page history view
		SCROLL_DELAY_MS: 100,  // Milliseconds to wait after render before scrolling to the change
		ORES_COLORS: { HIGH: 0.9, MEDIUM: 0.7, LOW: 0.5 }, // Thresholds for ORES color coding (High/Med/Low risk)
	},
});

const ALL_NAMESPACES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 100, 101, 126, 127, 828, 829];
const ARTICLE_SPACE  = [0];
const TALK_SPACE     = [1, 3, 5, 7, 9, 11, 13, 15, 101, 119, 127, 829];
const NOT_TALK_SPACE = [0, 2, 4, 6, 8, 10, 12, 14, 100, 118, 126, 828];
const USER_SPACE     = [2];
const META_SPACE     = [4, 5, 6];

const WARNING_TYPES = [
	{ type: 'vandalism',     label: 'Vandalism',                  singleIssue: false, noLevel4: false, exclude_namespaces: [] },
	{ type: 'unsourced',     label: 'Unsourced',                  singleIssue: false, noLevel4: false, exclude_namespaces: [...TALK_SPACE, ...USER_SPACE] },
	{ type: 'npa',           label: 'Personal attacks',           singleIssue: false, noLevel4: false, exclude_namespaces: [...NOT_TALK_SPACE] },
	{ type: 'disruptive',    label: 'Disruptive',                 singleIssue: false, noLevel4: true,  exclude_namespaces: [] },

	{ type: 'delete',        label: 'Blanking',                   singleIssue: false, noLevel4: false, exclude_namespaces: [...TALK_SPACE, ...USER_SPACE] },
	{ type: 'tpv',           label: "Editing others' comments",   singleIssue: false,  noLevel4: false, exclude_namespaces: [...NOT_TALK_SPACE] },
	{ type: 'advert',        label: 'Advert/promo',               singleIssue: false, noLevel4: false, exclude_namespaces: [] },
	{ type: 'spam',          label: 'Spam links',                 singleIssue: false, noLevel4: false, exclude_namespaces: [] },

	{ type: 'test',          label: 'Test edits',                 singleIssue: false, noLevel4: true,  exclude_namespaces: [...USER_SPACE] },
	{ type: 'talkinarticle', label: 'Commentary',                 singleIssue: false, noLevel4: true,  exclude_namespaces: [...TALK_SPACE, ...USER_SPACE] },
	{ type: 'npov',          label: 'NPOV violation',             singleIssue: false, noLevel4: false, exclude_namespaces: [...TALK_SPACE, ...USER_SPACE] },

	{ type: 'error',         label: 'False facts/stats',          singleIssue: false, noLevel4: false, exclude_namespaces: [...TALK_SPACE, ...USER_SPACE] },
	{ type: 'ai',            label: 'Raw AI paste',               singleIssue: false, noLevel4: false, exclude_namespaces: [...TALK_SPACE, ...USER_SPACE] },
	{ type: 'mos',           label: 'MOS violation',              singleIssue: false, noLevel4: false, exclude_namespaces: [...TALK_SPACE, ...USER_SPACE] },

	{ type: 'chat',          label: 'Talk page not forum',        singleIssue: false, noLevel4: false, exclude_namespaces: [...NOT_TALK_SPACE] },
	{ type: 'aitalk',        label: 'AI talk comments',           singleIssue: false, noLevel4: false, exclude_namespaces: [...NOT_TALK_SPACE] },

	{ type: 'harass',        label: 'Harassment',                 singleIssue: false, noLevel4: false, exclude_namespaces: [...NOT_TALK_SPACE] },

	{ type: 'english',       label: 'Non-English communication',  singleIssue: true, exclude_namespaces: [...NOT_TALK_SPACE] },

	{ type: 'ewsoft',        label: 'Edit warring (softer)',      singleIssue: true, exclude_namespaces: [...TALK_SPACE] },
	{ type: 'badlistentry',  label: 'Non-notable in list',        singleIssue: true, exclude_namespaces: [...TALK_SPACE] },
	{ type: 'notenglishedit',label: 'Non-English content',        singleIssue: true, exclude_namespaces: [...TALK_SPACE] },

	{ type: 'editsummary',   label: 'No edit summary',            singleIssue: true, exclude_namespaces: [...TALK_SPACE] },
	{ type: 'hijacking',     label: 'Article hijacking',          singleIssue: true, exclude_namespaces: [...TALK_SPACE] },
	{ type: 'engvar',        label: 'English variety disruption', singleIssue: true, exclude_namespaces: [...TALK_SPACE] },

	{ type: 'elinbody',      label: 'External link in body',      singleIssue: true, exclude_namespaces: [...TALK_SPACE] },
	{ type: 'coi',           label: 'Conflict of Interest',       singleIssue: true, exclude_namespaces: [...TALK_SPACE] },
];

const REVERT_DESCRIPTIONS = {
	vandalism:     'non-constructive edits',
	unsourced:     'unsourced content added',
	disruptive:    'disruptive content',
	delete:        'unexplained deletion',
	advert:        'potentially [[WP:PROMO|promotional]] edits',
	spam:          'external links added',
	test:          'editing test',
	talkinarticle: 'commentary in article',
	npov:          'non-[[WP:NPOV|neutral]] content',
	error:         'erroneous facts or statistics added',
	other:         'edits',
	other_agf:     '[[WP:AGF|good-faith]] edits',
};

const REASONS_FOR_MANUAL_AIV_REPORT = [
	'LTA'
];

const BLOCK_REASONS = [
	{
		id: 'nothere',
		buttonLabel: 'INDEF NOTHERE',
		summary: 'Clearly [[WP:NOTHERE|not here to build an encyclopedia]]',
		expiry: 'indefinite',
		template: 'Uw-nothereblock'
	},
	{
		id: 'voa',
		buttonLabel: 'INDEF VOA',
		summary: '[[Wikipedia:DISRUPTONLY|Vandalism-only account]]',
		expiry: 'indefinite',
		template: 'Uw-voablock'
	},
	{
		id: 'tempevadeblock',
		buttonLabel: 'INDEF BLOCK EVASION (TA)',
		summary: '[[Wikipedia:Blocking policy#Evasion of blocks|Block evasion]]',
		expiry: 'indefinite',
		template: 'Uw-tempevadeblock'
	},

];

// ── core/utils.js ──────────────────────────
/**
 * Human-readable time since a date.
 * @param {Date} date
 * @returns {string}
 */
function timeSince(date) {
	const s = Math.floor((Date.now() - date) / 1000);
	const intervals = [
		[31536000, 'year'], [2592000, 'month'], [86400, 'day'], [3600, 'hour'], [60, 'minute'], [1, 'second']
	];
	for (const [sec, label] of intervals) {
		const count = Math.floor(s / sec);
		if (count >= 1) return `${count} ${label}${count === 1 ? '' : 's'} ago`;
	}
	return 'just now';
}

/**
 * Builds a wiki URL for various link types.
 * @param {'page'|'contribs'|'talk'|'twinkletalk'|'history'|'diff'|'aiv'} type
 * @param {string}       name     Username, page title, or new revid (for diffs)
 * @param {number}       [oldid]  Old revid (diffs only)
 * @param {string}       [title]  Page title (diffs only)
 */
function wikiUrl(type, name, oldid, title) {
	const enc = encodeURIComponent;
	switch (type) {
		case 'page':    return `/wiki/${enc(name)}`;
		case 'contribs':return `/wiki/Special:Contributions/${enc(name)}`;
		case 'block':   return `/wiki/Special:Block/${enc(name)}`;
		case 'talk':    return `/wiki/User_talk:${enc(name)}`;
		case 'twinkletalk': return `/wiki/User_talk:${enc(name)}?vanarticle=${enc(title ?? '')}`;
		case 'history': return `/w/index.php?title=${enc(name)}&action=history`;
		case 'diff':
			if (oldid === 0) return `/w/index.php?oldid=${name}`;
			return `/w/index.php?title=${enc(title ?? '')}&diff=${name}&oldid=${oldid}`;
		case 'aiv':     return '/wiki/Wikipedia:Administrator_intervention_against_vandalism';
		default:        return '';
	}
}

/**
 * Extracts a target username from a rollback-style edit comment.
 * 
 * @param {string} comment - The edit summary.
 * @returns {string|null} The username or null if not found.
 */
function extractUserFromRollbackComment(comment) {
	if (!comment) return null;

	// Regex to capture the username from [[Special:Contribs/Username|...]] 
	// or [[Special:Contributions/Username|...]]
	const match = /\[\[Special:Contrib(?:utions|s)\/([^|\]]+)/i.exec(comment);
	
	if (match && match[1]) {
		// Replace underscores with spaces for consistency with standard usernames
		return match[1].trim().replace(/_/g, ' ');
	}
	
	return null;
}

/**
 * Standardized logging for application events.
 * 
 * @param {string} prefix - The class or module prefix.
 * @param {...any} args - Values to log.
 */
function log(prefix, ...args) {
	if (window.CVPI_DEBUG) {
		const tag = prefix ? `[CVPI:${prefix}]` : '[CVPI]';
		console.log(tag, ...args);
	}
}

// ── services/SettingsService.js ──────────────────────────
class SettingsService {
	constructor() {
		this.STORAGE_KEY = 'CVPI_storage';
		this.defaults = {
			version: CVPI_CONFIG.VERSION,
			statistics: {
				recentChanges: { seen: 0, reverted: 0 },
				reports: { AIV: 0 },
				warnings: {}
			},
			preferences: {
				showLabels: true,
				openTwinkleOnOther: true,
				colorblindMode: false
			}
		};
	}

	load() {
		let stored = null;
		try {
			stored = mw.storage.getObject(this.STORAGE_KEY);
		} catch (e) {
			console.error('[CVPI] Could not parse settings from mw.storage', e);
		}
		
		// 1. Start with defaults
		const merged = JSON.parse(JSON.stringify(this.defaults));

		// 2. Overlay stored data if it exists
		if (stored) {
			merged.version = stored.version;

			if (stored.statistics) {
				merged.statistics = {
					recentChanges: { ...merged.statistics.recentChanges, ...stored.statistics.recentChanges },
					reports: { ...merged.statistics.reports, ...stored.statistics.reports },
					warnings: { ...stored.statistics.warnings }
				};
			}

			if (stored.preferences) {
				merged.preferences = { ...merged.preferences, ...stored.preferences };
			}
		}

		return merged;
	}

	save() {
		try {
			const current = this.load();
			current.version = this.defaults.version;
			mw.storage.setObject(this.STORAGE_KEY, current);
		} catch (e) {
			console.error('[CVPI] Could not save settings to mw.storage', e);
		}
	}

	/**
	 * Updates a user preference.
	 */
	setPreference(key, value) {
		const settings = this.load();
		settings.preferences[key] = value;
		mw.storage.setObject(this.STORAGE_KEY, settings);
	}

	/**
	 * Increments a statistical counter.
	 * 
	 * @param {string} category - e.g., 'recentChanges', 'reports', 'warnings'
	 * @param {string} key      - e.g., 'seen', 'AIV', 'uw-vandalism'
	 * @param {string|number} [subkey] - e.g., '1', '2' (for warning levels)
	 */
	incrementStat(category, key, subkey = null) {
		const settings = this.load();
		if (!settings.statistics[category]) {
			settings.statistics[category] = {};
		}

		if (subkey !== null) {
			if (!settings.statistics[category][key]) {
				settings.statistics[category][key] = {};
			}
			settings.statistics[category][key][subkey] = (settings.statistics[category][key][subkey] || 0) + 1;
		} else {
			settings.statistics[category][key] = (settings.statistics[category][key] || 0) + 1;
		}

		try {
			mw.storage.setObject(this.STORAGE_KEY, settings);
		} catch (e) {
			console.error('[CVPI] Could not save updated stats', e);
		}
	}
}

// ── state/UserTracker.js ──────────────────────────
class UserTracker {
	constructor() {
		this.monitored = new Map();
		this.unmonitored = new Set(); // Users who have been explicitly unmonitored
		this.atAIV = new Set();
		this.blocked = new Set();
		this.sessionWarnLevels = new Map();
		this.userHasWarnings = new Map(); // user -> boolean
		this.userWarnings = new Map(); // user -> Array<Warning>
		this.MLRSRollbackHistory = new Map(); // targetUser -> Set of unique rollbackers

		this.toolUser = {
			name: '',
			rights: new Set(),
			isAdmin: false
		};
	}

	/**
	 * Sets the identity and rights of the user operating the tool.
	 * 
	 * @param {string} name 
	 * @param {string[]} rights 
	 */
	setToolUser(name, rights) {
		this.toolUser.name = name;
		this.toolUser.rights = new Set(rights || []);
		this.toolUser.isAdmin = this.toolUser.rights.has('block');
	}

	/**
	 * Checks if the tool user has administrator rights.
	 * 
	 * @returns {boolean}
	 */
	isToolUserAdmin() {
		return this.toolUser.isAdmin;
	}

	// ─── Monitored Status ──────────────────────────────────────────────────
	isMonitored(user) { 
		return this.monitored.has(user); 
	}

	/**
	 * Checks if a user should be displayed as monitored in the UI.
	 * 
	 * @param {string} user 
	 * @returns {boolean}
	 */
	shouldShowAsMonitored(user) {
		const record = this.monitored.get(user);
		if (!record) return false;
		const reason = record.reason;
		if (reason && reason.startsWith('editfilter')) {
			const filterId = this._getFilterIdFromReason(reason);
			/**
			 * Let's make it so these users display as if they
			 * are not monitored, by setting this to return false.
			 */
			//return this._isFilterUrgent(filterId);
			return false;
		}
		return true;
	}

	_getFilterIdFromReason(reason) {
		if (!reason || !reason.startsWith('editfilter')) return null;
		const parts = reason.split('-');
		return parts.at(-1) || null;
	}

	_isFilterUrgent(filterId) {
		if (!filterId) return false;
		const urgentFilters = CVPI_CONFIG.API.URGENT_EDIT_FILTER_LOG_IDS ? CVPI_CONFIG.API.URGENT_EDIT_FILTER_LOG_IDS.split('|') : [];
		return urgentFilters.includes(filterId);
	}
	
	getMonitorRecord(user) {
		return this.monitored.get(user);
	}

	_getMonitorExpirationDuration(reason) {
		if (reason === 'MLRS') {
			return 1000 * 60 * 30; // 30 minutes
		}
		if (reason?.startsWith('editfilter')) {
			const filterId = this._getFilterIdFromReason(reason);
			if (this._isFilterUrgent(filterId)) {
				return 1000 * 60 * 10; // 10 minutes
			}
			return 1000 * 60 * 5; // 5 minutes
		}
		return Infinity;
	}

	monitor(user, reason = 'manual') { 
		// If the user was manually unmonitored, we don't let automated sources (MLRS, edit filters, alerts) pick them back up.
		// Only allow manual overrides (clicking 'Monitor' in the UI).
		if (reason !== 'manual' && reason !== 'Manual' && this.unmonitored.has(user)) {
			return false;
		}

		const now = Date.now();
		const newDuration = this._getMonitorExpirationDuration(reason);
		const newExpiry = newDuration === Infinity ? Infinity : now + newDuration;

		const existing = this.monitored.get(user);
		if (existing) {
			const existingDuration = this._getMonitorExpirationDuration(existing.reason);
			const existingExpiry = existingDuration === Infinity ? Infinity : existing.timestamp + existingDuration;

			// Only overwrite if the new expiry is further in the future
			if (newExpiry < existingExpiry) {
				return false;
			}
		}

		this.monitored.set(user, { reason, timestamp: now }); 

		// If we're monitoring them (manually, via revert, etc.), they're no longer "unmonitored"
		this.unmonitored.delete(user);
		return true;
	}

	expireMonitoredUsers() {
		const now = Date.now();
		for (const [user, record] of this.monitored.entries()) {
			const duration = this._getMonitorExpirationDuration(record.reason);
			if (duration !== Infinity && (now - record.timestamp >= duration)) {
				this.monitored.delete(user);
			}
		}
	}

	/**
	 * Records a rollback performed by a third party.
	 * Returns true if this rollback caused the user to cross the monitoring threshold.
	 * 
	 * @param {string} targetUser
	 * @param {string} rollbacker
	 * @returns {boolean}
	 */
	recordMLRSRollback(targetUser, rollbacker) {
		if (!this.MLRSRollbackHistory.has(targetUser)) {
			this.MLRSRollbackHistory.set(targetUser, new Set());
		}
		
		const rollbackers = this.MLRSRollbackHistory.get(targetUser);
		const sizeBefore = rollbackers.size;
		rollbackers.add(rollbacker);
		
		// Threshold: 2+ unique rollbackers
		return sizeBefore < 2 && rollbackers.size >= 2;
	}
	
	unmonitor(user) { 
		this.monitored.delete(user); 
		this.unmonitored.add(user); // Mark as explicitly unmonitored to block MLRS
	}

	// ─── AIV Status ────────────────────────────────────────────────────────
	isAtAIV(user) { 
		return this.atAIV.has(user); 
	}
	
	markAtAIV(user) { 
		this.atAIV.add(user); 
	}

	// ─── Blocked Status ────────────────────────────────────────────────────
	isBlocked(user) { 
		return this.blocked.has(user); 
	}
	
	markBlocked(user) { 
		this.blocked.add(user); 
	}

	// ─── Session Warning Levels ────────────────────────────────────────────
	getHighestLevel(user) { 
		return this.sessionWarnLevels.get(user) ?? 0; 
	}
	
	updateLevel(user, level) {
		const current = this.getHighestLevel(user);
		if (level > current) {
			this.sessionWarnLevels.set(user, level);
		}
	}

	hasWarnings(user) {
		return (this.userWarnings.get(user)?.length ?? 0) > 0;
	}

	getWarnings(user) {
		return this.userWarnings.get(user) ?? [];
	}

	setWarnings(user, warnings) {
		this.userWarnings.set(user, warnings);
	}

	// ─── Transient View ────────────────────────────────────────────────────
	
	/**
	 * Returns a transient data transfer object combining all tracked state for a user.
	 * 
	 * @param {string} username
	 * @returns {object}
	 */
	getUser(username) {
		const warnings = this.getWarnings(username);
		const monitorRecord = this.getMonitorRecord(username);

		return {
			name: username,
			isMonitored: this.shouldShowAsMonitored(username),
			isBlocked: this.isBlocked(username),
			isAtAIV: this.isAtAIV(username),
			warnLevel: this.getHighestLevel(username),
			monitorReason: monitorRecord?.reason,
			warnings: warnings,
			hasWarnings: warnings.length > 0,
			canWarn: !this.isBlocked(username) && !this.isAtAIV(username)
		};
	}
}

// ── state/ChangeQueue.js ──────────────────────────
/** 
 * Possible states for a change in the queue.
 * @typedef {'pending'|'pendingPromotion'|'ready'|'monitored'|'viewed'|'ignored'} ChangeStatus 
 */

/**
 * Represents a single Wikipedia edit (revision) and its associated metadata.
 * 
 * @typedef {object} Change
 * @property {number}       revid           - The unique revision ID.
 * @property {string}       title           - The full page title.
 * @property {string}       user            - The username of the editor.
 * @property {string}       timestamp       - ISO timestamp of the edit.
 * @property {number}       timestampMs     - Numeric timestamp for easier comparison.
 * @property {number}       old_revid       - The revision ID of the previous edit.
 * @property {number}       [diff_from_revid] - The revision ID used as the 'from' in the diff.
 * @property {number}       [rev_count]     - How many consecutive edits the user made.
 * @property {object[]}     [pageHistory]   - Array of recent revision objects for the page.
 * @property {number|null}  ores            - The ORES goodfaith.false score (0–1).
 * @property {boolean}      oresRequested   - Whether an API call for the score is in flight.
 * @property {string|null}  reason          - Why this change was shown to the user.
 * @property {number}       firstSeen       - Timestamp (ms) when CVPI first ingested this change.
 * @property {ChangeStatus} status          - Current state in the lifecycle.
 * @property {boolean}      stale           - Whether this is no longer the latest revision of the page.
 * @property {Promise<string|null>} [diffPromise] - Promise resolving to the diff HTML.
 * @property {number}       [diffsize]      - The numeric size change (bytes).
 * @property {string[]}     tags            - MediaWiki tags associated with the edit.
 * @property {Array<{text: string, revid: number, parentid: number}>} comments - The edit summaries (plural for streaks).
 */

class ChangeQueue {
	/**
	 * @param {import('./UserTracker').UserTracker} userTracker - Reference to the user tracker.
	 * @param {() => number}  getThreshold   - Function that returns the current ORES threshold.
	 * @param {() => void}    onPromotion    - Callback when an item needs pre-fetching.
	 * @param {() => void}    onReady        - Callback when an item becomes ready for review.
	 */
	constructor(userTracker, getThreshold, onPromotion, onReady) {
		/** 
		 * Internal storage for all changes, keyed by revid.
		 * @type {Map<number, Change>} 
		 * @private
		 */
		this._map           = new Map();
		/**
		 * Tracks the latest known revision ID for any page we have encountered.
		 * Keyed by page title. Values are { revid, user }.
		 * @type {Map<string, {revid: number, user: string}>}
		 */
		this.pageState      = new Map();
		this.userTracker    = userTracker;
		this._getThreshold  = getThreshold;
		this._onPromotion   = onPromotion;
		this._onReady       = onReady;
	}

	/**
	 * Standardized logging for queue-level events.
	 * @param {...any} args - Values to log.
	 * @private
	 */
	_log(...args) {
		log('Queue', ...args);
	}

	// ─── Reads ─────────────────────────────────────────────────────────────

	/**
	 * Retrieves a change object by its revision ID.
	 * @param {number} revid 
	 * @returns {Change|undefined}
	 */
	get(revid) { 
		return this._map.get(revid); 
	}

	/**
	 * Checks if a revision ID is already present in the queue.
	 * @param {number} revid 
	 * @returns {boolean}
	 */
	has(revid) { 
		return this._map.has(revid); 
	}

	/**
	 * Determines if a change's local page history is out of sync with the latest known page state.
	 *
	 * @param {Change} change
	 * @returns {boolean}
	 */
	isHistoryStale(change) {
		const latest = this.pageState.get(change.title);
		if (!latest) return false;

		// If we don't have history yet, it's definitely out of date
		if (!change.pageHistory || change.pageHistory.length === 0) return true;

		// If the latest revision we know about is not the one at the top of our history, we need a refresh
		return latest.revid > change.pageHistory[0].revid;
	}

	/** 
	 * Returns all changes matching the given status(es). 
	 * @param {...ChangeStatus} statuses
	 * @returns {Change[]}
	 */
	byStatus(...statuses) {
		return [...this._map.values()].filter(c => statuses.includes(c.status));
	}

	/** 
	 * Identifies the highest-priority item available for display.
	 * Rules: Monitored users first, then highest ORES score.
	 * 
	 * @returns {Change|null}
	 */
	nextReady() {
		// 1. Priority: monitored users
		const monitored = this.byStatus('monitored');
		if (monitored.length) {
			return monitored[0];
		}

		// 2. Secondary: highest ORES scores among ready items
		return this.byStatus('ready').sort((a, b) => (b.ores ?? 0) - (a.ores ?? 0))[0] ?? null;
	}

	/**
	 * Calculates summary statistics for the current queue state.
	 * @returns {object}
	 */
	stats() {
		const all = [...this._map.values()];

		return {
			ready:               all.filter(c => c.status === 'ready' || (c.status === 'monitored' && !this.userTracker.shouldShowAsMonitored(c.user))).length,
			monitored:           all.filter(c => c.status === 'monitored' && this.userTracker.shouldShowAsMonitored(c.user)).length,
			pending:             all.filter(c => c.status === 'pending').length,
			pendingPromotion:    all.filter(c => c.status === 'pendingPromotion').length,
			ignored:             all.filter(c => c.status === 'ignored').length,
		};
	}

	/**
	 * Synchronizes the internal page state with recent changes and checks for staleness.
	 * Identifies active titles, fetches missing revisions if needed, and evaluates staleness for all queue items.
	 *
	 * @param {import('./ApiService').ApiService} api
	 * @param {object[]} rcs - Recent changes array
	 * @param {Change|null} currentChange - The change currently being viewed
	 * @returns {Promise<{needsRerender: boolean, streakExtended: boolean, newRevid?: number}>}
	 */
	async syncPageState(api, rcs, currentChange) {
		const titlesSet = new Set(
			[...this._map.values()]
				.filter(c => c.status !== 'ignored' && !c.stale && c.status !== 'viewed')
				.map(c => c.title)
		);
		if (currentChange) {
			titlesSet.add(currentChange.title);
		}

		if (!titlesSet.size || !rcs) return { needsRerender: false, streakExtended: false };

		// Immediately feed the RC list into the page state map
		this.updatePageState(rcs);

		const nowMs = Date.now();
		const expirationMs = CVPI_CONFIG.QUEUE.TOO_OLD_FOR_US_TO_CARE_ABOUT_MS;
		const titlesToFetch = [];

		// Identify which active titles need an explicit manual fetch
		for (const title of titlesSet) {
			const changesForTitle = [...this._map.values()].filter(
				c => c.title === title && !c.stale && c.status !== 'ignored'
			);

			if (!changesForTitle.length) continue;

			const youngest = Math.max(...changesForTitle.map(c => c.timestampMs));
			const editAgeMs = nowMs - youngest;

			// 1. Expiration check: ignore everything over the limit
			if (editAgeMs > expirationMs) {
				changesForTitle.forEach(c => { 
					if (c.status !== 'viewed') c.status = 'ignored';
					c.stale = true;
				});
				continue;
			}

			// 2. Verification check using pageState
			const latestKnown = this.pageState.get(title);
			
			// If we already know our edit is stale, skip fetching
			const youngestRevid = Math.max(...changesForTitle.map(c => c.revid));
			if (latestKnown && latestKnown.revid > youngestRevid) {
				continue; 
			}

			// If we don't have it in the map (or it's our edit), and it's old enough to have fallen off the RC list, fetch it
			const inRcs = rcs.some(rc => rc.title === title);
			if (!inRcs) {
				if (editAgeMs >= (CVPI_CONFIG.API.RC_LOOKBACK_MS - 2000)) {
					titlesToFetch.push(title);
				} else {
					// It's young enough that it should be in the RC list. Since it isn't,
					// it must have been overwritten. We mark it stale immediately to save a fetch.
					changesForTitle.forEach(c => { 
						if (c.status !== 'viewed') c.status = 'ignored';
						c.stale = true;
					});
				}
			}
		}

		if (titlesToFetch.length > 0) {
			this._log(`Fetching latest revision IDs for ${titlesToFetch.length} titles not in RC list: ${titlesToFetch.join(', ')}`);
			const fetchedData = await api.getLatestRevisionIds(titlesToFetch);
			
			// Convert Map to array of objects for updatePageState
			const fetchedArray = [];
			for (const [title, data] of fetchedData.entries()) {
				fetchedArray.push({ title, revid: data.revid, user: data.user });
			}
			this.updatePageState(fetchedArray);
		}

		this.evaluateStaleness();

		let result = { needsRerender: false, streakExtended: false };

		// Check if we need to refresh history or extend the streak based on the newest known state
		if (currentChange && this.isHistoryStale(currentChange)) {
			const latest = this.pageState.get(currentChange.title);
			
			if (currentChange.stale && latest && latest.user === currentChange.user) {
				this._log('Streak extension detected for:', currentChange.user, 'on', currentChange.title);
				
				// Update the revid in the queue and local object
				const oldRevid = currentChange.revid;
				this.updateRevid(oldRevid, latest.revid);
				
				result = { needsRerender: true, streakExtended: true, newRevid: latest.revid };
			} else {
				// Normal stale (different user) -> just refresh history
				this._log('Current change became stale, refreshing history:', currentChange.title);
				result = { needsRerender: true, streakExtended: false };
			}
		}

		return result;
	}

	// ─── Writes ────────────────────────────────────────────────────────────

	/**
	 * Adds raw API recent-change objects that we haven't seen before.
	 * New items start in the 'pending' status.
	 * 
	 * @param {object[]} rawChanges - Array of recentchange objects from MediaWiki API.
	 */
	ingest(rawChanges) {
		for (const rc of rawChanges) {
			const score = rc.oresscores?.goodfaith?.false ?? null;

			if (this._map.has(rc.revid)) {
				const existing = this._map.get(rc.revid);
				if (existing.status === 'pending' && score !== null) {
					this.applyOresScore(rc.revid, score);
				}
				continue;
			}

			const { comment, ...rcRest } = rc;
			const newChange = {
				...rcRest,
				comments:      [{ text: comment || '', revid: rc.revid, parentid: rc.old_revid }],
				timestampMs:   new Date(rc.timestamp).getTime(),
				firstSeen:     Date.now(),
				status:        'pending',
				stale:         false,
				ores:          score,
				oresRequested: false,
				reason:        null,
				diffPromise:   undefined,
				diffsize:      undefined,
			};
			
			this._map.set(rc.revid, newChange);
			this._evaluatePending(newChange);
		}
	}

	/**
	 * Mark a change as viewed.
	 * @param {number} revid 
	 */
	markViewed(revid) {
		const c = this._map.get(revid);
		if (c) {
			this._log('Marking viewed:', revid);
			c.status = 'viewed';
		}
	}

	/**
	 * Applies pre-fetched history data to a change object.
	 * 
	 * @param {number} revid 
	 * @param {object} historyData - Result from api.getPageHistoryData()
	 */
	applyHistoryData(revid, historyData) {
		const c = this._map.get(revid);
		if (!c || !historyData) return;

		c.pageHistory     = historyData.history;

		// Only update streak-related metadata if this revision is actually the 
		// current head of the page history we just fetched.
		if (historyData.history?.[0]?.revid === revid) {
			c.rev_count       = historyData.revCount;
			c.diff_from_revid = historyData.priorRevid ?? c.old_revid;

			// Extract comments for the streak
			if (historyData.revCount && historyData.history?.length > 0) {
				c.comments = historyData.history
					.slice(0, historyData.revCount)
					.map(rev => ({
						text: rev.comment?.replace(/\/\*.*?\*\//g,"").trim() || '', // let's omit section names?
						revid: rev.revid,
						parentid: rev.parentid
					}))
					.reverse(); // Oldest to newest for edit summaries
			}
		}
	}

	/**
	 * Apply an ORES score to a change, triggering immediate evaluation.
	 * 
	 * @param {number}      revid
	 * @param {number|null} score - The ORES score (0-1).
	 */
	applyOresScore(revid, score) {
		const c = this._map.get(revid);
		if (!c) return;

		c.ores = score;

		// Only trigger evaluation if it's currently pending.
		// (Monitored users might have already been promoted).
		if (c.status === 'pending' && score !== null) {
			this._evaluatePending(c);
		}
	}

	/**
	 * Promotes a change from 'pendingPromotion' to its final 'ready' or 'monitored' state.
	 * This happens after all supporting data (diff, history) has been pre-fetched.
	 * 
	 * @param {number} revid
	 * @param {object} priorRevData - Data about the previous revision(s).
	 * @param {Promise<string|null>} diffPromise
	 */
	promoteChange(revid, priorRevData, diffPromise) {
		const c = this._map.get(revid);
		
		// Promotion only happens from the intermediate 'pendingPromotion' state.
		if (!c || c.status !== 'pendingPromotion') {
			return;
		}

		// Inject the pre-fetched data
		this.applyHistoryData(revid, priorRevData);
		c.diffPromise = diffPromise;

		// If the number of revisions we're looking at exceeds RV_LIMIT, then we
		// don't have the full picture and shouldn't act as if we do.
		if (c.rev_count >= CVPI_CONFIG.API.RV_LIMIT) {
			c.status = 'ignored';
			this._log(`Ignoring change ${revid} since ${c.rev_count} is >= ${CVPI_CONFIG.API.RV_LIMIT}`);
		} else {
			// Final status depends on whether the user is being monitored.
			c.status = this.userTracker.isMonitored(c.user) ? 'monitored' : 'ready';
			
			this._log('Change promoted to', c.status, ':', revid);
			this._onReady?.(c);
		}
	}

	/**
	 * Updates the revision ID of a change.
	 * Used for streak extensions where multiple consecutive edits are combined.
	 * 
	 * @param {number} oldRevid 
	 * @param {number} newRevid 
	 */
	updateRevid(oldRevid, newRevid) {
		const c = this._map.get(oldRevid);
		if (!c) return;

		this._map.delete(oldRevid);
		c.revid = newRevid;
		this._map.set(newRevid, c);
		this._log('Revid updated for streak extension:', oldRevid, '->', newRevid);
	}

	/**
	 * Updates the internal pageState map with the latest known revisions.
	 * Allows bypassing manual fetches if the system already knows the page has been overwritten.
	 * 
	 * @param {Iterable<{title: string, revid: number, user: string}>} revisions - List of recent changes or explicitly fetched revisions.
	 */
	updatePageState(revisions) {
		for (const rev of revisions) {
			const existing = this.pageState.get(rev.title);
			if (!existing || existing.revid < rev.revid) {
				this.pageState.set(rev.title, { revid: rev.revid, user: rev.user });
			}
		}
	}

	/**
	 * Evaluates all items in the queue against the pageState map to determine if they are stale.
	 * Items that are no longer the latest revision are marked stale.
	 * Unviewed items are dropped (ignored), while viewed history items remain but are marked stale.
	 */
	evaluateStaleness() {
		for (const c of this._map.values()) {
			const latest = this.pageState.get(c.title);
			
			// If we know about a newer revision for this page...
			if (latest && latest.revid > c.revid) {
				const wasStale = c.stale;
				c.stale = true;

				// If it hasn't been viewed yet, drop it from the active queue entirely.
				// (We don't drop 'viewed' items so they stay in history, just marked as stale).
				if (c.status === 'ready' || c.status === 'monitored' || c.status === 'pending' || c.status === 'pendingPromotion') {
					if (!wasStale) {
						this._log('Filtering stale change (overwritten):', c.revid, c.title);
					}
					c.status = 'ignored';
				}
			} else {
				// If we don't have a newer revid, ensure it's not marked stale.
				// (This handles the case where it recovers, e.g. after a streak extension).
				c.stale = false;
			}
		}
	}

	/**
	 * Updates the status of all 'ready', 'pending', or 'pendingPromotion'
	 * changes for a specific user to 'monitored'. Called when a user is
	 * added to the watch list.
	 * 
	 * @param {string} username 
	 */
	promoteUserToMonitored(username) {
		const record = this.userTracker.getMonitorRecord(username);
		const reason = record?.reason || 'Monitored';

		for (const c of this._map.values()) {
			if (c.user === username && (c.status === 'ready' || c.status === 'pending' || c.status === 'pendingPromotion')) {
				this._log('Promoting user to monitored:', username, 'revid:', c.revid, 'from status:', c.status);
				c.status = 'monitored';
				c.reason = reason;
			}
		}
	}

	/** Trim ready queue to MAX_SIZE by discarding lowest-scoring items. */
	trim() {
		const ready = this.byStatus('ready').sort((a, b) => (b.ores ?? 0) - (a.ores ?? 0));
		
		if (ready.length > CVPI_CONFIG.QUEUE.MAX_SIZE) {
			this._log('Trimming ready queue. Excess items being ignored.');
			
			ready.slice(CVPI_CONFIG.QUEUE.MAX_SIZE).forEach(c => { 
				c.status = 'ignored'; 
			});
		}
	}

	/**
	 * discards 'pending' changes that have timed out waiting for an ORES score.
	 */
	expireStale() {
		const now = Date.now();
		const timeoutMs = CVPI_CONFIG.API.RC_LOOKBACK_MS + 10_000;
		
		for (const c of this.byStatus('pending')) {
			if (now - c.firstSeen > timeoutMs) {
				this._log('Expiring stale pending item (timeout):', c.revid);
				c.status = 'ignored';
			}
		}
	}

	/**
	 * Iterates through all 'pending' items and attempts to evaluate their promotion eligibility.
	 */
	evaluatePending() {
		for (const c of this.byStatus('pending')) {
			this._evaluatePending(c);
		}
	}

	// ─── Private ───────────────────────────────────────────────────────────

	/** 
	 * Logic to decide if a 'pending' change should be promoted or ignored.
	 * 
	 * @param {Change} change - The change object to evaluate.
	 * @private
	 */
	_evaluatePending(change) {
		if (change.status !== 'pending') return;

		const threshold = this._getThreshold();
		const monitorRecord = this.userTracker.getMonitorRecord(change.user);
		
		const promote = (reason) => {
			change.reason = reason;
			change.status = 'pendingPromotion';
			this._onPromotion?.();
		};

		// Promotion Path A: User is on the monitor list (bypass ORES).
		if (monitorRecord) {
			this._log('Evaluating pending: Monitored user bypass ORES.', change.revid);
			promote(monitorRecord.reason || 'Monitored');
			return;
		} 
		
		// Promotion Path B: Threshold is set to zero (show everything).
		if (threshold === 0) {
			promote('ORES');
			return;
		} 
		
		// Promotion Path C: Score is available and passes the threshold.
		if (change.ores !== null) {
			if (change.ores >= threshold) {
				promote('ORES');
			} else {
				change.status = 'ignored';
			}
		}
	}

	/** 
	 * Returns all changes currently awaiting their pre-fetched data.
	 * @returns {Change[]}
	 */
	pendingPromotion() {
		return [...this._map.values()].filter(c => c.status === 'pendingPromotion');
	}
}

// ── services/ApiService.js ──────────────────────────
class ApiService {
	/**
	 * @param {mw.Api} mwApi
	 */
	constructor(mwApi) {
		this._api = mwApi;
		this.online = false;
		this._onHttpError = null;
		this.callCount = 0;
	}

	/**
	 * Returns the total number of API calls made.
	 * @returns {number}
	 */
	getCallCount() {
		return this.callCount;
	}

	/**
	 * Registers a callback for HTTP errors.
	 * @param {function} cb
	 */
	onHttpError(cb) {
		this._onHttpError = cb;
	}

	/**
	 * Standardized logging for API-level events.
	 * @param {...any} args
	 * @private
	 */
	_log(...args) {
		log('Api', ...args);
	}

	// ─── Private helpers ───────────────────────────────────────────────────────

	/**
	 * Splits an array into smaller arrays of a specified size.
	 * Used for batching API requests to comply with limits.
	 * 
	 * @param {Array} array
	 * @param {number} size
	 * @returns {Array<Array>} An array of arrays (chunks).
	 * @private
	 */
	_chunk(array, size) {
		const out = [];
		for (let i = 0; i < array.length; i += size) {
			out.push(array.slice(i, i + size));
		}
		return out;
	}

	/**
	 * Wrapper for mw.Api().get() with standard error handling.
	 * @param {object} params
	 * @returns {Promise<object>}
	 * @private
	 */
	async _get(params) {
		try {
			this.callCount++;
			const res = await this._api.get(params);
			this.online = true;
			return res;
		} catch (e) {
			if (e === 'http') {
				this.online = false;
				this._onHttpError?.();
			}
			throw e;
		}
	}

	/**
	 * Wrapper for mw.Api().postWithEditToken() for write actions.
	 * @param {object} params
	 * @returns {Promise<object>}
	 * @private
	 */
	async _post(params) {
		/*
		if (window.CVPI_DEBUG) {
			document.documentElement.style.whiteSpace = 'pre-wrap';
			document.documentElement.style.fontFamily = 'monospace';
			document.documentElement.textContent = JSON.stringify(params, null, '\t');
			return;
		}
		*/
		try {
			this.callCount++;
			const res = await this._api.postWithEditToken(params);
			this.online = true;
			return res;
		} catch (e) {
			if (e === 'http') {
				this.online = false;
				this._onHttpError?.();
			}
			throw e;
		}
	}

	/**
	 * Consolidated query helper to reduce boilerplate for 'action: query' calls.
	 * Wraps the API call with timing and logging for better debugging.
	 * 
	 * @param {object} params
	 * @param {number} [formatversion=2]
	 * @returns {Promise<object|null>}
	 * @private
	 */
	async _query(params, formatversion = 2) {
		const startTime = performance.now();
		const callLabel = params.list || params.prop || 'unknown-query';

		try {
			const res = await this._get({ 
				action: 'query', 
				format: 'json', 
				formatversion, 
				...params 
			});

			const duration = (performance.now() - startTime).toFixed(1);
			this._log(`Query [${callLabel}] completed in ${duration}ms`, params);

			return res;
		} catch (e) {
			const duration = (performance.now() - startTime).toFixed(1);
			this._log(`Query [${callLabel}] FAILED after ${duration}ms:`, e, params);
			return null;
		}
	}

	/**
	 * Replaces bare URLs in a string with clickable anchor tags.
	 * Used to make (most) links in diff content clickable.
	 * 
	 * @param {string} html
	 * @returns {string} The text with URLs converted to links.
	 * @private
	 */
	_linkify(html) {
		// regex source: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
		// Retrieved 2026-01-28, License - CC BY-SA 4.0
		// Updated to exclude trailing &lt; and &gt; characters common in diff markers.
		const urlPattern = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b((?:(?!&lt;|&gt;|&quot;)[-a-zA-Z0-9()@:%_+.~#?&//=;,])*)/g;
		
		return html.replace(urlPattern, (url) => {
			return `<a href="https://artikeldigital.com/en/${url}" target="_blank">${url}</a>`;
		});
	}

	// ─── Public API ────────────────────────────────────────────────────────────

	/**
	 * Fetches the most recent changes from the API, filtered for namespaces and bots.
	 * @returns {Promise<object[]|null>} Array of recent change objects, or null on error.
	 */
	async getRecentChanges() {
		const res = await this._query({
			list: 'recentchanges',
			rcnamespace: CVPI_CONFIG.API.NAMESPACE_FILTER,
			rclimit: 'max',
			rcend: new Date(Date.now() - CVPI_CONFIG.API.RC_LOOKBACK_MS).toISOString(),
			rcprop: 'title|ids|sizes|flags|user|tags|comment|timestamp|oresscores',
			rcshow: '!bot',
			rctype: 'edit|new',
			rctoponly: 1,

		}, 1);

		return res?.query?.recentchanges || null;
	}

	/**
	 * Fetches recent edit filter triggers
	 * @param {number|null} [lookbackMs=null] - Optional time window in milliseconds to filter results.
	 * @param {Array} editFilterIDs
	 * @returns {Promise<object[]|null>} Array of recent change objects, or null on error.
	 */
	async getRecentEditFilterTriggers(lookbackMs = null) {
		const urgent = CVPI_CONFIG.API.URGENT_EDIT_FILTER_LOG_IDS || '';
		const lessUrgent = CVPI_CONFIG.API.LESS_URGENT_EDIT_FILTER_LOG_IDS || '';
		const combined = [urgent, lessUrgent].filter(Boolean).join('|');

		const params = {
			list: 'abuselog',
			formatversion:'2',
			aflfilter: combined,
			afllimit: '100',
			aflprop: 'ids|user|result|hidden|filter|ids',
		};

		if (lookbackMs) {
			params.aflend = new Date(Date.now() - lookbackMs).toISOString();
		}

		const res = await this._query(params, 1);
		return res?.query?.abuselog || null;
	}

	/**
	 * Fetches recent rollbacks
	 * @param {number|null} [lookbackMs=null] - Optional time window in milliseconds to filter results.
	 * @returns {Promise<object[]|null>} Array of recent change objects, or null on error.
	 */
	async getRecentRollbacks(lookbackMs = null) {
		const params = {
			list: 'recentchanges',
			rclimit: 500,
			rcprop: 'user|comment|ids',
			rctag: 'mw-rollback',
		};

		if (lookbackMs) {
			params.rcend = new Date(Date.now() - lookbackMs).toISOString();
		}

		const res = await this._query(params, 1);
		return res?.query?.recentchanges || null;
	}

	/**
	 * Fetches contributions for a specific user.
	 * 
	 * @param {string} ucuser
	 * @param {string|null} [ucend=null] - ISO timestamp lower bound.
	 * @param {number|null} [ucnamespace=null] - Optional namespace filter.
	 * @param {string|null} [uctag=null] - Optional tag filter (e.g., 'CVPI').
	 * @returns {Promise<object[]|null>} Array of contribution objects, or null on error.
	 */
	async getContributions(ucuser, ucend = null, ucnamespace = null, uctag = null) {
		const params = { 
			list: 'usercontribs', 
			uclimit: 'max', 
			ucuser 
		};

		if (ucend)       params.ucend      = ucend;
		if (ucnamespace) params.ucnamespace = ucnamespace;
		if (uctag)       params.uctag      = uctag;

		const res = await this._query(params, 1);
		return res?.query?.usercontribs || null;
	}

	/**
	 * Fetches the HTML diff between two revisions and calculates the size difference.
	 * 
	 * @param {number} fromrev
	 * @param {number} torev
	 * @returns {Promise<{ html: string, size: number }|null>}
	 */
	async getDiff(fromrev, torev) {
		if (!fromrev || !torev) return null;

		try {
			const res = await this._get({
				action: 'compare', 
				fromrev, 
				torev,
				prop: 'diff|size|timestamp', 
				difftype: 'table',
				format: 'json', 
				formatversion: 2,
			});

			const { body, tosize, fromsize } = res.compare;

			// If the diff body is empty, it means there are no changes between the revisions.
			const isEmpty = !body || body.trim() === '';

			// Reformat the table slightly to remove the large colspan on line numbers,
			// allowing for more compact styling of the diff markers.
			const fixed = (body ?? '').replace(
				/<td colspan="2" class="diff-lineno">/g,
				'<td class="diff-marker-header"></td><td class="diff-lineno">'
			);

			const html = `<table class="diff"><colgroup>
				<col class="diff-marker"><col class="diff-content">
				<col class="diff-marker"><col class="diff-content">
			</colgroup><tbody>${fixed}<tr class="cvpi-diff-bottompadding"></tr></tbody></table>`;

			return { 
				html: this._linkify(html), 
				size: tosize - fromsize,
				isEmpty
			};
		} catch (error) {
			this._log(`getDiff FAILED for ${torev}:`, error);
			return null; 
		}
	}

	/**
	 * Fetches the current latest revision IDs for a list of page titles.
	 * Used to determine if a change is still the latest on the page.
	 * 
	 * @param {string[]} titles
	 * @returns {Promise<Map<string, { revid: number, user: string }>>}
	 */
	async getLatestRevisionIds(titles) {
		const latestMap = new Map();
		const unique = [...new Set(titles)];
		if (!unique.length) return latestMap;

		const batches = this._chunk(unique, CVPI_CONFIG.API.BATCH_SIZE);

		await Promise.all(batches.map(async batch => {
			const res = await this._query({ 
				prop: 'revisions', 
				titles: batch.join('|'), 
				rvprop: 'ids|user' 
			});

			res?.query?.pages?.forEach(p => {
				if (p.revisions?.[0]) {
					latestMap.set(p.title, {
						revid: p.revisions[0].revid,
						user:  p.revisions[0].user
					});
				}
			});
		}));

		return latestMap;
	}

	/**
	 * Identifies the most recent revision by a different editor, counts the number of consecutive
	 * revisions by the current user, and returns that along with the raw history.
	 *
	 * @param {string} title
	 * @returns {Promise<{ priorRevid: number|null, revCount: number|null, history: object[] }>}
	 */
	async getPageHistoryData(title) {
		const res = await this._query({
			prop: 'revisions', 
			titles: title,
			rvprop: 'ids|user|size|timestamp|comment',
			rvlimit: CVPI_CONFIG.API.RV_LIMIT, 
			rvdir: 'older',
		});

		const page = res?.query?.pages?.[0];
		if (!page || page.missing || (page.revisions?.length ?? 0) < 2) {
			return { priorRevid: null, revCount: null, history: [] };
		}

		const revs = page.revisions;
		const editor = revs[0].user;

		// Walk down the history until we find a different user
		for (let i = 1; i < revs.length; i++) {
			if (revs[i].user !== editor) {
				return {
					priorRevid: revs[i].revid,
					revCount: i,
					history: revs
				};
			}
		}

		// If we reached the limit or the start of the page without finding another user,
		// return the history we have.
		return {
			priorRevid: null,
			revCount: revs.length,
			history: revs
		};
		}
	/**
	 * Fetches the wikitext content and revision ID of a page.
	 * 
	 * @param {string} title
	 * @returns {Promise<{ content: string|null, revid: number|null }>}
	 */
	async getPage(title) {
		const res = await this._query({ 
			prop: 'revisions', 
			titles: title, 
			rvprop: 'content|ids', 
			rvslots: '*' 
		});

		const page = res?.query?.pages?.[0];
		if (!page || page.missing) {
			return { content: '', revid: null };
		}

		return {
			content: page.revisions[0].slots.main.content,
			revid:   page.revisions[0].revid,
		};
	}

	/**
	 * Saves (overwrites or creates) a page with new content.
	 * 
	 * @param {string} title
	 * @param {string} text
	 * @param {string} summary
	 * @param {number|null} [baserevid=null] - Optional base revision for conflict detection.
	 * @returns {Promise<{ success: boolean, error?: string }>}
	 */
	async savePage(title, text, summary, baserevid = null) {
		try {
			const params = { 
				action: 'edit', 
				title, 
				text, 
				summary, 
				tags: 'CVPI', 
				format: 'json' 
			};

			if (baserevid !== null) {
				params.baserevid = baserevid;
			}

			await this._post(params);
			return { success: true };
		} catch (error) { 
			return { success: false, error }; 
		}
	}

	/**
	 * Appends content to the end of a page.
	 * 
	 * @param {string} title
	 * @param {string} appendtext
	 * @param {string} summary
	 * @returns {Promise<{ success: boolean, error?: string }>}
	 */
	async appendToPage(title, appendtext, summary) {
		try {
			await this._post({ 
				action: 'edit', 
				title, 
				appendtext, 
				summary, 
				tags: 'CVPI', 
				discussiontoolsautosubscribe: 'no', 
				format: 'json' 
			});
			return { success: true };
		} catch (error) { 
			return { success: false, error }; 
		}
	}

	/**
	 * Performs a rollback of a user's edits on a page.
	 * 
	 * @param {string} title
	 * @param {string} user
	 * @param {string} summary
	 * @returns {Promise<{ success: boolean, error?: string }>}
	 */
	async rollback(title, user, summary) {
		/*
		if (window.CVPI_DEBUG) {
			document.documentElement.style.whiteSpace = 'pre-wrap';
			document.documentElement.style.fontFamily = 'monospace';
			document.documentElement.textContent = `${title}\n${user}\n${summary}`;
			return;
		}
		*/
		try {
			this.callCount++;
			const res = await this._api.rollback(title, user, { 
				summary, 
				tags: 'CVPI' 
			});

			return res?.revid 
				? { success: true } 
				: { success: false, error: 'No revid returned' };

		} catch (e) {
			const map = { 
				alreadyrolled: 'Already rolled back.', 
				editconflict: 'Conflict.', 
				http: 'No connection.' 
			};
			return { success: false, error: map[e] || e };
		}
	}

	/**
	 * Blocks a user
	 * 
	 * @param {string} user
	 * @param {string} summary
	 * @param {string} expiry
	 * @returns {Promise<{ success: boolean, error?: string }>}
	 */
	async block(user, summary = 'Clearly [[WP:NOTHERE|not here to build an encyclopedia]]', expiry = 'indefinite') {
		try {
			this.callCount++;
			await this._post({
				action: 'block',
				user,
				expiry,
				reason: summary,
				nocreate: '',
				autoblock: '',
				allowusertalk: '',
				tags: 'CVPI'
			});
			return { success: true };
		} catch (e) {
			return { success: false, error: e };
		}
	}

	/**
	 * Checks if a user is currently blocked from editing.
	 * 
	 * @param {string} user
	 * @returns {Promise<{ success: boolean, blocked?: boolean, error?: string }>}
	 */
	async isUserBlocked(user) {
		const res = await this._query({
			action: 'query',
			list: 'blocks',
			bkusers: user
		}, 1);

		const now = new Date();
		const blocks = res?.query?.blocks || [];

		const isBlocked = blocks.some(block => {
			// Skip partial blocks
			if (block.partial) return false;

			// Check expiry
			if (block.expiry === 'infinity') return true;

			return new Date(block.expiry) > now;
		});

		return {
			success: !!res,
			blocked: isBlocked
		};
	}

	/**
	 * Fetches the entire AIV page content and extracts all reported usernames.
	 * 
	 * @returns {Promise<{success: boolean, users: string[], error?: string}>}
	 */
	async getUsersAtAIV() {
		const res = await this._query({
			prop: 'revisions', 
			titles: 'Wikipedia:Administrator intervention against vandalism',
			rvprop: 'content', 
			rvslots: '*',
		});

		if (!res) return { success: false, users: [], error: 'Could not fetch AIV page' };

		const content = res?.query?.pages?.[0]?.revisions?.[0]?.slots?.main?.content || '';
		
		// Regex to find common report templates on AIV (vandal, ipvandal, user-uaa)
		// {{vandal|Username}} or {{ipvandal|1.2.3.4}} or {{user-uaa|Username}}
		const regex = /\{\{(?:(?:ip)?vandal|user-uaa)\|(?:1=)?([^|}]+)/gi;
		const users = [];
		let match;

		while ((match = regex.exec(content)) !== null) {
			const u = match[1].trim();
			if (u && !users.includes(u)) {
				users.push(u);
			}
		}

		return { 
			success: true, 
			users: users 
		};
	}

	/**
	 * Fetches the entire UAA page content and extracts all reported usernames.
	 * 
	 * @returns {Promise<{success: boolean, users: string[], error?: string}>}
	 */
	async getUsersAtUAA() {
		const res = await this._query({
			prop: 'revisions', 
			titles: 'Wikipedia:Usernames for administrator attention',
			rvprop: 'content', 
			rvslots: '*',
		});

		if (!res) return { success: false, users: [], error: 'Could not fetch UAA page' };

		const content = res?.query?.pages?.[0]?.revisions?.[0]?.slots?.main?.content || '';
		
		// Regex to find UAA report template in {{user-uaa|1=Example name one}} or
		// {{user-uaa|Example name two}} formats
		const regex = /^\*\s*\{\{user-uaa\|(?:1=)?([^|}]+)/gim;
		const users = [];
		let match;

		while ((match = regex.exec(content)) !== null) {
			const u = match[1].trim();
			if (u && !users.includes(u)) {
				users.push(u);
			}
		}

		return { 
			success: true, 
			users: users 
		};
	}

	/**
	 * Returns the rights for the current logged-in user.
	 * @returns {Promise<string[]|null>}
	 */
	async getUserRights() {
		return mw.user.getRights();
	}

	/**
	 * Fetches user information. Right now we're just getting
	 * "options" so we can known the timezone to use.
	 * @returns {Promise<object|null>}
	 */
	async getUserInfo() {
		const res = await this._query({
			action: 'query',
			meta: 'userinfo',
			uiprop: 'options'
		});
		return res?.query?.userinfo || null;
	}
}

// ── services/WarningService.js ──────────────────────────
// ─── Warning parser helpers ──────────────────────────────────────────────────

/** 
 * Matches MediaWiki template comments like <!-- Template:uw-vandalism1 -->
 */
const COMMENT_RE  = /<\!-- Template:([uU]w-[a-z-]+)([1-4]?(?:im)?) -->/gi;

/** Matches standard MediaWiki UTC timestamps in signatures. */
const DATE_RE     = /\d{2}:\d{2}, \d{1,2} [A-Z][a-z]+ \d{4} \(UTC\)/g;

/** Matches internal links to user talk pages or contributions to identify the issuer. */
const USERLINK_RE = /\[\[(?:User|User_talk|User talk|Special:Contributions)[:/]([^|\]#]+)/gi;

const MONTH_NAMES = [
	'January','February','March','April','May','June',
	'July','August','September','October','November','December',
];

/** 
 * @typedef {object} Warning
 * @property {Date|null} dateObject - Parsed Date object of the warning.
 * @property {string}    timestamp  - Relative time string (e.g., "5 mins ago") or raw timestamp.
 * @property {string}    template   - The base name of the warning template (e.g., "uw-vandalism").
 * @property {number}    level      - The warning level (1-4).
 * @property {string}    issuer     - The username of the person who issued the warning.
 */

/**
 * Parses all uw-* warning templates from a user talk page wikitext.
 * 
 * @param {string} wikitext - The content of the user talk page.
 * @returns {Warning[]} An array of parsed warning objects.
 */
function parseWarnings(wikitext) {
	if (typeof wikitext !== 'string') {
		return [];
	}

	const warnings = [];
	let commentMatch;

	// Reset regex state for global search
	COMMENT_RE.lastIndex = 0;

	while ((commentMatch = COMMENT_RE.exec(wikitext)) !== null) {
		const base  = commentMatch[1].trim();

		// Filter out non-actionable templates
		if (base === 'uw-cluebotwarning') {
			continue;
		}

		const level = parseInt(commentMatch[2], 10) || 0;
		const afterComment = commentMatch.index + commentMatch[0].length;

		// Search for the signature timestamp immediately following the template comment
		DATE_RE.lastIndex = afterComment;
		const dateMatch = DATE_RE.exec(wikitext);

		// If no date found within a reasonable distance (355 chars), it's likely not a valid signature
		if (!dateMatch || dateMatch.index - afterComment > 355) {
			continue;
		}

		// Extract the text between the template and the date to find the issuer's username
		const chunk = wikitext.substring(afterComment, DATE_RE.lastIndex);
		let issuer  = 'Unknown';

		USERLINK_RE.lastIndex = 0;
		let userMatch;
		while ((userMatch = USERLINK_RE.exec(chunk)) !== null) {
			// .split('/')[0] to strip sandbox when users have their
			// sandbox linked in their signature for some reason.
			issuer = userMatch[1].trim().split('/')[0];
		}

		// Decode HTML entities and percent-encoding in usernames
		try {
			issuer = decodeURIComponent(issuer.replace(/_/g, ' '));
			if (issuer.includes('&#')) {
				const el = document.createElement('textarea');
				el.innerHTML = issuer;
				issuer = el.value;
			}
		} catch { 
			/* keep raw value if decoding fails */ 
		}

		let dateObject  = null;
		let timestamp   = dateMatch[0];

		// Attempt to parse the signature date into a JS Date object
		try {
			dateObject = new Date(dateMatch[0].replace(/(\d+:\d+), (.*) \(UTC\)/, '$2 $1 UTC'));
			timestamp  = timeSince(dateObject);
		} catch { 
			/* keep raw timestamp string if parsing fails */ 
		}

		warnings.push({ 
			dateObject, 
			timestamp, 
			template: base, 
			level, 
			issuer 
		});
	}

	return warnings;
}

// ─── Staleness check ────────────────────────────────────────────────────────

/**
 * Determines if a warning is still "active" based on the configured staleness method.
 * 
 * @param {Warning} warning - The warning object to check.
 * @param {'current_month'|'previous_seven_days'} [staleWarningMethod] - The staleness logic to use.
 * @returns {boolean}
 */
function isActiveWarning(warning, staleWarningMethod = CVPI_CONFIG.LOGIC.STALE_WARNING_METHOD) {
	if (!warning.dateObject) {
		return false;
	}

	if (staleWarningMethod === 'current_month') {
		// Active if issued in the same calendar month/year as now
		const now = new Date();
		return warning.dateObject.getUTCMonth()    === now.getUTCMonth() &&
		       warning.dateObject.getUTCFullYear() === now.getUTCFullYear();
	}

	// Active if issued within the last 7 days (sliding window)
	return Date.now() - warning.dateObject <= 7 * 24 * 60 * 60 * 1000;
}

// ─── WarningService class ────────────────────────────────────────────────────

class WarningService {
	/**
	 * @param {import('./ApiService').ApiService} api - Reference to the API service.
	 * @param {import('./UserTracker').UserTracker} userTracker - Tracker for user session state.
	 */
	constructor(api, userTracker) {
		this._api            = api;
		this.userTracker     = userTracker;
		this.staleWarningMethod = CVPI_CONFIG.LOGIC.STALE_WARNING_METHOD;
	}

	/**
	 * Generates the header string for the current month's section on a talk page.
	 * @returns {string} (e.g., "January 2026")
	 * @private
	 */
	_currentMonthSection() {
		const now = new Date();
		return `${MONTH_NAMES[now.getUTCMonth()]} ${now.getUTCFullYear()}`;
	}

	/**
	 * Analyzes a user's talk page to find the highest active warning level.
	 *
	 * @param {string} user
	 * @returns {Promise<{ level: number, content: string, warnings: Array<Object> }>} Highest active level, full page content, and parsed warnings.
	 */
	async fetchWarnings(user) {
		const { content } = await this._api.getPage(`User talk:${user}`);

		if (!content) {
			return { level: 0, content: '', warnings: [] };
		}

		const warnings = parseWarnings(content);

		// Filter for active warnings and find the maximum level
		const level = warnings
			.filter(w => isActiveWarning(w, CVPI_CONFIG.LOGIC.STALE_WARNING_METHOD))
			.reduce((max, w) => Math.max(max, w.level), 0);

		return { level, content, warnings };
	}


	/**
	 * Issues a new warning template to a user's talk page.
	 *
	 * @param {{ user: string, title: string }} item
	 * @param {string}      template
	 * @param {number|null} level    - Level 1-4, 0 (auto), or null (single-issue).
	 * @returns {Promise<{ success: boolean, reason?: string, error?: string }>}
	 */
	async warn(item, template, level) {
		const { user, title } = item;

		// Don't auto-warn for vandalism if the user is already at AIV.
		if (template === 'vandalism' && (level === 0 || level === '0') && this.userTracker.isAtAIV(user)) {
			return { success: true, skipped: true, reason: 'user_at_aiv' };
		}

		// 1. Fetch current talk page content
		const page = await this._api.getPage(`User talk:${user}`);
		if (page.content === null) {
			return { success: false, error: 'Failed to fetch talk page.' };
		}

		// 2. Determine the correct warning level to issue
		const { level: resolvedLevel, additionalNote } = this._resolveLevel(user, page.content, level);
		
		if (resolvedLevel !== null && resolvedLevel > 4) {
			// Already at level 4; caller is responsible for AIV reporting.
			return { success: false, reason: 'max_level', additionalNote }; 
		}

		// 3. Construct new content and summary
		const newContent = this._insertWarning(page.content, template, resolvedLevel, title);
		const summary    = this._buildSummary(template, resolvedLevel, title);

		// 4. Save the page
		const result = await this._api.savePage(`User talk:${user}`, newContent, summary, page.revid);
		
		return { 
			...result, 
			level: resolvedLevel 
		};
	}

	// ─── Private helpers ────────────────────────────────────────────────────

	/**
	 * Resolves the numeric warning level (1-4) to issue.
	 * 
	 * @param {string} user - Username.
	 * @param {string} currentContent - Talk page wikitext.
	 * @param {number|string|null} requestedLevel - The requested level (0 for auto).
	 * @returns {{ level: number|null, additionalNote: string|null }} The resolved level and any additional notes.
	 * @private
	 */
	_resolveLevel(user, currentContent, requestedLevel) {
		// Single-issue templates don't have levels
		if (requestedLevel === null) {
			return { level: null, additionalNote: null };
		}

		if (requestedLevel === 0 || requestedLevel === '0') {
			// Auto-level logic:
			// Find the highest active level on the page, factor in session memory, then increment.
			const warnings  = parseWarnings(currentContent);
			
			const fromPage  = warnings
				.filter(w => isActiveWarning(w, this.staleWarningMethod))
				.reduce((max, w) => Math.max(max, w.level), 0);
				
			const fromSession = this.userTracker.getHighestLevel(user);

			let additionalNote = null;
			// If session memory shows level 4 but the page shows 0, the user likely blanked their talk page.
			if (fromSession >= 4 && fromPage === 0) {
				additionalNote = `see {{ph|User talk:${user}|user talk page history}} for warnings`;
			}
			
			return { 
				level: Math.max(fromPage, fromSession) + 1, 
				additionalNote 
			};
		}

		return { 
			level: parseInt(String(requestedLevel), 10), 
			additionalNote: null 
		};
	}

	/**
	 * Inserts a warning template into the appropriate month section of the wikitext.
	 * 
	 * @param {string} content - Original talk page content.
	 * @param {string} template - Warning type key.
	 * @param {number|null} level - Level 1-4 or null.
	 * @param {string} pageTitle - Title of the page where the vandalism occurred.
	 * @returns {string} The updated wikitext.
	 * @private
	 */
	_insertWarning(content, template, level, pageTitle) {
		const abbrev   = template === 'vandalism' ? 'vand' : template; // Replace "vandalism" with "vand" because "vandalism" is a dirty word.
		const suffix   = level ?? '';
		const tpl      = `uw-${abbrev}${suffix}`;
		const pagePart = pageTitle ? `|${pageTitle}` : '';
		
		// Construct the template call with substitution and signature
		const newWarn  = `{{subst:${tpl}${pagePart}}} ~~~~`;
		
		const month    = this._currentMonthSection();
		const monthRe  = new RegExp(`== ?${month} ?==`);

		// Ensure the month section header exists
		if (!monthRe.test(content)) {
			content += `\n\n== ${month} ==\n`;
		}

		// Split content into sections to find the current month's header
		const sections = content.split(/(?=== ?[^=]+ ?==)/g);
		let placed = false;

		for (let i = 0; i < sections.length; i++) {
			if (monthRe.test(sections[i])) {
				// Append warning at the end of the month's section
				sections[i] = sections[i].trimEnd() + `\n\n${newWarn}\n`;
				placed = true;
				break;
			}
		}

		// Fallback: append at the very end
		if (!placed) {
			sections[sections.length - 1] = sections[sections.length - 1].trimEnd() + `\n\n${newWarn}\n`;
		}

		// Clean up excessive newlines
		return sections.join('').replace(/(\n){3,}/g, '\n\n').trim();
	}

	/**
	 * Builds a concise edit summary for the warning.
	 * 
	 * @param {string} template - Warning type key.
	 * @param {number|null} level - Level 1-4 or null.
	 * @param {string} pageTitle - Page title.
	 * @returns {string}
	 * @private
	 */
	_buildSummary(template, level, pageTitle) {
		const labels = { 
			1: 'Information about', 
			2: 'Notice regarding', 
			3: 'Warning:', 
			4: 'Final warning:' 
		};

		const prefix = labels[level] ?? 'Regarding';
		const abbrev = template === 'vandalism' ? 'vand' : template;
		const tplName = `uw-${abbrev}${level ?? ''}`;

		return `${prefix} edits to [[${pageTitle}]] (${tplName})`;
	}

	/**
	 * Places a block template on the user's talk page.
	 * 
	 * @param {string} user - Username.
	 * @param {string} template - The block template name.
	 * @param {string} blockReason - The reason for the block.
	 * @returns {Promise<{ success: boolean, error?: string }>}
	 */
	async postBlockTemplate(user, template, blockReason) {
		const page = await this._api.getPage(`User talk:${user}`);
		if (page.content === null) {
			return { success: false, error: 'Failed to fetch talk page.' };
		}

		const month = this._currentMonthSection();
		const monthRe = new RegExp(`== ?${month} ?==`);
		const blockWarn = `{{subst:${template}}} ~~~~`;

		let content = page.content;
		if (!monthRe.test(content)) {
			content += `\n\n== ${month} ==\n`;
		}

		const sections = content.split(/(?=== ?[^=]+ ?==)/g);
		let placed = false;

		for (let i = 0; i < sections.length; i++) {
			if (monthRe.test(sections[i])) {
				sections[i] = sections[i].trimEnd() + `\n\n${blockWarn}\n`;
				placed = true;
				break;
			}
		}

		if (!placed) {
			sections[sections.length - 1] = sections[sections.length - 1].trimEnd() + `\n\n${blockWarn}\n`;
		}

		const newContent = sections.join('').replace(/(\n){3,}/g, '\n\n').trim();
		const summary = `Notification of block: ${blockReason} (${template})`;

		return await this._api.savePage(`User talk:${user}`, newContent, summary, page.revid);
	}
}

// ── ui/ActionBus.js ──────────────────────────
const INPUT_MODES = {
	DEFAULT: {
		allowHotkeys: true,
		allowSwipes: true,
		allowedKeys: [' ', ']', '[', 'ArrowRight', 'ArrowLeft', 'q', 't', 'u', 'd', 'ArrowDown']
	},
	MODAL: {
		allowHotkeys: false,
		allowSwipes: false,
		allowedKeys: ['Escape', 'Enter']
	},
	TYPING: {
		allowHotkeys: false,
		allowSwipes: false,
		allowedKeys: ['Escape', 'Enter']
	},
	DASHBOARD: {
		allowHotkeys: true,
		allowSwipes: false,
		allowedKeys: [' ', ']', '[', 'ArrowRight', 'ArrowLeft', 'q', 't', 'u', 'd', 'ArrowDown']
	}
};

class ActionBus {
	/**
	 * @param {import('../core/EventEmitter').EventEmitter} events
	 */
	constructor(events) {
		this.events = events;
		this.currentMode = 'DEFAULT';
		
		this._handleClickBound = this._handleClick.bind(this);
		this._handleKeyBound = this._handleKey.bind(this);
		this._handleTouchStartBound = this._handleTouchStart.bind(this);
		this._handleTouchEndBound = this._handleTouchEnd.bind(this);

		this._touchStartX = 0;
		this._touchStartY = 0;
		this._isInsideToolbarPanel = false;
	}

	init() {
		document.addEventListener('click', this._handleClickBound);
		document.addEventListener('keydown', this._handleKeyBound);
		document.addEventListener('touchstart', this._handleTouchStartBound, { passive: true });
		document.addEventListener('touchend', this._handleTouchEndBound, { passive: true });
	}

	destroy() {
		document.removeEventListener('click', this._handleClickBound);
		document.removeEventListener('keydown', this._handleKeyBound);
		document.removeEventListener('touchstart', this._handleTouchStartBound);
		document.removeEventListener('touchend', this._handleTouchEndBound);
	}

	setMode(mode) {
		if (INPUT_MODES[mode]) {
			this.currentMode = mode;
		}
	}

	_getEffectiveMode(eventTarget) {
		if (eventTarget) {
			const tag = eventTarget.nodeName;
			if (tag === 'INPUT' || tag === 'TEXTAREA' || eventTarget.isContentEditable) {
				return 'TYPING';
			}
		}
		return this.currentMode;
	}

	_handleClick(e) {
		const actionEl = e.target.closest('[data-action]');
		if (actionEl) {
			if (actionEl.hasAttribute('disabled') || actionEl.classList.contains('cvpi-action-btn--disabled')) {
				return;
			}
			
			const actionId = actionEl.getAttribute('data-action');
			// Collect any relevant data attributes
			const data = { event: e, target: actionEl };
			for (const attr of actionEl.attributes) {
				if (attr.name.startsWith('data-') && attr.name !== 'data-action') {
					const key = attr.name.substring(5).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
					data[key] = attr.value;
				}
			}

			// We emit the exact action string to the event bus
			this.events.emit(`action:${actionId}`, data);
		}
	}

	_handleKey(e) {
		const modeName = this._getEffectiveMode(e.target);
		const rules = INPUT_MODES[modeName];
		
		// 1. Core Guard
		if (!rules.allowedKeys.includes(e.key)) return;
		if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;

		// 2. Prevent default browser scrolling behavior for spacebar if handled
		if (e.key === ' ' || e.key === 'ArrowDown' || e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
			e.preventDefault();
		}

		// 3. Map to specific action
		let actionId = null;
		const payload = {};

		if (modeName === 'MODAL' || modeName === 'TYPING') {
			if (e.key === 'Escape') actionId = 'close-modal';
			if (e.key === 'Enter') actionId = 'submit-modal';
		} else if (rules.allowHotkeys) {
			switch(e.key) {
				case ' ': actionId = 'cvpi-action-next'; break;
				case ']': actionId = 'cvpi-action-next'; break;
				case '[': actionId = 'cvpi-action-prev'; break;
				case 'ArrowRight': actionId = 'cvpi-action-next'; break;
				case 'ArrowLeft': actionId = 'cvpi-action-prev'; break;
				case 'ArrowDown': actionId = 'cvpi-action-scroll-next'; break;
				case 'q': actionId = 'cvpi-action-hotkey-rollback'; payload.type = 'vandalism'; break;
				case 't': actionId = 'cvpi-action-hotkey-rollback'; payload.type = 'test'; break;
				case 'u': actionId = 'cvpi-action-hotkey-rollback'; payload.type = 'unsourced'; break;
				case 'd': actionId = 'cvpi-action-hotkey-rollback'; payload.type = 'delete'; break;
			}
		}

		if (actionId) {
			this.events.emit(`action:${actionId}`, payload);
		}
	}

	_handleTouchStart(e) {
		const modeName = this._getEffectiveMode(e.target);
		const rules = INPUT_MODES[modeName];
		
		if (!rules.allowSwipes) return;

		const touch = e.changedTouches[0];
		this._touchStartX = touch.pageX;
		this._touchStartY = touch.pageY;
		
		const openToolbarPanel = e.target.closest('.cvpi-toolbar__toolbarPanel[data-state="cvpi-toolbar--open"]');
		this._isInsideToolbarPanel = !!openToolbarPanel;
	}

	_handleTouchEnd(e) {
		const modeName = this._getEffectiveMode(e.target);
		const rules = INPUT_MODES[modeName];
		
		if (!rules.allowSwipes || this._isInsideToolbarPanel) return;

		const touch = e.changedTouches[0];
		const distX = touch.pageX - this._touchStartX;
		const distY = touch.pageY - this._touchStartY;

		const threshold = 75;
		const restraint = 100;

		if (Math.abs(distX) >= threshold && Math.abs(distY) <= restraint) {
			if (distX > 0) {
				this.events.emit('action:cvpi-action-prev');
			} else {
				this.events.emit('action:cvpi-action-next');
			}
		}
	}
}

// ── core/EventEmitter.js ──────────────────────────
class EventEmitter {
	constructor() {
		this.events = {};
	}

	on(event, cb) {
		(this.events[event] || (this.events[event] = [])).push(cb);
	}

	off(event, cb) {
		if (!this.events[event]) return;
		this.events[event] = this.events[event].filter(l => l !== cb);
	}

	emit(event, data) {
		const listeners = this.events[event] || [];
		for (const cb of listeners) {
			cb(data);
			if (data && data.stopPropagation === true) {
				break;
			}
		}
	}
}

// ── ui/ComponentBuilder.js ──────────────────────────
class ComponentBuilder {
	/**
	 * Concise element factory to reduce DOM boilerplate.
	 */
	static createElement(tag, props = {}, children = []) {
		const el = document.createElement(tag);
		
		for (const [key, value] of Object.entries(props)) {
			if (key === 'className') {
				el.className = value;
			} else if (key === 'innerHTML') {
				el.innerHTML = value;
			} else if (key === 'textContent') {
				el.textContent = value;
			} else if (key.startsWith('data-')) {
				el.setAttribute(key, value);
			} else if (key === 'disabled') {
				if (value) el.setAttribute('disabled', 'true');
			} else {
				el[key] = value;
			}
		}

		children.forEach(c => c && el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c));
		return el;
	}

	/**
	 * Creates a standardized action button.
	 */
	static createActionButton(config) {
		const btn = document.createElement('div');
		const classes = [config.baseClass || 'cvpi-action-btn'];
		
		if (config.id) {
			btn.id = config.id;
		}
		if (config.actionId) {
			btn.setAttribute('data-action', config.actionId);
		}
		if (config.className) {
			classes.push(...config.className.split(' '));
		}
		if (config.keepOpen) {
			btn.setAttribute('data-keep-open', 'true');
		}
		if (config.disabled) {
			classes.push('cvpi-action-btn--disabled');
			btn.setAttribute('disabled', 'true');
		}

		btn.className = classes.join(' ');

		if (config.icon) {
			const iconSpan = document.createElement('span');
			const iconClass = config.iconClass || 'material-symbols-outlined';
			iconSpan.className = `${iconClass} cvpi-action-btn__symbol`;
			iconSpan.textContent = config.icon;
			
			if (config.badge) {
				const badgeSpan = document.createElement('span');
				badgeSpan.className = 'cvpi-action-btn__badge';
				badgeSpan.textContent = config.badge;
				iconSpan.appendChild(badgeSpan);
			}
			
			btn.appendChild(iconSpan);
		} else if (config.symbol) {
			const symbolSpan = document.createElement('span');
			symbolSpan.className = 'cvpi-action-btn__symbol';
			symbolSpan.innerHTML = config.symbol;

			if (config.badge) {
				const badgeSpan = document.createElement('span');
				badgeSpan.className = 'cvpi-action-btn__badge';
				badgeSpan.textContent = config.badge;
				symbolSpan.appendChild(badgeSpan);
			}

			btn.appendChild(symbolSpan);
		}
		const topLabel = document.createElement('span');
		topLabel.className = 'cvpi-action-btn__label cvpi-action-btn__label--top';
		topLabel.textContent = config.topLabel || '';
		btn.appendChild(topLabel);

		const bottomLabel = document.createElement('span');
		bottomLabel.className = 'cvpi-action-btn__label cvpi-action-btn__label--bottom';
		bottomLabel.textContent = config.bottomLabel || config.label || '';
		btn.appendChild(bottomLabel);

		return btn;
	}
}

// ── ui/ToolbarManager.js ──────────────────────────
class ToolbarManager {
	/**
	 * @param {import('../core/EventEmitter').EventEmitter} events
	 */
	constructor(events, onCloseAll) {
		this.events = events;
		this._onCloseAll = onCloseAll;
		if (!ToolbarManager._listenerAttached) {
			document.addEventListener('click', this._handleDocumentClick.bind(this));
			ToolbarManager._listenerAttached = true;
		}
	}

	_handleDocumentClick(event) {
		// First, find if we clicked on anything actionable (has data-action)
		const actionEl = event.target.closest('[data-action]');
		
		// Then, find if we clicked inside a toolbar at all
		const toolbarEl = event.target.closest('.cvpi-toolbar');

		// Check if the click happened inside an area that should keep the menu open
		const keepOpenEl = event.target.closest('[data-keep-open="true"]');

		// If click is completely outside any toolbar, close all menus
		if (!toolbarEl) {
			this.closeAllMenus();
			return;
		}

		const clickedInsideSubmenu = event.target.closest('.cvpi-toolbar__inline-toolbar, .cvpi-toolbar__toolbarPanel');

		// Handle sub-menu toggling (if we clicked a parent button that owns a menu)
		const parentLi = event.target.closest('.cvpi-toolbar__action-item, .cvpi-toolbar__vertical-menu-item');
		if (parentLi && (!clickedInsideSubmenu || parentLi.classList.contains('cvpi-toolbar__vertical-menu-item'))) {
			// Don't do anything if the item is disabled
			if (parentLi.hasAttribute('disabled') || parentLi.querySelector('[disabled]')) {
				return;
			}

			// Handle inline toolbars
			const submenu = parentLi._submenu || parentLi.querySelector('.cvpi-toolbar__inline-toolbar');
			
			if (submenu) {
				const isCurrentlyOpen = submenu.getAttribute('data-state') === 'cvpi-toolbar--open';

				// Close EVERY open menu in the app
				this.closeAllMenus();

				// If it wasn't open before, open it now
				if (!isCurrentlyOpen) {
					submenu.setAttribute('data-state', 'cvpi-toolbar--open');
					this.events.emit('ui:toolbar-opened', { panel: submenu });
				}
				return;
			}

			// Handle smart toolbarPanels
			if (parentLi._toolbarPanelContent) {
				const parentMenu = parentLi.closest('.cvpi-toolbar');
				if (!parentMenu) return;
				
				const globalToolbarPanel = parentMenu.querySelector('.cvpi-toolbar__toolbarPanel');
				if (!globalToolbarPanel) return;
				
				const isCurrentlyOpenForThis = globalToolbarPanel.getAttribute('data-state') === 'cvpi-toolbar--open' && globalToolbarPanel._activeTrigger === parentLi;
				
				// Close EVERY open menu in the app
				this.closeAllMenus();

				if (isCurrentlyOpenForThis) {
					// We just closed it, so we're done
					return;
				}
				
				// Open it
				Array.from(globalToolbarPanel.children).forEach(child => child.style.display = 'none');
				parentLi._toolbarPanelContent.style.display = '';
				
				globalToolbarPanel.setAttribute('data-state', 'cvpi-toolbar--open');
				globalToolbarPanel._activeTrigger = parentLi;

				// Ensure scroll starts at the top
				globalToolbarPanel.scrollTop = 0;
				parentLi._toolbarPanelContent.scrollTop = 0;

				const actionId = parentLi.querySelector('[data-action]')?.getAttribute('data-action');
				if (actionId) {
					globalToolbarPanel.setAttribute('data-active-action', actionId);
				}

				// Smart positioning
				const toolbarRect = parentMenu.getBoundingClientRect();
				const buttonRect = parentLi.getBoundingClientRect();
				const toolbarPanelRect = globalToolbarPanel.getBoundingClientRect();
				
				let idealLeft = (buttonRect.left - toolbarRect.left) + (buttonRect.width / 2) - (toolbarPanelRect.width / 2);
				let clampedLeft = Math.max(0, Math.min(idealLeft, toolbarRect.width - toolbarPanelRect.width));
				globalToolbarPanel.style.left = `${clampedLeft}px`;
				
				return;
			}
		}

		// Close logic: If we clicked an action inside a menu, we usually close the menu.
		// However, if the click was inside a "keep-open" area, we skip this.
		if (keepOpenEl) {
			return;
		}

		if (actionEl && clickedInsideSubmenu) {
			this.closeAllMenus();
		}
	}

	closeAllMenus(excludeActionIds = []) {
		let closedAny = false;
		document.querySelectorAll('.cvpi-toolbar__inline-toolbar[data-state="cvpi-toolbar--open"], .cvpi-toolbar__toolbarPanel[data-state="cvpi-toolbar--open"]')
			.forEach(menu => {
				const activeAction = menu.getAttribute('data-active-action');
				if (activeAction && excludeActionIds.includes(activeAction)) {
					return;
				}

				menu.setAttribute('data-state', 'cvpi-toolbar--closed');
				if (menu.classList.contains('cvpi-toolbar__toolbarPanel')) {
					menu._activeTrigger = null;
				}
				closedAny = true;
			});
		
		if (closedAny) {
			this.events.emit('ui:toolbar-closed');
		}

		if (typeof this._onCloseAll === 'function') {
			this._onCloseAll();
		}
	}


	/**
	 * Creates a new floating toolbar DOM element.
	 * @param {Array<Object>} items - Configuration array for toolbar items
	 * @param {string} placement - Toolbar toolbarPanel placement ('top' or 'bottom')
	 * @returns {HTMLElement} The constructed <menu> element
	 */
	createToolbar(items, placement = 'top') {
		const menu = document.createElement('menu');
		menu.className = 'cvpi-toolbar';

		// Create the global toolbarPanel container for this toolbar
		const globalToolbarPanel = document.createElement('div');
		globalToolbarPanel.className = 'cvpi-toolbar__toolbarPanel';
		globalToolbarPanel.setAttribute('data-state', 'cvpi-toolbar--closed');
		globalToolbarPanel.setAttribute('data-placement', placement);
		menu.appendChild(globalToolbarPanel);

		items.forEach((item, index) => {
			const li = document.createElement('li');
			li.className = 'cvpi-toolbar__action-item';
			
			li.dataset.actionId = item.actionId;
			if (item.itemClassName) {
				li.classList.add(...item.itemClassName.split(' '));
			}

			if (index === 0) li.classList.add('cvpi-toolbar__action-item--left-end');
			if (index === items.length - 1) li.classList.add('cvpi-toolbar__action-item--right-end');

			if (item.type === 'custom' && item.element) {
				li.appendChild(item.element);
			} else {
				li.appendChild(ComponentBuilder.createActionButton({ ...item, baseClass: 'cvpi-action-btn' }));
			}

			// Append inline submenu if it exists
			if (item.inlineToolbar) {
				const inlineMenu = document.createElement('menu');
				inlineMenu.className = 'cvpi-toolbar__inline-toolbar';
				inlineMenu.setAttribute('data-state', 'cvpi-toolbar--closed');

				item.inlineToolbar.forEach(sub => {
					const subLi = document.createElement('li');
					subLi.className = 'cvpi-toolbar__action-item';
					if (sub.type === 'custom' && sub.element) {
						subLi.appendChild(sub.element);
					} else {
						subLi.appendChild(ComponentBuilder.createActionButton({ ...sub, baseClass: 'cvpi-action-btn' }));
					}
					inlineMenu.appendChild(subLi);
				});
				li.appendChild(inlineMenu);
				li._submenu = inlineMenu;
			}

			// Assign vertical submenu content if it exists
			if (item.verticalMenu) {
				const verticalMenu = document.createElement('menu');
				verticalMenu.className = 'cvpi-toolbar__vertical-menu';

				item.verticalMenu.forEach(sub => {
					const subLi = document.createElement('li');
					subLi.className = 'cvpi-toolbar__vertical-menu-item';
					
					if (sub.toolbarPanel) {
						subLi._toolbarPanelContent = sub.toolbarPanel instanceof HTMLElement ? sub.toolbarPanel : sub.toolbarPanel.element;
						subLi._toolbarPanelContent.style.display = 'none';
						globalToolbarPanel.appendChild(subLi._toolbarPanelContent);
					}

					// Unified menu item structure: [Icon Slot] [Label Slot] [Action Slot]
					const itemEl = document.createElement('div');
					itemEl.className = 'cvpi-menu-item';
					if (sub.keepOpen) itemEl.setAttribute('data-keep-open', 'true');
					if (sub.actionId) itemEl.setAttribute('data-action', sub.actionId);
					if (sub.disabled) {
						itemEl.classList.add('cvpi-menu-item--disabled');
						itemEl.setAttribute('disabled', 'true');
					}

					// 1. Icon Slot
					const iconSlot = document.createElement('div');
					iconSlot.className = 'cvpi-menu-item__icon';
					if (sub.icon || sub.symbol) {
						const iconSpan = document.createElement('span');
						const iconClass = sub.iconClass || 'material-symbols-outlined';
						iconSpan.className = `${iconClass} cvpi-action-btn__symbol`;
						iconSpan.innerHTML = sub.icon || sub.symbol;
						iconSlot.appendChild(iconSpan);
					}
					itemEl.appendChild(iconSlot);

					// 2. Label Slot
					const labelEl = document.createElement('div');
					labelEl.className = 'cvpi-menu-item__label';
					labelEl.textContent = sub.label || '';
					itemEl.appendChild(labelEl);

					// 3. Action Slot
					const actionSlot = document.createElement('div');
					actionSlot.className = 'cvpi-menu-item__action';
					if (sub.element) {
						actionSlot.appendChild(sub.element);
					}
					itemEl.appendChild(actionSlot);

					subLi.appendChild(itemEl);
					verticalMenu.appendChild(subLi);
				});
				li._toolbarPanelContent = verticalMenu;
			}

			// Assign toolbarPanel content if it exists
			if (item.toolbarPanel) {
				if (item.toolbarPanel instanceof HTMLElement) {
					li._toolbarPanelContent = item.toolbarPanel;
				} else if (item.toolbarPanel.element) {
					li._toolbarPanelContent = item.toolbarPanel.element;
				}
			}
			
			if (li._toolbarPanelContent) {
				li._toolbarPanelContent.style.display = 'none';
				globalToolbarPanel.appendChild(li._toolbarPanelContent);
			}

			menu.appendChild(li);
		});

		return menu;
	}
}

ToolbarManager._listenerAttached = false;

// ── state/HistoryStack.js ──────────────────────────
class HistoryStack {
	/**
	 * @param {import('./ChangeQueue').ChangeQueue} queue
	 * @param {Function} renderCallback - Function to call when navigation changes the active item
	 * @param {Function} logCallback - Function to call for internal logging
	 */
	constructor(queue, renderCallback, logCallback) {
		this.queue = queue;
		this._render = renderCallback;
		this._log = logCallback;

		/** @type {number[]} Stack of revids we have already viewed (for "Previous" button) */
		this._historyStack = [];

		/** @type {number[]} Stack of revids to "redo" (for "Next" after "Previous") */
		this._futureStack  = [];

		/** @type {number|null} Revision ID of the change currently displayed on screen */
		this._currentRevid = null;
	}

	get historyStackLength() { return this._historyStack.length; }
	get futureStackLength() { return this._futureStack.length; }

	get currentRevid() {
		return this._currentRevid;
	}

	set currentRevid(revid) {
		this._currentRevid = revid;
	}

	/**
	 * Convenience getter for the change currently on screen.
	 * @returns {import('./ChangeQueue').Change|null}
	 */
	get currentChange() {
		return this._currentRevid ? (this.queue.get(this._currentRevid) ?? null) : null;
	}

	/**
	 * Advances the UI to the next available change in the queue or stack.
	 * Prioritizes the "future stack" (redo) over new items from the queue.
	 */
	async showNext() {
		this._log('showNext requested');

		// Push current item to history before moving forward
		if (this._currentRevid) {
			this._historyStack.push(this._currentRevid);
		}

		let next = null;

		if (this._futureStack.length) {
			// Redo: pull from the stack of items we went "back" from
			const revid = this._futureStack.pop();
			next = this.queue.get(revid) ?? null;
			
			if (next) {
				next.status = 'viewed';
			}
			this._log('Pulled from future stack:', revid);
		} else {
			// New item: pull the next highest priority item from the ready queue
			next = this.queue.nextReady();
			if (next) {
				this.queue.markViewed(next.revid);
				this._log('Pulled from ready queue:', next.revid);
			}
		}

		this._currentRevid = next?.revid ?? null;

		if (!next) {
			this._log('No next item available.');
		}

		// Trigger the rendering process for the selected change
		await this._render(this.currentChange);
	}

	/**
	 * Goes back to the previously viewed change.
	 */
	async showPrevious() {
		this._log('showPrevious requested');

		// Push current item to future stack so we can "redo" back to it
		if (this._currentRevid) {
			this._futureStack.push(this._currentRevid);
		}

		// Pull last item from history
		const revid = this._historyStack.pop() ?? null;
		
		if (revid) {
			const change = this.queue.get(revid);
			if (change) {
				change.status = 'viewed';
			}
		}

		this._currentRevid = revid;
		await this._render(this.currentChange);
	}
}

// ── workflows/ActionService.js ──────────────────────────
class ActionService {
	/**
	 * @param {import('./ApiService').ApiService} api
	 * @param {import('./UserTracker').UserTracker} users
	 * @param {import('./ChangeQueue').ChangeQueue} queue
	 * @param {import('./WarningService').WarningService} warnings
	 * @param {import('./SettingsService').SettingsService} settings
	 * @param {import('../state/HistoryStack').HistoryStack} nav
	 * @param {import('./EventEmitter').EventEmitter} events
	 * @param {Function} promptForReason
	 * @param {Function} logCallback
	 * @param {Function} promptBlockReason
	 */
	constructor(api, users, queue, warnings, settings, nav, events, promptForReason, logCallback, promptBlockReason) {
		this.api = api;
		this.users = users;
		this.queue = queue;
		this.warnings = warnings;
		this.settings = settings;
		this.nav = nav;
		this.events = events;
		this.promptForReason = promptForReason;
		this._log = logCallback;
		this.promptBlockReason = promptBlockReason;
	}

	/**
	 * Performs a full rollback and issues an appropriate warning.
	 *
	 * @param {number}      revid
	 * @param {string}      type   Warning type key (from config).
	 * @param {number|null} level  Level (1-4) | 0=auto | null=single-issue.
	 * @param {string|null} reason Free-text reason (for 'other' types).
	 */
	async handleRollback(revid, type, level, reason = null) {
		const change = this.queue.get(revid);
		if (!change) return;

		const { title, user } = change;
		this._log('Handling rollback for:', user, 'on', title);
		this.events.emit('ui:progress', { message: 'Working…', duration: 5 });

		this.users.monitor(user, 'automatic');
		this.queue.promoteUserToMonitored(user);

		await this.nav.showNext();

		// Construct the edit summary
		const isAtAIV = this.users.isAtAIV(user);
		const description = REVERT_DESCRIPTIONS[type] ?? 'edits';
		const reasonPart  = reason ? `: ${reason}` : '';
		const summary = isAtAIV 
		        ? `[[WP:CVPI|Interceptor]]: Reverting edits`
		        : `[[WP:CVPI|Interceptor]]: Reverting ${description} by [[Special:Contributions/${user}|${user}]]${reasonPart}`;
		// 1. Perform Rollback
		const rollbackResult = await this.api.rollback(title, user, summary);

		if (!rollbackResult.success) {
			this._log('Rollback failed for:', title);
			this.events.emit('ui:progress', { message: rollbackResult.error || 'Rollback failed', duration: 3 });
			return;
		}

		// Mark stale immediately for instantaneous visual feedback
		change.stale = true;
		if (this.nav.currentRevid === revid) {
			this.events.emit('ui:updateStaleIndicator', true);
		}

		this.events.emit('ui:progress', { message: 'Rollback complete' });
		this.settings.incrementStat('recentChanges', 'reverted');

		// 2. Handle 'Other' types (delegated to Twinkle)
		if (type === 'other' || type === 'other_agf') {
			const openTwinkle = this.settings.load().preferences.openTwinkleOnOther !== false;
			if (openTwinkle) {
				window.open(wikiUrl('twinkletalk', user, revid, title), '_blank');
			}
			return;
		}

		// 3. Issue Warning
		// Skip automatic warning (level 0) if user is blocked or already at AIV.
		const isBlocked = this.users.isBlocked(user);
		const shouldSkipWarn = (level === 0 || level === '0') && (isBlocked || isAtAIV);

		let warnResult = { success: true };
		if (shouldSkipWarn) {
			warnResult = { success: true, skipped: true, reason: isBlocked ? 'blocked' : 'user_at_aiv' };
		} else {
			warnResult = await this.warnings.warn({ user, title }, type, level);
		}

		if (!warnResult.success) {
			if (type === 'vandalism' && warnResult.reason === 'max_level') {
				// Vandalism past final warning -> prompt for AIV or do nothing if admin
				if (this.users.toolUser.isAdmin) {
					this._log('User reached max warning level but we are an administrator');
					this.events.emit('ui:progress', { message: 'User already at max warning level', duration: 3 });
				} else {
					this._log('User reached max warning level, reporting to AIV:', user);
					this.reportToAIV(revid, 'Vandalism past final warning', warnResult.additionalNote);
				}
			} else if (warnResult.reason === 'max_level') {
				this.events.emit('ui:progress', { message: 'User already at max warning level', duration: 3 });
			} else if (warnResult.error) {
				this.events.emit('ui:progress', { message: warnResult.error, duration: 3 });
			}
		} else if (warnResult.skipped) {
			const reasonMsg = warnResult.reason === 'blocked' ? 'user is blocked' : 'user at AIV';
			this.events.emit('ui:progress', { message: `Warning skipped: ${reasonMsg}`, duration: 3 });
		} else {
			this.events.emit('ui:progress', { message: 'Warn complete' });
			// Update statistics for warnings
			const statLevel = warnResult.level === null ? 0 : warnResult.level;
			this.settings.incrementStat('warnings', `uw-${type}`, statLevel);
		}
		// Update session tracking for the user's warning level
		const newLevel = warnResult.level ?? 0;
		this.users.updateLevel(user, newLevel);
		const sessionMax = this.users.getHighestLevel(user);
		this.events.emit('ui:updateField', { revid: revid, fieldType: 'warnlevel', newValue: { level: newLevel, sessionMax } });

		// Refresh page history so it includes our rollback
		try {
			const historyData = await this.api.getPageHistoryData(title);
			this.queue.applyHistoryData(revid, historyData);

			// If the user happens to have navigated back to this item already, update the UI
			if (this.nav.currentRevid === revid) {
				this.events.emit('ui:syncState', change);
			}
		} catch (err) {
			this._log('Failed to refresh history after rollback:', err);
		}
	}

	/**
	 * Submits a report to the AIV page.
	 *
	 * @param {number}      revid           - The revision ID that triggered this report.
	 * @param {string|null} message         - The reason for the report. If null, the user will be prompted.
	 * @param {string|null} additionalNote  - Optional additional note (e.g., blanked talk page).
	 */
	async reportToAIV(revid, message = null, additionalNote = null) {
		const change = this.queue.get(revid);
		if (!change) return;

		const { user } = change;

		// 1. Safety check: Is user already reported in this session or blocked?
		if (this.users.isAtAIV(user) || this.users.isBlocked(user)) {
			if (message === null) {
				this.events.emit('ui:progress', { message: `${user} is already reported or blocked` });
				if (this.nav.currentRevid === revid) {
					this.events.emit('ui:syncState', change);
				}
			}
			return;
		}

		// 2. Manual Reporting Flow: Prompt for reason and advance UI
		let reportMessage = message;
		if (reportMessage === null) {
			const reason = await this.promptForReason(`Reason for reporting ${user} to AIV (required)`, { choices: REASONS_FOR_MANUAL_AIV_REPORT });
			if (!reason || reason.trim() === '') {
				this.events.emit('ui:progress', { message: 'Cancelled' });
				if (this.nav.currentRevid === revid) {
					this.events.emit('ui:syncState', change);
				}
				return;
			}
			reportMessage = reason.trim();
			
			this._log('Handling manual AIV report for:', user, 'Reason:', reportMessage);
		}

		this.events.emit('ui:progress', { message: 'Reporting…', duration: 5 });

		// 3. Remote check: Is user already blocked?
		const blockedResult = await this.api.isUserBlocked(user);
		if (!blockedResult.success) {
			this.events.emit('ui:progress', { message: blockedResult.error, duration: 3 });
			if (this.nav.currentRevid === revid) {
				this.events.emit('ui:syncState', change);
			}
			return;
		}
		if (blockedResult.blocked) {
			this.events.emit('ui:progress', { message: `${user} is already blocked` });
			if (!this.users.isBlocked(user)) {
				this.users.markBlocked(user);
			}
			if (this.nav.currentRevid === revid) {
				this.events.emit('ui:syncState', change);
			}
			return;
		}

		// 4. Remote check: Is user already reported on-wiki?
		const reportedResult = await this.api.getUsersAtAIV();
		if (!reportedResult.success) {
			this.events.emit('ui:progress', { message: reportedResult.error, duration: 3 });
			if (this.nav.currentRevid === revid) {
				this.events.emit('ui:syncState', change);
			}
			return;
		}

		// Sync our local session list with the current AIV page
		for (const u of reportedResult.users) {
			if (!this.users.isAtAIV(u)) {
				this.users.markAtAIV(u);
			}
		}

		if (this.users.isAtAIV(user)) {
			this.events.emit('ui:progress', { message: `${user} is already at AIV` });
			if (this.nav.currentRevid === revid) {
				this.events.emit('ui:syncState', change);
			}
			return;
		}

		// 5. Post the report
		const formattedAdditionalNote = additionalNote ? `; ${additionalNote}`: '';
		const entry = `\n* {{vandal|${user}}} &ndash; ${reportMessage}${formattedAdditionalNote} ~~~~`;

		let summary = `[[WP:CVPI|Interceptor]]: Reporting [[Special:Contributions/${user}|${user}]]`;
		if (message === null) {
			summary = `[[WP:CVPI|Interceptor]]: Reporting an editor`;
		}

		const reportResult = await this.api.appendToPage(
			'Wikipedia:Administrator intervention against vandalism',
			entry,
			summary
		);

		if (reportResult.success) {
			this.events.emit('ui:progress', { message: `Reported ${user} to AIV` });
			this.users.markAtAIV(user);
			this.settings.incrementStat('reports', 'AIV');

			if (this.nav.currentRevid === revid) {
				this.events.emit('ui:syncState', change);
			}
		} else {
			this.events.emit('ui:progress', { message: reportResult.error || 'Failed to report to AIV', duration: 3 });
			if (this.nav.currentRevid === revid) {
				this.events.emit('ui:syncState', change);
			}
		}
	}

	/**
	 * Prompts the user for a custom reason, then performs a rollback.
	 *
	 * @param {number} revid
	 * @param {boolean} agf Whether this is an Assume Good Faith revert.
	 */
	async promptAndRollback(revid, agf) {
		const title = agf ? 'Revert (AGF) - Reason' : 'Revert - Reason';
		const reason = await this.promptForReason(title);
		if (reason !== null && reason.trim() !== '') {
			await this.handleRollback(revid, agf ? 'other_agf' : 'other', null, reason);
		}
	}

	/**
	 * Issues a warning without performing a rollback.
	 * Used for single-issue templates or things already reverted.
	 *
	 * @param {number} revid
	 * @param {string} type
	 */
	async handleWarnOnly(revid, type) {
		const change = this.queue.get(revid);
		if (!change) return;

		this._log('Handling warn-only for:', change.user, type);
		this.events.emit('ui:progress', { message: 'Working…', duration: 5 });

		const { title, user } = change;
		const warnResult = await this.warnings.warn({ user, title }, type, null);

		this.users.monitor(user, 'automatic');
		this.queue.promoteUserToMonitored(user);

		if (!warnResult.success) {
			if (warnResult.reason === 'max_level') {
				this.events.emit('ui:progress', { message: 'User already at max warning level', duration: 3 });
			} else {
				this.events.emit('ui:progress', { message: warnResult.error, duration: 3 });
			}
			return;
		}

		this.events.emit('ui:progress', { message: 'Warn complete' });
		this.settings.incrementStat('warnings', `uw-${type}`, 0);

		// Update UI tracking
		const newLevel = warnResult.level ?? 0;
		this.users.updateLevel(user, newLevel);
		const sessionMax = this.users.getHighestLevel(user);
		this.events.emit('ui:updateField', { revid: revid, fieldType: 'warnlevel', newValue: { level: newLevel, sessionMax } });
		if (this.nav.currentRevid === revid) {
			this.events.emit('ui:syncState', change);
		}
	}

	/**
	 * Prompts for block reason, executes block, and adds block template to user talk.
	 * 
	 * @param {number} revid - Triggering revision ID.
	 */
	async handleBlock(revid) {
		const change = this.queue.get(revid);
		if (!change) return;

		const { user } = change;

		// 1. Prompt for reason
		const blockConfig = await this.promptBlockReason(user);
		if (!blockConfig) {
			this.events.emit('ui:progress', { message: 'Cancelled' });
			return;
		}

		this.events.emit('ui:progress', { message: 'Blocking...', duration: 5 });

		// 2. Execute block
		const blockResult = await this.api.block(user, blockConfig.summary, blockConfig.expiry);
		if (!blockResult.success) {
			this.events.emit('ui:progress', { message: blockResult.error || 'Failed to block', duration: 3 });
			return;
		}

		// Update local blocked state
		this.users.markBlocked(user);
		if (this.nav.currentRevid === revid) {
			this.events.emit('ui:syncState', change);
		}

		this.events.emit('ui:progress', { message: 'User blocked. Adding template...', duration: 5 });

		// 3. Post block template to talk page
		const warnResult = await this.warnings.postBlockTemplate(user, blockConfig.template, blockConfig.summary);
		if (!warnResult.success) {
			this.events.emit('ui:progress', { message: warnResult.error || 'Failed to add block template', duration: 3 });
		} else {
			this.events.emit('ui:progress', { message: 'Block complete' });
		}
	}
}

// ── services/MLRSScanner.js ──────────────────────────
class MLRSScanner {
	/**
	 * @param {import('./ApiService').ApiService} api
	 * @param {import('./UserTracker').UserTracker} users
	 * @param {import('./ChangeQueue').ChangeQueue} queue
	 * @param {import('./EventEmitter').EventEmitter} events
	 * @param {Function} logCallback
	 */
	constructor(api, users, queue, events, logCallback) {
		this.api = api;
		this.users = users;
		this.queue = queue;
		this.events = events;
		this._log = logCallback;

		/** @type {Set<number>} Set of Recent Change IDs processed by MLRS during this session */
		this._processedRcids = new Set();

		/** @type {number|null} Reference to the interval timer */
		this._timer = null;
	}

	/**
	 * Starts the periodic MLRS discovery loop.
	 */
	start() {
		if (this._timer) return;

		this._log('Starting MLRS Scanner...');
		this._timer = setInterval(() => this.scan(), CVPI_CONFIG.LOGIC.MLRS_INTERVAL_MS);
		
		// Run initial scan immediately
		this.scan();
	}

	/**
	 * Stops the scanner.
	 */
	stop() {
		if (this._timer) {
			clearInterval(this._timer);
			this._timer = null;
		}
	}

	/**
	 * Multiple Logged Rollbackers Seen (MLRS)
	 * Fetches recent rollbacks and identifies users being reverted by 2+ unique editors.
	 */
	async scan() {
		this._log('MLRS Discovery starting...');
		
		try {
			const lookbackMs = CVPI_CONFIG.LOGIC.MLRS_LOOKBACK_MS;
			const rollbacks = await this.api.getRecentRollbacks(lookbackMs);
			
			if (!rollbacks?.length) {
				if (window.CVPI_DEBUG) this.events.emit('ui:progress', { message: 'MLRS: 0 rollbacks found', duration: 3 });
				return;
			}

			const myName = mw.user.getName();
			
			for (const rb of rollbacks) {
				// skip if we've already seen this recent change entry
				if (this._processedRcids.has(rb.rcid)) continue;
				this._processedRcids.add(rb.rcid);

				// Exclude rollbacks performed by the current user
				if (rb.user === myName) continue;

				const targetUser = extractUserFromRollbackComment(rb.comment);
				if (!targetUser) continue;

				// Record the rollback and check if it crosses the monitoring threshold
				const shouldMonitor = this.users.recordMLRSRollback(targetUser, rb.user);

				if (shouldMonitor && !this.users.shouldShowAsMonitored(targetUser)) {
					this._log(`MLRS: Monitoring ${targetUser} (threshold reached via persistent history)`);
					
					this.users.monitor(targetUser, 'MLRS');
					
					// Any edits by this user already in the queue should be promoted immediately
					this.queue.promoteUserToMonitored(targetUser);
				}
			}

		} catch (err) {
			console.error('[CVPI] MLRS error:', err);
		}
	}
}

// ── services/EditFilterLogScanner.js ──────────────────────────
class EditFilterLogScanner {
	/**
	 * @param {import('./ApiService').ApiService} api
	 * @param {import('../state/UserTracker').UserTracker} users
	 * @param {import('../state/ChangeQueue').ChangeQueue} queue
	 * @param {import('../core/EventEmitter').EventEmitter} events
	 * @param {Function} logCallback
	 */
	constructor(api, users, queue, events, logCallback) {
		this.api = api;
		this.users = users;
		this.queue = queue;
		this.events = events;
		this._log = logCallback;

		/** @type {Set<number>} Set of Edit Filter Log IDs processed during this session */
		this._processedLogIds = new Set();

		/** @type {number|null} Reference to the interval timer */
		this._timer = null;
	}

	/**
	 * Starts the periodic Edit Filter Log discovery loop.
	 */
	start() {
		if (this._timer) return;

		this._log('Starting Edit Filter Log Scanner...');
		this._timer = setInterval(() => this.scan(), CVPI_CONFIG.LOGIC.EDIT_FILTER_LOG_INTERVAL_MS);
		
		// Run initial scan immediately
		this.scan();
	}

	/**
	 * Stops the scanner.
	 */
	stop() {
		if (this._timer) {
			clearInterval(this._timer);
			this._timer = null;
		}
	}

	/**
	 * Fetches recent edit filter triggers and monitors users who hit them.
	 */
	async scan() {
		this._log('Edit Filter Log Discovery starting...');
		
		try {
			const lookbackMs = CVPI_CONFIG.LOGIC.EDIT_FILTER_LOG_LOOKBACK_MS;
			const triggers = await this.api.getRecentEditFilterTriggers(lookbackMs);
			
			if (!triggers?.length) {
				return;
			}

			const myName = mw.user.getName();
			
			for (const entry of triggers) {
				// skip if we've already seen this log entry
				if (this._processedLogIds.has(entry.id)) continue;
				this._processedLogIds.add(entry.id);

				// Exclude triggers performed by the current user
				if (entry.user === myName) continue;

				const urgentFilter = CVPI_CONFIG.API.URGENT_EDIT_FILTER_LOG_IDS.split('|').includes(entry.filter_id);
				
				const reason = entry.filter_id ? `editfilter-${urgentFilter ? 'high' : 'low' }-${entry.filter_id}` : 'editfilter';
				const didMonitor = this.users.monitor(entry.user, reason);
				
				if (didMonitor) {
					this._log(`EditFilter: Monitoring ${entry.user} (triggered edit filter ${entry.filter_id || ''})`);
					
					// Any edits by this user already in the queue should be promoted immediately
					this.queue.promoteUserToMonitored(entry.user);
				}
			}

		} catch (err) {
			console.error('[CVPI] EditFilterLog error:', err);
		}
	}
}

// ── workflows/ChangeDetailLoader.js ──────────────────────────
class ChangeDetailLoader {
	/**
	 * @param {import('./ApiService').ApiService} api
	 * @param {import('./WarningService').WarningService} warnings
	 * @param {import('./UserTracker').UserTracker} users
	 * @param {import('./EventEmitter').EventEmitter} events
	 * @param {Function} logCallback
	 */
	constructor(api, warnings, users, events, logCallback) {
		this.api = api;
		this.warnings = warnings;
		this.users = users;
		this.events = events;
		this._log = logCallback;
	}

	/**
	 * Kicks off parallel async operations to fetch detailed data for a change.
	 * 
	 * @param {import('./ChangeQueue').Change} change
	 * @param {() => import('./ChangeQueue').Change|null} getActiveChange - Callback to get the current change object on screen
	 */
	async load(change, getActiveChange) {
		// Run all loads in parallel
		return Promise.all([
			this._loadDiff(change, getActiveChange),
			this._loadWarnLevel(change, getActiveChange)
		]);
	}

	/**
	 * Fetches and displays the diff HTML for a change.
	 * 
	 * @private
	 */
	async _loadDiff(change, getActiveChange) {
		const { old_revid, diff_from_revid, diffPromise } = change;

		try {
			let html;
			if (diffPromise) {
				html = await diffPromise;
			} else if (old_revid === 0) {
				html = '<i>(New page)</i>';
				change.diffPromise = Promise.resolve(html);
			} else {
				const res = await this.api.getDiff(diff_from_revid ?? old_revid, change.revid);
				if (res) {
					change.diffsize = res.size;
					html = res.html;
					// Cache the diff for future navigation (e.g. going back in history)
					change.diffPromise = Promise.resolve(html);
				}
			}

			// Only update if this is still the active change object
			if (getActiveChange() === change) {
				this.events.emit('ui:updateField', { revid: change.revid, fieldType: 'diff', newValue: html ?? null });
				this.events.emit('ui:updateField', { revid: change.revid, fieldType: 'diffsize', newValue: change.diffsize });
				
				// Post-render UX
				setTimeout(() => this.events.emit('ui:scrollToFirstChange'), CVPI_CONFIG.UI.SCROLL_DELAY_MS);
				this.events.emit('ui:updateStaleIndicator', change.stale);
			}

		} catch (err) {
			this._log('diff load error:', err);
			if (getActiveChange() === change) {
				this.events.emit('ui:updateField', { revid: change.revid, fieldType: 'diff', newValue: '<i>Error loading diff.</i>' });
			}
		}
	}

	/**
	 * Fetches the current warning level and talk page content for a user.
	 * 
	 * @private
	 */
	async _loadWarnLevel(change, getActiveChange) {
		const { user } = change;

		try {
			const { level, warnings } = await this.warnings.fetchWarnings(user);

			this.users.updateLevel(user, level);
			const sessionMax = this.users.getHighestLevel(user);

			change.warnLevel = level;
			this.users.setWarnings(user, warnings);

			if (getActiveChange() === change) {
				this.events.emit('ui:updateField', { revid: change.revid, fieldType: 'warnlevel', newValue: { level, sessionMax } });
				this.events.emit('ui:syncState', change);
			}

		} catch (err) {
			this._log('warn level error:', err);
			if (getActiveChange() === change) {
				this.events.emit('ui:updateField', { revid: change.revid, fieldType: 'warnlevel', newValue: '?' });
			}
		}
	}
}

// ── ui/UIManager.js ──────────────────────────
class UIManager {
	/**
	 * @param {import('../core/EventEmitter').EventEmitter} events
	 * @param {import('./ActionBus').ActionBus} actionBus
	 * @param {object} cb
	 * @param {() => number} cb.getThreshold
	 * @param {(key: string) => any} cb.getPreference
	 * @param {() => object} cb.getStats
	 * @param {() => number} cb.getApiCallCount
	 * @param {(user: string) => object} cb.getUser
	 * @param {() => object} cb.getDeviceStats
	 * @param {string} cb.staleWarningMethod
	 */
	constructor(events, actionBus, cb) {
		this._events = events;
		this._actionBus = actionBus;
		this._cb = cb;
		this._progressTimers = [];
		this._isProgressActive = false;
		this._liveTimeInterval = null;
		this._clockInterval = null;
		this._activeRevid = null;
		this._timezone = 'UTC';

		// Store original page state for restoration
		this._originalTitle = document.title;
		this._originalOverflow = document.body.style.overflow;

		// Bind event handlers so they can be removed
		this._onVisibilityChangeBound = () => this._onVisibilityChange();

		this._toolbarManager = new ToolbarManager(this._events, () => this._resetDashboardGroups());
		this._registerEvents();
	}

	// ─── Setup ─────────────────────────────────────────────────────────────

	updateTimezone(timecorrection) {
		if (!timecorrection) return;

		const parts = timecorrection.split('|');
		const type = parts[0];

		if (type === 'ZoneInfo' && parts[2]) {
			this._timezone = parts[2];
		} else if (type === 'Offset' || type === 'System') {
			const offset = parseInt(parts[1], 10);
			if (!isNaN(offset)) {
				this._timezone = offset; // Store as numeric offset in minutes
			}
		}

		// Refresh clock immediately
		this._startClock();
	}

	setupPage() {
		document.title = 'CVPI';
		document.body.style.overflow = 'clip';

		/*
		// Prevent zooming since I keep accidentally zooming and it breaks the dimensions
		let viewport = document.querySelector('meta[name="viewport"]');
		if (!viewport) {
			viewport = document.createElement('meta');
			viewport.name = 'viewport';
			document.head.appendChild(viewport);
		}
		viewport.content = 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no';
		*/
		this._injectFonts();
		this._injectStyles();

		document.body.appendChild(ComponentBuilder.createElement('div', { className: 'cvpi-background-blur' }));

		this._root = ComponentBuilder.createElement('div', {
			className: 'cvpi-wrapper',
			innerHTML: `
				<p id="cvpi-loading-message">Loading CVPI…</p>
				<div class="cvpi-footer">
					<div id="cvpi-footer-status" class="cvpi-footer-cell--status-area">
						<span id="cvpi-footer-analyzing-container">
							<span id="cvpi-footer-data--analyzing">Analyzing 0 edits...</span>
						</span>
					</div>
					<div class="cvpi-footer__center">
						<span id="cvpi-footer-reason" class="cvpi-meta__reason"></span>
					</div>
					<div id="cvpi-footer-clock" class="cvpi-footer-timestamp">--:--:--</div>
				</div>
			`
		});
		
		document.body.appendChild(this._root);

		if (this._cb.getPreference('showLabels') === false) {
			this._root.classList.add('cvpi-hide-labels');
		}

		if (this._cb.getPreference('colorblindMode') === true) {
			this._root.classList.add('cvpi-colorblind');
		}

		this._startClock();

		document.addEventListener('visibilitychange', this._onVisibilityChangeBound);
		window.addEventListener('resize', () => this._syncLayoutMode());

		this._buildStaticUI();
		this._syncLayoutMode();
	}

	_registerEvents() {
		this._events.on('ui:progress', ({ message, duration }) => this.showProgress(message, duration));
		this._events.on('ui:updateField', ({ revid, fieldType, newValue }) => this.updateSpecificFieldForRevision(revid, fieldType, newValue));
		this._events.on('ui:updateStaleIndicator', (isStale) => this.updateStaleIndicator(isStale));
		this._events.on('ui:syncState', (change) => this.syncState(change));
		this._events.on('ui:scrollToFirstChange', () => this.scrollToFirstChange());
		
		this._events.on('ui:toolbar-opened', ({ panel }) => {
			if (panel && panel.id === 'cvpi-dashboard-container') {
				this._actionBus.setMode('DASHBOARD');
			}
		});

		this._events.on('ui:toolbar-closed', () => {
			if (this._actionBus.currentMode === 'DASHBOARD') {
				this._actionBus.setMode('DEFAULT');
			}
		});

		// Dashboard menu resets
		this._events.on('action:cvpi-action-rollback', (data) => this._handleRollbackClick(data));
		this._events.on('action:cvpi-action-warn-only', (data) => {
			this._toolbarManager.closeAllMenus();
			this._resetDashboardGroups();
		});
		this._events.on('action:cvpi-action-other-rollback', (data) => {
			this._toolbarManager.closeAllMenus();
			this._resetDashboardGroups();
		});
		this._events.on('action:cvpi-action-report-aiv', (data) => this._handleReportClick(data));
		this._events.on('action:cvpi-action-block', (data) => this._handleBlockClick(data));
		this._events.on('action:cvpi-action-arm-single', (data) => this._handleArmSingle(data));

		// Preferences & Toggles
		this._events.on('action:cvpi-action-label-toggle', (data) => {
			const showLabels = !!data.event.target.checked;
			this._root.classList.toggle('cvpi-hide-labels', !showLabels);
		});
		this._events.on('action:cvpi-action-colorblind-toggle', (data) => {
			const colorblindMode = !!data.event.target.checked;
			this._root.classList.toggle('cvpi-colorblind', colorblindMode);
		});

		this._events.on('action:cvpi-action-show-statistics', () => {
			const deviceStatistics = this._cb.getDeviceStats();
			var percentage = 0;
			if (deviceStatistics.recentChanges.seen && deviceStatistics.recentChanges.reverted) {
				percentage = ((deviceStatistics.recentChanges.reverted / deviceStatistics.recentChanges.seen) * 100).toFixed(1);
			}
			const statisticsHtml = `
			<ul style="list-style: none;">
				<li><strong>Edits seen:</strong> ${deviceStatistics.recentChanges?.seen || 0}</li>
				<li><strong>Edits reverted:</strong> ${deviceStatistics.recentChanges?.reverted || 0} (${percentage}%)</li>
				<li><strong>AIV reports:</strong> ${deviceStatistics.reports?.AIV || 0}</li>
			</ul>
			`;
			this.showInfoModal('Device statistics', statisticsHtml);
		});
		
		// Fullscreen
		this._events.on('action:cvpi-action-fullscreen', () => {
			if (!document.fullscreenElement) {
				document.documentElement.requestFullscreen().catch(err => {
					console.error(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`);
				});
			} else {
				document.exitFullscreen();
			}
		});
	}

	_handleRollbackClick(data) {
		const btn = data.target;
		const group = btn.closest('.cvpi-rollback-group');
		const isMulti = group?.classList.contains('cvpi-rollback-group--multi');
		const isAutoBtn = btn.classList.contains('cvpi-btn--auto');
		
		if (isMulti && isAutoBtn && this._root.classList.contains('cvpi-mobile-layout')) {
			if (!group.classList.contains('cvpi-rollback-group--active')) {
				this._resetDashboardGroups();
				group.classList.add('cvpi-rollback-group--active');
				group.querySelectorAll('button:not(.cvpi-btn--auto)').forEach(b => b.classList.remove('cvpi-hidden'));
				
				// Stop the event here so App.js doesn't actually execute the rollback yet
				data.stopPropagation = true;
				return;
			}
		}

		this._toolbarManager.closeAllMenus();
		this._resetDashboardGroups();
	}

	_handleReportClick(data) {
		const btn = data.target;
		if (!btn.classList.contains('cvpi-btn--active')) {
			this._resetDashboardGroups();
			btn.classList.add('cvpi-btn--active');
			btn._originalText = btn.innerHTML;
			btn.innerHTML = `Confirm Report?`;
			data.stopPropagation = true; // Don't let App.js hear this until confirmed
			return;
		}
		this._toolbarManager.closeAllMenus();
		this._resetDashboardGroups();
	}

	_handleBlockClick(data) {
		this._toolbarManager.closeAllMenus();
		this._resetDashboardGroups();
	}

	_handleArmSingle(data) {
		const btn = data.target;
		const group = btn.closest('.cvpi-rollback-group');
		if (group && this._root.classList.contains('cvpi-mobile-layout')) {
			this._resetDashboardGroups();
			group.classList.add('cvpi-rollback-group--active');
			group.querySelectorAll('button:not(.cvpi-btn--arm)').forEach(b => b.classList.remove('cvpi-hidden'));
		}
	}

	_syncLayoutMode() {
		if (this._root && window.innerWidth < 1000) {
			this._root.classList.add('cvpi-mobile-layout');
		} else if (this._root) {
			this._root.classList.remove('cvpi-mobile-layout');
		}
	}

	_buildStaticUI() {
		// Static Toolbars
		this._actionsToolbar = this._buildStaticActionsToolbar();
		this._root.appendChild(this._actionsToolbar);
		this._root.appendChild(this._buildStaticNavToolbar());

		// Progress notification container (for "sliding out from under" the actions toolbar)
		this._progressNotification = ComponentBuilder.createElement('div', { className: 'cvpi-progress-notification' });
		this._root.appendChild(this._progressNotification);

		// Static containers for History and Talk (Sidebar version)
		this._historySidebarPane = document.createElement('div');
		this._historySidebarPane.className = 'cvpi-history cvpi-sidebar-pane--history cvpi-sidebar-pane';
		this._root.appendChild(this._historySidebarPane);

		this._talkSidebarPane = document.createElement('div');
		this._talkSidebarPane.className = 'cvpi-talk cvpi-sidebar-pane--talk cvpi-sidebar-pane';
		this._root.appendChild(this._talkSidebarPane);

		// Static container for Change Component (Diffs)
		this._changeComponentContainer = document.createElement('div');
		this._changeComponentContainer.style.display = 'contents';
		this._root.appendChild(this._changeComponentContainer);
	}

	_buildStaticNavToolbar() {
		const stepperWrapper = ComponentBuilder.createElement('div', { className: 'cvpi-stepper-wrapper' }, [
			ComponentBuilder.createElement('div', { className: 'cvpi-stepper-btn', textContent: '−', 'data-action': 'cvpi-action-threshold-dec' }),
			ComponentBuilder.createElement('div', { className: 'cvpi-stepper-value', id: 'cvpi-action-threshold-value-display', textContent: this._cb.getThreshold().toFixed(2) }),
			ComponentBuilder.createElement('div', { className: 'cvpi-stepper-btn', textContent: '+', 'data-action': 'cvpi-action-threshold-inc' })
		]);

		const labelToggleContainer = ComponentBuilder.createElement('label', { className: 'cvpi-switch' }, [
			ComponentBuilder.createElement('input', {
				type: 'checkbox',
				'data-action': 'cvpi-action-label-toggle',
				checked: this._cb.getPreference('showLabels') !== false
			}),
			ComponentBuilder.createElement('span', { className: 'cvpi-switch-slider' })
		]);

		const twinkleToggleContainer = ComponentBuilder.createElement('label', { className: 'cvpi-switch' }, [
			ComponentBuilder.createElement('input', {
				type: 'checkbox',
				'data-action': 'cvpi-action-twinkle-toggle',
				checked: this._cb.getPreference('openTwinkleOnOther') !== false
			}),
			ComponentBuilder.createElement('span', { className: 'cvpi-switch-slider' })
		]);

		const colorblindToggleContainer = ComponentBuilder.createElement('label', { className: 'cvpi-switch' }, [
			ComponentBuilder.createElement('input', {
				type: 'checkbox',
				'data-action': 'cvpi-action-colorblind-toggle',
				checked: this._cb.getPreference('colorblindMode') === true
			}),
			ComponentBuilder.createElement('span', { className: 'cvpi-switch-slider' })
		]);

		const moreMenu = [
			{ type: 'custom', label: 'ORES Threshold', icon: 'gate', keepOpen: true, element: stepperWrapper },
			{ type: 'custom', label: 'Show UI labels', keepOpen: true, element: labelToggleContainer },
			{ type: 'custom', label: 'Colorblind mode', keepOpen: true, element: colorblindToggleContainer },
			{ type: 'custom', label: "Open user talk on 'other' rollbacks", keepOpen: true, element: twinkleToggleContainer },
			{
				actionId: 'cvpi-action-changelog',

				label: 'Changelog',
				icon: 'list_alt',
				keepOpen: true,
				toolbarPanel: this._buildChangelogElement(CVPI_CHANGELOG)
			},
			{
				actionId: 'cvpi-action-show-statistics',
				label: 'Show statistics',
				'icon': 'heap_snapshot_thumbnail'
			},
			{
				actionId: 'cvpi-action-fullscreen',
				label: 'Full screen',
				icon: 'fullscreen'
			}
		];

		const config = [
			{
				actionId: 'cvpi-action-more',
				bottomLabel: 'More',
				icon: 'more_vert',
				verticalMenu: moreMenu
			},
			{ actionId: 'cvpi-action-prev', bottomLabel: 'Prev', icon: 'arrow_back', disabled: true, itemClassName: 'cvpi-only-mobile' },
			{ actionId: 'cvpi-action-next', bottomLabel: 'Next', icon: 'arrow_forward', disabled: true, itemClassName: 'cvpi-only-mobile' }
		];
		return this._createToolbar(config, 'cvpi-toolbar--top', 'bottom');
	}

	_buildStaticActionsToolbar() {
		this._historyToolbarPanel = document.createElement('div');
		this._historyToolbarPanel.className = 'cvpi-history cvpi-toolbarPanel--history cvpi-toolbarPanel';

		this._talkToolbarPanel = document.createElement('div');
		this._talkToolbarPanel.className = 'cvpi-talk cvpi-toolbarPanel--talk cvpi-toolbarPanel';

		this._dashboardContainer = ComponentBuilder.createElement('div', { 
			id: 'cvpi-dashboard-container',
			className: 'cvpi-rollback-dashboard'
		});

		const config = [
			{ actionId: 'cvpi-action-prev', bottomLabel: 'Prev', icon: 'arrow_back', disabled: true, itemClassName: 'cvpi-only-desktop' },
			{
				actionId: 'cvpi-toolbar-history',
				topLabel: 'Page',
				bottomLabel: 'history',
				icon: 'history',
				keepOpen: true,
				disabled: true,
				toolbarPanel: this._historyToolbarPanel,
				itemClassName: 'cvpi-only-mobile'
			},
			{
				id: 'cvpi-btn-monitor',
				actionId: 'cvpi-toolbar-monitor',
				topLabel: 'Monitor',
				bottomLabel: 'editor',
				icon: 'person_add',
				keepOpen: true,
				disabled: true
			},
			{
				id: 'cvpi-btn-vandalism',
				actionId: 'cvpi-toolbar-vandalism',
				bottomLabel: 'Roll & warn',
				icon: 'warning',
				className: 'cvpi-toolbar-btn--vandalism',
				disabled: true
			},
			{
				actionId: 'cvpi-toolbar-dashboard',
				topLabel: 'Open',
				bottomLabel: 'dashboard',
				icon: 'view_cozy',
				disabled: true,
				toolbarPanel: this._dashboardContainer
			},
			{ actionId: 'cvpi-action-next', bottomLabel: 'Next', icon: 'arrow_forward', disabled: true, itemClassName: 'cvpi-only-desktop' },
			{
				actionId: 'cvpi-toolbar-talk',
				topLabel: 'User',
				bottomLabel: 'talk',
				icon: 'forum',
				keepOpen: true,
				disabled: true,
				toolbarPanel: this._talkToolbarPanel,
				itemClassName: 'cvpi-only-mobile'
			}
		];

		return this._createToolbar(config, 'cvpi-toolbar--actions', 'top');
	}

	/**
	 * Cleans up the UI, restores original page styles, and removes event listeners.
	 */
	destroy() {
		if (this._clockInterval) clearInterval(this._clockInterval);
		this._progressTimers.forEach(clearTimeout);

		document.removeEventListener('visibilitychange', this._onVisibilityChangeBound);

		this._root?.remove();

		document.title = this._originalTitle;
		document.body.style.overflow = this._originalOverflow;
	}

	_onVisibilityChange() {
		if (document.visibilityState === 'visible') {
			document.title = 'CVPI';
		} else {
			this.updateQueueDisplay(this._cb.getStats());
		}
	}

	// ─── Progress banner ───────────────────────────────────────────────────

	/** @param {string} message  @param {number} [duration=2] seconds */
	showProgress(message, duration = 2) {
		const notificationEl = this._progressNotification;
		if (!notificationEl) return;

		this._progressTimers.forEach(clearTimeout);
		this._progressTimers = [];
		this._isProgressActive = true;

		notificationEl.textContent = message;
		notificationEl.classList.add('cvpi-progress-notification--visible');
		
		if (duration > 0) {
			this._progressTimers.push(setTimeout(() => {
				this.hideProgress();
			}, duration * 1000));
		}
	}

	hideProgress() {
		this._isProgressActive = false;
		const notificationEl = this._progressNotification;
		if (!notificationEl) return;

		notificationEl.classList.remove('cvpi-progress-notification--visible');
	}

	// ─── Main display ──────────────────────────────────────────────────────

	/**
	 * Re-renders the full viewer for a change (or blank state when null).
	 * @param {import('./ChangeQueue').Change|null} change
	 */
	displayChange(change) {
		this._clearMainContent();

		this._toolbarManager.closeAllMenus(['cvpi-action-more', 'cvpi-action-changelog']);

		this._activeRevid = change ? change.revid : null;

		this.syncState(change);

		if (!change) {
			this._changeComponentContainer.appendChild(this._buildPlaceholder());
		} else {
			this._changeComponentContainer.appendChild(this._buildTitleArea(change));
			this._changeComponentContainer.appendChild(this._buildMetaArea(change));
			this._changeComponentContainer.appendChild(this._buildCommentsArea(change));
			this._changeComponentContainer.appendChild(this._buildDiffArea(change));
			this._changeComponentContainer.appendChild(this._buildScrollArrow());
		}

		// Update footer change-specific promotion reason
		const reasonContainer = document.getElementById('cvpi-footer-reason');
		if (reasonContainer) {
			if (change) {
				const getReasonType = (str) => {
					const [first, second] = str.split('-');
					if (second) {
						return `${first}-${second}`;
					}
					return first;
				};
				const reason = change.reason || 'ORES';
				const reasonType = getReasonType(reason);

				var reasonDisplay = reasonType;
				if (reason == 'ORES') {
					reasonDisplay = 'ORES score';
				} else if (reason.startsWith('editfilter')) {
					reasonDisplay = `user recently tripped edit filter`;
				} else if (reason == 'previously' || reason == 'automatically') {
					reasonDisplay = `prior rollback`;
				}
				
				reasonContainer.innerHTML = `Reason: <span class="cvpi-meta__reason__${reasonType}">${window.CVPI_DEBUG ? reason : reasonDisplay}</span>`;
			} else {
				reasonContainer.innerHTML = '';
			}
		}
	}

	/**
	 * Synchronizes static UI elements with the current change state.
	 * @param {import('./ChangeQueue').Change|null} change
	 */
	syncState(change) {
		// 1. Sync toolbar buttons
		[
			{ id: 'cvpi-toolbar-history', config: this._getHistoryButtonConfig(change) },
			{ id: 'cvpi-toolbar-talk', config: this._getTalkButtonConfig(change) },
			{ id: 'cvpi-toolbar-dashboard', config: this._getDashboardConfig(change) },
			{ id: 'cvpi-toolbar-vandalism', config: this._getVandalismButtonConfig(change) },
			{ id: 'cvpi-toolbar-monitor', config: this._getMonitorButtonConfig(change) }
		].forEach(c => this._updateButtonState(c.id, c.config));

		// 2. Sync revision-specific dataset attributes for toolbar buttons
		['cvpi-btn-vandalism', 'cvpi-btn-monitor'].forEach(id => {
			const btn = document.getElementById(id);
			if (btn) {
				if (change) btn.dataset.revid = change.revid;
				else delete btn.dataset.revid;
			}
		});

		// 3. Sync Panels & Dashboards
		const syncPanel = (panes, buildContent, isList = false) => {
			panes.forEach(({ el, isToolbar }) => {
				if (!el) return;
				el.innerHTML = '';
				if (change) {
					const content = buildContent(change, isToolbar);
					[...content.childNodes].forEach(n => el.appendChild(n));
					if (isList) {
						const list = el.querySelector('.cvpi-talk__list');
						if (list) setTimeout(() => list.scrollTop = list.scrollHeight, 0);
					}
				}
			});
		};

		syncPanel([
			{ el: this._historySidebarPane, isToolbar: false },
			{ el: this._historyToolbarPanel, isToolbar: true }
		], (c, isT) => this._buildHistoryComponent(c, isT));

		syncPanel([
			{ el: this._talkSidebarPane, isToolbar: false },
			{ el: this._talkToolbarPanel, isToolbar: true }
		], (c, isT) => this._buildTalkComponent(isT, c), true);

		if (this._dashboardContainer) {
			this._dashboardContainer.innerHTML = change ? this._getDashboardHtml(change) : '';
		}

		// 4. Generic queue display sync
		this.updateStaleIndicator(change?.stale);
		this.updateQueueDisplay(this._cb.getStats());
	}

	/**
	 * Displays a floating changelog component.
	 * 
	 * @param {object} changelog - The CVPI_CHANGELOG object containing all version updates.
	 * @returns {Promise<void>}
	 */
	showChangelog() {
		return new Promise(resolve => {
			// Open the changelog by simulating a click on the "Changelog" button
			const trigger = this._root.querySelector('[data-action="cvpi-action-changelog"]');
			if (trigger) {
				trigger.click();
			}
			
			// Resolve the promise when the user closes the menu
			const originalClose = this._toolbarManager.closeAllMenus.bind(this._toolbarManager);
			this._toolbarManager.closeAllMenus = () => {
				originalClose();
				this._toolbarManager.closeAllMenus = originalClose;
				resolve();
			};
		});
	}

	// ─── Partial updates (called after async ops complete) ─────────────────

	/**
	 * Updates a specific UI element associated with a change revision.
	 *
	 * @param {number} revid      - The unique revision ID to target.
	 * @param {string} fieldType  - Which part of the UI to update (e.g. 'ores', 'diff', 'warnlevel').
	 * @param {any}    newValue   - The raw data or string to inject.
	 */
	updateSpecificFieldForRevision(revid, fieldType, newValue) {
		const elementId = `cvpi-${fieldType}-${revid}`;
		const el = document.getElementById(elementId);
		if (!el) return;

		switch (fieldType) {
		case 'diff':
			el.innerHTML = newValue ?? '<i>Diff not available.</i>';
			break;

		case 'diffsize':
			el.innerHTML = this._formatSize(newValue);
			break;

		case 'ores':
			el.innerHTML = this._formatOres(newValue);
			break;

		case 'warnlevel': {
			let text = '?';
			let level = 0;

			if (typeof newValue === 'object' && newValue !== null) {
				const { level: lvl, sessionMax } = newValue;
				level = lvl;
				text = `Level: ${sessionMax}`;
				if (sessionMax > level) {
					text += ` (user may have removed warnings)`;
				}
			} else {
				text = `(WL: ${newValue})`;
				level = parseInt(String(newValue), 10) || 0;
			}
			
			el.textContent = text;
			el.style.color = (level >= 4) ? '#d33' : (level >= 2) ? '#f80' : 'inherit';
			break;
		}

		default:
			console.warn(`[CVPI:UI] Unknown fieldType for update: ${fieldType}`);
		}
	}

	/**
	 * Updates a toolbar button's visual state and accessibility attributes.
	 * This method is location-agnostic; it finds the button by its actionId
	 * anywhere within the CVPI root element.
	 *
	 * @param {string} actionId - The unique data-action ID of the button.
	 * @param {Object} state - The desired state changes.
	 * @param {string} [state.label] - New text content for the label.
	 * @param {string} [state.icon] - New Material Icon name.
	 * @param {boolean} [state.disabled] - Whether the button should be disabled.
	 * @param {string} [state.activeClass] - Class to add/toggle (e.g., alert states).
	 * @param {boolean} [state.active] - Whether the activeClass should be present.
	 * @private
	 */
	_updateButtonState(actionId, state) {
		const btns = this._root.querySelectorAll(`[data-action="${actionId}"]`);
		if (!btns.length) return;

		btns.forEach(btn => {
			if (state.topLabel !== undefined) {
				const labelEl = btn.querySelector('.cvpi-action-btn__label--top');
				if (labelEl) labelEl.textContent = state.topLabel;
			}

			if (state.bottomLabel !== undefined) {
				const labelEl = btn.querySelector('.cvpi-action-btn__label--bottom');
				if (labelEl) labelEl.textContent = state.bottomLabel;
			}

			if (state.icon !== undefined) {
				const iconEl = btn.querySelector('.cvpi-action-btn__symbol');
				if (iconEl) iconEl.textContent = state.icon;
			}

			if (state.badge !== undefined) {
				const symbolEl = btn.querySelector('.cvpi-action-btn__symbol');
				if (symbolEl) {
					let badgeEl = symbolEl.querySelector('.cvpi-action-btn__badge');
					if (state.badge === null || state.badge === 0 || state.badge === '') {
						if (badgeEl) badgeEl.remove();
					} else {
						if (!badgeEl) {
							badgeEl = document.createElement('span');
							badgeEl.className = 'cvpi-action-btn__badge';
							symbolEl.appendChild(badgeEl);
						}
						badgeEl.textContent = state.badge;

						// Allow specific styling for the badge (e.g., color coding warning levels)
						if (state.badgeClassName !== undefined) {
							badgeEl.className = `cvpi-action-btn__badge ${state.badgeClassName}`;
						}
					}
				}
			}

			if (state.disabled !== undefined) {
				btn.classList.toggle('cvpi-action-btn--disabled', state.disabled);
				if (state.disabled) {
					btn.setAttribute('disabled', 'true');
				} else {
					btn.removeAttribute('disabled');
				}
			}

			if (state.activeClass !== undefined) {
				btn.classList.toggle(state.activeClass, !!state.active);
			}
		});
	}

	updateStaleIndicator(isStale) {
		const el = document.querySelector('.cvpi-diff');
		if (!el) return;
		el.classList.toggle('cvpi-diff--overridden', isStale === true);
	}

	updateThreshold(value) {
		const el = document.getElementById('cvpi-action-threshold-value-display');
		if (el) el.textContent = value.toFixed(2);
	}

	/**
	 * Updates the footer with current queue statistics.
	 * 
	 * @param {object} stats - Statistics object from ChangeQueue.
	 */
	updateQueueDisplay(stats) {
		if (!stats) return;

		const totalReady = stats.ready + (stats.monitored || 0);

		this._updateButtonState('cvpi-action-prev', {
			disabled: (stats.historyStack || 0) === 0
		});

		this._updateButtonState('cvpi-action-next', {
			disabled: !this._activeRevid && totalReady === 0 && (stats.futureStack || 0) === 0,
			activeClass: 'cvpi-next--alert',
			active: stats.monitored > 0,
			badge: totalReady > 0 ? totalReady : null
		});

		// Update global "Analyzing" count in the footer
		const analyzingEl = document.getElementById('cvpi-footer-data--analyzing');
		if (analyzingEl) {
			const totalAnalyzing = stats.pending + (stats.pendingPromotion || 0);
			analyzingEl.textContent = `Analyzing ${totalAnalyzing} edit${totalAnalyzing === 1 ? '' : 's'}...`;
		}

		// Update document title if hidden
		const totalCount = stats.ready + (stats.monitored || 0) + (this._activeRevid ? 1 : 0);
		if (document.visibilityState === 'hidden' && totalCount > 0) {
			document.title = `CVPI (${totalCount})`;
		} else {
			document.title = 'CVPI';
		}
	}


	_getDashboardHtml(change) {
		const revid = change?.revid;
		if (!revid) return '';

		let html = '<h1>Multi-issue templates</h1>';

		html += '<div class="cvpi-rollback-dashboard__grid cvpi-rollback-dashboard__grid--multi">';

		// Multi-level warning types
		for (const wt of WARNING_TYPES.filter(w => !w.singleIssue && !w.exclude_namespaces.includes(change.ns))) {
			html += `<div class="cvpi-rollback-group cvpi-rollback-group--multi">`;
			for (let lvl = 1; lvl <= 4; lvl++) {
				const disabled = wt.noLevel4 && lvl === 4 ? 'disabled' : '';
				const btnClass = (lvl === 4 && !wt.noLevel4) ? 'mw-ui-button mw-ui-destructive' : 'mw-ui-button';
				html += `<button class="${btnClass} cvpi-hidden" data-action="cvpi-action-rollback" data-revid="${revid}" data-type="${wt.type}" data-level="${lvl}" ${disabled}>${lvl}</button>`;
			}
			const autoClass = wt.type === 'vandalism' ? 'mw-ui-button mw-ui-destructive' : 'mw-ui-button mw-ui-neutral';
			html += `<button class="${autoClass} cvpi-btn--auto" data-action="cvpi-action-rollback" data-revid="${revid}" data-type="${wt.type}" data-level="0" data-keep-open="true" data-label="${wt.label}">
				<span class="cvpi-btn-text-collapsed">${wt.label}...</span>
				<span class="cvpi-btn-text-expanded">${wt.label}<br/>(auto level)</span>
			</button>`;
			html += `</div>`;
		}
		html += '</div>';

		// Single-issue types
		html += '<h1>Single-issue templates</h1>';
		html += '<div class="cvpi-rollback-dashboard__grid cvpi-rollback-dashboard__grid--single">';
		for (const wt of WARNING_TYPES.filter(w => w.singleIssue && !w.exclude_namespaces.includes(change.ns))) {
			html += `
				<div class="cvpi-rollback-group cvpi-rollback-group--single">
					<button class="mw-ui-button mw-ui-quiet cvpi-btn--arm" data-action="cvpi-action-arm-single" data-keep-open="true">${wt.label}</button>
					<button class="mw-ui-button mw-ui-destructive cvpi-hidden" data-action="cvpi-action-rollback" data-revid="${revid}" data-type="${wt.type}" data-level="null">Warn &amp; Roll</button>
					<button class="mw-ui-button cvpi-hidden" data-action="cvpi-action-warn-only" data-revid="${revid}" data-type="${wt.type}">Warn Only</button>
				</div>
			`;
		}
		html += '</div>';

		html += '<h1>Other actions</h1>';

		// Other / AGF / AIV or Block (for Admins)
		const isAdm = this._cb.isToolUserAdmin();
		html += `
			<div class="cvpi-rollback-dashboard__grid">
				<button class="mw-ui-button" data-action="cvpi-action-other-rollback" data-revid="${revid}" data-agf="false" data-keep-open="true">Other (prompt)</button>
				<button class="mw-ui-button" data-action="cvpi-action-other-rollback" data-revid="${revid}" data-agf="true" data-keep-open="true">Other AGF (prompt)</button>
				${isAdm ?
					`<button class="mw-ui-button mw-ui-destructive" data-action="cvpi-action-block" data-revid="${revid}" data-keep-open="true">Block</button>` :
					`<button class="mw-ui-button mw-ui-destructive" data-action="cvpi-action-report-aiv" data-revid="${revid}" data-keep-open="true">Report to AIV</button>`
				}
			</div>
		`;

		return html;
	}

	_resetDashboardGroups() {
		if (!this._dashboardContainer) return;

		// Reset multi-level groups
		this._dashboardContainer.querySelectorAll('.cvpi-rollback-group--multi').forEach(group => {
			group.classList.remove('cvpi-rollback-group--active');
			group.querySelectorAll('button:not(.cvpi-btn--auto)').forEach(b => b.classList.add('cvpi-hidden'));
		});

		// Reset single-issue groups
		this._dashboardContainer.querySelectorAll('.cvpi-rollback-group--single').forEach(group => {
			group.classList.remove('cvpi-rollback-group--active');
			group.querySelectorAll('button:not(.cvpi-btn--arm)').forEach(b => b.classList.add('cvpi-hidden'));
		});

		// Reset "Other" action buttons
		this._dashboardContainer.querySelectorAll('.cvpi-btn--active').forEach(btn => {
			btn.classList.remove('cvpi-btn--active');
			if (btn._originalText) {
				btn.innerHTML = btn._originalText;
				delete btn._originalText;
			}
		});
	}

	/**
	 * Creates the core DOM structure and event listeners for a modal.
	 *
	 * @param {string} title
	 * @param {HTMLElement} contentElement
	 * @param {HTMLElement} actionsElement
	 * @param {Function} onCancel
	 * @param {Function} [onSubmit]
	 * @returns {{ dismiss: Function, handleCancel: Function, handleSubmit: Function }}
	 * @private
	 */
	_createBaseModal(title, contentElement, actionsElement, onCancel, onSubmit) {
		this._actionBus.setMode('MODAL');

		const overlay = document.createElement('div');
		overlay.className = 'cvpi-modal';

		const dismiss = () => {
			overlay.remove();
			this._actionBus.setMode('DEFAULT');
			this._events.off('action:close-modal', handleCancel);
			if (onSubmit) {
				this._events.off('action:submit-modal', handleSubmit);
			}
		};

		const handleCancel = () => {
			dismiss();
			if (onCancel) onCancel();
		};

		const handleSubmit = () => {
			dismiss();
			if (onSubmit) onSubmit();
		};

		this._events.on('action:close-modal', handleCancel);
		if (onSubmit) {
			this._events.on('action:submit-modal', handleSubmit);
		}

		overlay.addEventListener('click', e => { if (e.target === overlay) handleCancel(); });

		const box = document.createElement('div');
		box.className = 'cvpi-modal-box';
		box.innerHTML = `<h3>${title}</h3>`;

		box.appendChild(contentElement);
		box.appendChild(actionsElement);
		overlay.appendChild(box);
		document.body.appendChild(overlay);

		return { dismiss, handleCancel, handleSubmit };
	}

	/**
	 * Displays an information-only modal with a single "Close" button.
	 * 
	 * @param {string} title
	 * @param {string} contentHtml
	 * @returns {Promise<void>}
	 */
	showInfoModal(title, contentHtml) {
		return new Promise(resolve => {
			const contentContainer = document.createElement('div');
			contentContainer.className = 'cvpi-modal-content';
			contentContainer.innerHTML = contentHtml;
			contentContainer.style.marginBottom = '1em';

			const actions = document.createElement('div');
			actions.className = 'cvpi-modal-actions';

			let modalControllers;

			const onCancel = () => resolve();

			const closeBtn = ComponentBuilder.createElement('button', { 
				innerHTML: 'Close', 
				className: 'mw-ui-button mw-ui-progressive', 
				onclick: () => modalControllers.handleCancel() 
			});
			actions.appendChild(closeBtn);

			modalControllers = this._createBaseModal(title, contentContainer, actions, onCancel);
		});
	}

	/**
	 * @param {string} title
	 * @param {Object} [options]
	 * @param {string[]} [options.choices]
	 * @returns {Promise<string|null>}
	 */
	promptForReason(title, { choices = null } = {}) {
		return new Promise(resolve => {
			const contentContainer = document.createElement('div');
			
			const choiceList = document.createElement('div');
			choiceList.className = 'cvpi-modal-choices-container';
			
			const inputArea = document.createElement('div');
			inputArea.style.display = choices ? 'none' : 'block';

			const input = document.createElement('input');
			input.className = 'cvpi-modal-input';
			input.type = 'text';
			input.placeholder = 'Enter reason…';
			inputArea.appendChild(input);

			contentContainer.appendChild(choiceList);
			contentContainer.appendChild(inputArea);

			const actions = document.createElement('div');
			actions.className = 'cvpi-modal-actions';

			let modalControllers;

			const onCancel = () => resolve(null);
			const onSubmit = () => resolve(input.value.trim());

			if (choices) {
				choices.forEach(c => {
					const choiceElement = document.createElement('button');
					choiceElement.className = 'mw-ui-button mw-ui-destructive';
					choiceElement.textContent = c;
					choiceElement.onclick = () => { modalControllers.dismiss(); resolve(c); };
					choiceList.appendChild(choiceElement);
				});

				const custom = document.createElement('button');
				custom.className = 'mw-ui-button';
				custom.textContent = 'Custom reason…';
				custom.onclick = () => {
					choiceList.style.display = 'none';
					inputArea.style.display = 'block';
					input.focus();
				};
				choiceList.appendChild(custom);
			}

			const submitBtn = ComponentBuilder.createElement('button', { 
				innerHTML: 'Submit', 
				className: 'mw-ui-button mw-ui-progressive', 
				onclick: () => modalControllers.handleSubmit() 
			});
			actions.appendChild(submitBtn);

			const cancelBtn = ComponentBuilder.createElement('button', { 
				innerHTML: 'Cancel', 
				className: 'mw-ui-button', 
				onclick: () => modalControllers.handleCancel() 
			});
			actions.appendChild(cancelBtn);

			modalControllers = this._createBaseModal(title, contentContainer, actions, onCancel, onSubmit);

			if (!choices) {
				input.focus();
			}
		});
	}

	/**
	 * Prompts the user to select a block reason from the configured block list.
	 * 
	 * @param {string} user - The username to block.
	 * @returns {Promise<Object|null>} The selected block reason configuration or null.
	 */
	promptBlockReason(user) {
		return new Promise(resolve => {
			const contentContainer = document.createElement('div');
			
			const choiceList = document.createElement('div');
			choiceList.className = 'cvpi-modal-choices-container';
			contentContainer.appendChild(choiceList);

			const actions = document.createElement('div');
			actions.className = 'cvpi-modal-actions';

			let modalControllers;

			// Populate block reason buttons
			BLOCK_REASONS.forEach(reason => {
				const btn = document.createElement('button');
				btn.className = 'mw-ui-button mw-ui-destructive';
				btn.textContent = reason.buttonLabel;
				btn.onclick = () => {
					if (!btn.classList.contains('cvpi-btn--active')) {
						// Reset other active reason buttons
						choiceList.querySelectorAll('.cvpi-btn--active').forEach(activeBtn => {
							activeBtn.classList.remove('cvpi-btn--active');
							activeBtn.textContent = activeBtn._originalText;
							delete activeBtn._originalText;
						});
						
						btn.classList.add('cvpi-btn--active');
						btn._originalText = btn.textContent;
						btn.textContent = 'Confirm?';
						return;
					}
					
					modalControllers.dismiss();
					resolve(reason);
				};
				choiceList.appendChild(btn);
			});

			// Other / Open block option
			const otherBtn = document.createElement('button');
			otherBtn.className = 'mw-ui-button';
			otherBtn.textContent = 'Other...';
			otherBtn.onclick = () => {
				window.open(wikiUrl('block', user), '_blank');
				modalControllers.dismiss();
				resolve(null);
			};
			choiceList.appendChild(otherBtn);

			// Cancel button
			const cancelBtn = ComponentBuilder.createElement('button', { 
				innerHTML: 'Cancel', 
				className: 'mw-ui-button', 
				onclick: () => modalControllers.handleCancel() 
			});
			actions.appendChild(cancelBtn);

			modalControllers = this._createBaseModal(
				`Block options for ${user} (required)`, 
				contentContainer, 
				actions, 
				() => resolve(null)
			);
		});
	}

	// ─── Scroll helpers ────────────────────────────────────────────────────

	scrollToFirstChange(target = null) {
		const container = document.querySelector('.cvpi-diff');
		if (!container) return;
		const el = target ?? container.querySelector('ins, del');
		if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
		this._updateScrollArrow(container);
	}

	scrollToNextHidden() {
		const container = document.querySelector('.cvpi-diff');
		if (!container) return;
		const rect = container.getBoundingClientRect();
		for (const el of container.querySelectorAll('ins, del, td[data-marker="−"]')) {
			if (el.getBoundingClientRect().top > rect.bottom) {
				this.scrollToFirstChange(el);
				return;
			}
		}
	}

	// ─── Toolbar Configuration ──────────────────────────────────────────

	_getVandalismButtonConfig(change) {
		if (!change) {
			return { topLabel: 'Vandalism', bottomLabel: 'roll & warn', icon: 'warning', disabled: true };
		}

		const user = change.user;
		const u = this._cb.getUser(user);
		const { isBlocked, isAtAIV, warnLevel } = u;

		const iAmAdmin = this._cb.isToolUserAdmin();

		let topLabel = 'Vandalism';
		let bottomLabel = 'roll & warn';
		let icon = 'warning';

		if (isBlocked) {
			topLabel = '(User blocked)'
			bottomLabel = 'roll back';
			icon = 'undo';
		} else if (isAtAIV) {
			topLabel = '(Reported @ AIV)';
			bottomLabel = 'roll back';
			icon = 'undo';
		} else if (iAmAdmin && warnLevel >= 4) {
			topLabel = '(Max level)';
			bottomLabel = 'roll back';
			icon = 'undo';
		} else if (warnLevel >= 4)  {
			topLabel = 'Report to AIV'
			bottomLabel = 'roll back';
			icon = 'brightness_alert';
		}

		return { 
			topLabel,
			bottomLabel, 
			icon, 
			disabled: false 
		};
	}

	_getMonitorButtonConfig(change) {
		if (!change) {
			return {
				topLabel: 'Monitor',
				bottomLabel: 'editor',
				icon: 'person_add',
				disabled: true
			};
		}

		const u = this._cb.getUser(change.user);
		const { isMonitored, monitorReason: reason } = u;

		return {
			topLabel: isMonitored ? 'Unmonitor' : 'Monitor',
			bottomLabel: isMonitored ? reason : 'editor',
			icon: this._getMonitorIcon(isMonitored, reason),
			disabled: false
		};
	}

	_getHistoryButtonConfig(change) {
		return {
			disabled: !change?.pageHistory?.length
		};
	}

	_getTalkButtonConfig(change) {
		if (!change) {
			return { topLabel: 'User', bottomLabel: 'talk', disabled: true, badge: null };
		}

		const user = change.user;
		const u = this._cb.getUser(user);
		const { warnLevel: sessionMax, hasWarnings } = u;

		return {
			topLabel: 'User',
			bottomLabel: 'talk',
			disabled: !hasWarnings,
			badge: sessionMax > 0 ? sessionMax : null,
			badgeClassName: sessionMax >= 4 ? 'cvpi-badge--warning-high' : (sessionMax >= 2 ? 'cvpi-badge--warning-mid' : '')
		};
	}

	_getDashboardConfig(change) {
		return {
			disabled: !change
		};
	}


	/**
	 * A generic factory that takes a configuration array and wraps the
	 * resulting toolbar in a styled container.
	 *
	 * @param {Array<Object>} config - Toolbar configuration items.
	 * @param {string} [extraClass] - Optional CSS class for the container.
	 * @returns {HTMLElement} The toolbar container.
	 * @private
	 */
	_createToolbar(config, extraClass = '', placement = 'top') {
		const toolbar = this._toolbarManager.createToolbar(config, placement);
		if (extraClass) {
			toolbar.classList.add(...extraClass.split(' '));
		}
		return toolbar;
	}

	// ─── Private builders ──────────────────────────────────────────────────

	_buildChangelogElement(changelog) {
		const dash = document.createElement('div');
		dash.className = 'cvpi-changelog cvpi-toolbarPanel--history';
		dash.style.padding = '1em';
		dash.style.width = '24rem';
		
		let changelogHtml = '';
		for (const [v, entries] of Object.entries(changelog)) {
			changelogHtml += `
				<h2 style="margin-top: 1.5em; border-bottom: 1px solid var(--border-color-divider, #a2a9b1); padding-bottom: 8px; font-size: 1.2em;">
					v${v}
				</h2>
				<ul style="padding-left: 20px; line-height: 1.6; font-size: 0.9em;">
					${entries.map(e => {
						const escaped = this._escapeHtml(e);
						const withBold = escaped.replace(/&lt;b&gt;/g, '<b>').replace(/&lt;\/b&gt;/g, '</b>');
						return `<li style="margin-bottom: 8px;">${withBold}</li>`;
					}).join('')}
				</ul>`;
		}
		
		dash.innerHTML = `
			<div style="max-height: 60dvh; overflow-y: auto; padding-right: 0.5em;">
				<p><em>Click outside of this changelog to continue...</em></p>
				${changelogHtml}
			</div>
			<button class="mw-ui-button mw-ui-progressive cvpi-btn--wide" style="height: 4em; border-radius: 0.5em; margin: 1em auto 0; display: block;">Okay</button>
		`;

		dash.querySelector('button').onclick = () => {
			this._toolbarManager.closeAllMenus();
		};

		return dash;
	}

	_buildHistoryComponent(change, isToolbarPanel = false) {
		const container = document.createElement('div');
		container.className = `cvpi-history${isToolbarPanel ? ' cvpi-toolbarPanel--history' : ' cvpi-sidebar-pane--history'}`;

		if (!change?.pageHistory?.length) {
			if (change) container.innerHTML = '<p class="cvpi-talk__empty">No history available.</p>';
			return container;
		}

		const pageHistUrl = wikiUrl('history', change.title);
		const btnClass = `mw-ui-button mw-ui-neutral cvpi-btn--wide ${isToolbarPanel ? '' : 'cvpi-btn--sidebar-top'}`;
		
		const currentIdx = change.pageHistory.findIndex(r => r.revid === change.revid);
		
		let listHtml = '';
		for (let i = 0; i < change.pageHistory.slice(0, CVPI_CONFIG.UI.HISTORY_LIMIT).length; i++) {
			const rev = change.pageHistory[i];
			const isCurrentUser   = (rev.user === change.user);
			const isActiveRevision = (currentIdx !== -1) && (i >= currentIdx) && (i < currentIdx + (change.rev_count || 1));
			const diffHref  = wikiUrl('diff', rev.revid, rev.parentid, change.title);
			
			listHtml += `
				<li class="${isActiveRevision ? 'cvpi-history__item--active' : ''}">
					<a href="https://artikeldigital.com/en/${diffHref}" target="_blank">
						<span class="cvpi-history__ts">${timeSince(new Date(rev.timestamp))}</span>
						<span class="cvpi-history__user${isCurrentUser ? ' cvpi-history__user--current' : ''}">${this._escapeHtml(rev.user)}</span>
						<span class="cvpi-history__comments">${this._escapeHtml(rev.comment || '')}</span>
					</a>
				</li>
			`;
		}

		container.innerHTML = `
			<a href="https://artikeldigital.com/en/${pageHistUrl}" target="_blank" class="${btnClass}" style="display: block; text-align: center; box-sizing: border-box; text-decoration: none;">Page history</a>
			<ul class="cvpi-history__list">${listHtml}</ul>
		`;
		
		return container;
	}

	/**
	 * @param {boolean} isToolbarPanel
	 * @param {import('./ChangeQueue').Change} change
	 */
	_buildTalkComponent(isToolbarPanel = false, change) {
		const container = document.createElement('div');
		container.className = `cvpi-talk${isToolbarPanel ? ' cvpi-toolbarPanel--talk' : ' cvpi-sidebar-pane--talk'}`;

		const user = change?.user;
		if (!user) return container;

		const btnClass = `mw-ui-button mw-ui-neutral cvpi-btn--wide ${isToolbarPanel ? '' : 'cvpi-btn--sidebar-top'}`;
		const talkUrl = wikiUrl('talk', user);
		
		const warnings = this._cb.getUser(user).warnings;
		if (warnings.length === 0) {
			container.innerHTML = `
				<a href="https://artikeldigital.com/en/${talkUrl}" target="_blank" class="${btnClass}" style="display: block; text-align: center; box-sizing: border-box; text-decoration: none;">User talk</a>
				<p class="cvpi-talk__empty">No recent warnings found.</p>
			`;
			return container;
		}

		// Sort and group warnings by month
		const sorted = [...warnings].sort((a, b) => (a.dateObject ?? 0) - (b.dateObject ?? 0));
		const groups = new Map();
		const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
		
		for (const w of sorted) {
			const key = w.dateObject
				? `${MONTHS[w.dateObject.getUTCMonth()]} ${w.dateObject.getUTCFullYear()}`
				: 'Unknown Date';
			if (!groups.has(key)) groups.set(key, []);
			groups.get(key).push(w);
		}

		let listHtml = '';
		for (const [month, items] of groups) {
			listHtml += `<ul class="cvpi-talk__list"><li class="cvpi-talk__month">${month}</li>`;

			for (const w of items) {
				const active = isActiveWarning(w, this._cb.staleWarningMethod);
				const final  = w.level >= 4 && active;
				const ts = w.timestamp.includes('second') ? 'Just now' : w.timestamp;
				const className = `cvpi-talk__item${active ? '' : ' cvpi-talk__item--stale'}${final ? ' cvpi-talk__item--final' : ''} cvpi-talkPageContent-item`;

				// Use onclick attribute to trigger open
				listHtml += `
					<li class="${className}" onclick="window.open('${talkUrl}', '_blank')">
						<div class="cvpi-talk__ts">${ts}</div>
						<div class="cvpi-talk__level">Level <strong>${w.level}</strong></div>
						<div class="cvpi-talk__name">${this._escapeHtml(w.template)}</div>
						<div class="cvpi-talk__issuer">${this._escapeHtml(w.issuer)}</div>
					</li>
				`;
			}
			listHtml += `</ul>`;
		}

		container.innerHTML = `
			<a href="https://artikeldigital.com/en/${talkUrl}" target="_blank" class="${btnClass}" style="display: block; text-align: center; box-sizing: border-box; text-decoration: none;">User talk</a>
			${listHtml}
		`;

		return container;
	}

	_buildPlaceholder() {
		const placeholder = document.createElement('div');
		placeholder.className = 'cvpi-placeholder';
		placeholder.textContent = 'Waiting for next change…';
		return placeholder;
	}

	_buildTitleArea(change) {
		const { title } = change;
		const pageHref = wikiUrl('page', title);
		const titleEl = document.createElement('div');
		titleEl.className = 'cvpi-meta__title';
		titleEl.innerHTML = `<a href="https://artikeldigital.com/en/${pageHref}" target="_blank">${title}</a>`;
		return titleEl;
	}

	_buildMetaArea(change) {
		const { revid, user } = change;
		const contribsHref   = wikiUrl('contribs', user);
		const monitorClasses = this._cb.getUser(user).isMonitored ? ` cvpi-user--monitored cvpi-user--monitored--${this._cb.getUser(user).monitorReason.replaceAll(' ', '_')}` : '';

		const detailsEl = document.createElement('div');
		detailsEl.className = 'cvpi-meta__details';
		detailsEl.innerHTML = `
			<span id="cvpi-diffsize-${revid}" class="cvpi-meta__size">${this._formatSize(change.diffsize)}</span>
			<span class="cvpi-meta__user${monitorClasses}">
				by <a href="https://artikeldigital.com/en/${contribsHref}" target="_blank">${user}</a>
				<span id="cvpi-warnlevel-${revid}" class="cvpi-meta__warn">(…)</span>
			</span>
		`;
		return detailsEl;
	}

	_buildCommentsArea(change) {
		const { comments, title, revid, old_revid, diff_from_revid } = change;
		const commentsEl = document.createElement('div');
		commentsEl.className = 'cvpi-meta__summary';

		const diffHref = wikiUrl('diff', revid, diff_from_revid ?? old_revid, title);
		const count = change.rev_count ?? 1;
		//const countDisplay = `${count > 1 ? `Σ ${count} revisions` : '1 revision'}`;
		const countDisplay = `${count > 1 ? `Σ ${count} revisions:` : ''}`; // let's try nothing if not more than 1
		const revCountHtml = `<span id="cvpi-meta__revcount-container"><a class="cvpi-meta__revcount" href="https://artikeldigital.com/en/${diffHref}" target="_blank">${countDisplay}</a></span>`;

		const filtered = (comments || []).filter(c => c && c.text && c.text.trim().length > 0);

		if (filtered.length > 0) {
			commentsEl.innerHTML = revCountHtml + ' ' + filtered.map((c, i) => {
				const isLast = i === filtered.length - 1;
				const diffHref = wikiUrl('diff', c.revid, c.parentid, title);
				return `<a class="cvpi-comment" href="https://artikeldigital.com/en/${diffHref}" target="_blank">${this._escapeHtml(c.text)}${isLast ? '' : ';'}</a>`;
			}).join('');
		} else {
			commentsEl.innerHTML = revCountHtml + ' <div class="cvpi-comment">(no edit summary)</div>';
		}

		return commentsEl;
	}

	_buildDiffArea(change) {
		const { revid } = change;
		const diffEl = document.createElement('div');
		diffEl.className = 'cvpi-diff';
		diffEl.id = `cvpi-diff-${revid}`;
		diffEl.textContent = 'Loading diff…';
		diffEl.addEventListener('scroll', () => this._updateScrollArrow(diffEl));
		return diffEl;
	}

	_buildScrollArrow() {
		const arrow = document.createElement('div');
		arrow.id = 'cvpi-arrow';
		arrow.className = 'cvpi-arrow';
		arrow.textContent = '▼';
		arrow.title = 'Scroll to next change';
		arrow.style.display = 'none';
		arrow.addEventListener('click', () => this.scrollToNextHidden());
		return arrow;
	}

	// ─── Utilities ─────────────────────────────────────────────────────────

	_escapeHtml(s) {
		return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
	}

	_formatOres(score) {
		if (score == null) return '<i>N/A</i>';
		const { HIGH, MEDIUM, LOW } = CVPI_CONFIG.UI.ORES_COLORS;
		const color = score > HIGH ? '#d33' : score > MEDIUM ? '#e66' : score > LOW ? '#f80' : 'inherit';
		return `<span style="color:${color};font-weight:bold">${score.toFixed(3)}</span>`;
	}

	_formatSize(size) {
		if (size == null) return '<small>…</small>';
		const prefix = size > 0 ? '+' : '';
		const spanClass = `cvpi-meta__size--${size > 0 ? 'increase' : size < 0 ? 'decrease' : 'samesize'}`;
		return `<span class="${spanClass}">${prefix}${size}</span>`;
	}

	_getMonitorIcon(isMonitored, reason) {
		if (!isMonitored) return 'person_add';
		
		if (reason && reason.startsWith('editfilter')) {
			return 'frame_person_off';
		}

		switch (reason) {
			case 'Automatic':  return 'person_remove';
			case 'MLRS':       return 'groups';
			case 'editfilter': return 'frame_person_off';
			case 'Previously': return 'person_remove';
			default:           return 'person_remove';
		}
	}

	_updateScrollArrow(container) {
		const arrows = document.querySelectorAll('.cvpi-arrow');
		if (arrows.length === 0 || !container) return;
		const rect = container.getBoundingClientRect();
		const hidden = [...container.querySelectorAll('ins, del, td[data-marker="−"]')].some(el => el.getBoundingClientRect().top > rect.bottom);
		arrows.forEach(a => { a.style.display = hidden ? 'block' : 'none'; });
	}

	_clearMainContent() {
		if (this._changeComponentContainer) {
			this._changeComponentContainer.innerHTML = '';
		}
		const loadingMsg = document.getElementById('cvpi-loading-message');
		if (loadingMsg) {
			loadingMsg.remove();
		}
	}

	_startClock() {
		const el = document.getElementById('cvpi-footer-clock');
		if (!el) return;
		
		if (this._clockInterval) {
			clearInterval(this._clockInterval);
		}

		const update = () => {
			if (window.CVPI_DEBUG) {
				if (!update.startTime) {
					update.startTime = Date.now();
				}
				const elapsedSeconds = (Date.now() - update.startTime) / 1000;
				
				// let her settle down
				if (elapsedSeconds < 5) {
					el.textContent = 'API calls: calculating...';
					return;
				}

				const rate = (this._cb.getApiCallCount() / (elapsedSeconds / 3600));
				el.textContent = `API calls: ${rate.toFixed(1)}/hr`;
				return;
			}

			const now = new Date();
			
			if (typeof this._timezone === 'string') {
				// IANA timezone name
				try {
					el.textContent = now.toLocaleTimeString('en-GB', { 
						timeZone: this._timezone, 
						hour12: false 
					});
				} catch (e) {
					// Fallback to UTC if timezone is invalid
					el.textContent = now.toLocaleTimeString('en-GB', { timeZone: 'UTC', hour12: false });
				}
			} else if (typeof this._timezone === 'number') {
				// Fixed offset in minutes from UTC
				const local = new Date(now.getTime() + (this._timezone * 60000));
				const h = String(local.getUTCHours()).padStart(2, '0');
				const m = String(local.getUTCMinutes()).padStart(2, '0');
				const s = String(local.getUTCSeconds()).padStart(2, '0');
				el.textContent = `${h}:${m}:${s}`;
			}
		};
		
		update();
		this._clockInterval = setInterval(update, 1000);
	}

	_injectFonts() {
		const fonts = [
			'https://tools-static.wmflabs.org/fontcdn/css?family=IBM+Plex+Mono:400,700&subset=latin',
			'https://tools-static.wmflabs.org/fontcdn/css?family=Material+Symbols+Outlined:400'
		];
		fonts.forEach(href => {
			document.head.appendChild(ComponentBuilder.createElement('link', { rel: 'stylesheet', href }));
		});
	}

	_injectStyles() {
		const style = document.createElement('style');
		style.textContent = CVPI_STYLE;
		document.head.appendChild(style);
	}
}

// ── core/App.js ──────────────────────────
class App {
	/**
	 * Initializes the application and its dependencies.
	 */
	constructor() {
		// ── Services ──────────────────────────────────────────────────────
		// Dependency injection: App owns the services and passes them where needed.
		const mwApi = new mw.Api();
		this.api      = new ApiService(mwApi);
		this.settings = new SettingsService();

		// ── Session state ─────────────────────────────────────────────────
		this.users = new UserTracker();

		/** Current ORES threshold (0–1). Edits below this probability are filtered out. */
		this.scoreThreshold  = CVPI_CONFIG.LOGIC.DEFAULT_SCORE_THRESHOLD;

		// ─── Queue ─────────────────────────────────────────────────────────
		// The queue manages the state and lifecycle of recent changes.
		this.queue = new ChangeQueue(
			this.users, 
			() => this.scoreThreshold,
			() => this.triggerPromotion(),
			(change) => this._handleReadyItem(change)
		);

		// ── Warning service ───────────────────────────────────────────────
		// Handles logic for parsing talk pages and issuing warnings.
		this.warnings = new WarningService(this.api, this.users);

		// ── Navigation state ──────────────────────────────────────────────
		this.nav = new HistoryStack(
			this.queue, 
			(change) => this._render(change),
			(...args) => this._log(...args)
		);

		/** Flag to prevent overlapping execution of the tick loop */
		this._isFetching = false;

		/** Reference to the interval timer for the tick loop */
		this._tickTimer  = null;

		/** @type {Set<number>} Set of revision IDs seen by the user during this session */
		this._seenRevids = new Set();

		/** Timer for debouncing promotion fetches */
		this._promotionTimer = null;

		// ── Event Hub ─────────────────────────────────────────────────────
		// Dispatches background logic events to the UI layer
		this.events = new EventEmitter();

		// ── Action Bus ────────────────────────────────────────────────────
		// Dispatches UI intents.
		this.actionBus = new ActionBus(this.events);
		this.actionBus.init();

		// ── UI ────────────────────────────────────────────────────────────
		// The UI manager handles all DOM interactions and layout.
		this.ui = new UIManager(this.events, this.actionBus, {
			getThreshold:      () => this.scoreThreshold,
			getPreference:     (key) => this.settings.load().preferences[key],
			getStats:          () => ({ ...this.queue.stats(), historyStack: this.nav.historyStackLength, futureStack: this.nav.futureStackLength }),
			getApiCallCount:   () => this.api.getCallCount(),
			getUser:           (user) => this.users.getUser(user),
			staleWarningMethod: CVPI_CONFIG.LOGIC.STALE_WARNING_METHOD,
			getDeviceStats:    () => this.settings.load().statistics,
			isToolUserAdmin:   () => this.users.isToolUserAdmin(),
		});

		this.actions = new ActionService(
			this.api,
			this.users,
			this.queue,
			this.warnings,
			this.settings,
			this.nav,
			this.events,
			(...args) => this.ui.promptForReason(...args),
			(...args) => this._log(...args),
			(user) => this.ui.promptBlockReason(user)
		);

		this.mlrs = new MLRSScanner(
			this.api,
			this.users,
			this.queue,
			this.events,
			(...args) => this._log(...args)
		);

		this.eflScanner = new EditFilterLogScanner(
			this.api,
			this.users,
			this.queue,
			this.events,
			(...args) => this._log(...args)
		);

		this.details = new ChangeDetailLoader(
			this.api,
			this.warnings,
			this.users,
			this.events,
			(...args) => this._log(...args)
		);

		// ── Event Handlers ────────────────────────────────────────────────
		this.api.onHttpError(() => {
			this.ui.showProgress('No connection', 5);
		});

		this._registerActionHandlers();
	}

	_registerActionHandlers() {
		this.events.on('action:cvpi-action-next', () => this.nav.showNext());
		this.events.on('action:cvpi-action-prev', () => this.nav.showPrevious());
		this.events.on('action:cvpi-action-scroll-next', () => this.ui.scrollToNextHidden());
		
		this.events.on('action:cvpi-action-rollback', (data) => {
			const revid = Number(data.revid);
			const levelStr = data.level;
			const level = levelStr ? (levelStr === 'null' ? null : Number(levelStr)) : 0;
			if (revid && data.type) this.actions.handleRollback(revid, data.type, level);
		});

		this.events.on('action:cvpi-action-hotkey-rollback', (data) => {
			if (this.nav.currentRevid) {
				this.actions.handleRollback(this.nav.currentRevid, data.type, 0);
			}
		});

		this.events.on('action:cvpi-action-warn-only', (data) => {
			const revid = Number(data.revid);
			if (revid && data.type) this.actions.handleWarnOnly(revid, data.type);
		});

		this.events.on('action:cvpi-toolbar-monitor', (data) => {
			const revid = Number(data.revid);
			if (revid) this.toggleMonitor(revid);
		});

		this.events.on('action:cvpi-toolbar-vandalism', (data) => {
			const revid = Number(data.revid);
			if (revid) this.actions.handleRollback(revid, 'vandalism', 0);
		});

		this.events.on('action:cvpi-action-report-aiv', (data) => {
			const revid = Number(data.revid);
			if (revid) this.actions.reportToAIV(revid);
		});

		this.events.on('action:cvpi-action-block', (data) => {
			const revid = Number(data.revid);
			if (revid) {
				this.actions.handleBlock(revid);
			}
		});

		this.events.on('action:cvpi-action-other-rollback', (data) => {
			const revid = Number(data.revid);
			const agf = data.agf === 'true';
			if (revid) this.actions.promptAndRollback(revid, agf);
		});

		this.events.on('action:cvpi-action-threshold-inc', () => this.adjustThreshold(0.05));
		this.events.on('action:cvpi-action-threshold-dec', () => this.adjustThreshold(-0.05));

		this.events.on('action:cvpi-action-label-toggle', (data) => {
			this.settings.setPreference('showLabels', !!data.event.target.checked);
		});

		this.events.on('action:cvpi-action-colorblind-toggle', (data) => {
			this.settings.setPreference('colorblindMode', !!data.event.target.checked);
		});

		this.events.on('action:cvpi-action-twinkle-toggle', (data) => {
			this.settings.setPreference('openTwinkleOnOther', !!data.event.target.checked);
		});
	}

	/**
	 * Standardized logging for application-level events.
	 * @param {...any} args - Values to log.
	 * @private
	 */
	_log(...args) {
		log('', ...args);
	}

	// ─── Getters ────────────────────────────────────────────────────────────

	/**
	 * Convenience getter for the change currently on screen.
	 * @returns {import('./ChangeQueue').Change|null}
	 */
	get currentlyShownChange() {
		return this.nav.currentChange;
	}

	// ─── Lifecycle ──────────────────────────────────────────────────────────

	/**
	 * Main entry point for the application logic.
	 * Performs permissions checks and starts the background loop.
	 */
	async run() {
		// Rights and user preferences are both hard requirements or useful starting state.
		// Fetch them in parallel to minimize latency.
		const [rights, userInfo] = await Promise.all([
			this.api.getUserRights(),
			this.api.getUserInfo()
		]);

		if (!rights.includes('rollback')) {
			alert('CVPI requires rollback rights.');
			setTimeout(() => { 
				window.location.href = 'https://en.wikipedia.org/wiki/Wikipedia:Requests_for_permissions/Rollback'; 
			}, 3000);
			return;
		}

		this.users.setToolUser(userInfo?.name || mw.user.getName(), rights);

		// Check for version update to show changelog
		const settings = this.settings.load();
		const oldVersion = settings.version;
		const newVersion = CVPI_CONFIG.VERSION;

		// Prepare the UI container
		this.ui.setupPage();

		// Apply timezone from MediaWiki preferences if available
		if (userInfo?.options?.timecorrection) {
			this.ui.updateTimezone(userInfo.options.timecorrection);
		}

		if (oldVersion !== newVersion) {
			await this.ui.showChangelog(CVPI_CHANGELOG);
		}

		// Save the app version to storage (marks version as "seen")
		this.settings.save();

		// Seed monitored users from recent history to maintain session continuity
		await this._seedMonitoredUsers();

		// Perform initial tick and show the first available item
		await this.tick();
		await this.nav.showNext();

		// Start the periodic refresh loops
		this._tickTimer = setInterval(() => this.tick(), CVPI_CONFIG.LOGIC.REFRESH_RATE_MS);
		this.mlrs.start();
		this.eflScanner.start();
	}

	/**
	 * Completely shuts down the application.
	 */
	terminate() {
		this._log('Terminating application...');
		if (this.actionBus) {
			this.actionBus.destroy();
			this.actionBus = null;
		}
		if (this._tickTimer) {
			clearInterval(this._tickTimer);
			this._tickTimer = null;
		}
		this.mlrs.stop();
		this.eflScanner.stop();
		this.ui.destroy();
		window.CVPI = null;
	}

	// ─── Actions ────────────────────────────────────────────────────────────

	/**
	 * Increases or decreases the ORES score threshold.
	 * @param {number} delta
	 */
	adjustThreshold(delta) {
		const next = Math.round((this.scoreThreshold + delta) * 100) / 100;

		if (next >= 0 && next < 1) {
			this.scoreThreshold = next;
			this.ui.updateThreshold(this.scoreThreshold);
		}
	}

	/**
	 * Toggles whether a specific user is on the monitored list.
	 * @param {number} revid
	 */
	toggleMonitor(revid) {
		const change = this.queue.get(revid);
		if (!change) return;

		const { user } = change;
		const nowMonitored = !this.users.shouldShowAsMonitored(user);

		if (nowMonitored) {
			this.users.monitor(user, 'manual');
			this.queue.promoteUserToMonitored(user);
			this._log('User added to monitored list:', user);
		} else {
			this.users.unmonitor(user);
			this._log('User removed from monitored list:', user);
		}

		// Sync the static toolbar state immediately if this is the active revision
		// Sync the static toolbar state immediately if this is the active revision
		if (this.nav.currentRevid === revid) {
			this.ui.syncState(change);
		}
	}

	// ─── Tick loop ───────────────────────────────────────────────────────────

	/**
	 * The main periodic background loop. 
	 * Fetches fresh changes, updates scores, and keeps the queue trimmed.
	 */
	async tick() {
		if (this._isFetching) return;
		this._isFetching = true;

		try {
			this.users.expireMonitoredUsers();

			// Stage 1: Fetch & ingest new changes from Recent Changes API
			const rcs = await this.api.getRecentChanges();
			if (rcs?.length) {
				this._log(`Ingesting ${rcs.length} changes.`);
				this.queue.ingest(rcs);
			}

			// Stage 2: Clean up queue and evaluate pending items
			this.queue.expireStale();
			this.queue.evaluatePending();
			this.triggerPromotion();

			// Stage 3: Filter changes that are no longer the latest revision on their page.
			const cur = this.currentlyShownChange;
			const { needsRerender, streakExtended, newRevid } = await this.queue.syncPageState(this.api, rcs, cur);

			if (cur) {
				this.ui.updateStaleIndicator(cur.stale);

				if (needsRerender) {
					if (streakExtended) {
						// Keep seenRevids in sync to avoid double counting the logical edit
						if (this._seenRevids.has(cur.revid)) {
							this._seenRevids.add(newRevid);
						}

						this.nav.currentRevid = newRevid;

						// Refresh full state (diff and history) for the combined streak
						this.api.getPageHistoryData(cur.title).then(data => {
							this.queue.applyHistoryData(cur.revid, data);

							// Clear old diff promise and re-render
							cur.diffPromise = null;
							this._render(cur);
						});
					} else {
						this.api.getPageHistoryData(cur.title).then(data => {
							this.queue.applyHistoryData(cur.revid, data);
							if (this.nav.currentRevid === cur.revid) {
								this.ui.syncState(cur);
							}
						});
					}
				}
			}

			// Stage 4: Trim the queue to stay within size limits
			this.queue.trim();

			// Stage 5: Update the UI stats display periodically
			const stats = { ...this.queue.stats(), historyStack: this.nav.historyStackLength, futureStack: this.nav.futureStackLength };
			this._log('Queue stats (sync):', stats);
			this.ui.updateQueueDisplay(stats);

		} catch (err) {
			console.error('[CVPI] tick error:', err);
		}

		this._isFetching = false;
	}

	// ─── Private helpers ─────────────────────────────────────────────────────

	/**
	 * Schedules a batch fetch for items awaiting promotion.
	 * Debounced to batch rapid-fire arrivals (e.g. from ORES scores).
	 */
	triggerPromotion() {
		if (this._promotionTimer) return;

		this._promotionTimer = setTimeout(async () => {
			this._promotionTimer = null;
			try {
				await this._fetchDiffsAndPromote();
			} catch (err) {
				this._log('Promotion fetch error:', err);
			}
		}, CVPI_CONFIG.LOGIC.PROMOTE_DEBOUNCE_MS || 300);
	}

	/**
	 * Fetches the necessary diff and history data for items awaiting promotion.
	 * Once data is fetched, the items are moved to 'ready' or 'monitored' status.
	 * @private
	 */
	async _fetchDiffsAndPromote() {
		const toPromote = this.queue.pendingPromotion();
		if (!toPromote.length) return;

		this._log(`Promoting ${toPromote.length} items to ready/monitored.`);
		
		const uniqueTitles = [...new Set(toPromote.map(c => c.title))];
		
		// Batch fetch prior revision data for all unique titles
		const priorRevMap = new Map(
			await Promise.all(uniqueTitles.map(async t => [t, await this.api.getPageHistoryData(t)]))
		);

		// Process each change in parallel
		await Promise.all(toPromote.map(async (change) => {
			const prior = priorRevMap.get(change.title) ?? { priorRevid: null, revCount: null, history: [] };
			const fromRevid = prior.priorRevid ?? change.old_revid;

			let diffPromise;

			if (change.old_revid === 0) {
				diffPromise = Promise.resolve('<i>(New page)</i>');
			} else {
				const res = await this.api.getDiff(fromRevid, change.revid);
				
				if (res) {
					// Filter out empty diffs (null edits or self-undos).
					if (res.isEmpty) {
						this._log(`Filtering empty diff for ${change.revid} (${change.title})`);
						change.status = 'ignored';
						return;
					}
					
					change.diffsize = res.size;
					diffPromise = Promise.resolve(res.html);
				} else {
					// Fallback if API fails
					diffPromise = Promise.resolve(null);
				}
			}

			this.queue.promoteChange(change.revid, prior, diffPromise);
		}));
	}

	/** 
	 * Seed monitored list from recent CVPI contributions.
	 * 
	 * @private
	 */
	async _seedMonitoredUsers() {
		const hours = CVPI_CONFIG.API.MONITOR_PAST_ROLLBACKS_HOURS;
		if (hours <= 0) return;

		const ucend = new Date(Date.now() - hours * 36e5).toISOString();
		const contribs = await this.api.getContributions(mw.user.getName(), ucend, 3, 'CVPI');
		
		for (const c of contribs ?? []) {
			// Look for edit summaries containing warnings issued by CVPI
			if (c.comment?.includes('(uw-')) {
				const user = c.title.replace(/^User talk:/i, '');
				this.users.monitor(user, 'previously');
			}
		}
	}

	/** 
	 * Main rendering flow for a change.
	 * 
	 * @param {import('./ChangeQueue').Change|null} change - The change to display.
	 * @private
	 */
	async _render(change) {
		this.ui.displayChange(change);
		if (!change) return;

		// Track seen statistics
		if (!this._seenRevids.has(change.revid)) {
			this._seenRevids.add(change.revid);
			this.settings.incrementStat('recentChanges', 'seen');
		}

		// Kick off parallel async operations via the details loader
		this.details.load(change, () => this.nav.currentChange);
	}

	/**
	 * Handler for whenever a change in the queue becomes 'ready' or 'monitored'.
	 * 
	 * @param {import('./ChangeQueue').Change} change - The newly ready change.
	 * @private
	 */
	async _handleReadyItem(change) {
		// 1. Immediately update the UI stats display
		const stats = { ...this.queue.stats(), historyStack: this.nav.historyStackLength, futureStack: this.nav.futureStackLength };
		this.ui.updateQueueDisplay(stats);

		// 2. If the UI is currently idle (no active revision), show this next item immediately
		if (!this.nav.currentRevid) {
			this._log('Idle, pushing newly ready item to UI:', change.revid);
			await this.nav.showNext();
		}
	}
}

// ── core/index.js ──────────────────────────
if (
	mw.config.get('wgRelevantPageName') === 'Wikipedia:Interceptor/run' &&
	mw.config.get('wgAction') === 'view'
) {
	const targetElement = document.querySelector('a.last-modified-bar.active');

	if (targetElement) {
		targetElement.classList.remove('active');
	}
}

/**
 * Bootstraps the application, clearing any existing instance first.
 */
const launchCVPI = () => {
	// If already running, do nothing
	if (window.CVPI) return;

	window.CVPI = new App();
	window.CVPI.run();
};

mw.util.addPortletLink(
	'p-personal',
	{
		href: mw.util.getUrl('Wikipedia:Interceptor/run'),
		text: 'Interceptor',
		id: 'pt-CVPI',
		tooltip: 'Run Interceptor',
		nextnode: '#pt-preferences',
		icon: 'cdxIconAlert'
	}
);

if (
	mw.config.get('wgRelevantPageName') === 'Wikipedia:Interceptor/run' &&
	mw.config.get('wgAction') === 'view'
) {
	launchCVPI();
}

})();

// </nowiki>

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.