GeoPixelcons++

Unified GeoPixels enhancement suite - by Pixelcons

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         GeoPixelcons++
// @namespace    http://tampermonkey.net/
// @version      1.9.9
// @description  Unified GeoPixels enhancement suite - by Pixelcons
// @author       ariapokoteng, Manako, D.V.H., JainIlluverii
// @match        *://geopixels.net/*
// @match        *://*.geopixels.net/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @connect      *
// @license      MIT
// @icon         https://www.google.com/s2/favicons?sz=64&domain=geopixels.net
// ==/UserScript==


(function () {
    'use strict';

    const VERSION = '1.9.9';

    // ============================================================
    //  SETTINGS SYSTEM
    // ============================================================
    const STORAGE_KEY = 'geopixelcons_settings';
    const FEATURE_LIST = [
        { key: 'bulkPurchaseColors', name: 'Bulk Purchase Colors', icon: '🛒', desc: 'Advanced color purchasing with queue management.', features: ['Bulk color purchase with preview modal', 'Queue management in profile panel', 'Duplicate detection & insufficient-pixels handling', 'Purchase progress tracking'] },
        { key: 'ghostPlusPlus', name: 'Ghost++ Template Overlay', icon: '👻', desc: 'A scalable, multi-template ghost/overlay manager that replaces the native ghost image tool.', features: ['Indexed-core rendering that scales to large, high-colour templates', 'Draggable, resizable, fully collapsible manager with a two-column layout', 'Thumbnail template library with hover preview, edit/teleport, and bulk export/delete', 'Sort/filter/search palette with hover-to-copy hex readout', 'Segmented completion progress bar with per-colour breakdown, unaffected by which colours are toggled on/off', 'One-click full-opacity template Preview toggle'] },
        { key: 'guildOverhaul', name: 'Guild Overhaul', icon: '⚔️', desc: 'Comprehensive guild interface improvements.', features: ['Enhanced member management UI', 'Bank/treasury system', 'Color limit tracking', 'Role hierarchy display', 'Guild-specific moderation tools'] },
        { key: 'hidePaintMenu', name: 'Paint Menu Controls', icon: '🫣', desc: 'Adds a collapse/expand toggle for the bottom controls panel.', features: ['Collapse & expand the bottom paint controls', 'Reposition controls (left/center/right)', 'Smooth CSS animations'] },
        { key: 'paintBrushSwap', name: 'Paint Brush Swap', icon: '🖌️', desc: 'Rapid paintbrush tool switching with keyboard shortcuts.', features: ['Configurable keyboard shortcuts for brush swap', 'Brush preset profiles for different painting patterns', 'Quick-switch between brush types'] },
        { key: 'regionScreenshot', name: 'Region Screenshot', icon: '📸', desc: 'Capture region-level screenshots with coordinate overlays.', features: ['Region image capture with coordinate overlay', 'Alpha channel support', 'Save as PNG directly'] },
        { key: 'regionsHighscore', name: 'Regions Highscore', icon: '🏆', desc: 'Displays regional pixel/color contribution rankings.', features: ['Sort rankings by player or guild', 'Filter by pixel count, color, or region', 'Historical contribution statistics'] },
        { key: 'themeEditor', name: 'Theme Editor', icon: '🎨', desc: 'Visual map theme editor — edit MapLibre GL styles with color pickers, save/load/manage custom themes.', features: ['Bundled themes (Fjord, Obsidian, Monokai, Ayu Mirage, etc.)', 'Simple & Full color editing modes', 'Live preview toggle for instant feedback', 'Import/export themes as JSON files', 'Quick theme-switch submenu in the dropdown', 'Theme manager with create, edit & delete'] },
        { key: 'mapMarkers', name: 'Map Markers', icon: '📌', desc: 'Place and manage image stickers on the map canvas. Images scale and persist with the map.', features: ['Upload PNG/JPEG/WebP files or use image URLs', 'Drag to define placement bounds (click-only rejected with prompt)', 'Hold Shift during drag to force aspect-ratio lock', 'Per-marker lock/unlock aspect ratio toggle', 'Per-marker opacity slider and visibility toggle', 'Edit mode with 8 fixed-size handles (corners + edge midpoints)', 'Drag-to-sort cards to reorder rendering order', 'Compact card view with click-to-expand controls', 'Draggable management modal', 'Persistent storage via IndexedDB'] },
    ];

    const EXTENSION_LIST = [
        { key: 'extAutoHoverMenus', name: 'Auto-open Menus on Hover', icon: '🖱️', desc: 'Automatically opens group button dropdown menus when you hover over them.', features: ['Hover over any group button to auto-open its dropdown', 'Configurable vertical hover zone (250px)', 'Per-button cooldown to prevent rapid toggles', 'MutationObserver-based — detects new buttons automatically'] },
        { key: 'extGoToLastLocation', name: 'Auto-Go to Last Location', icon: '📍', desc: 'Automatically returns you to your last location on page load if you spawned at the default area.', features: ['Detects if you spawned in the default area', 'Auto-clicks the "Last Location" button on load', 'One-shot — only fires once per page load', 'Automatic cleanup after 10 seconds to prevent leaks'] },
        { key: 'extPillHoverLabels', name: 'Hover Labels', icon: '💊', desc: 'Adds the expanding pill-style hover animation with text labels to all submenu buttons under controls-left.', features: ['Expanding pill animation on hover', 'Shows button title/name as a label', 'Applies to all native dropdown submenu buttons', 'Respects dark mode colors', 'MutationObserver-based — detects dynamically added buttons'] },
        { key: 'extJanitorView', name: 'Janitor View', icon: '🛡️', desc: 'Reveals the hidden moderation button for janitors/moderators.', features: ['Removes the hidden class from the moderation group button', 'Makes the 🛡️ Moderation button visible in the controls'] },
        { key: 'extMapMovementLock', name: 'Map Movement Lock', icon: '🔒', desc: 'Adds a right-side lock button that freezes map panning, zooming, and page scrolling until you unlock it.', features: ['Creates a lock toggle in controls-right', 'Blocks mouse, touch, keyboard, zoom button, scripted pan/zoom movement, and page-wide scrolling while locked', 'Preserves the locked state across reloads while the extension is enabled'] },
        { key: 'extGuildSearch', name: 'Guild Search Button', icon: '🔎', desc: 'Inserts a search icon button in the guild submenu to open the Guild Search modal — allows searching other guilds without leaving your own.', features: ['Adds a search button directly below the Guild menu button in its submenu', 'Calls the native toggleGuildSearchModal() when clicked'] },
        { key: 'extLogOutButton', name: 'Log Out Button', icon: '🚪', desc: 'Appends a Log Out button to the bottom of the right controls panel. Hides automatically when you are not logged in.', features: ['Exit-icon Log Out button at the bottom of controls-right', 'Calls the native logOut() when clicked', 'Auto-hides while the user is logged out and reappears on login'] },
        { key: 'ghostPaletteSearch', name: 'Ghost Palette Color Search (legacy)', icon: '🔍', desc: 'Superseded by Ghost++ Template Overlay. Adds a searchable color filter to the native ghost image palette — only useful if Ghost++ is disabled.', features: ['Search ghost palette colors by hex code', 'Hide unmatched colors with a toggle', 'Enable filtered: enable matched colors and disable all others in the ghost palette', 'Enable owned and filtered: enable only owned colors currently shown by filters', 'Real-time glow/highlight on matching swatches'] },
        { key: 'ghostTemplateManager', name: 'Ghost Template Manager (legacy)', icon: '👻', desc: 'Superseded by Ghost++ Template Overlay. Full ghost image template history with import/export and overlay preview on the native ghost tool — only useful if Ghost++ is disabled.', features: ['IndexedDB-backed template history', 'Import/export ghost templates as files', 'Preview overlay on the map', 'Position encoding in image header', 'Duplicate detection'] },
        { key: 'showSyncGhostBtn', name: 'Sync Ghost With Selected Color', icon: '♻️', desc: 'Adds a button to the Image Tools (🖼️) dropdown. When toggled on in-game, changing your active paint color automatically enables only that color in the ghost palette and disables all others.', features: ['Toggle button in the Image Tools dropdown', 'Auto-enables only the currently selected paint color in the ghost palette, disabling the rest', 'Works with Ghost++\'s own focused template as well as the native ghost palette'] },
    ];

    const DEFAULT_SETTINGS = { useEmojiIcon: false, compactPaintOverflow: false, disableGroupNoise: false, startShiftLock: false, startInspectMode: false, smoothZoomButtons: false, mobileCompatibility: false, enableDebug: false, modernizeGhostPaletteBtns: false, rememberGhostModalPos: false, keybinds: { openSettings: { key: 'P', ctrl: true, shift: true }, mapMovementLock: { key: 'L', ctrl: true, shift: true } } };
    FEATURE_LIST.forEach(f => DEFAULT_SETTINGS[f.key] = true);
    // Ghost++ deliberately opts out of the blanket "every feature defaults on" rule above:
    // it wholesale replaces the native ghost-image tool, which is too large a UX change to
    // force on every user silently. New installs get it off until they enable it themselves.
    DEFAULT_SETTINGS.ghostPlusPlus = false;
    EXTENSION_LIST.forEach(f => DEFAULT_SETTINGS[f.key] = f.key === 'extPillHoverLabels' ? true : false);

    function loadSettings() {
        try {
            const raw = localStorage.getItem(STORAGE_KEY);
            if (!raw) return { ...DEFAULT_SETTINGS };
            const parsed = JSON.parse(raw);
            // Merge with defaults so new features default to enabled
            const merged = { ...DEFAULT_SETTINGS, ...parsed };
            // Deep-merge each keybind so partial saved entries inherit missing defaults.
            const savedKeybinds = parsed.keybinds || {};
            const savedOpenSettings = savedKeybinds.openSettings;
            if (savedOpenSettings &&
                savedOpenSettings.key === 'H' &&
                savedOpenSettings.ctrl === true &&
                savedOpenSettings.shift === true) {
                savedKeybinds.openSettings = { key: 'P', ctrl: true, shift: true };
            }
            merged.keybinds = { ...savedKeybinds };
            Object.entries(DEFAULT_SETTINGS.keybinds).forEach(([key, defaults]) => {
                merged.keybinds[key] = { ...defaults, ...(savedKeybinds[key] || {}) };
            });
            return merged;
        } catch (e) {
            return { ...DEFAULT_SETTINGS };
        }
    }

    function saveSettings(settings) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
    }

    const _settings = loadSettings();

    function gpcMobileCompatibilityActive() {
        if (!_settings.mobileCompatibility) return false;
        try {
            return window.matchMedia('(max-width: 768px)').matches || window.matchMedia('(pointer: coarse)').matches;
        } catch (_) {
            return window.innerWidth <= 768;
        }
    }

    // ============================================================
    //  DEBUG SYSTEM
    // ============================================================
    const _debugLog = [];

    /**
     * Push an entry into the debug log. Only records when enableDebug is true.
     * @param {string} message  Human-readable description of the event.
     * @param {object} [opts]
     * @param {Error|any} [opts.error]       The caught error object (message + stack captured).
     * @param {string}    [opts.uiComponent] Description of the UI element that triggered the action.
     */
    function dbgPush(message, opts = {}) {
        if (!_settings.enableDebug) return;
        const entry = { timestamp: new Date().toISOString(), message };
        if (opts.uiComponent) entry.uiComponent = opts.uiComponent;
        if (opts.error != null) {
            entry.error = String(opts.error);
            if (opts.error && opts.error.stack) entry.stack = opts.error.stack;
        }
        _debugLog.push(entry);
    }

    /** Returns the number of currently collected debug entries. */
    function dbgCount() { return _debugLog.length; }

    // Lets OTHER installed userscripts feed their own diagnostic events into
    // THIS script's Debug Log, instead of each needing an entirely separate
    // export/toggle of their own -- one exported file covering everything
    // instead of hunting through several. Exposed on the real page window
    // (unsafeWindow and window are the same actual DOM object) since that's
    // the one thing genuinely shared across separate userscripts running in
    // the same page, unlike anything declared with let/const at this
    // script's own top level. push() already no-ops unless Enable Debugging
    // is on (see dbgPush above), so a caller doesn't strictly need to check
    // isEnabled() first, but it's exposed anyway so one can skip building a
    // message/opts object it doesn't need yet.
    (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).__gpcDebugBridge = Object.freeze({
        push: (message, opts) => dbgPush(message, opts),
        isEnabled: () => !!_settings.enableDebug,
    });

    // Live point-in-time state, captured fresh whenever the Debug Log is
    // exported (never stored in _debugLog itself, which only holds the
    // timestamped event trail) \u2014 a support report saying "it's still not
    // working" is far easier to diagnose against a snapshot of what the
    // instance actually looks like RIGHT NOW than against the event log
    // alone, especially when the failure happened before anything even
    // got a chance to call dbgPush(). Scoped deliberately: every feature's
    // enabled/ok/error/disabled status (the single most useful signal for
    // "is something else interfering") plus a deeper Ghost++-specific dive
    // (the feature actually being actively debugged) and the small,
    // already-existing native-ghost-state read \u2014 not an attempt to dump
    // every other feature's own private internals, which would need
    // touching many files for uncertain benefit. Never includes anything
    // sensitive (auth tokens, user data) \u2014 only feature toggles, counts,
    // and DOM presence/visibility flags.
    function dbgCaptureStateSnapshot() {
        const snapshot = {
            capturedAt: new Date().toISOString(),
            version: VERSION,
            page: {
                url: location.href,
                userAgent: navigator.userAgent,
                readyState: document.readyState,
                viewport: { width: window.innerWidth, height: window.innerHeight },
                darkMode: (() => { try { return isDarkMode(); } catch (_) { return null; } })(),
            },
            settings: _settings,
            featureStatus: _featureStatus,
        };
        if (typeof gppState !== 'undefined') {
            try {
                const nativeBtn = document.getElementById('loadGhostImageBtn');
                const modalEl = document.getElementById('gpp-modal');
                snapshot.ghostPlusPlus = {
                    settings: gppState.settings,
                    runtimeReady: gppState.runtimeReady,
                    templateCount: gppState.templates ? gppState.templates.length : null,
                    guildTemplateCount: gppState.guildTemplates ? gppState.guildTemplates.length : null,
                    focusedTemplateId: gppState.focusedTemplateId,
                    shimActive: (typeof gppShimActive !== 'undefined') ? gppShimActive : null,
                    openerMounted: !!document.getElementById('gpp-opener'),
                    nativeButtonFound: !!nativeBtn,
                    nativeButtonHidden: nativeBtn ? nativeBtn.classList.contains('hidden') : null,
                    modalMounted: !!modalEl,
                    modalCurrentlyOpen: modalEl ? !modalEl.classList.contains('gpp-hidden') : null,
                };
            } catch (err) {
                snapshot.ghostPlusPlus = { error: 'Failed to capture Ghost++ state: ' + (err && err.message ? err.message : String(err)) };
            }
            try {
                snapshot.nativeGhostState = (typeof gppReadNativeGhostSnapshot === 'function') ? gppReadNativeGhostSnapshot() : null;
            } catch (err) {
                snapshot.nativeGhostState = { error: 'Failed to read native ghost state: ' + (err && err.message ? err.message : String(err)) };
            }
        }
        return snapshot;
    }

    /** Downloads the current debug log (plus a live state snapshot) as a .txt file. */
    function dbgExport() {
        const lines = _debugLog.map(e => {
            let s = `[${e.timestamp}] ${e.message}`;
            if (e.uiComponent) s += `\n  UI Component : ${e.uiComponent}`;
            if (e.error)       s += `\n  Error        : ${e.error}`;
            if (e.stack)       s += `\n  Stack        :\n    ${e.stack.replace(/\n/g, '\n    ')}`;
            return s;
        });
        const sep = '\n' + '\u2500'.repeat(60) + '\n';
        const header = `GeoPixelcons++ Debug Log\nVersion : ${VERSION}\nGenerated: ${new Date().toISOString()}\nEntries  : ${_debugLog.length}\n${'='.repeat(60)}\n\n`;
        const body = _debugLog.length ? lines.join(sep) : '(No timestamped log entries recorded yet.)';
        let snapshotSection;
        try {
            snapshotSection = '\n\n' + '='.repeat(60) + '\nSTATE SNAPSHOT (captured just now, at export time)\n' + '='.repeat(60) + '\n' + JSON.stringify(dbgCaptureStateSnapshot(), null, 2) + '\n';
        } catch (err) {
            snapshotSection = '\n\n(Failed to capture state snapshot: ' + (err && err.message ? err.message : String(err)) + ')\n';
        }
        const blob = new Blob([header + body + snapshotSection], { type: 'text/plain' });
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = `gpc-debug-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
        document.body.appendChild(a);
        a.click();
        setTimeout(() => { try { a.remove(); URL.revokeObjectURL(a.href); } catch (_) {} }, 1000);
    }

    let _themeEditor = null; // Populated by theme editor module
    let _regionScreenshot = null; // Populated by region screenshot module
    let _regionsHighscore = null; // Populated by regions highscore module
    let _mapMarkers = null; // Populated by map markers module

    // ─── Shared coord cache for screenshot/highscore flyouts ────────
    const COORD_CACHE_KEY = 'gpc_cachedCoords';
    const AUTO_SS_KEY = 'gpc_autoScreenshotEnabled';
    function loadCachedCoords() { try { return JSON.parse(localStorage.getItem(COORD_CACHE_KEY)); } catch { return null; } }
    function saveCachedCoords(c) { localStorage.setItem(COORD_CACHE_KEY, JSON.stringify(c)); }
    function isAutoScreenshotEnabled() { return localStorage.getItem(AUTO_SS_KEY) === '1'; }
    function setAutoScreenshot(on) { localStorage.setItem(AUTO_SS_KEY, on ? '1' : '0'); }

    const _featureStatus = {}; // key => 'ok' | 'error' | 'disabled'
    FEATURE_LIST.forEach(f => {
        _featureStatus[f.key] = _settings[f.key] ? 'pending' : 'disabled';
    });
    EXTENSION_LIST.forEach(f => {
        _featureStatus[f.key] = _settings[f.key] ? 'pending' : 'disabled';
    });

    // ============================================================
    //  DARK THEME DETECTION (Geopixels++ compatibility)
    // ============================================================
    function isDarkMode() {
        const gppSettings = localStorage.getItem('geo++_settings');
        if (gppSettings) {
            try {
                const parsed = JSON.parse(gppSettings);
                if (parsed.theme && parsed.theme !== 'system') {
                    return parsed.theme === 'simple_black';
                }
            } catch(e) {}
        }
        return document.body.classList.contains('dark') ||
               window.matchMedia('(prefers-color-scheme: dark)').matches;
    }

    // Theme-aware colors
    function t(light, dark) { return isDarkMode() ? dark : light; }

    // ============================================================
    //  UI: SETTINGS MODAL (Tabbed)
    // ============================================================
    function createSettingsModal() {
        // Remove existing
        const existing = document.getElementById('gpc-settings-modal');
        if (existing) { existing.remove(); return; }

        const dark = isDarkMode();
        const overlay = document.createElement('div');
        overlay.id = 'gpc-settings-modal';
        overlay.style.cssText = `
            position: fixed; inset: 0; z-index: 100000;
            background: rgba(0,0,0,0.5); display: flex;
            align-items: center; justify-content: center;
            font-family: system-ui, -apple-system, sans-serif;
        `;

        const modal = document.createElement('div');
        modal.style.cssText = `
            background: ${dark ? '#1e1e2e' : '#ffffff'};
            color: ${dark ? '#cdd6f4' : '#1e293b'};
            border-radius: 12px; padding: 0; width: 460px; max-width: 95vw;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
            overflow: hidden;
        `;

        // Header
        const header = document.createElement('div');
        header.style.cssText = `
            padding: 16px 20px; display: flex; align-items: center;
            justify-content: space-between;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border-bottom: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
        `;
        header.innerHTML = `<span style="font-weight:700;font-size:16px;">⚙️ GeoPixelcons++</span>`;

        const closeBtn = document.createElement('button');
        closeBtn.textContent = '✕';
        closeBtn.style.cssText = `
            background:none; border:none; font-size:18px; cursor:pointer;
            color:${dark ? '#a6adc8' : '#64748b'}; padding:4px 8px; border-radius:4px;
        `;
        closeBtn.onmouseenter = () => closeBtn.style.background = dark ? '#45475a' : '#e2e8f0';
        closeBtn.onmouseleave = () => closeBtn.style.background = 'none';
        closeBtn.onclick = () => overlay.remove();
        header.appendChild(closeBtn);
        modal.appendChild(header);

        // Tab bar
        const tabBar = document.createElement('div');
        tabBar.style.cssText = `
            display: flex; background: ${dark ? '#1e1e2e' : '#ffffff'};
            border-bottom: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
        `;
        const tabs = ['Extensions', 'GPC Settings', 'Keybindings'];
        const tabBtns = [];
        const tabPanels = [];

        tabs.forEach((tabName, i) => {
            const btn = document.createElement('button');
            btn.textContent = tabName;
            btn.style.cssText = `
                flex: 1; padding: 10px 16px; font-size: 13px; font-weight: 600;
                border: none; cursor: pointer; transition: 0.2s;
                background: ${i === 0 ? (dark ? '#1e1e2e' : '#ffffff') : (dark ? '#313244' : '#f1f5f9')};
                color: ${i === 0 ? (dark ? '#cdd6f4' : '#1e293b') : (dark ? '#6c7086' : '#94a3b8')};
                border-bottom: 2px solid ${i === 0 ? '#22c55e' : 'transparent'};
            `;
            btn.addEventListener('click', () => switchTab(i));
            tabBtns.push(btn);
            tabBar.appendChild(btn);
        });
        modal.appendChild(tabBar);

        function switchTab(idx) {
            tabBtns.forEach((b, i) => {
                const active = i === idx;
                b.style.background = active ? (dark ? '#1e1e2e' : '#ffffff') : (dark ? '#313244' : '#f1f5f9');
                b.style.color = active ? (dark ? '#cdd6f4' : '#1e293b') : (dark ? '#6c7086' : '#94a3b8');
                b.style.borderBottom = active ? '2px solid #22c55e' : '2px solid transparent';
            });
            tabPanels.forEach((p, i) => {
                p.style.display = i === idx ? 'block' : 'none';
            });
        }

        // Warning banner (hidden by default)
        const banner = document.createElement('div');
        banner.id = 'gpc-restart-banner';
        banner.style.cssText = `
            display: none; padding: 10px 20px;
            background: ${dark ? '#f9e2af33' : '#fef3c7'};
            color: ${dark ? '#f9e2af' : '#92400e'};
            font-size: 13px; font-weight: 600;
            border-bottom: 1px solid ${dark ? '#f9e2af44' : '#fde68a'};
        `;
        banner.textContent = '⚠️ Refresh the page to apply changes';
        modal.appendChild(banner);

        // ---- Floating tooltip helper ----
        let activeTooltip = null;
        function removeTooltip() {
            if (activeTooltip) { activeTooltip.remove(); activeTooltip = null; }
        }

        function showTooltip(e, feature) {
            removeTooltip();
            const tip = document.createElement('div');
            tip.style.cssText = `
                position: fixed; z-index: 100001; padding: 12px 16px; border-radius: 8px;
                background: ${dark ? '#313244' : '#ffffff'}; color: ${dark ? '#cdd6f4' : '#1e293b'};
                box-shadow: 0 8px 24px rgba(0,0,0,0.25); font-size: 13px; max-width: 280px;
                border: 1px solid ${dark ? '#45475a' : '#e2e8f0'}; pointer-events: none;
            `;
            let html = `<div style="font-weight:700;margin-bottom:6px;">${feature.icon} ${feature.name}</div>`;
            html += `<div style="margin-bottom:6px;color:${dark ? '#a6adc8' : '#64748b'};">${feature.desc}</div>`;
            html += '<ul style="margin:0;padding-left:18px;">';
            feature.features.forEach(f => { html += `<li style="margin-bottom:2px;">${f}</li>`; });
            html += '</ul>';
            tip.innerHTML = html;
            document.body.appendChild(tip);
            // Position near cursor
            const tipW = tip.offsetWidth, tipH = tip.offsetHeight;
            let tx = e.clientX + 12, ty = e.clientY + 12;
            if (tx + tipW > window.innerWidth - 8) tx = e.clientX - tipW - 12;
            if (ty + tipH > window.innerHeight - 8) ty = e.clientY - tipH - 12;
            tip.style.left = tx + 'px';
            tip.style.top = ty + 'px';
            activeTooltip = tip;
        }

        function showSimpleTooltip(e, text) {
            removeTooltip();
            const tip = document.createElement('div');
            tip.style.cssText = `
                position: fixed; z-index: 100001; padding: 10px 14px; border-radius: 8px;
                background: ${dark ? '#313244' : '#ffffff'}; color: ${dark ? '#cdd6f4' : '#1e293b'};
                box-shadow: 0 8px 24px rgba(0,0,0,0.25); font-size: 13px; max-width: 260px;
                border: 1px solid ${dark ? '#45475a' : '#e2e8f0'}; pointer-events: none; line-height: 1.5;
            `;
            tip.textContent = text;
            document.body.appendChild(tip);
            const tipW = tip.offsetWidth, tipH = tip.offsetHeight;
            let tx = e.clientX + 12, ty = e.clientY + 12;
            if (tx + tipW > window.innerWidth - 8) tx = e.clientX - tipW - 12;
            if (ty + tipH > window.innerHeight - 8) ty = e.clientY - tipH - 12;
            tip.style.left = tx + 'px';
            tip.style.top = ty + 'px';
            activeTooltip = tip;
        }

        // ---- Navigate to a feature's UI element ----
        function navigateToFeature(key) {
            function flashEl(el) {
                if (!el) return;
                el.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
                el.style.transition = 'box-shadow .3s';
                el.style.boxShadow = '0 0 0 3px #facc15, 0 0 16px 4px rgba(250,204,21,.5)';
                setTimeout(() => { el.style.boxShadow = ''; setTimeout(() => { el.style.transition = ''; }, 300); }, 1500);
            }
            function flashAll(els) { els.forEach(el => flashEl(el)); }
            const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
            const nav = {
                ghostPaletteSearch: () => {
                    // Open ghost modal, then flash the color search container
                    if (typeof _pw.toggleGhostModal === 'function') _pw.toggleGhostModal(true);
                    setTimeout(() => flashEl(document.querySelector('.color-search-container')), 400);
                },
                ghostTemplateManager: () => {
                    // Open ghost modal, then flash the toolbar buttons
                    if (typeof _pw.toggleGhostModal === 'function') _pw.toggleGhostModal(true);
                    setTimeout(() => flashAll(Array.from(document.querySelectorAll('.gp-to-btn'))), 400);
                },
                guildOverhaul: () => {
                    const guildBtn = document.querySelector('#guildMenuBtn');
                    if (guildBtn) guildBtn.click();
                },
                hidePaintMenu: () => {
                    flashEl(document.querySelector('#gpc-hide-paint-toggle'));
                },
                paintBrushSwap: () => {
                    flashEl(document.querySelector('#brush-swap-toggle'));
                },
                regionsHighscore: () => {
                    if (_regionsHighscore) _regionsHighscore.toggleSelectionMode();
                },
                regionScreenshot: () => {
                    if (_regionScreenshot) _regionScreenshot.toggleSelectionMode();
                },
                bulkPurchaseColors: () => {
                    if (typeof _pw.toggleProfile === 'function') _pw.toggleProfile();
                    setTimeout(() => flashEl(document.querySelector('#gp-bulk-profile-card')), 400);
                },
                themeEditor: () => {
                    if (_themeEditor) _themeEditor.toggleModal();
                },
                extAutoHoverMenus: () => {
                    // No specific UI to navigate to
                },
                extGoToLastLocation: () => {
                    // No specific UI to navigate to
                },
                extPillHoverLabels: () => {
                    // No specific UI to navigate to
                },
                extJanitorView: () => {
                    const modBtn = document.getElementById('modGroupBtn');
                    if (modBtn) flashEl(modBtn);
                },
                extMapMovementLock: () => {
                    flashEl(document.getElementById('gpc-map-movement-lock-btn'));
                },
                extGuildSearch: () => {
                    flashEl(document.getElementById('gpc-guild-search-btn'));
                },
                extLogOutButton: () => {
                    flashEl(document.getElementById('gpc-logout-btn'));
                },
            };
            const fn = nav[key];
            if (fn) fn();
        }

        // ---- Helper: build a toggle row ----
        function buildToggleRow(f, showHelp) {
            const status = _featureStatus[f.key];
            const enabled = _settings[f.key] !== false;

            const row = document.createElement('div');
            row.dataset.featureKey = f.key;
            row.style.cssText = `
                display: flex; align-items: center; justify-content: space-between;
                padding: 10px 14px; border-radius: 8px;
                background: ${enabled
                    ? (status === 'error' ? (dark ? '#f9e2af22' : '#fefce8') : (dark ? '#a6e3a122' : '#f0fdf4'))
                    : (dark ? '#f38ba822' : '#fef2f2')};
                border: 1px solid ${enabled
                    ? (status === 'error' ? (dark ? '#f9e2af44' : '#fde68a') : (dark ? '#a6e3a144' : '#bbf7d0'))
                    : (dark ? '#f38ba844' : '#fecaca')};
                transition: all 0.2s;
            `;

            const labelWrap = document.createElement('div');
            labelWrap.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;min-width:0;';

            const iconSpan = document.createElement('span');
            iconSpan.textContent = f.icon;
            const nameSpan = document.createElement('span');
            nameSpan.textContent = f.name;
            nameSpan.style.cssText = 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;border-bottom:1px dashed ' + (dark ? '#6c7086' : '#94a3b8') + ';transition:color .15s,border-color .15s;';
            nameSpan.addEventListener('mouseenter', () => { nameSpan.style.color = '#3b82f6'; nameSpan.style.borderBottomColor = '#3b82f6'; });
            nameSpan.addEventListener('mouseleave', () => { nameSpan.style.color = ''; nameSpan.style.borderBottomColor = dark ? '#6c7086' : '#94a3b8'; });
            nameSpan.addEventListener('click', () => {
                overlay.remove();
                navigateToFeature(f.key);
            });
            labelWrap.appendChild(iconSpan);
            labelWrap.appendChild(nameSpan);

            if (showHelp && f.desc) {
                const helpBtn = document.createElement('span');
                helpBtn.textContent = '❓';
                helpBtn.style.cssText = 'cursor:help;font-size:14px;flex-shrink:0;margin-left:2px;';
                helpBtn.addEventListener('mouseenter', (ev) => showTooltip(ev, f));
                helpBtn.addEventListener('mouseleave', removeTooltip);
                labelWrap.appendChild(helpBtn);
            }

            // Toggle switch
            const toggle = document.createElement('label');
            toggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0; margin-left:8px;';
            const input = document.createElement('input');
            input.type = 'checkbox';
            input.checked = enabled;
            input.style.cssText = 'opacity:0;width:0;height:0;';

            const slider = document.createElement('span');
            slider.style.cssText = `
                position:absolute; inset:0; border-radius:12px; transition:0.2s;
                background: ${enabled ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
            `;
            const knob = document.createElement('span');
            knob.style.cssText = `
                position:absolute; top:2px; left:${enabled ? '22px' : '2px'};
                width:20px; height:20px; border-radius:50%; transition:0.2s;
                background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
            `;
            slider.appendChild(knob);

            input.addEventListener('change', () => {
                _settings[f.key] = input.checked;
                saveSettings(_settings);
                slider.style.background = input.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
                knob.style.left = input.checked ? '22px' : '2px';
                row.style.background = input.checked
                    ? (dark ? '#a6e3a122' : '#f0fdf4')
                    : (dark ? '#f38ba822' : '#fef2f2');
                row.style.borderColor = input.checked
                    ? (dark ? '#a6e3a144' : '#bbf7d0')
                    : (dark ? '#f38ba844' : '#fecaca');
                banner.style.display = 'block';
            });

            toggle.appendChild(input);
            toggle.appendChild(slider);

            row.appendChild(labelWrap);
            row.appendChild(toggle);
            return row;
        }

        // ============ TAB 1: Extensions ============
        const extPanel = document.createElement('div');
        extPanel.style.cssText = 'padding: 12px 20px; display: flex; flex-direction: column; gap: 8px; max-height: 50vh; overflow-y: auto;';

        // Section: Built-in features
        const builtinLabel = document.createElement('div');
        builtinLabel.style.cssText = `font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:${dark ? '#6c7086' : '#94a3b8'};margin-bottom:2px;`;
        builtinLabel.textContent = 'Built-in Features';
        extPanel.appendChild(builtinLabel);

        FEATURE_LIST.forEach(f => extPanel.appendChild(buildToggleRow(f, true)));

        // Section: Additional extensions
        const extLabel = document.createElement('div');
        extLabel.style.cssText = `font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:${dark ? '#6c7086' : '#94a3b8'};margin-top:8px;margin-bottom:2px;`;
        extLabel.textContent = 'Additional Extensions';
        extPanel.appendChild(extLabel);

        EXTENSION_LIST.forEach(f => extPanel.appendChild(buildToggleRow(f, true)));

        tabPanels.push(extPanel);
        modal.appendChild(extPanel);

        // ============ TAB 2: GeoPixelcons++ Settings ============
        const settingsPanel = document.createElement('div');
        settingsPanel.style.cssText = 'padding: 12px 20px; display: none; flex-direction: column; gap: 12px;';

        // Emoji icon toggle
        const emojiRow = document.createElement('div');
        emojiRow.style.cssText = `
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 14px; border-radius: 8px;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
        `;
        const emojiLabel = document.createElement('div');
        emojiLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
        emojiLabel.innerHTML = '<span>😢</span><span>Use emoji for menu button</span>';
        const emojiHelp = document.createElement('span');
        emojiHelp.textContent = '❓';
        emojiHelp.style.cssText = 'cursor:help;font-size:12px;flex-shrink:0;margin-left:4px;opacity:0.6;';
        emojiHelp.addEventListener('mouseenter', (ev) => showSimpleTooltip(ev, 'When enabled, replaces the GeoPixelcons++ button icon with the 😢 emoji.'));
        emojiHelp.addEventListener('mouseleave', removeTooltip);
        emojiLabel.appendChild(emojiHelp);

        const emojiToggle = document.createElement('label');
        emojiToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
        const emojiInput = document.createElement('input');
        emojiInput.type = 'checkbox';
        emojiInput.checked = !!_settings.useEmojiIcon;
        emojiInput.style.cssText = 'opacity:0;width:0;height:0;';
        const emojiSlider = document.createElement('span');
        emojiSlider.style.cssText = `
            position:absolute; inset:0; border-radius:12px; transition:0.2s;
            background: ${_settings.useEmojiIcon ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
        `;
        const emojiKnob = document.createElement('span');
        emojiKnob.style.cssText = `
            position:absolute; top:2px; left:${_settings.useEmojiIcon ? '22px' : '2px'};
            width:20px; height:20px; border-radius:50%; transition:0.2s;
            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        `;
        emojiSlider.appendChild(emojiKnob);

        emojiInput.addEventListener('change', () => {
            _settings.useEmojiIcon = emojiInput.checked;
            saveSettings(_settings);
            emojiSlider.style.background = emojiInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
            emojiKnob.style.left = emojiInput.checked ? '22px' : '2px';
            // Live-update the button
            const mainBtn = document.getElementById('geopixelconsGroupBtn');
            if (mainBtn) {
                if (emojiInput.checked) {
                    mainBtn.style.backgroundImage = 'none';
                    mainBtn.textContent = '😢';
                    mainBtn.style.fontSize = '20px';
                } else {
                    mainBtn.textContent = '';
                    mainBtn.style.fontSize = '';
                    mainBtn.style.backgroundImage = mainBtn.dataset.iconBg || '';
                }
            }
        });

        emojiToggle.appendChild(emojiInput);
        emojiToggle.appendChild(emojiSlider);
        emojiRow.appendChild(emojiLabel);
        emojiRow.appendChild(emojiToggle);
        settingsPanel.appendChild(emojiRow);

        // Compact paint overflow toggle
        const compactRow = document.createElement('div');
        compactRow.style.cssText = `
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 14px; border-radius: 8px;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            margin-top: 4px;
        `;
        const compactLabel = document.createElement('div');
        compactLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
        compactLabel.innerHTML = '<span>🖌️</span><span>Compact paint controls</span>';
        const compactHelp = document.createElement('span');
        compactHelp.textContent = '❓';
        compactHelp.style.cssText = 'cursor:help;font-size:12px;flex-shrink:0;margin-left:4px;opacity:0.6;';
        compactHelp.addEventListener('mouseenter', (ev) => showSimpleTooltip(ev, 'Moves the ✕ close button and 🖌️ brushes button from inside the paint modal to compact icons above it, alongside the collapse/drag controls. Helps on small or zoomed-in screens.'));
        compactHelp.addEventListener('mouseleave', removeTooltip);
        compactLabel.appendChild(compactHelp);

        const compactToggle = document.createElement('label');
        compactToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
        const compactInput = document.createElement('input');
        compactInput.type = 'checkbox';
        compactInput.checked = !!_settings.compactPaintOverflow;
        compactInput.style.cssText = 'opacity:0;width:0;height:0;';
        const compactSlider = document.createElement('span');
        compactSlider.style.cssText = `
            position:absolute; inset:0; border-radius:12px; transition:0.2s;
            background: ${_settings.compactPaintOverflow ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
        `;
        const compactKnob = document.createElement('span');
        compactKnob.style.cssText = `
            position:absolute; top:2px; left:${_settings.compactPaintOverflow ? '22px' : '2px'};
            width:20px; height:20px; border-radius:50%; transition:0.2s;
            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        `;
        compactSlider.appendChild(compactKnob);

        compactInput.addEventListener('change', () => {
            _settings.compactPaintOverflow = compactInput.checked;
            saveSettings(_settings);
            compactSlider.style.background = compactInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
            compactKnob.style.left = compactInput.checked ? '22px' : '2px';
            banner.style.display = 'block';
        });

        compactToggle.appendChild(compactInput);
        compactToggle.appendChild(compactSlider);
        compactRow.appendChild(compactLabel);
        compactRow.appendChild(compactToggle);
        settingsPanel.appendChild(compactRow);

        // Disable Group Noise toggle
        const noiseRow = document.createElement('div');
        noiseRow.style.cssText = `
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 14px; border-radius: 8px;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            margin-top: 4px;
        `;
        const noiseLabel = document.createElement('div');
        noiseLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
        noiseLabel.innerHTML = '<span>🚫</span><span>Disable Group Noise</span>';
        const noiseHelp = document.createElement('span');
        noiseHelp.textContent = '❓';
        noiseHelp.style.cssText = 'cursor:help;font-size:12px;flex-shrink:0;margin-left:4px;opacity:0.6;';
        noiseHelp.addEventListener('mouseenter', (ev) => showSimpleTooltip(ev, 'Permanently unchecks and hides the "Group Noise" toggle in the ghost template modal. Prevents automatic color grouping so every unique color is treated individually.'));
        noiseHelp.addEventListener('mouseleave', removeTooltip);
        noiseLabel.appendChild(noiseHelp);

        const noiseToggle = document.createElement('label');
        noiseToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
        const noiseInput = document.createElement('input');
        noiseInput.type = 'checkbox';
        noiseInput.checked = !!_settings.disableGroupNoise;
        noiseInput.style.cssText = 'opacity:0;width:0;height:0;';
        const noiseSlider = document.createElement('span');
        noiseSlider.style.cssText = `
            position:absolute; inset:0; border-radius:12px; transition:0.2s;
            background: ${_settings.disableGroupNoise ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
        `;
        const noiseKnob = document.createElement('span');
        noiseKnob.style.cssText = `
            position:absolute; top:2px; left:${_settings.disableGroupNoise ? '22px' : '2px'};
            width:20px; height:20px; border-radius:50%; transition:0.2s;
            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        `;
        noiseSlider.appendChild(noiseKnob);

        noiseInput.addEventListener('change', () => {
            _settings.disableGroupNoise = noiseInput.checked;
            saveSettings(_settings);
            noiseSlider.style.background = noiseInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
            noiseKnob.style.left = noiseInput.checked ? '22px' : '2px';
            banner.style.display = 'block';
        });

        noiseToggle.appendChild(noiseInput);
        noiseToggle.appendChild(noiseSlider);
        noiseRow.appendChild(noiseLabel);
        noiseRow.appendChild(noiseToggle);
        settingsPanel.appendChild(noiseRow);

        // Start in Shift Lock toggle
        const shiftRow = document.createElement('div');
        shiftRow.style.cssText = `
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 14px; border-radius: 8px;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            margin-top: 4px;
        `;
        const shiftLabel = document.createElement('div');
        shiftLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
        shiftLabel.innerHTML = '<span>\uD83D\uDD12</span><span>Start in Shift Lock</span>';
        const shiftHelp = document.createElement('span');
        shiftHelp.textContent = '❓';
        shiftHelp.style.cssText = 'cursor:help;font-size:12px;flex-shrink:0;margin-left:4px;opacity:0.6;';
        shiftHelp.addEventListener('mouseenter', (ev) => showSimpleTooltip(ev, 'Automatically enables Shift Lock on page load so you can paint without holding Shift.'));
        shiftHelp.addEventListener('mouseleave', removeTooltip);
        shiftLabel.appendChild(shiftHelp);

        const shiftToggle = document.createElement('label');
        shiftToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
        const shiftInput = document.createElement('input');
        shiftInput.type = 'checkbox';
        shiftInput.checked = !!_settings.startShiftLock;
        shiftInput.style.cssText = 'opacity:0;width:0;height:0;';
        const shiftSlider = document.createElement('span');
        shiftSlider.style.cssText = `
            position:absolute; inset:0; border-radius:12px; transition:0.2s;
            background: ${_settings.startShiftLock ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
        `;
        const shiftKnob = document.createElement('span');
        shiftKnob.style.cssText = `
            position:absolute; top:2px; left:${_settings.startShiftLock ? '22px' : '2px'};
            width:20px; height:20px; border-radius:50%; transition:0.2s;
            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        `;
        shiftSlider.appendChild(shiftKnob);

        shiftInput.addEventListener('change', () => {
            _settings.startShiftLock = shiftInput.checked;
            saveSettings(_settings);
            shiftSlider.style.background = shiftInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
            shiftKnob.style.left = shiftInput.checked ? '22px' : '2px';
        });

        shiftToggle.appendChild(shiftInput);
        shiftToggle.appendChild(shiftSlider);
        shiftRow.appendChild(shiftLabel);
        shiftRow.appendChild(shiftToggle);
        settingsPanel.appendChild(shiftRow);

        // Start in Inspect Mode toggle
        const inspectRow = document.createElement('div');
        inspectRow.style.cssText = `
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 14px; border-radius: 8px;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            margin-top: 4px;
        `;
        const inspectLabel = document.createElement('div');
        inspectLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
        inspectLabel.innerHTML = '<span>\uD83D\uDD0D</span><span>Start in Inspect Mode</span>';
        const inspectHelp = document.createElement('span');
        inspectHelp.textContent = '❓';
        inspectHelp.style.cssText = 'cursor:help;font-size:12px;flex-shrink:0;margin-left:4px;opacity:0.6;';
        inspectHelp.addEventListener('mouseenter', (ev) => showSimpleTooltip(ev, 'Automatically switches to Inspect Mode on page load instead of starting in Paint (Action) mode.'));
        inspectHelp.addEventListener('mouseleave', removeTooltip);
        inspectLabel.appendChild(inspectHelp);

        const inspectToggle = document.createElement('label');
        inspectToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
        const inspectInput = document.createElement('input');
        inspectInput.type = 'checkbox';
        inspectInput.checked = !!_settings.startInspectMode;
        inspectInput.style.cssText = 'opacity:0;width:0;height:0;';
        const inspectSlider = document.createElement('span');
        inspectSlider.style.cssText = `
            position:absolute; inset:0; border-radius:12px; transition:0.2s;
            background: ${_settings.startInspectMode ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
        `;
        const inspectKnob = document.createElement('span');
        inspectKnob.style.cssText = `
            position:absolute; top:2px; left:${_settings.startInspectMode ? '22px' : '2px'};
            width:20px; height:20px; border-radius:50%; transition:0.2s;
            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        `;
        inspectSlider.appendChild(inspectKnob);

        inspectInput.addEventListener('change', () => {
            _settings.startInspectMode = inspectInput.checked;
            saveSettings(_settings);
            inspectSlider.style.background = inspectInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
            inspectKnob.style.left = inspectInput.checked ? '22px' : '2px';
        });

        inspectToggle.appendChild(inspectInput);
        inspectToggle.appendChild(inspectSlider);
        inspectRow.appendChild(inspectLabel);
        inspectRow.appendChild(inspectToggle);
        settingsPanel.appendChild(inspectRow);

        // Smooth Zoom Buttons toggle
        const smoothZoomRow = document.createElement('div');
        smoothZoomRow.style.cssText = `
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 14px; border-radius: 8px;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            margin-top: 4px;
        `;
        const smoothZoomLabel = document.createElement('div');
        smoothZoomLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
        smoothZoomLabel.innerHTML = '<span>🔢</span><span>Smooth Zoom Buttons</span>';
        const smoothZoomHelp = document.createElement('span');
        smoothZoomHelp.textContent = '❓';
        smoothZoomHelp.style.cssText = 'cursor:help;font-size:12px;flex-shrink:0;margin-left:4px;opacity:0.6;';
        smoothZoomHelp.addEventListener('mouseenter', (ev) => showSimpleTooltip(ev, 'Replaces the +/\u2212 zoom buttons with a smooth zoom control: vertical slider, exact value input, hold-to-repeat \u00b1 buttons, and scroll-wheel zoom on the widget.'));
        smoothZoomHelp.addEventListener('mouseleave', removeTooltip);
        smoothZoomLabel.appendChild(smoothZoomHelp);

        const smoothZoomToggle = document.createElement('label');
        smoothZoomToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
        const smoothZoomInput = document.createElement('input');
        smoothZoomInput.type = 'checkbox';
        smoothZoomInput.checked = !!_settings.smoothZoomButtons;
        smoothZoomInput.style.cssText = 'opacity:0;width:0;height:0;';
        const smoothZoomSlider = document.createElement('span');
        smoothZoomSlider.style.cssText = `
            position:absolute; inset:0; border-radius:12px; transition:0.2s;
            background: ${_settings.smoothZoomButtons ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
        `;
        const smoothZoomKnob = document.createElement('span');
        smoothZoomKnob.style.cssText = `
            position:absolute; top:2px; left:${_settings.smoothZoomButtons ? '22px' : '2px'};
            width:20px; height:20px; border-radius:50%; transition:0.2s;
            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        `;
        smoothZoomSlider.appendChild(smoothZoomKnob);

        smoothZoomInput.addEventListener('change', () => {
            _settings.smoothZoomButtons = smoothZoomInput.checked;
            saveSettings(_settings);
            smoothZoomSlider.style.background = smoothZoomInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
            smoothZoomKnob.style.left = smoothZoomInput.checked ? '22px' : '2px';
            banner.style.display = 'block';
        });

        smoothZoomToggle.appendChild(smoothZoomInput);
        smoothZoomToggle.appendChild(smoothZoomSlider);
        smoothZoomRow.appendChild(smoothZoomLabel);
        smoothZoomRow.appendChild(smoothZoomToggle);
        settingsPanel.appendChild(smoothZoomRow);

        // Mobile Compatibility toggle
        const mobileCompatRow = document.createElement('div');
        mobileCompatRow.style.cssText = `
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 14px; border-radius: 8px;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            margin-top: 4px;
        `;
        const mobileCompatLabel = document.createElement('div');
        mobileCompatLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
        mobileCompatLabel.innerHTML = '<span>📱</span><span>Mobile Compatibility</span>';
        const mobileCompatHelp = document.createElement('span');
        mobileCompatHelp.textContent = '❓';
        mobileCompatHelp.style.cssText = 'cursor:help;font-size:12px;flex-shrink:0;margin-left:4px;opacity:0.6;';
        mobileCompatHelp.addEventListener('mouseenter', (ev) => showSimpleTooltip(ev, 'Mobile-focused fixes for small or touch screens. Currently disables draggable guild/ghost modals on mobile and makes the ghost menu overhaul switch between controls and preview using the preview collapse button. Refresh the page after toggling.'));
        mobileCompatHelp.addEventListener('mouseleave', removeTooltip);
        mobileCompatLabel.appendChild(mobileCompatHelp);

        const mobileCompatToggle = document.createElement('label');
        mobileCompatToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
        const mobileCompatInput = document.createElement('input');
        mobileCompatInput.type = 'checkbox';
        mobileCompatInput.checked = !!_settings.mobileCompatibility;
        mobileCompatInput.style.cssText = 'opacity:0;width:0;height:0;';
        const mobileCompatSlider = document.createElement('span');
        mobileCompatSlider.style.cssText = `
            position:absolute; inset:0; border-radius:12px; transition:0.2s;
            background: ${_settings.mobileCompatibility ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
        `;
        const mobileCompatKnob = document.createElement('span');
        mobileCompatKnob.style.cssText = `
            position:absolute; top:2px; left:${_settings.mobileCompatibility ? '22px' : '2px'};
            width:20px; height:20px; border-radius:50%; transition:0.2s;
            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        `;
        mobileCompatSlider.appendChild(mobileCompatKnob);

        mobileCompatInput.addEventListener('change', () => {
            _settings.mobileCompatibility = mobileCompatInput.checked;
            saveSettings(_settings);
            mobileCompatSlider.style.background = mobileCompatInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
            mobileCompatKnob.style.left = mobileCompatInput.checked ? '22px' : '2px';
            banner.style.display = 'block';
        });

        mobileCompatToggle.appendChild(mobileCompatInput);
        mobileCompatToggle.appendChild(mobileCompatSlider);
        mobileCompatRow.appendChild(mobileCompatLabel);
        mobileCompatRow.appendChild(mobileCompatToggle);
        settingsPanel.appendChild(mobileCompatRow);

        // Enable Debugging toggle
        const debugRow = document.createElement('div');
        debugRow.style.cssText = `
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 14px; border-radius: 8px;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            margin-top: 4px;
        `;
        const debugLabel = document.createElement('div');
        debugLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
        debugLabel.innerHTML = '<span>🔶</span><span>Enable Debugging</span>';
        const debugHelp = document.createElement('span');
        debugHelp.textContent = '❓';
        debugHelp.style.cssText = 'cursor:help;font-size:12px;flex-shrink:0;margin-left:4px;opacity:0.6;';
        debugHelp.addEventListener('mouseenter', (ev) => showSimpleTooltip(ev, 'When enabled, errors are recorded in memory and a Debug Logs button appears in the dropdown. Click it to export a log file. Refresh the page after toggling.'));
        debugHelp.addEventListener('mouseleave', removeTooltip);
        debugLabel.appendChild(debugHelp);

        const debugToggle = document.createElement('label');
        debugToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
        const debugInput = document.createElement('input');
        debugInput.type = 'checkbox';
        debugInput.checked = !!_settings.enableDebug;
        debugInput.style.cssText = 'opacity:0;width:0;height:0;';
        const debugSlider = document.createElement('span');
        debugSlider.style.cssText = `
            position:absolute; inset:0; border-radius:12px; transition:0.2s;
            background: ${_settings.enableDebug ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
        `;
        const debugKnob = document.createElement('span');
        debugKnob.style.cssText = `
            position:absolute; top:2px; left:${_settings.enableDebug ? '22px' : '2px'};
            width:20px; height:20px; border-radius:50%; transition:0.2s;
            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        `;
        debugSlider.appendChild(debugKnob);

        debugInput.addEventListener('change', () => {
            _settings.enableDebug = debugInput.checked;
            saveSettings(_settings);
            debugSlider.style.background = debugInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
            debugKnob.style.left = debugInput.checked ? '22px' : '2px';
            banner.style.display = 'block';
        });

        debugToggle.appendChild(debugInput);
        debugToggle.appendChild(debugSlider);
        debugRow.appendChild(debugLabel);
        debugRow.appendChild(debugToggle);
        settingsPanel.appendChild(debugRow);

        // Ghost Menu UI Overhaul toggle (Ghost Template Manager)
        const modernBtnsRow = document.createElement('div');
        modernBtnsRow.style.cssText = `
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 14px; border-radius: 8px;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            margin-top: 4px;
        `;
        const modernBtnsLabel = document.createElement('div');
        modernBtnsLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
        modernBtnsLabel.innerHTML = '<span>🎛️</span><span>Ghost Menu UI Overhaul</span>';
        const modernBtnsHelp = document.createElement('span');
        modernBtnsHelp.textContent = '❓';
        modernBtnsHelp.style.cssText = 'cursor:help;font-size:12px;flex-shrink:0;margin-left:4px;opacity:0.6;';
        modernBtnsHelp.addEventListener('mouseenter', (ev) => showSimpleTooltip(ev, 'Requires Ghost Template Manager. Removes the ✚₊ collapse toggle added by geopixels++, shows all its hidden buttons permanently, and applies consistent flat styling to every action button in the ghost palette panel.'));
        modernBtnsHelp.addEventListener('mouseleave', removeTooltip);
        modernBtnsLabel.appendChild(modernBtnsHelp);

        const modernBtnsToggle = document.createElement('label');
        modernBtnsToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
        const modernBtnsInput = document.createElement('input');
        modernBtnsInput.type = 'checkbox';
        modernBtnsInput.checked = !!_settings.modernizeGhostPaletteBtns;
        modernBtnsInput.style.cssText = 'opacity:0;width:0;height:0;';
        const modernBtnsSlider = document.createElement('span');
        modernBtnsSlider.style.cssText = `
            position:absolute; inset:0; border-radius:12px; transition:0.2s;
            background: ${_settings.modernizeGhostPaletteBtns ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
        `;
        const modernBtnsKnob = document.createElement('span');
        modernBtnsKnob.style.cssText = `
            position:absolute; top:2px; left:${_settings.modernizeGhostPaletteBtns ? '22px' : '2px'};
            width:20px; height:20px; border-radius:50%; transition:0.2s;
            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        `;
        modernBtnsSlider.appendChild(modernBtnsKnob);

        modernBtnsInput.addEventListener('change', () => {
            _settings.modernizeGhostPaletteBtns = modernBtnsInput.checked;
            saveSettings(_settings);
            modernBtnsSlider.style.background = modernBtnsInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
            modernBtnsKnob.style.left = modernBtnsInput.checked ? '22px' : '2px';
            banner.style.display = 'block';
        });

        modernBtnsToggle.appendChild(modernBtnsInput);
        modernBtnsToggle.appendChild(modernBtnsSlider);
        modernBtnsRow.appendChild(modernBtnsLabel);
        modernBtnsRow.appendChild(modernBtnsToggle);
        settingsPanel.appendChild(modernBtnsRow);

        // Remember ghost template modal position & size toggle
        const ghostPosRow = document.createElement('div');
        ghostPosRow.style.cssText = `
            display: flex; align-items: center; justify-content: space-between;
            padding: 10px 14px; border-radius: 8px;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            margin-top: 4px;
        `;
        const ghostPosLabel = document.createElement('div');
        ghostPosLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
        ghostPosLabel.innerHTML = '<span>📌</span><span>Remember ghost template position and size</span>';
        const ghostPosHelp = document.createElement('span');
        ghostPosHelp.textContent = '❓';
        ghostPosHelp.style.cssText = 'cursor:help;font-size:12px;flex-shrink:0;margin-left:4px;opacity:0.6;';
        ghostPosHelp.addEventListener('mouseenter', (ev) => showSimpleTooltip(ev, 'When enabled, saves and restores the ghost template modal\'s on-screen position and size across page refreshes and re-opens. Requires Ghost Template Manager.'));
        ghostPosHelp.addEventListener('mouseleave', removeTooltip);
        ghostPosLabel.appendChild(ghostPosHelp);

        const ghostPosToggle = document.createElement('label');
        ghostPosToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
        const ghostPosInput = document.createElement('input');
        ghostPosInput.type = 'checkbox';
        ghostPosInput.checked = !!_settings.rememberGhostModalPos;
        ghostPosInput.style.cssText = 'opacity:0;width:0;height:0;';
        const ghostPosSlider = document.createElement('span');
        ghostPosSlider.style.cssText = `
            position:absolute; inset:0; border-radius:12px; transition:0.2s;
            background: ${_settings.rememberGhostModalPos ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
        `;
        const ghostPosKnob = document.createElement('span');
        ghostPosKnob.style.cssText = `
            position:absolute; top:2px; left:${_settings.rememberGhostModalPos ? '22px' : '2px'};
            width:20px; height:20px; border-radius:50%; transition:0.2s;
            background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
        `;
        ghostPosSlider.appendChild(ghostPosKnob);

        ghostPosInput.addEventListener('change', () => {
            _settings.rememberGhostModalPos = ghostPosInput.checked;
            saveSettings(_settings);
            ghostPosSlider.style.background = ghostPosInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
            ghostPosKnob.style.left = ghostPosInput.checked ? '22px' : '2px';
        });

        ghostPosToggle.appendChild(ghostPosInput);
        ghostPosToggle.appendChild(ghostPosSlider);
        ghostPosRow.appendChild(ghostPosLabel);
        ghostPosRow.appendChild(ghostPosToggle);
        settingsPanel.appendChild(ghostPosRow);

        tabPanels.push(settingsPanel);
        modal.appendChild(settingsPanel);

        // ============ TAB 3: Keybindings ============
        const kbPanel = document.createElement('div');
        kbPanel.style.cssText = 'padding: 12px 20px; display: none;';

        const GPC_KEYBINDS = [
            { key: 'openSettings',    label: 'Open GeoPixelcons++ Settings', defKey: 'P', defCtrl: true, defShift: true },
            { key: 'mapMovementLock', label: 'Toggle Map Movement Lock',      defKey: 'L', defCtrl: true, defShift: true },
        ];

        const kbInputs = {};

        GPC_KEYBINDS.forEach(({ key, label, defKey, defCtrl, defShift }) => {
            const cur = (_settings.keybinds && _settings.keybinds[key]) || { key: defKey, ctrl: defCtrl, shift: defShift };

            const row = document.createElement('div');
            row.style.cssText = `
                padding: 10px 14px; border-radius: 8px;
                background: ${dark ? '#313244' : '#f1f5f9'};
                border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
                margin-bottom: 8px;
            `;

            const rowLabel = document.createElement('div');
            rowLabel.style.cssText = `font-size:13px;font-weight:600;margin-bottom:8px;color:${dark ? '#cdd6f4' : '#1e293b'};`;
            rowLabel.textContent = label;

            const controlsRow = document.createElement('div');
            controlsRow.style.cssText = 'display:flex;align-items:center;gap:14px;flex-wrap:wrap;';

            // Ctrl checkbox
            const ctrlLabelEl = document.createElement('label');
            ctrlLabelEl.style.cssText = `display:flex;align-items:center;gap:5px;font-size:13px;cursor:pointer;color:${dark ? '#cdd6f4' : '#1e293b'};`;
            const ctrlCb = document.createElement('input');
            ctrlCb.type = 'checkbox'; ctrlCb.checked = !!cur.ctrl;
            ctrlLabelEl.appendChild(ctrlCb);
            ctrlLabelEl.appendChild(Object.assign(document.createElement('span'), { textContent: 'Ctrl' }));

            // Shift checkbox
            const shiftLabelEl = document.createElement('label');
            shiftLabelEl.style.cssText = ctrlLabelEl.style.cssText;
            const shiftCb = document.createElement('input');
            shiftCb.type = 'checkbox'; shiftCb.checked = !!cur.shift;
            shiftLabelEl.appendChild(shiftCb);
            shiftLabelEl.appendChild(Object.assign(document.createElement('span'), { textContent: 'Shift' }));

            // Key text input
            const keyWrap = document.createElement('label');
            keyWrap.style.cssText = `display:flex;align-items:center;gap:6px;font-size:13px;color:${dark ? '#cdd6f4' : '#1e293b'};`;
            keyWrap.appendChild(Object.assign(document.createElement('span'), { textContent: 'Key:' }));
            const keyIn = document.createElement('input');
            keyIn.type = 'text'; keyIn.maxLength = 1;
            keyIn.value = (cur.key || defKey).toUpperCase();
            keyIn.style.cssText = `
                width:36px; padding:4px 8px; border-radius:5px; text-align:center;
                text-transform:uppercase; font-family:monospace; font-size:13px; font-weight:700;
                border:1px solid ${dark ? '#45475a' : '#d1d5db'};
                background:${dark ? '#181825' : '#fff'};
                color:${dark ? '#cdd6f4' : '#1e293b'};
            `;
            keyIn.addEventListener('input', () => {
                keyIn.value = keyIn.value.replace(/[^a-zA-Z0-9]/g, '').slice(-1).toUpperCase();
                updatePreview();
            });
            keyWrap.appendChild(keyIn);

            // Live preview of the combo
            const previewEl = document.createElement('span');
            previewEl.style.cssText = `font-size:11px;color:${dark ? '#6c7086' : '#94a3b8'};font-family:monospace;`;
            function updatePreview() {
                const parts = [];
                if (ctrlCb.checked) parts.push('Ctrl');
                if (shiftCb.checked) parts.push('Shift');
                if (keyIn.value) parts.push(keyIn.value.toUpperCase());
                previewEl.textContent = parts.length ? '→ ' + parts.join('+') : '';
            }
            ctrlCb.addEventListener('change', updatePreview);
            shiftCb.addEventListener('change', updatePreview);
            updatePreview();

            controlsRow.appendChild(ctrlLabelEl);
            controlsRow.appendChild(shiftLabelEl);
            controlsRow.appendChild(keyWrap);
            controlsRow.appendChild(previewEl);
            row.appendChild(rowLabel);
            row.appendChild(controlsRow);
            kbPanel.appendChild(row);
            kbInputs[key] = { ctrlEl: ctrlCb, shiftEl: shiftCb, keyEl: keyIn };
        });

        const kbSaveBtn = document.createElement('button');
        kbSaveBtn.textContent = '💾 Save Shortcuts';
        kbSaveBtn.style.cssText = `
            width: 100%; padding: 10px 20px; border-radius: 8px; border: none; cursor: pointer;
            background: #22c55e; color: #fff; font-size: 14px; font-weight: 600;
            transition: opacity 0.15s; margin-top: 4px;
        `;
        kbSaveBtn.onmouseover = () => { kbSaveBtn.style.opacity = '0.85'; };
        kbSaveBtn.onmouseleave = () => { kbSaveBtn.style.opacity = '1'; };
        kbSaveBtn.onclick = () => {
            if (!_settings.keybinds) _settings.keybinds = {};
            GPC_KEYBINDS.forEach(({ key }) => {
                const { ctrlEl, shiftEl, keyEl } = kbInputs[key];
                _settings.keybinds[key] = {
                    ctrl: ctrlEl.checked,
                    shift: shiftEl.checked,
                    key: (keyEl.value || '').toUpperCase().slice(0, 1),
                };
            });
            saveSettings(_settings);
            banner.style.display = 'block';
            kbSaveBtn.textContent = '✅ Saved!';
            setTimeout(() => { kbSaveBtn.textContent = '💾 Save Shortcuts'; }, 2000);
        };
        kbPanel.appendChild(kbSaveBtn);

        tabPanels.push(kbPanel);
        modal.appendChild(kbPanel);

        // Footer
        const footer = document.createElement('div');
        footer.style.cssText = `
            padding: 12px 20px;
            background: ${dark ? '#313244' : '#f8fafc'};
            border-top: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            font-size: 11px;
            color: ${dark ? '#6c7086' : '#94a3b8'};
            text-align: center;
        `;
        footer.textContent = 'GeoPixelcons++ v' + VERSION;
        modal.appendChild(footer);

        overlay.appendChild(modal);
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay) { removeTooltip(); overlay.remove(); }
        });
        document.body.appendChild(overlay);
    }

    // Open the actual Settings modal from anywhere on the page. Capture phase
    // prevents GeoPixels' single-key shortcut handler from also consuming the key.
    document.addEventListener('keydown', (e) => {
        if (e.repeat || e.altKey || e.metaKey) return;
        const target = e.target;
        if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;

        const kb = {
            ...DEFAULT_SETTINGS.keybinds.openSettings,
            ...((_settings.keybinds && _settings.keybinds.openSettings) || {}),
        };
        const key = kb.key || DEFAULT_SETTINGS.keybinds.openSettings.key;
        if (kb.ctrl !== e.ctrlKey || kb.shift !== e.shiftKey) return;
        if (e.key.toUpperCase() !== key.toUpperCase()) return;

        e.preventDefault();
        e.stopImmediatePropagation();
        createSettingsModal();
    }, true);

    // ============================================================
    //  UI: CHANGELOG MODAL
    // ============================================================
    const CHANGELOG = [
        {
            version: '1.9.9',
            date: '2026-07-29',
            items: [
                { type: 'changed', text: 'Ghost Palette Search: "Sync Ghost With Selected Color" is now its own independent extension with its own Settings toggle, instead of a display option nested inside "Ghost Palette Color Search (legacy)". It never actually depended on that feature\'s search/filter tools -- the two were only ever bundled in the same file historically. Existing users who had it enabled keep it enabled with no action needed' },
            ]
        },
        {
            version: '1.9.8',
            date: '2026-07-29',
            items: [
                { type: 'fixed', text: 'Ghost Palette Search: 1.9.7\'s auto-enable of "Ghost Palette Color Search (legacy)" only fired when checking "Sync Ghost With Selected Color" itself -- unchecking the legacy feature afterward (or having it off from before 1.9.7) left the display option checked but silently doing nothing. Settings now self-corrects that mismatch every time it opens, and unchecking the legacy feature live-unchecks the display option too instead of only taking effect on the next reload' },
            ]
        },
        {
            version: '1.9.7',
            date: '2026-07-28',
            items: [
                { type: 'fixed', text: 'Ghost Palette Search: the ♻️ "Sync Ghost With Selected Color" display option did nothing if the "Ghost Palette Color Search (legacy)" feature itself was off, since the button lives entirely inside that feature — enabling the display option now also enables that feature automatically (a page refresh is still needed), instead of silently failing with no indication why' },
                { type: 'changed', text: 'Debug System: Ghost Palette Search now logs its own search-box mount wait too, including a note that it stays permanently unmounted while Ghost++ owns the overlay -- which is expected, not a bug' },
            ]
        },
        {
            version: '1.9.6',
            date: '2026-07-28',
            items: [
                { type: 'changed', text: 'Debug System: six more features (Paint Menu Controls, Region Screenshot, Regions Highscore, Auto-open Menus on Hover, Ghost Template Manager, Pill Hover Labels) that wait for a native page element or global to appear now retry with a bounded giveup and log every step to the Debug Log, instead of polling forever with no diagnostic trail if they never found what they were waiting for' },
            ]
        },
        {
            version: '1.9.5',
            date: '2026-07-28',
            items: [
                { type: 'fixed', text: 'Ghost Palette Color Search: the ♻️ "Sync Ghost With Selected Color" button could silently fail to mount if the game\'s Image Tools menu hadn\'t rendered yet, with no giveup and no way to tell why from a Debug Log — it now retries the same way Ghost++\'s own button does and logs every step, including whether it stayed hidden behind its own separate "Sync Ghost With Selected Color" display-option checkbox in Settings' },
            ]
        },
        {
            version: '1.9.4',
            date: '2026-07-28',
            items: [
                { type: 'added', text: 'Debug System: other installed userscripts can now feed their own diagnostic events into this script\'s Debug Log, so a single exported file can cover more than one addon at once when troubleshooting an interaction between them' },
            ]
        },
        {
            version: '1.9.3',
            date: '2026-07-28',
            items: [
                { type: 'fixed', text: 'Ghost++: other addons that read the native "which ghost colors are enabled" state directly could see it as empty even with colors correctly enabled in Ghost++ itself, until you happened to open Ghost++\'s own menu at least once — that native state now stays correctly in sync from the moment Ghost++ finishes loading, not just after its menu has been opened' },
            ]
        },
        {
            version: '1.9.2',
            date: '2026-07-28',
            items: [
                { type: 'fixed', text: 'Ghost++: the separate GeoPixels++ addon\'s "Toggle ghost image" keybind (Y by default) stopped doing anything visible once Ghost++ took over the overlay slot, since it only toggles the native ghost canvas Ghost++ had already emptied out — Y now also toggles Ghost++\'s own overlay, so it still works as a quick "peek at the map" shortcut' },
            ]
        },
        {
            version: '1.9.1',
            date: '2026-07-27',
            items: [
                { type: 'fixed', text: 'Ghost++: fixed a bug (reported on Firefox) where enabling the Ghost++ setting and reloading could still leave the OLD native ghost tool active instead of switching to Ghost++ — its own init now waits for the page to finish loading, and its native-button replacement now retries if that button hasn\'t mounted yet instead of silently giving up' },
                { type: 'fixed', text: 'Ghost++: the native ghost button was hidden immediately, but a keyboard shortcut (or anything else that clicks it programmatically) could still pop the old native ghost tool before you\'d ever manually opened Ghost++ once — the click-blocking that prevents this now activates immediately instead of waiting for your first open' },
                { type: 'added', text: 'Ghost++ Progress: clicking "Show errors" or "Show missing" now pops an alert with the resulting count (e.g. "Found 12 errors among your currently enabled colors"), matching the native ghost tool\'s own scan alerts' },
                { type: 'changed', text: 'Ghost++ palette: a fully-completed color now shows a large checkmark (with a thin black outline so it stays visible on bright colors) across the whole swatch instead of a small white circle with a tiny check mark, similar in style to how a disabled color shows its diagonal slash' },
                { type: 'added', text: 'Ghost++: with Enable Debugging on (Settings), the exported Debug Log now records whether Ghost++ started immediately or waited for the page to finish loading, whether it found (or had to wait for) the native ghost button, and whether it finished initializing successfully or hit an error — so a "Ghost++ doesn\'t seem to be working" report can be diagnosed from an exported log instead of guesswork' },
                { type: 'added', text: 'Debug Logs: the exported file now also includes a live state snapshot (every feature\'s enabled/ok/error status, plus a deeper Ghost++-specific dive) captured at the moment you click "Debug Logs" — and the button now always exports something, even with zero recorded events, since the snapshot alone can be the more useful half when something failed too early to log anything' },
            ]
        },
        {
            // Consolidates every version between the last release actually
            // published to Greasyfork (1.8.2) and this one into a single
            // entry — local version numbers had continued on far past
            // Greasyfork (through 1.21.0) across many small, incremental
            // releases while none of them were ever pushed there. See
            // git history / this repo's own archive/ folder for the full
            // granular record if needed; this entry summarizes the
            // net user-facing result of all of it.
            version: '1.9.0',
            date: '2026-07-26',
            items: [
                { type: 'added', text: 'New: Ghost++ Template Overlay — a scalable, multi-template ghost/overlay manager (opt-in, enable it in Settings) that can replace the native ghost image tool: draggable/resizable two-column panel, thumbnail template library with hover preview and drag-reorder, sort/filter/search palette with hover-to-copy hex values, segmented completion progress bar, click-to-place positioning with a live cursor-following preview, and a Group Noise implementation matching the native tool\'s own near-identical-color merging' },
                { type: 'added', text: 'Ghost++: keyboard shortcuts — arrow keys nudge the focused template\'s position one cell at a time, and E enters click-to-place mode' },
                { type: 'added', text: 'Ghost++: a "Guild Templates" section lists your guild\'s project templates and loads them in at 0% opacity so they don\'t immediately cover the map — these are never saved to your template library or written to disk, since guild projects change frequently; they\'re re-fetched fresh every page load' },
                { type: 'changed', text: 'Ghost++ Guild Templates are now TRUE lazy-loaded for real performance with large guilds — expanding the section only fetches the lightweight project list and shows browser-native thumbnails; a template\'s actual pixel data is only fetched/decoded the first time you click to view it, not for the whole guild up front' },
                { type: 'fixed', text: 'Ghost++: a hidden template (0% opacity) — guild or regular — no longer holds a GPU texture / canvas resource at all; previously it was still fully uploaded and just skipped drawing, which was real, avoidable memory and upload cost for large templates' },
                { type: 'added', text: 'Ghost++ View Settings: new "Auto-hide unfocused templates" checkbox (off by default) — when on, focusing a template automatically hides every other one, so only ever one is visible at a time; also reduces memory/GPU usage for large libraries' },
                { type: 'fixed', text: 'The guild menu\'s "Set as Ghost" button no longer fails with "Could not find the selected project" — it now loads the guild project straight into Ghost++, position included, without ever saving it to your library either' },
                { type: 'added', text: 'Ghost++ Manage Templates: a fixed-size Grid view (now the default) alongside the existing List view — click a thumbnail to load/focus that template, plus per-card multiselect, show/hide, and go-to-location buttons; template previews render at full resolution instead of a blurrier shared thumbnail, darkened until you hover, with always-visible corner buttons' },
                { type: 'changed', text: 'Ghost++: shows a "Loading templates…" placeholder while your library decodes in the background instead of looking empty; bulk export in Manage Templates now shows real progress and encodes noticeably faster (native browser base64 conversion instead of a manual byte-by-byte loop, small-batch parallel re-encoding)' },
                { type: 'added', text: 'Ghost++ palette: bulk actions (All/None/Owned/Match palette/Set palette), "Buy all colors", a "Get hex values" export menu, left/right-click-drag to enable/disable colors, and a "Group Noise Changes" section showing exactly which colors got merged and their combined hex value' },
                { type: 'added', text: 'Ghost++ Progress: "Nearest error" teleports instantly and pulses a highlight ring on the exact cell that needs correcting; all teleports (grid/Manage Templates/Nearest error) zoom out further for a better overview and never zoom back in if you\'re already closer' },
                { type: 'fixed', text: 'Ghost++: fixed poor performance with thousands of error/missing crosshairs on screen — the redraw math is now computed once per frame instead of once per marker' },
                { type: 'changed', text: 'Ghost++ Progress: error/missing pixel counts now always show under the progress bar whenever there are any, instead of only after checking Show errors/Show missing' },
                { type: 'changed', text: 'Ghost++ Template Settings: Preview is a separate, transient full-opacity view that never touches your saved Opacity slider; flip/rotate buttons were removed (easy to do in any image editor beforehand)' },
                { type: 'fixed', text: 'Ghost++: Unset position, drag-reorder order, and each section\'s collapsed/expanded state now all persist correctly across reloads' },
                { type: 'fixed', text: 'Ghost++: scrollable sections (palette grid, Manage Templates list, main panel) no longer shift the rest of the layout when their scrollbar appears or disappears' },
                { type: 'changed', text: 'Ghost++: the whole compatibility shim keeps the guild menu, queue-ghost-color, and other native-ghost-aware tools working correctly while Ghost++ owns the overlay slot' },
                { type: 'changed', text: 'Ghost Template Manager and Ghost Palette Color Search remain available as legacy fallbacks, unaffected if you already had them enabled' },
                { type: 'added', text: 'Ghost Template Manager: the Group Noise checkbox state is now saved with each history entry and restored automatically when you reload it — overridden as always if "Disable Group Noise" is enabled; templates saved before this update (or imported from older JSON bundles) default to disabled' },
                { type: 'changed', text: 'The shipped script is now minified, cutting its file size roughly in half — keeps room to grow well under Greasyfork\'s size limit' },
                { type: 'added', text: 'Ghost++ View Settings: new "Gray unselected color boxes" checkbox (on by default) — turn it off to keep only the diagonal slash on a disabled color and see its true color underneath, instead of also graying/dimming it' },
                { type: 'added', text: 'Ghost++ View Settings: new "Palette view" grid/list toggle — list mode turns the color grid into compact rows showing the hex value, placed/total, and a mini progress bar, still affected by the same sort and filters as the grid' },
                { type: 'fixed', text: 'Ghost++ guild menu: fixed a crash ("Unsupported index type: null") and a "click does nothing" bug that could both occur if the same guild project was viewed from both the Guild Templates section and "Set as Ghost" — they now correctly share one entry instead of quietly creating a duplicate' },
                { type: 'fixed', text: 'Ghost++ Progress: the Scan button (and the rest of that row) no longer gets stuck showing "Scanning…" after you switch templates while a large template\'s scan is still finishing in the background — it now reflects whichever template is actually focused' },
                { type: 'fixed', text: 'Ghost++ Progress: automatic scans (on focusing a template, or after placing pixels with Autoscan on) now reliably update the Scan button when they finish, instead of sometimes staying stuck on "Scanning…" until an unrelated action (like toggling a color) happened to refresh it' },
                { type: 'fixed', text: 'Ghost Palette Color Search\'s "Sync Ghost With Selected Color" button (under the image group dropdown) works again once Ghost++ is enabled — it now drives Ghost++\'s own focused template instead of the native color palette, which Ghost++ leaves empty' },
                { type: 'fixed', text: 'Ghost++: a guild template loaded via the guild menu\'s "Set as Ghost" now shows its preview thumbnail and full Template Settings panel like any other template; its position is always locked (checkbox forced on and disabled) since guild templates can\'t be moved from this UI' },
                { type: 'fixed', text: 'Ghost++: worked around a Firefox-only CSS Grid bug where the color palette and Templates grids could collapse/stack their cells on top of each other (instead of showing squares) once the side panel got narrow' },
                { type: 'removed', text: 'Ghost++: removed the in-panel "Guild Templates" browsing section — the guild menu\'s own "Set as Ghost" button remains fully supported for loading a guild project into Ghost++' },
                { type: 'changed', text: 'Ghost++ View Settings: "Auto-hide unfocused templates" is now on by default and renamed to "Only show current template on map"' },
                { type: 'fixed', text: 'Ghost++: reopening the panel repeatedly no longer gets progressively slower — the panel\'s content areas are now built once instead of torn down and rebuilt on every open, which was silently piling up duplicate event listeners the longer a session went on (worse with a larger template library)' },
                { type: 'fixed', text: 'Ghost++: the left panel\'s header and every section\'s horizontal divider now reach the true edge of the panel instead of stopping short — an ambient padding rule (plus a redundant, unused scrollbar reservation) on the panel container was insetting content that should have spanned edge to edge' },
                { type: 'changed', text: 'Ghost++ palette: "Get hex values", "Filters", and the Sort dropdown now have centered text' },
                { type: 'changed', text: 'Ghost++ palette: the bulk color-selection buttons are now three clearer rows — Enable all/Disable all, Enable owned/Enable filtered, Match palette/Set palette — instead of one row of terse labels' },
                { type: 'added', text: 'Ghost++ palette: new "Enable filtered" button — enables only the colors currently matching your search/filters and disables the rest' },
                { type: 'fixed', text: 'Ghost++ Progress: a template that was already loaded before you refreshed the page now automatically scans the first time you open Ghost++, instead of sitting with no progress shown until you manually click Scan or switch templates away and back' },
                { type: 'fixed', text: 'Ghost++: the Place/Unset/Go to/Preview button row now wraps onto extra lines as you drag the panel splitter narrower, instead of overflowing past the right panel\'s edge and disappearing behind the left panel' },
                { type: 'added', text: 'Ghost++: the drop zone now has an "or load from a URL" option alongside drop/paste/click-to-choose, matching the legacy Ghost Template Manager\'s own URL upload — paste an image or exported .json template URL and it ingests the same way a local file would' },
            ]
        },
        {
            version: '1.8.2',
            date: '2026-06-15',
            items: [
                { type: 'fixed', text: 'Keyboard Shortcuts: Ctrl+Shift+P now reliably opens the GeoPixelcons++ Settings modal without conflicting with the site\'s H shortcut; saved copies of the old Ctrl+Shift+H default migrate automatically' },
            ]
        },
        {
            version: '1.8.1',
            date: '2026-06-15',
            items: [
                { type: 'fixed', text: 'Guild Search Button: the search control now expands to show its "Search Guilds" hover label like the other left-side submenu buttons' },
            ]
        },
        {
            version: '1.8.0',
            date: '2026-06-15',
            items: [
                { type: 'added', text: 'GeoPixelcons++ Settings: new "Keybindings" tab to customise the keyboard shortcuts for "Open Settings" and "Toggle Map Movement Lock" — each has Ctrl / Shift checkboxes and a single key input; changes take effect after a page refresh' },
                { type: 'changed', text: 'Ghost Palette Color Search: "Pixel count range" filter is now a checkbox called "Filter within pixel count…" matching the other filter options — checking it reveals the min / max inputs beneath' },
                { type: 'added', text: 'Ghost Palette Color Search: new "Owned colors only" filter checkbox — hides all ghost palette colors not owned by the current user' },
                { type: 'added', text: 'Region Screenshot: "Show Palette" button in the preview modal extracts all unique colors from the captured region, shows pixel counts per color, and provides a copy-to-clipboard hex list' },
                { type: 'added', text: 'Guild Search Button: new extra extension — inserts a search icon button below the Guild menu button that opens the Guild Search modal, letting guild members search other guilds without leaving their own' },
                { type: 'added', text: 'Log Out Button: new extra extension — appends a log-out icon button to the right controls panel; auto-hides while the user is signed out and reappears on login' },
            ]
        },
        {
            version: '1.7.0',
            date: '2026-06-14',
            items: [
                { type: 'added', text: 'Theme Editor: light themes now have a "Marsh / Wetland (tufts)" control under Land & Nature — a visibility toggle plus opacity slider to hide or fade the grass-tuft texture that previously could not be removed or recolored' },
                { type: 'added', text: 'Theme Editor: added a "Grass" color row under Land & Nature to recolor or hide the flat grass landcover tint' },
                { type: 'added', text: 'Theme Editor: added a global "Road Width" slider under Roads that scales all road line widths (light and dark bases) while preserving zoom-based scaling' },
                { type: 'changed', text: 'Theme Editor: light "Roads" controls now cover casings, ramps/links, service roads, bridges and tunnels (grouped by class) so orange road outlines can be fully hidden or recolored — previously the casing stayed visible' },
                { type: 'added', text: 'Ghost Palette Color Search: added a "Pixel count range" (min/max) filter in the Filters dropdown to show only colors within a given pixel-count range' },
                { type: 'added', text: 'Map Movement Lock: added a Ctrl+Shift+L keyboard shortcut to toggle the lock' },
                { type: 'fixed', text: 'Ghost Template Manager: collapsing/expanding the preview panel no longer resets the modal\'s total width — it keeps the controls width fixed and only adds or removes the preview on the right' },
            ]
        },
        {
            version: '1.6.0',
            date: '2026-06-01',
            items: [
                { type: 'added', text: 'GeoPixelcons++ main panel: added Ctrl+Shift+H keyboard shortcut to toggle the main dropdown panel open and closed' },
            ]
        },
        {
            version: '1.5.0',
            date: '2026-05-25',
            items: [
                { type: 'added', text: 'Ghost Template Manager: added an "Enable owned and filtered" button beside "Enable filtered" that enables only owned ghost colors currently shown by the active search/filter controls' },
                { type: 'added', text: 'Map Movement Lock: added a settings-controlled extension that creates a right-side lock button to freeze map panning and zooming until unlocked' },
                { type: 'added', text: 'GeoPixelcons++ Settings: added a Mobile Compatibility toggle that disables draggable guild/ghost modals on mobile and makes the Ghost Menu UI Overhaul switch between controls and preview instead of squeezing both panels onscreen' },
                { type: 'fixed', text: 'Ghost Template Manager: clicking "Place on Map" now temporarily hides the ghost menu overhaul so the map is unobstructed while choosing the ghost placement point' },
                { type: 'changed', text: 'Map Movement Lock: locking movement now also suppresses page-wide browser scrolling so oversized UI cannot move the viewport separately from the map' },
            ]
        },
        {
            version: '1.4.0',
            date: '2026-05-21',
            items: [
                { type: 'added', text: 'GeoPixelcons++ Settings: new "Remember ghost template position and size" toggle — when enabled, saves and restores the ghost template modal\'s on-screen position and size across page refreshes; requires Ghost Template Manager to be enabled' },
                { type: 'changed', text: 'Ghost Palette Color Search: replaced the bulky multi-select list with a compact Filters dropdown of checkboxes; added "Hide in-progress colors" and "Hide unstarted colors" alongside the existing unmatched/completed filters' },
                { type: 'changed', text: 'Ghost Palette Color Search: search is now a compact one-line textarea that also accepts space-separated and newline-separated terms, and "Show search results first" now sits directly below it' },
                { type: 'added', text: 'Ghost Palette Color Search: new wheelable "Sort by" dropdown — sort by most/least used, most/least remaining, most/least percent remaining, or color hex; returning to Most used now re-sorts correctly after other methods' },
                { type: 'changed', text: 'Ghost Palette Color Search: Sort by is now self-labeled inside the dropdown text, matching the compact Filters control without a separate external label' },
                { type: 'changed', text: 'Ghost Palette Color Search: Filters and Sort by now stack vertically based on the actual search panel width, so narrowing the Ghost Menu UI Overhaul left panel moves Sort by below Filters even on wide screens' },
                { type: 'changed', text: 'Ghost Palette Color Search: progress-dependent filters and sort methods now hide automatically until placed/total ghost progress stats are detected, while search, unmatched filtering, most/least used, and color sorting remain available' },
                { type: 'changed', text: 'Ghost Palette Color Search: "Enable filtered" now enables the colors currently visible in the ghost palette and disables hidden colors, so it works with search plus every filter combination' },
                { type: 'fixed', text: 'Ghost Template Manager: "Remember ghost template position and size" now reapplies saved geometry after the modal layout initializes and after the native open button runs, preventing the default centered size from overwriting saved placement' },
                { type: 'changed', text: 'Ghost Template Manager: the middle divider in the Ghost Menu UI Overhaul modal can now be dragged from roughly 5% to 95% of the modal width, allowing either side to be nearly minimized or maximized' },
                { type: 'fixed', text: 'Ghost Template Manager: bottom-right modal resizing now preserves the current left/right split ratio so the whole modal feels like it is resizing instead of only expanding the left panel' },
                { type: 'added', text: 'Ghost Template Manager: Recent thumbnails now show compact inline more/less tiles when history has more than 10 images, expanding from 10 to 50 recent templates in a scrollable fixed-square grid' },
                { type: 'added', text: 'Region Screenshot: preview modal now has a Background selector with the existing transparent export plus a new solid color option; the chosen color applies to preview, download, and clipboard copy' },
                { type: 'changed', text: 'Region Screenshot: Background mode and solid color are now remembered between screenshots, including silent downloads, so repeated captures reuse the last chosen background' },
                { type: 'added', text: 'Bulk Purchase Colors: the profile queue now has the same sorting methods as the ghost palette controls, including wheel-to-change sorting and ghost-progress-aware remaining/% remaining options' },
                { type: 'changed', text: 'Bulk Purchase Colors: profile queue rows and the queue Sort by dropdown now use Tailwind-compatible theme colors so they match GeoPixels++ Simple Black and other site themes; ghost counts now show color-formatted total, placed/total, and completion percentage' },
            ]
        },
        {
            version: '1.3.25',
            date: '2026-05-18',
            items: [
                { type: 'added', text: 'Ghost Template Manager: added a draggable horizontal divider between the preview image and the action buttons — drag up/down to freely set the preview height, overriding the default aspect-ratio sizing' },
            ]
        },
        {
            version: '1.3.24',
            date: '2026-05-18',
            items: [
                { type: 'fixed', text: 'Ghost Template Manager: preview image container now uses aspect-ratio (4:3) instead of a fixed 210px height, so the preview scales taller as the user widens the right panel with the splitter' },
            ]
        },
        {
            version: '1.3.23',
            date: '2026-05-18',
            items: [
                { type: 'added', text: 'Ghost Template Manager: added a draggable panel splitter on the left edge of the preview panel — drag left to widen the right panel (giving the image preview more space), drag right to shrink it; width is remembered across collapse/expand cycles' },
            ]
        },
        {
            version: '1.3.22',
            date: '2026-05-17',
            items: [
                { type: 'added', text: 'Ghost Palette Color Search: new ♻️ \'Sync Ghost With Selected Color\' toggle button injected into the Image Tools (🖼️) dropdown — when active (green), changing the selected paint color automatically enables only that color in the ghost palette and disables all others; ignored if the color is not in the template' },
                { type: 'added', text: 'Ghost Palette Color Search: Sync Ghost button is hidden by default; enabled via a new ♻️ Sync Ghost With Selected Color row in the Extensions tab (under Additional Extensions), with the same green/red state styling as other extension rows' },
                { type: 'changed', text: 'Settings modal — Extra Settings tab: replaced all nine per-row description divs with compact inline ❓ tooltip icons; hovering shows the description in a floating tooltip, significantly reducing the tab height' },
                { type: 'changed', text: 'Settings modal — Extensions tab: removed the 🟢/🔴/🟡 status dot from all feature and extension toggle rows; enabled/disabled state is already conveyed by the toggle switch color' },
            ]
        },
        {
            version: '1.3.21',
            date: '2026-05-14',
            items: [
                { type: 'fixed', text: 'Start Shift Lock: the Find Art button now works correctly when shift-lock is active — FindRandomArt is patched to temporarily call toggleShiftDown() around each invocation, since shiftDown is a closure variable that cannot be overridden directly' },
            ]
        },
        {
            version: '1.3.20',
            date: '2026-05-13',
            items: [
                { type: 'fixed', text: 'Ghost Template Manager (overhaul): the bottom-right resize handle now correctly adjusts the modal width while the preview panel is collapsed, updating the collapsed width variable so the CSS rule does not block the resize' },
            ]
        },
        {
            version: '1.3.19',
            date: '2026-05-13',
            items: [
                { type: 'fixed', text: 'Ghost Template Manager (overhaul): rebuilt the Collapse preview / Expand preview animation so the modal and right preview panel resize together using a coordinated CSS state, avoiding the left-panel jump/reflow jank during toggles' },
            ]
        },
        {
            version: '1.3.18',
            date: '2026-05-13',
            items: [
                { type: 'fixed', text: 'Ghost Template Manager (overhaul): action buttons (Toggle All, Set Palette, Match My Palette, Bulk Purchase Colors, Enable Only Owned Ghost Colors, Get/Set Ghost Colors, Enable filtered) no longer wrap text — long labels truncate with ellipsis and the full text appears on hover' },
                { type: 'fixed', text: 'Ghost Template Manager (overhaul): clicking "Collapse preview" now shrinks the entire modal instead of expanding the left panel to fill the freed space; the modal width is restored when the right panel is re-expanded' },
            ]
        },
        {
            version: '1.3.17',
            date: '2026-05-13',
            items: [
                { type: 'fixed', text: 'Ghost Template Manager: importing a JSON history export then immediately re-opening history no longer reverses the order — entries are now inserted oldest-first so the newest-first display order is preserved after import' },
                { type: 'fixed', text: 'Ghost Template Manager: loading a template from the history modal (card click or Load & Go To) now moves that entry to the top of history to reflect recency; the recent-image strip (Ghost Menu UI Overhaul) is also refreshed to stay in sync' },
                { type: 'fixed', text: 'Ghost Template Manager: clicking a recent-image thumbnail now promotes that entry to the top of history and immediately refreshes the recent strip so the order stays accurate' },
            ]
        },
        {
            version: '1.3.16',
            date: '2026-05-12',
            items: [
                { type: 'added', text: 'Ghost Template Manager: new "Ghost Menu UI Overhaul" setting (off by default) — when enabled, removes the ✚₊ collapse toggle injected by geopixels++, makes all its hidden buttons permanently visible, and applies a unified flat slate styling to every action button under the ghost palette container' },
                { type: 'changed', text: 'Ghost Palette Color Search: removed the ✅ emoji from the "Enable filtered" button and moved it out of the search filter box into its own row above the search container, closer to the other ghost color enable/disable buttons' },
                { type: 'added', text: 'Ghost Template Manager: full modal layout overhaul (requires "Ghost Menu UI Overhaul") — widens the modal to a two-column layout with a collapsible right panel; image preview, action buttons (URL / File / History / Save Pos / Place on Map / Clear Image), and recent-image history are moved to the right panel; left panel retains scroll content (progress, filter controls, color palette)' },
                { type: 'added', text: 'Ghost Palette Color Search: "Hide completed colors" checkbox — when checked, hides all color swatches whose geopixels++ progress badge shows 100% (detected from the multiline title set by the Refresh % button); works independently of and combines with the existing "Hide unmatched colors" filter' },
                { type: 'added', text: 'Ghost Template Manager: modal is draggable from the header bar and all 4 edges (grab cursor feedback); resizable from the bottom-right SE corner handle only (guild modal pattern); initial size 800×75vh; conflicting Tailwind classes stripped so resize works without !important fights' },
                { type: 'fixed', text: 'Ghost Template Manager: ghostColorPalette max-height (Tailwind max-h-48) overridden so the palette expands freely with its content instead of capping at 192px' },
                { type: 'fixed', text: 'Ghost Template Manager: recent-image grid rows no longer spread to fill the panel height — align-content:start packs them to the top' },
                { type: 'fixed', text: 'Ghost Template Manager: close button was invisible (bg-white on bg-white header); now theme-aware with transparent background and visible hover state; falls back to a freshly created button if the site\'s onclick selector returns null' },
                { type: 'fixed', text: 'Ghost Template Manager: preview image was not showing — container detection was matching the outermost ancestor div instead of the direct parent of #ghostPreviewImage; fixed to use parentElement of the image itself' },
            ]
        },
        {
            version: '1.3.15',
            date: '2026-05-11',
            items: [
                { type: 'changed', text: 'Source refactored into modular build system (src/ + build.js) — no functional changes for end users; versioned output files are unchanged' },
            ]
        },
        {
            version: '1.3.14',
            date: '2026-05-11',
            items: [
                { type: 'added', text: 'Map Markers: re-added GIF support — upload animated GIFs as map markers; rendered as a live DOM overlay (positioned relative to the map container, using CSS-pixel coordinates to fix the zoom-level skew bug) so animation plays correctly while the canvas handles static images as before' },
            ]
        },
        {
            version: '1.3.13',
            date: '2026-05-10',
            items: [
                { type: 'removed', text: 'Map Markers: removed GIF and MP4/video upload support — file picker now accepts PNG/JPEG/WebP only; the Video URL button has been removed; all GIF/video overlay rendering code has been eliminated' },
            ]
        },
        {
            version: '1.3.12',
            date: '2026-05-10',
            items: [
                { type: 'changed', text: 'Map Markers: compact card view is now the default on open' },
                { type: 'fixed', text: 'Map Markers: drag-to-sort handle in full card view no longer blocks the opacity slider — only the ⠇ grip icon initiates a drag; the rest of the card is fully interactive' },
                { type: 'fixed', text: 'Map Markers: uploaded MP4/WebM video markers now render visibly — the data URL is converted to a blob URL before being set as the video src (browsers silently fail to play large data-URL videos); autoplay is deferred until after the element is in the DOM' },
                { type: 'fixed', text: 'Map Markers: GIF overlays no longer compress horizontally when zooming — removed contain:strict from the overlay container, which was preventing objectFit from applying correctly to img elements inside a CSS containment context' },
            ]
        },
        {
            version: '1.3.11',
            date: '2026-05-10',
            items: [
                { type: 'added', text: 'Ghost Palette Color Search: \u201cEnable filtered\u201d button next to \u201cHide unmatched colors\u201d \u2014 when clicked, every color whose hex matches the current search terms is enabled in the ghost palette and every non-matching color is disabled; also clears the \u201cShow All\u201d (disable filter) toggle if it was active' },
                { type: 'fixed', text: 'Map Markers: YouTube markers no longer use an <iframe> (blocked by site CSP frame-src) — now renders as a clickable thumbnail overlay (thumbnail fetched via GM_xmlhttpRequest to bypass img-src CSP); clicking opens the video in a new tab' },
                { type: 'removed', text: 'Map Markers: YouTube embed markers removed — the site CSP blocks both iframe embedding and direct thumbnail image loading; the workaround was too limited to be useful' },
            ]
        },
        {
            version: '1.3.10',
            date: '2026-05-10',
            items: [
                { type: 'fixed', text: 'Map Markers: image/GIF/video data now stored in IndexedDB instead of GM_setValue — eliminates the "Message exceeded maximum allowed size of 64MiB" Chrome extension runtime error that occurred when many large images were stored, which blocked all other Tampermonkey userscripts from loading' },
                { type: 'added', text: 'Map Markers: automatic one-time migration — existing marker images stored in the old GM_setValue keys are moved to IndexedDB on first load and the old keys are cleaned up' },
            ]
        },
        {
            version: '1.3.9',
            date: '2026-05-10',
            items: [
                { type: 'added', text: 'Map Markers: GIF and MP4 embeds — upload animated GIFs or MP4 videos as map markers; rendered as live DOM overlays so animation/playback is preserved (GIF plays, video autoplays muted/looped)' },
                { type: 'added', text: 'Map Markers: drag-to-sort — grab the ⠿ handle on any marker card and drag it up or down to reorder the marker rendering/list order; new order is persisted immediately' },
                { type: 'added', text: 'Map Markers: compact card view — toggle button in the modal header switches to a condensed single-row view showing thumbnail, name, and coordinates only; clicking any compact card expands it in-place to reveal the full controls' },
            ]
        },
        {
            version: '1.3.8',
            date: '2026-05-10',
            items: [
                { type: 'added', text: 'Smooth Zoom Buttons: new extra setting (on by default) — replaces the native +/− zoom buttons with a smooth zoom control: vertical slider, exact value input box, hold-to-repeat ± buttons, and scroll-wheel zoom on the widget' },
            ]
        },
        {
            version: '1.3.7',
            date: '2026-05-10',
            items: [
                { type: 'added', text: 'Map Markers: new feature \u2014 upload any PNG/JPEG/WebP/GIF image and place it as a persistent sticker on the map canvas; images scale and translate with the map in real time using the same grid coordinate system as Censor rects in GeoPixels++' },
                { type: 'added', text: 'Map Markers: \u201cPlace on Map\u201d button enters placement mode for a specific marker \u2014 hold and drag to define the bounding box; click without dragging is rejected with an instructional message' },
                { type: 'added', text: 'Map Markers: Hold Shift during placement drag to lock the image\u2019s natural aspect ratio; per-marker \ud83d\udd12/\ud83d\udd13 toggle persists the lock preference for future edits' },
                { type: 'added', text: 'Map Markers: \u201cEdit\u201d button shows 8 fixed-size handles (4 corners + 4 edge midpoints) for resizing/stretching; handles are a consistent 6\u202fpx radius regardless of map zoom level; corner drags respect the Shift / lock-aspect setting, edge midpoints stretch freely' },
                { type: 'added', text: 'Map Markers: draggable management modal with per-image thumbnail, name input, opacity slider, visibility toggle, Place / Edit / Remove actions, and placement status' },
                { type: 'added', text: 'Map Markers: images and placement state persisted via GM_setValue \u2014 survive page refreshes; image data stored per-marker under separate keys to keep the metadata payload small' },
            ]
        },
        {
            version: '1.3.6',
            date: '2026-05-09',
            items: [
                { type: 'fixed', text: 'Ghost Template Manager: loading from Image History with encoded coords placed the image at the old position \u2014 root cause: applyCoordinatesToGame called initializeGhostFromStorage before the game\'s FileReader had written ghostImageData to localStorage, so it returned early; fix: write ghostImageCoords immediately, then poll until BOTH the place button is re-enabled AND ghostImageData is present before calling initializeGhostFromStorage' },
                { type: 'fixed', text: 'Ghost Template Manager: \u2018Go To\u2019 button said \u201cNo ghost image template currently set\u201d after loading from History \u2014 ghostImageCoords is now written before the poll starts so it is always available' },
                { type: 'fixed', text: 'Ghost Template Manager: loading a new image with no encoded position cleared ghostImageCoords from localStorage \u2014 if the user had manually placed a previous image the overlay appeared positioned but saving produced no coords; coords are now only cleared by the explicit Clear button' },
                { type: 'fixed', text: 'Ghost Template Manager: \u2018Go To\u2019 button in the panel said \u201cNo ghost image template currently set\u201d after loading from History because ghostImageTopLeft (in-memory, page scope) was never set by the race-losing initializeGhostFromStorage call \u2014 fixed by the same direct-assignment approach above' },
                { type: 'fixed', text: 'Region Screenshot: screenshot filenames now use local time instead of UTC \u2014 falls back to UTC if local time formatting fails' },
            ]
        },
        {
            version: '1.3.5',
            date: '2026-05-08',
            items: [
                { type: 'changed', text: 'Paint Brush Swap: reduced to a single resize handle \u2014 top-right corner when the paint menu is at the bottom (default), bottom-right corner when the paint menu is docked to the top; height drag direction flips accordingly' },
                { type: 'added', text: 'Paint Brush Swap: \u21BA reset-size button in the footer clears the stored width/height and restores the dropdown to its natural size' },
            ]
        },
        {
            version: '1.3.4',
            date: '2026-05-08',
            items: [
                { type: 'fixed', text: 'Paint Brush Swap: collapsed brush dropdown was intercepting canvas clicks after any resize drag \u2014 the div retained its physical footprint despite being visually hidden; fixed with pointer-events:none on the closed state and pointer-events:auto on .open' },
                { type: 'fixed', text: 'Paint Brush Swap: resize drag was laggy because the CSS max-height transition kept firing on every mousemove; transitions are now disabled for the duration of the drag via a .brush-swap-resizing class and restored on mouseup' },
            ]
        },
        {
            version: '1.3.3',
            date: '2026-05-08',
            items: [
                { type: 'fixed', text: 'Paint Brush Swap: resize handles on the brush dropdown were jittery and drifting \u2014 now use capture-phase listeners, a single start-size snapshot, and set explicit width alongside maxWidth/maxHeight to match the guild panel resize behavior' },
            ]
        },
        {
            version: '1.3.2',
            date: '2026-05-08',
            items: [
                { type: 'changed', text: 'Paint Brush Swap \u2014 Export: brush list now shows as a scrollable checklist with checkboxes; Select All / Select None controls; JSON output and Copy/Download actions only include checked brushes; header updates live to show selection count' },
                { type: 'changed', text: 'Paint Brush Swap \u2014 Import: JSON is parsed on input/file-upload and immediately rendered as a scrollable checklist; only checked brushes are committed when clicking \u201cImport Selected\u201d; invalid entries still reported with a count' },
            ]
        },
        {
            version: '1.3.1',
            date: '2026-05-08',
            items: [
                { type: 'changed', text: 'Paint Brush Swap: Export/Import footer buttons now show "📤 Export" and "📥 Import" labels instead of bare arrows — same height, wider to fit the text' },
            ]
        },
        {
            version: '1.3.0',
            date: '2026-05-07',
            items: [
                { type: 'added', text: 'Paint Brush Swap: Export Brushes \u2014 \u2B06 button at the bottom of the brush dropdown opens a modal with the full brush list as JSON; copy to clipboard or download as a .json file' },
                { type: 'added', text: 'Paint Brush Swap: Import Brushes \u2014 \u2B07 button opens a modal where you can paste JSON or upload a .json file; imported brushes are appended to your existing list (no overwrites), invalid entries are skipped with a count' },
            ]
        },
        {
            version: '1.2.2',
            date: '2026-05-07',
            items: [
                { type: 'fixed', text: 'Ghost Template Manager: loading a template from Image History that was saved without coordinates threw "parameter 1 is not of type File" — images retrieved from IndexedDB are Blobs, not Files; when no encoded position is decoded the blob is now wrapped in a File before being passed to DataTransfer' },
            ]
        },
        {
            version: '1.2.1',
            date: '2026-05-06',
            items: [
                { type: 'fixed', text: 'Debug System: applyCoordinatesToGame now logs to the debug file when the place button never becomes enabled (5s timeout) or when initializeGhostFromStorage is missing from page scope — both are silent failures that previously produced no debug output' },
                { type: 'fixed', text: 'Debug System: IndexedDB open failure now rejects with a real Error object (was a bare string) and is captured in the debug log, making DB unavailability visible' },
                { type: 'fixed', text: 'Debug System: HistoryManager.getAll transaction errors now reject (instead of hanging forever) and are captured in the debug log' },
                { type: 'fixed', text: 'Debug System: cleanCanvas.toBlob inside processAndLoadImage now has a 10s timeout — if the browser never calls the callback the hang is caught, logged, and the load fails gracefully instead of stalling silently' },
            ]
        },
        {
            version: '1.2.0',
            date: '2026-05-06',
            items: [
                { type: 'added', text: 'Debug System: new "Enable Debugging" toggle in Settings — when on, a "Debug Logs" button appears in the dropdown that exports a timestamped .txt log of all caught errors with their UI component, message, and stack trace' },
                { type: 'added', text: 'Debug System: all feature initialization catch blocks, Ghost Template Manager operations (processAndLoadImage, URL upload, Save Pos, Auto-Cache, Export, Import history), and card interactions now feed into dbgPush when debugging is enabled' },
            ]
        },
        {
            version: '1.1.9',
            date: '2026-04-26',
            items: [
                { type: 'fixed', text: 'Guild XP Tracker: player markers on map showed numeric user IDs instead of display names — buildPlayerMarkerData was using the dict key (now a numeric ID) as the name; fixed to use data.name with key as fallback' },
                { type: 'added', text: 'Ghost Template Manager: selective export in Image History — ☐ per-card checkbox, Select All / Select None buttons, and "Export Selected" exports only checked templates (same JSON format; compatible with import); "Export All" removed in favour of Select All + Export Selected' },
            ]
        },
        {
            version: '1.1.8',
            date: '2026-04-26',
            items: [
                { type: 'fixed', text: 'Auto-Hover Menus: menuGroupBtn and modGroupBtn have class="... relative ..." on the button element itself — button.closest(\'.relative\') returned the button instead of the wrapper div, so parent/dropdown/isMenuOpen were all wrong and every button→dropdown mousemove re-toggled the menu closed; fixed by using button.closest(\'div.relative\'); also added _gpcOpened flag + wrapper mouseleave guard as a defensive layer against re-clicks on re-hover' },
                { type: 'fixed', text: 'Guild XP Tracker: users who rename no longer appear as LEFT + JOINED — snapshots now key by numeric user ID; display name is stored separately and stays current' },
                { type: 'fixed', text: 'Guild XP Tracker: one-time migration auto-converts old name-keyed snapshots to ID-keyed format so existing history remains valid' },
                { type: 'fixed', text: 'Brush Editor (Opera GX failsafe — pending end-user validation): after panel closes, overlay gets pointer-events:none + visibility:hidden to prevent ghost click-through on browsers that may not apply Tailwind hidden reliably' },
                { type: 'added', text: 'Brush Editor: left-click (or drag) immediately activates cells; right-click (or drag) erases — no toggle delay' },
                { type: 'added', text: 'Brush Editor: "Fill All" button beside "Reset Active Brush" — instantly activates every cell in the grid' },
            ]
        },
        {
            version: '1.1.7',
            date: '2026-04-19',
            items: [
                { type: 'added', text: 'Setting: Disable Group Noise — permanently unchecks and grays out the "Group Noise" toggle in the ghost modal' },
                { type: 'added', text: 'Setting: Start in Shift Lock — auto-enables Shift Lock on page load' },
                { type: 'added', text: 'Setting: Start in Inspect Mode — auto-switches to Inspect Mode on page load' },
                { type: 'added', text: 'Changelog modal accessible from the GeoPixelcons++ dropdown menu' },
            ]
        },
        {
            version: '1.1.6',
            date: '2026-04-19',
            items: [
                { type: 'renamed', text: 'Extension: "Janny Tools" renamed to "Janitor View" throughout' },
                { type: 'added', text: 'Setting: Compact Paint Controls — moves ✕ close and 🖌️ brushes buttons above the paint modal as compact icons' },
                { type: 'added', text: 'Compact brush button forwards scroll-to-swap wheel events' },
                { type: 'added', text: 'Compact brush dropdown repositioned via fixed positioning on document.body to avoid transform issues' },
                { type: 'added', text: 'Compact ✕ button positioned absolutely to the right of the topBar, isolated from centered buttons' },
                { type: 'improved', text: 'Ghost template colors now sorted by pixel count (most used → least used) in both bulk purchase paths' },
                { type: 'added', text: 'Bulk purchase modal and profile queue now show ghost template pixel counts per color (purple labels)' },
                { type: 'added', text: 'Legend in bulk purchase modal: "Purple numbers = pixels used in the loaded ghost template"' },
                { type: 'added', text: 'Ghost palette MutationObserver auto-refreshes queue ghost counts when template is loaded/unloaded' },
            ]
        },
        {
            version: '1.1.4',
            date: '2026-04-18',
            items: [
                { type: 'added', text: 'Extension: Janitor View — reveals the hidden moderation button for janitors/moderators' },
                { type: 'fixed', text: 'VERSION constant now correctly synced with @version header (was stuck at 1.0.3)' },
            ]
        },
        {
            version: '1.1.3',
            date: '2026-04-17',
            items: [
                { type: 'note', text: 'Version bump — identical to 1.0.3 (header version sync)' },
            ]
        },
        {
            version: '1.0.3',
            date: '2026-04-17',
            items: [
                { type: 'fixed', text: 'Guild Overhaul: auto-center panel on open — fixes draggable guild panel getting stuck off-screen' },
                { type: 'improved', text: 'Bulk Purchase: newly purchased colors are now automatically added to the active palette' },
            ]
        },
        {
            version: '1.0.2',
            date: '2026-04-16',
            items: [
                { type: 'added', text: 'Paint Brush Swap: click-to-expand brush preview grids (36×36 → 120×120)' },
                { type: 'added', text: 'Brush preview tooltip on hover ("click to expand")' },
                { type: 'fixed', text: 'Region Screenshot & Regions Highscore: no longer re-enable doubleClickZoom (respects site default)' },
            ]
        },
        {
            version: '1.0.0',
            date: '2026-04-15',
            items: [
                { type: 'note', text: 'Initial release — unified enhancement suite' },
                { type: 'added', text: 'Features: Bulk Purchase Colors, Ghost Palette Search, Ghost Template Manager, Guild Overhaul, Paint Menu Controls, Paint Brush Swap, Region Screenshot, Regions Highscore, Theme Editor' },
                { type: 'added', text: 'Extensions: Auto-open Menus on Hover, Auto-Go to Last Location, Hover Labels' },
                { type: 'added', text: 'Settings modal with per-feature toggles, status indicators, and emoji icon option' },
            ]
        },
    ];

    function showChangelog() {
        const existing = document.getElementById('gpc-changelog-modal');
        if (existing) existing.remove();

        const dark = isDarkMode();
        const overlay = document.createElement('div');
        overlay.id = 'gpc-changelog-modal';
        overlay.style.cssText = 'position:fixed;inset:0;z-index:100001;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);';

        const TYPE_COLORS = {
            added:    { bg: dark ? '#052e16' : '#f0fdf4', text: dark ? '#4ade80' : '#166534', label: 'NEW' },
            improved: { bg: dark ? '#1e1b4b' : '#eef2ff', text: dark ? '#818cf8' : '#4338ca', label: 'IMPROVED' },
            fixed:    { bg: dark ? '#422006' : '#fffbeb', text: dark ? '#fbbf24' : '#92400e', label: 'FIX' },
            renamed:  { bg: dark ? '#164e63' : '#ecfeff', text: dark ? '#22d3ee' : '#0e7490', label: 'RENAMED' },
            changed:  { bg: dark ? '#1c1917' : '#fafaf9', text: dark ? '#a8a29e' : '#57534e', label: 'CHANGED' },
            removed:  { bg: dark ? '#450a0a' : '#fff1f2', text: dark ? '#f87171' : '#9f1239', label: 'REMOVED' },
            note:     { bg: dark ? '#1e293b' : '#f8fafc', text: dark ? '#94a3b8' : '#64748b', label: 'NOTE' },
        };

        const modal = document.createElement('div');
        modal.style.cssText = `
            background: ${dark ? '#1e1e2e' : '#ffffff'};
            color: ${dark ? '#cdd6f4' : '#1e293b'};
            border-radius: 16px;
            width: 90%;
            max-width: 560px;
            max-height: 80vh;
            display: flex;
            flex-direction: column;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3);
            overflow: hidden;
        `;

        // Header
        const header = document.createElement('div');
        header.style.cssText = `
            padding: 20px 24px 16px;
            border-bottom: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            display: flex;
            align-items: center;
            justify-content: space-between;
            flex-shrink: 0;
        `;
        const title = document.createElement('h2');
        title.style.cssText = 'margin:0;font-size:1.25rem;font-weight:700;';
        title.textContent = '📋 Changelog';
        const closeBtn = document.createElement('button');
        closeBtn.style.cssText = `width:2rem;height:2rem;border-radius:50%;border:none;background:${dark ? '#45475a' : '#f1f5f9'};cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;color:${dark ? '#cdd6f4' : '#64748b'};`;
        closeBtn.textContent = '✕';
        closeBtn.addEventListener('click', () => overlay.remove());
        header.appendChild(title);
        header.appendChild(closeBtn);
        modal.appendChild(header);

        // ── Filter bar ────────────────────────────────────────────────
        // Collect every type that actually appears in the changelog data
        const allTypes = [...new Set(
            CHANGELOG.flatMap(r => r.items.map(it => it.type))
        )];
        // Canonical display order; unknown types appended at the end
        const typeOrder = ['added','fixed','improved','changed','removed','renamed','note'];
        allTypes.sort((a, b) => {
            const ai = typeOrder.indexOf(a), bi = typeOrder.indexOf(b);
            return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
        });

        const filterBar = document.createElement('div');
        filterBar.style.cssText = `display:flex;flex-wrap:wrap;gap:6px;padding:10px 24px;border-bottom:1px solid ${dark ? '#45475a' : '#e2e8f0'};flex-shrink:0;`;

        // activeTypes tracks which labels are enabled (all on by default)
        const activeTypes = new Set(allTypes);

        function applyFilters() {
            body.querySelectorAll('[data-cl-type]').forEach(row => {
                row.style.display = activeTypes.has(row.dataset.clType) ? '' : 'none';
            });
            // Hide version headers whose every item is hidden
            body.querySelectorAll('[data-cl-section]').forEach(section => {
                const rows = section.querySelectorAll('[data-cl-type]');
                const anyVisible = [...rows].some(r => r.style.display !== 'none');
                section.style.display = anyVisible ? '' : 'none';
            });
        }

        allTypes.forEach(type => {
            const tc = TYPE_COLORS[type] || TYPE_COLORS.note;
            const chip = document.createElement('button');
            chip.style.cssText = `font-size:0.6rem;font-weight:700;padding:3px 8px;border-radius:4px;border:none;cursor:pointer;transition:opacity .15s;background:${tc.text}18;color:${tc.text};`;
            chip.textContent = tc.label;
            chip.title = `Toggle ${tc.label} entries`;
            chip.dataset.active = '1';
            chip.addEventListener('click', () => {
                if (activeTypes.has(type)) {
                    activeTypes.delete(type);
                    chip.dataset.active = '0';
                    chip.style.opacity = '0.35';
                } else {
                    activeTypes.add(type);
                    chip.dataset.active = '1';
                    chip.style.opacity = '1';
                }
                applyFilters();
            });
            filterBar.appendChild(chip);
        });

        modal.appendChild(filterBar);

        // Body (scrollable)
        const body = document.createElement('div');
        body.style.cssText = 'overflow-y:auto;padding:16px 24px 24px;flex:1;';

        CHANGELOG.forEach((release, i) => {
            const section = document.createElement('div');
            section.dataset.clSection = release.version;
            section.style.cssText = `margin-bottom:${i < CHANGELOG.length - 1 ? '20px' : '0'};`;

            const versionHeader = document.createElement('div');
            versionHeader.style.cssText = `display:flex;align-items:center;gap:10px;margin-bottom:10px;`;
            const vBadge = document.createElement('span');
            vBadge.style.cssText = `font-size:0.95rem;font-weight:700;color:${dark ? '#cba6f7' : '#7c3aed'};`;
            vBadge.textContent = 'v' + release.version;
            const vDate = document.createElement('span');
            vDate.style.cssText = `font-size:0.75rem;color:${dark ? '#6c7086' : '#94a3b8'};`;
            vDate.textContent = release.date;
            versionHeader.appendChild(vBadge);
            versionHeader.appendChild(vDate);
            if (i === 0) {
                const currentBadge = document.createElement('span');
                currentBadge.style.cssText = `font-size:0.6rem;font-weight:700;padding:2px 6px;border-radius:4px;background:${dark ? '#22c55e33' : '#dcfce7'};color:${dark ? '#4ade80' : '#166534'};`;
                currentBadge.textContent = 'CURRENT';
                versionHeader.appendChild(currentBadge);
            }
            section.appendChild(versionHeader);

            release.items.forEach(item => {
                const tc = TYPE_COLORS[item.type] || TYPE_COLORS.note;
                const row = document.createElement('div');
                row.dataset.clType = item.type;
                row.style.cssText = `display:flex;align-items:flex-start;gap:8px;padding:5px 8px;margin-bottom:3px;border-radius:6px;background:${tc.bg};`;
                const badge = document.createElement('span');
                badge.style.cssText = `font-size:0.55rem;font-weight:700;padding:2px 5px;border-radius:3px;color:${tc.text};background:${tc.text}18;white-space:nowrap;margin-top:2px;flex-shrink:0;`;
                badge.textContent = tc.label;
                const text = document.createElement('span');
                text.style.cssText = `font-size:0.8rem;color:${dark ? '#bac2de' : '#334155'};line-height:1.4;`;
                text.textContent = item.text;
                row.appendChild(badge);
                row.appendChild(text);
                section.appendChild(row);
            });

            if (i < CHANGELOG.length - 1) {
                const divider = document.createElement('div');
                divider.style.cssText = `height:1px;background:${dark ? '#45475a' : '#e2e8f0'};margin-top:16px;`;
                section.appendChild(divider);
            }

            body.appendChild(section);
        });

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

    // ============================================================
    //  UI: CONTROLS-LEFT SUBMENU
    // ============================================================
    function waitForControlsLeft(cb) {
        const el = document.getElementById('controls-left');
        if (el) return cb(el);
        setTimeout(() => waitForControlsLeft(cb), 500);
    }

    waitForControlsLeft((controlsLeft) => {
        // Create the group container
        const group = document.createElement('div');
        group.className = 'relative';

        // Main button
        const mainBtn = document.createElement('button');
        mainBtn.id = 'geopixelconsGroupBtn';
        mainBtn.className = 'w-10 h-10 bg-white shadow rounded-full flex items-center justify-center hover:bg-gray-100 cursor-pointer';
        mainBtn.title = 'GeoPixelcons++';
        const _iconBg = 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAIAAABMXPacAAAAAXNSR0IArs4c6QAAIABJREFUeAFUu3dUHEm27qu/37r3vXvnjLkz03POmDPn9EwbmZZ62k23WqZbLW/xtvBWCARCQg7vPTIIbyQkPBTeFhRQBYU3ZfDeFZR3mRmx460s1Ofeq/UpVmSSUq21f/HtvSOyOPTf/99fffrxvz1LdxDy7o0L7472hw71PBjquSfi3R/qvj/UHSrqDhPx7ot4YaKeB6Le0CF+2DA/VMS/L+q7P8QPHuLfG+KHsPO+UFHffVHfPVHfvaG++4N99wZ77wl5wYKu4IGOwP62wL7W4J6WsLqK2MbGyu5eHo/P6+3r5Q/w+wX9A6wG+gf6+/r7evr72gf6y7u6MupbHpa3eRa0OmQ2WSbVX4/jXotruhbfciWu7TKr9stxHZfiOi/EtJ+Pav8hvOXMo/rv79edDK3+9l716Qe15yNrryfX22Q1euQ333/XlNTQ8aZP2Cwa6hYN9Q4Je4YGewcFPYPCniHhwaR7UNAhGKjmvq0vTux7HSUqfSx681hU9lRUFi4qixwqezr05sng68eDpQ+FJQ8FxQ8FhQ8FhWGC/DBB/gNB3gNB7oOBnPsDr+4LskMF2feE2feF2aHC7NDBl6GDL+4Jnt8TPAsZyArpy7jLTwtqj/fL8Lf58j//+N/+n/926Oypv2+tJWNTLNBRwEQDEw90wvsJE0+YWELHmy/jgIkDFAUoBlCEeRIOzGOzHhLmEaEeEuoJUI+I6TFQT8D0GAwRYHxMDE9A/5TSpO1tl+3vDVK0msGIAUCAMeCDCQ1Ig7FMo+vYUORJdkO7F9wb5+0bNixrN624+47tWpdeo1u/wWtQ5z+svzNhvCc2PpijHi4YnyxTT1dN4atUxDodscY8WWWeLtFhC3SwmLo9bnQf1Drw1Dbte7btezbNG7bc2cC+1YzpjaaNvXmKlgNSEUYJWAVIDrALaAtgE5gNo1I6OzQ/UKIfSsfCGBiKgqEEdhyMBmEkCMNhIJIVPxp6o6AnCnhmdUVDVzR0xkJnDKuOOOiIZdUeB21xuDUGWmKhOQY3ReOGCNwQwXCjNirjTn7610PD/feBTiAo3qw4guIISgCUCDiRoESCksxjAmHiCI4nOA5wHEExBEcTHElQOEER7Mg8JcwTQj8l1GNCPSHUU2IKB+opmMJpfcbudrlSPUVjAwMYACMCRsAGVmgfMeNyVfXc7lPegmvjkk3tukXtrmOn3rVP7yky3p0xPloyxe7QKSqUqcPPjPi5CT83oRc0fkHh5xQ8p/AzE8o0ogwjzjBAqgGn6lGyFidqIFED8WqcoIJoOXq4Rt+VGT2G9Dcbt67XrlrULjs1SBPHN5s3lbM0LQd6B5gdzOwA3iZ4A/A6oDVaL1kYXhS+NQxnYVE0DMXCUBwMxYAwAgQRIIiGvhjoiSFmAS8GumNJVxzpioPOWNIZBx1xxCxoiyNt8dAah5uiSXMsNMWQhmjMjUIN0XR9ZHGY6yFKF8vGGicCMo/sPJlAMsHJgJIITmEZsDCSCE5in4QkMJMgOMasOIJj2QmKIjQbdEJFECoK6EjGlLq9Wbi20WtklMgcegxAA9YD1gCa0+prFnYfdc871S/aVMx6Nq47dGpcB/UhUn3kFp2ixpkG/MzIPDMxLyjmhQm9pFA2jbMplMNgVuwcv6LY8aUJnhvY5zMNkGnA6VpI0kCC5j2GODWO0+AENY5X4Zg9/HCJ8hszuLZt3XjV6cRd8utcKlxQ9it0K5jZALSJ0TrG68CsA6xivGbQzsyKFoWlpvcYoshQDAgiYSCC8GNIbxzpYQW8WOiKYaP/MwDSGc8CaI+H9kTSFk9a46ApxqxYaIgGbjTmxjANkaKXYYcAsbEmkGJWKoFUAmmEpBKSRkgGgQwC6ewlpLP3IZWd42SCkwhOIJBAgMXGznE84FiCYgkTA3SySpG7vNyk1e8wbJph/yDAJkAqwKN7qpzJNc968bU3i5ff7Vq37r1bU20w6LVcn6em64wmNuI0k4twHoIcBnIRzmUgj8H5DOTTkM/gAhoXMJBH4zwacmmUY8LZRvzSCC+M8MyIswyQqmOVYiaRpIEkLSRrIEWNkjQ4XWEM53UHJXm/rb7j/qrItc9g3Sh3bV6LFKy2bmtlFL2MmRWMVjCsAFrDaAkxi3r1tES4NVhKD6WCMIoFIAgnfbGkN4H0xpOeWODFEl486Y6D7njSnUA648wA4kl7HGlPJK0JpCWONMeS5jhoioOGWGiIxdwYxI0affXwEOB0c4gz2HCzygLyjJBMc/QzCcki7OXBnUzz/YMxw4wq5cArPyNJApRCm3JWlst35VIMDADGBBiCKTb0SLSryBxedK4SXyxevPxWeaNOHjipyNJSpQZTnVFXZDLWUqYxxBTRdD7CBQgKERQwB8JFDBTQuJCBIvYmLmBwITtCPoJ8hsWQS8GrAzcYcaYOMvQkXUcOMKRpSboWp2nQ812lz7Po8sYgoy7MuM55U+bu3zLuJMK2PcimRefI3bzfvVy7ppo20csHDDAsYbyEYJZGc8pd8QRPKShAgmTSH0P6Egk/ycwgAXrioTeR8BIJLwG6EtjoH4wdCdDOLn/WAWz+iYOmWGiMg8ZY1BiLG6LHch8dInAQ5SxzcJ8DeWnWcyDPgDw3KxtINpDnP5N4Rv73PMtMKOvAH4BfKpRlS8u9FK3FgMEceiMgDaCJfVWWaIFTI7tQtHrpneZa5fad4b0sFZVLM3k0yqNQPoXyGVRMoxKGKaahmDGLxiUMLkaomMGlCBezc1zCQCGDi9iHDxiwVPIYsxso/NKM4bkRMvQsgwwdYSdanKmhMucXgtODllaTEXUfK72QzFk14x2c/tRnQu84iR0GwKoN2zRStjVbdztXq1fVYopZQQwLAMMcYhZokBkp8frCkrDW2P8S9yUDPwl6kwg/kY1+zwGARBZAVyLpSiKdiaQjEdoT4CAFtcSy0TcnItwUgxvjcEPMeO6jQ+YQvzSHOJuQHEJygR3/T+URkmN+4NXPj+USkkfIKzOqF4S8IJBFM6+XV7m7+4uIzTnswseAjIDmtbq88SW3Wun54tULb3WXKrb8BdsvFKZCispnmEKaKqKYAoouoplimi4xR7yIYUoQq9escClGZQi9Y6AM4dcIShgoYuANRccMj+Vs7byhmSKGyWcY1g00zqZxDgXZJjYdPdfDMz1k6eCFjk4fH3pRHmQwJGAqCMk95EL7+TZbw4Rd/TufO23DHpOU2xjjNIytuvCtBmTJZawqth90r7Xt6mYRWkCMjMZzNBbTeIrGUp1+emZwm1/A8DOgL5H0JpmVbHZAInQnkG4zgP/NgC3CwGah95WABdAUixtjx3IfHzoIujmgBYQUElJMyGtC3phVYr4sJuRgUkRIASH5BA4eKzygBZCrVFUurfaZKB0+yPcEM4D2aIo7veBb3HHt1fjV1/sX3iod29ezdrTvaKrSoHoxLgwtfRGQGRX0KjG8oTJ3QVap1782Um91qqzx/sj6oiflz59WZcd11RUszVQbNGUM/Zqh3jDoNcOaIG9rx72q06Fe6PiuLbB9IHdXWUAxeTSTQ+FcGt6nIyPK1VPPtxV3qxud7nmXVoT09vjtzAW1ldyKD3d0crMLfhro+/jR155RX4cWfvuo+MfkRuu3Ey5d+zbtxhtc+kat0bZiPVa0OaClJAyeQXiaQVMmmKRg3Igmd7ckg/W6vheYn4T5iZgfz7qhNwnYRJTEYuhKJJ2J0JEIHQmkPeGAAduMtsSy0W+ORY0xY3ksgIP4lhFS/rNqgNQRUktIJSFvzXpHyDtCysxsSn/+J8WEFGL8ZmOjfkcuRYAIAcL+ZbvMyT1ljkh6KjD7n0/6j93t/iaw4urTtJyx/kajIm9m8EGiW1WZs0QQPNbt01ltX1lgG/Xkhzth14PTQ+6G3+TzAldnQ/c37u+u35uevFNZ65z43Ole2p24lop3e7tvKLoQ4ce9Anfe2q1BylZodOtT3iwTuFf1xk/NlRhpNq1RTJ5c5VvJtXteap9T517R78Od9KmecEgqvnr/kU9FV/Cg3KFp+Vb57KV8yam0yR/TpbcK1i9liL970vq5f/5xnxdfP353vURi02CwqNa41C7nzu4LTcwYg8YZNEwjEYX5Km3dlOTZy4zchIfPo0Pfpd3b68zEZgDASyI8sxVYEySwuagzkU1EbYlmDLHQHA/N8agpdjTv0SFzZCsIqQZSA6T2Z9UBYWVmUG4eKwmpJqTqZyolAEU0U7W40qTW7bI9Dht9ttncoajG5e2QjsVzsc1n4kRfREku3E19+TpsXhI8yOfEpTilZbj2cT14FbcG6q6sTjrrt/2XJx225lzVm/7avXuG/YBtmfXS8GX5zHVq1Ypes2Y27OgtL/3G/cFe35gUh8iK3Hcaze3aFvsu+ZUO6jwPX+iGy+30jWb19dJpx5KOuKFJ/4o6i/SiOy3TgUO7XsNKbwnlM228VTHu0Cj2Hld7z2PPWeCIsdMU5kwwbqO0deP22czh7+IGf3w2dz5//2qJ5vJz6cmHlSf8c05GNttV7Fq+3fVv2aja1FbLFu9kl164n/pdWM7ZhJZLL6ZuFi9fL9u+USB78vQpw0+DniToSQQeK8JLNtfkBOhIwG1sRwTmPQG0xLNqjh81p6Bqwsb9fcTNk/qfR6558n9RMTvjHUCJwVi3vMZ/X2/ZzINNgCcV6qyxVYfq5WuVuu8iWs9m73zpV9LcGGjadTRuu472OEQFf1VX6jjT77Q778SoPEHvDgYXbHAHgysYXJHWyXzJwTp3xZLdvOCScYWDtuzxmh1es6OX7fRSt9EOzwfRHPe0LJcB/Y9lW1e60CUeuciDnzrxxSbq/PP+c09f+jSO3B7eCZyjAhZQgIRyal+2Khfa89a8Z42+q+C7DF4ycJ1CTuPoZpfSqmXLbZJ2msCOvaYL+dJvonsvFuyeLaPOlhiuvFZey544FVr6z7ulZyIav7id8UNclVXVvG2X7lqH4VI7c6kNX+Ca/vlG+VMN7R+ejvoySG8K4ScfFAboSSTdSe8LcnsC25K2JUBbArSa1RI/ai7CDYQ0AWkwiwuE+39M/s/LAyq1QKoBqpTqls2dKQZTAEAIRoCVmGla3QvuWrpRuXurgbFp0ZyKbv4mQ/6lU0pcpNWa9PazhFNNFY5LM77rcx609g42emGjKxhcCCtnYnAGPQepnbHeBYyuYHQBHQftuS0OXFJIHGCbg9dsYNUelu3VQ9cUwz7+jzw4XYqPA6pvVe5e6kaX2rFFs+67mFqbgo5A0XbIIh2yzgQvM5yO9fMvx6+85HkJdnxmTQHr2H8Nbi+Bl5TJltP3pXrnMeZq1fr5/An3Edp+EtuNIKtm/TcxA2fSps+X0SffoDPFpu+TRy+mdNwolVh16C91Upc68U8dcK4NX2imv3y2ejhu+WSp8Ydqg3NIgrrz2UFLCr0HrVGyGQCbgsxNURKwJkj8GUDCSA6bgpoJaSGk1awW87yZkKafdTA/eKYRSB3GjQpV987+AgbELnsABtAWTRVLt10b1i25hlstJvcBdfqK6lRM4z+z9r6yenrr2o9ZSZeXpSFz0xyT7h6m7xLKF4yu2Ghe+HoXoucQvTPRO2ONE2sFoxsxubEMtE5Y7roiuCqfsIUNDqzawao1zNlvtV+JjPe0bZMfDmo/zMm9XqOyaNg9G/E6qHs2UKJ5tIkebaN7i9S1Mtn53AXryjX/ngWXEWXgBr6zQe6sgP8yuMvw4znDvXnKaQY5jaHLb9ZOZwhdB2m7UXCYgFsd1JnUqZORA2fz97/OkF2rUV5uoS+0o/MtcK4RfmjGF1rRmZKtY48F30cIv0qeP1tlOlej9oh7PdTOVXRmYV4yZhvTZMIWg2TWAV3J0JkILIZk0p5kLgYJ0JIwkvv4EJAOQjqAdB6IkC7zZQch7QBtQNqBtBHSRqCNkGaEW/cUQqVq/SD6wB4twLzOmDm+bcfdsWllrDsMoWJdroZJnZb99Gz83Ivtby9cri93XZT4rC94YyYM4xBMBwLlA0Zvo9oNjB7ARt+F6FyJzgVpHImeAwY3QnmB0Z3oOVjliHZd5zovyUfsYNUJVlgTGEXWj+5fcWre+XvEyoecd194ZN1KevtwbOfeov7pLoqSo3sy48Vi6dU3G3ZdlF216M6UMnAV3d3AgesQwAJAblJwmQbHGXCYIU5isBdRFwoXfnw2Yt/PWA1ju3F8q4s5my755gn/ar3pXDs63wQ/cc3Rb8IXufp/xI2c8i99cDft8lVvi+KNszX0uUqFT8rb6W3dcF/vVttLzEuF3jTSk8KWge5k4KVAVwob/Y4k0p7EmoD1QdKw2QHdhPyXeIQciL0DpAtIO4F2sy0aMO7Y2hlQqrYxm3YIBmwieEylixRs2tTv27YjJ74+ZpUqNqJ3GN9r5d/Ml17xDxEJ7s2MOsvX/TF6gvFDvfY2ZQzAJh/dngsy+ILBHXTORO8KehYA6wA9h1a4AO1LKA9idAWtM96zp9ddx6p+1IlZE5BlezRhmxF9xalK9tfwtc/D+i0jX0RMbT9Y1kXvo3gFCpkzXn47b8lVOQ0xNi0b4aNL3jO6u5sQuI4D1iBgGQUsM24yzJkBpxniNEMcp8FZAjYDprNZkzfK5m/1IYtBsBmB6+3oVLLs64jhS3XUhVY41wTnm/Cpgs0vg+vdfRPa4tKWsp77e4XfqNeermd+KpcHpZXNqpmZPeP4sGi9NQfx04CfCr2ppMcsXgrpTiGdKaTDbAIzBlHuw0OEDBAiJMAnhE9Ir1nsBIBHSI/ZFu2EtCLMW98UKlQ7GLOZBwAMgAf2tHfbl6y5avtOxlOkS9+j3yD8Fth9k3/xW9cHgdKZJ+MCW/l6KEZpGEfptbe1Kl/M3DGqvbS7LmD0YmuA3hUMbmBwJTpX0DhjjdvK6HWk9yOMN6HcwMDBSkdmx1EndRC9O8csOMOyAzNpW/X8utWzzqP3By+GZiVL5Q8X1AkKJlGFHi4br9csWjXrOGPYcRQ5VQ3FLav9lujgTQhmlz/2W2DuLNKeMuwqZqN/IGcxdpchmw79N3ED9h2am73YchCsRXChnv4mfOS75LmfGtCFRuZkuvRH35yXEekz+flrBflbpW89g55faTT9UIvOFa3fzyqb1dDzapDsU+Nj46stBag3A3rTCUsinfSkAi+VdKeSrhToSCYdydCaKHrF1gARwDCBEUKGCQgI9JuRDBDCB+gBwgPSgXDv2rpAqdplA0/YQQ+4T671a563adTZ8eiASX2OFr3BuAxwOYNqab3PE7d5yZOJAfu1hRDEtCCUTZvuKXY9AQUhg+/uihPSeoHJHem9CB0ERh+zFVyw2gk0roPcC8YdH0B+hPHEJlesc2bkdsyG01zr9cWOW3jBGU87zzS5WT5IPuGa8EK6HTojj9uj01QofM3kzFu3ala7TWLOBPIepe53joXNawLW8Z1NFLbBhK4z/vOmgEWT1zzylAJHAk7TxJHFAO7zyHWKuVmjOJU5bCtEN/hgNYQsBvDFMuOJu92Xi5Tfxw/e8Ex48zx3rL1luq9b2Fj7Oi7B+l7+hUb6h3p85rkkKr9KpmZkGixVI4mSnpgWr7YXob5n0JdB+BmEn05606EnFVgGaaQzFTqSReazoFECowTGCIwCiDAIAQZZT5BBQgSE9GE8sLYxqFLvshWXzTygxqhXrvZrmrVr0tv30KFSbZEJlQEqA3iLUSVDJzYVTYw9mR12Ew+7YKaOQVUM9Wh7zR7TgdjotyFz1Ox4Ufteyi03TMUA5CM6AtNhQAWBzhOr3Xpe/yjrsQbqNkY+iEqg9U+wmmNaszJIHfryf9KMcUDqoh3zuXrrTEJ7/9Pp7dgtQ4aaid6iQqc1N+vWXUexpwy7TWOH9tXkqYWwBdWTLdPdTRS7a8rcNz5dNgSINY83jA+XjXfnjc5iNhE5isFJgrwXkJ2AOv1sxqJx+5YAWwqQpdB0uYU+n719nJPtFxQ73tlFG/UYMI1Mk4L+8vTsKw/Lf2pAP3Hhq4jekNzaKS0tVjISJZaomGklMyWeW+0qxf3PoD+TsFZIJ70ZwEsn3enQlUY6kofznhxio4/HCIwDNmMgI2ZPDAEeBBjAINzaHlSqdsztJsEEqzHu3VcHtErtmnVOfPrRvPY1gysRXcPQ7xhcgag82UhrT9i27Dafex1RcRilISp0b83JpPEGyke35jHcaYm1vttip901f8S8wqgK08lAR4IpGKncmD0XUdXN3sIbpn1PoP1pOhcx5Ujjiffs6CW7qZqrs9ybWOaiGPN5nBhSvLoXtaxJ2KPfGlD8msmiRuo+zPjNY08x4y0Gq7dDJXJdzpoyZ98YvI0Tt41VGkP6iv728Ga1nqlQGqs0yENCcySEI8XOM+C1gN0lzK0mzenMMTsRc72fsRJRlgPMuTLD+fD2jnd12pVlDDQCanNjrTwnpyY998dw7jkuOl/PHA2ss37RnyfZm9IwYjWWaJgZFZpSMpMS2QavlBnIMvsgk/Rmkp50swnYXCTKf3wIYIygMQITBMYPfEDYjCQiMIRBuLsnUqg2AfDBGYMaoz6FJqRT5tCqd+4zRa0ayhCqQWgO03OYKUdUFaV9VR+l2Qjtrr5mUN4H9ABQAKXykK/ZAeVt2nbtKr0yP+6N9z1Gm600e6GYjgSUhqlQMPmD3gMrOMZ1x7HqW+3JFsZFdzD5YlMAYwxCGlfQONIrlppxh4H8izqR09sXFs2zM/Fzu6HLxsfbTMIW4zuw7tS57TGDglaQ3yz2mwan1z15e4bsTXXyjiFkC0dsGMu0prhFvQtvJU1OJ6wbilSMh5RylmGPBewyCxwZ9p7H9kLq1DOxZdO2xSBYCtGtfuNFLn2pQHsvvnJnbJpS7WPGQFF6nUqRnvryfELfDzX4h3LdJ54lnIoVJ668+GcGM2p6Wo3ECmZ8RrrV8wb3ZeG+TMLPJL0ZpIf1AXSlDuY/OcQufxbApBnAQTo6iP7QvmL0IPrmfh9pMBpSaR50z7p16twGTAmbpreYqQBUh5llzCxhVM3Q6X1Vq0sRM702S2McjO9jyg8ZPDdnLbHBB2s9t4TOmXdP0Pv30JpXWdJJoyIUGdyB8gOTNzF6EZ0rljupZHaTtRZjz32WOqyRygtMXsTgBhoOaDl4246W2fNzz+/1uCek+ldv7z+eV4dtoLB1uL/A2NRNeU8xd5aYO0uUnwx58LWu+W3pu4aITcOjTTpkGweu0I/X6aApo2W1NHAVBazge6u0hwxxZsF1lnGfAxcpeM5h53H6Olf9w8txm2FkIQDrQeYWjzldTN1Mnuip7t6bFBt3dpBBS5k0gtHJC2mCs1VwukT1qdOLa8mt1k0m5/qtslnFtJYWaxipBktVzJSCGZ+a3ukpQb1ZwM8C1gQHDFIG8x8fYtc+mjADmDAXg1ECwwBDau2EUrVhPuFhy64ao0GN5mmP1KNL4zloSt42lWOmkuAqgqsw1DEshlqTprTlsXbVp6/qOqMNwnQAmDz1O+7yJXts8NBJnVpTrvQVOaO9AGohIDf0n8a9u0jHIWz0PYnBgw3xjvOG8OZ0rcV66eOBl7eMS85g8GF/xDrAFfad6Tmb8fLL7Tm2b3qakuZ2H6xQj7dwyBLcGVM5ts17SOjgDXRnmfEeZ25mCQJyK6JX9U/WTWYA4L+EAhaw74jx0qvBgCXGfwn7LmL3WeDMgsuUyXsWuc0SVym4S8Cab/wycdhpQG8hBJtBuMmjf6o0nc/VRGU0yoXj+9NS/eamUasQjk5dTBedqcDf5+x+5l31iVOmV9f+jVojp3ajekkt1iC2IGuZaRWS7DOjY2OK3lLMAnhGejJJTybwkgbz2Rcyk4AnCZ4yZ6ERQkYAhgzGiX3l2n9lHhVGo1ptFF/m0aH2HjQmbxkrAXGBrsNMNYYKwOWAKxDK6K/bWH4w3XVjbcwJmCBk8MRq97VxC1rhgeTu8w02fcmuw+UueOf2Ks+79ulN/UYgo+QQkxfbhmpdiNoJbzkvdl6fqrLcLH4qeHFbNeqKVT5g8AKdK1JxQOnCLNhu853Cgy51KVVBk7thW8zDLbg7h23rpu7MmPzm0N012keCXNp2Pe4kpdXUh83pHy4bn2xR97ax/yLjv4C9RYaf4lv9pym/Jey3BO6zxGUWXEZNjoNqjznsIgZ3MdgOUedfb117N28pwFZCuNXL3GhlTuWZLKK6ZjuG5P0jikmJYWuLExJ3rWT3x0r4Pn3pI6/WL0L452Lq7Hn0lQqDZ+1W24ZOqkOzGpCqmBk1nt6jRocG1PxCxM+A3gzoyYCeVCFbA/AEwdNmTRAyDCCi6Uk5e9JgPtsnWIPxiE6dKJz17FT6iJiEdUMlZmoJ0wiIi1EVoDqGfrO7/7Sd/7zyqW7dQ1R/XbfphUw+oHOhtt2WRBZ4z007ad8Yc1VTGz9S5w4bAb05NuPZoQrZbcOWPbv89e6gcSYqR7zpKGm4Nl5hs1USqRqqFRRZ6Obtsd4TtC60wglULsyyvXrEMzMvKn1+9968PlyO76/iYDFjWzXuNU0HLuPAJeQiNFjH1HZnZj3jNvqPqO4vGMO3Tfe22B2A/xz2HTJ85Z3mNaDyW0T+S8RdxmYet3H6YvmK9xxylRLONDhOMLea1OdejloLGQshsRKQG53U6RLjlZebb/K4O72DU62dsqFhx5jqH9/SZyvQsft9J0JGToQvn/DJ8RWoLDrwpdeawKb1frlRpsOzWixR02Ilmt41jPQ0mPpygJ8B/HTck8qmoPfLH08fOADhyb39WYxptt8nWAd4RKfJHFv07pB7iejYFX0NRrWAmhBuQwwXoXqaCanpPBXecCmqTih6tD1jN9p0Fel8kc4NNC6bY1ZKmRNedVltthcmuRpbsoa5Pszmg6Zkh9V3SUtDPqoFa9B7EoMraJywwh5vOk1UXhircNp4HYdWhztf+atHHBkF+18cqnQjAAAgAElEQVQx+06gcEFr9rIuTsFAx6OZvbBVKlqO7s6DT/8ep23RS8wELSKfGcapcTsyMGb7dVFmU5srbztkWh+5Td3fxP4LTMA89h3U/egY6l42c2cObi+Cxyx2kYLXNPo6adB91OguBedJcJ3G1nzTN/F9znyjhYBYCsiNbupiHXXmlfZBUv1Ot6CmoMorJPVSkuj7N+jMG90R/9bP7kkOP9n5PqLPMrfXWYRuteALBTtxfdtjalqmxVI1SDRoWkGPr+5Ot71l+C8wPwP3pAnNRXjyvQNgEuHJfeUszRjMr1bY4+UpgzZfvOLVvuUzzEQsaqswbgDUgPEgY9rDaIChMial55L6v4pdsYp5rpEHC1uu7YldweiFNU5YxZHxrjIrHqZxx86Uq/KqlMEXD6qe2y4MBuYH39xuKpzu9pBP3sRaT3YXpnai9+zQuuNk5WXRW4fpvCdLA/Wi2mdjVQ7yKVukdkNKR3rHDm0415Y4vtmU+43sPtmmY/bQbQmyrpwKGNF5y1DgAnbqVdkn1Aynp2vqKzMbO6xrpP7929Fy04NNdHuBCVjAPgJNRGSa7f0c31GD7wLynAOOmHiL0RehFbbcXW8J4zgBLpNgM0Cdezll3bBiIQCrAbDgo6st1Le5+lvhLbK2gbBHuZ8Htp0v1n9fis6+2vjYvfFI6MKxaPW55zvfBBf5TlG2/egWF1/OXcydUIq1SKLFUi2a0+AZJTMsnV/sKmX6MnFvqrAg/BDgcbYA4CmMp5Rqmd6oMbf8wACWGXTly1tebUt+w/STOV0Fw9QDaiRMPeARZFJgJGToB+395zJX/hk7m/XuqX7Xu+Wtxd6sIxtQjRO9w5ntvonnXTR9HoVBF4aeP9WJe0V1j00raa0pd6Zr8/jVPnKRNd73Yo/k1M4UG19Of+HFlhfXOxM957kv51rzqhJtugrOzfRab8vsTSsOaN09v+xB8rLSd1geo0CRcuQ7RVuWijyHjbeXcKCYcqpfCr8fr+ZW6PkdGQ0dlqVjDtWTMXL64ToKWkIBc8hvQJ2VlBkRGutRt3ZHSvnNMy5i7DqDbibXXkoVeI6YnMex8xi2H2KuvF27WThq1Q9WbBaCG5306demy6nTQaGZX3HyT6Yrvs1DP7ymj4Z0Hw8cPPp4+1iU7p/J+1+F1fnxdz2mwLqHuVlDWxUt1ixoJBok1cC8FqQqNLlPCYdHdvilmJ8mLIwwt6F4EtCUTj+neL/hAiBo3WRo35X7tsz6iZgHUs07mo17HTANBFcDqkWoEdHVGIcPjJ1KXzgV3iKWhHdxbZtKb8rn7ZCWA1pn/QpnvsvCNOHWkPjDdO5T3Wgb2pOIuAn6pee8Zw/o9YnJvudbA47MhhsYPbHGybRthzbdeHnW5fE358qjYZnPyCeWB0pljfY7w9a7Moet0Wv6hduxr1MfTO0Fzmjj1Uz4FvIbp24VCV3HjLcXkG3Tsk92+7uo8MnCrJnmiqyGFssCgUVW8+NV/YMNKngF35Yynt2bkY9jisJCLgekW5WN2TTN3WpdtR9SuZV0O0dX3arZc5tE9sPYcRRfr927/KzHVshYslkIbvYw52vpH7Pl396tP5ki/2c2/XW24Wz29qee9UdDZMdj1EeidScSlOefSW7k8z1mkNs4tujA197pPCoWe3eNUi2a1eJ5DUjVeHxXL+zrNAzkCYsjDxHM7oRpRra5u4wPNlwASpoaUquD2qR+g6aQGV2pkanDNLv82a4fVwOuAFwFUAmQJp7/NmHcNeXF3npEaPDNsU7PvXkbRu1IdC5707brPLu52svCHLf95gK8M4VVkunOgn1J/lBhDJaLV6Vt4jZP9ZgTaLyw1tW4Yc9s+AyVeXNTvYYLH8HqANodQ5vDvUXeSy3X0JI9mrWS8ThP62sCR3ZD50zxavrhGuM/ZLySI3CfoIPG1BfjK4MCH+60VBglw4x6Nb+9yyqv627am+Bp5cM1KnQF/MSMW9tiRnqOrrsuPuGZU8Wir8jgOmqw6ZNbFPOtXYIuJfRzenR2g8h+BCxa9Geim9wHKKs+sB4Ayz58uYH57qXyu6eC7zLVX7+kv8/Rf3aPd8SPd/Tx5vFY/bEo3ZFI+eUCxdnoau9xxmcGOwrRzWZ8oWD7acf6pBrJ1GhOh2c1IFHD2IZioqtSWBrDHkUgNLEtn6UYE9v1A5gAzWjUUX2zXj3auxP6Ag1VBwwXMBdQE7AV+D0AzAIolivOxLTkV0WVlnh5hDzYmApWLTnSSgfQuizyryoFzvp+r8EMH/1YC6jEjFKiXxNNdqSNvE6CvWlaK5tojxDX3qAWHbDak97m6BY9RyuChkpju5+HopU+Wj6G5COz3QXDpRbqEWsktWt5YxEvnPQVbD9YMiaomXvLjEfH7o38Qcu2rct5It/MJu7L5/RYL6hWkHalspvHKeh4kZVzu3v+/prp/jryldCu7UuJmUVGft1sb4tfKteuettzxOQ2jryHjGnZb1wCEs8lDVyvWnHoN1p30CfDuR6DGos+ZCkgFgP4Wjv+Nt/wdcTw6UzFlxm6b1OX/+5SdeSe7LNo1bFY49Eo7ZEoxXeZu98/rfYf1XnOYLcJZNuHrzfiS/kLr6VKqQ5JNSwAqRpPqNDgjKS2IPUQ4BGNfk6lVWDMfq0BEbyo07yZ23Rt2b4zZnqloGoB1ZvFZQsAqjYDqAWoAqjCUGuirjxOGB6Ksg8KdQ2L2Z65o1xwpvYciMZ1suG8UeS9UsWZex2LtsdppYTen6V2psY6CkYrMrF8EjRz82M1ra/sTWOezKIj3nLfn3KaqLmjmWzmZoXSSzxGPs7IJ5iNke5XvtKqa8y4c36mZcLkhidv88k6Fa9AwfO0Xfncj6l91l3Gc8l9YQkl27wmvDUHmiWsXRWMDN4pauutfHu3oMl/QhmyRPmLGZeuzcCEAu1AE7U5VVVUeDtfZFW+5i2kOENMUEF7zcs869T+G6U7F3NlFwsWLsa1u7dv2PSC9QCxGIAbXej0a/q7eNk/Y+ZPZsg/8qz9LHj06JOdY9G6Y3GGo5GaIxHKb1IVZ+N7PJoX3CbAfRochhmLLny9hrIrlfK3TVIVXtBimYqRKtHEPvWssOgQzUh2FZuY/VIJ+45lx2Tg7e55cOcCRHTalqkG2MLbALiB4DrWBLjmZwDVgKsB6hHjGeP3pszz1pMatwePNid99qROtNyeqF3Gqn80DHqOvrJX9VetiPvC7/nZXjnvZn09LSLkdWKYZnkYtBJGM8urim17dpWZcMTzDluD1mN1PsyaQNRYIKjIGqwvnu6tUi4OyMcaW1Ju7vGcMtNcoqaUrt3r0XImTsHcnUVXsidvFC1YNuku3H6Zl5VnkokY9TrWLoJuWa1YeZhT0VVWUP76rX/30t05Y+A84zWkt4wq2eyuY/Yk+tWR7Mwc34Ix6/IlTxHl071fV/ou6umzm6/WrLkGi0rN6fBWZ67YcgBb9BMLIdzi47OV9I8vd78IGzhyu+2YP+/TsJWj0eqjMYajsYYj0ZpPI1XHE/au5S1fe9HuOoVcp8FlCmyF+FoLvlS2H925Mq1FMjWWqbFEBeMqXNMnOrS7v0AjirBdPxgxPaVRh7SIfQWm+FVjJUZcjFsx6sB0I2sCtgjXswzMGDCqxkwzZSziJt6JCPzc692diLAVkfvasBO1bQ8qzmj5j7p+X36Ku7Cm6Pt/HLv2w9m4xw+vnPvR6upVf9ub88OtoJGCRqbeGHqd5Dtd6W4c4lBTHt15tuvDtfU5yQlhAe5W19ysr2YlPNyY4Y/XZHVkXE/PundftOvasx6rQLH7OEDMnEkdvlq67fBq/OGdR52lRcuDrUuT/eoNMaNZxdrV2uaW+qJc5Vj3k4JaN/5mkIzxHKKssrq7S/KwfNKolCkXh1KSMuxjKm1KJjwGtA9LOmXcGs9HBddebd6ooS49l9qVDtoJsDUfrAaw9QBcqKPPZCuPuBUd8ag5+mD+SITyaIz+SIzxSKzhSJTucITmSKT8auHOxfga3zHadZK4TBGnUWzTg69z8bXihboFjUyF5lQgVcKMErgDI4fUevVB34kJXtHrs4fnfbuVEQumdwjXAWrESIaYXYy6EF1HcB1BdeYyUAu4DqPqvT2v/PLH6fdPeif9zavR94H/6rDndLsVveMASufR8p/WuB4j+VE/nDgc4OqyIp0oKcx5cP/Bx38/cv38+c66EqyRglaC1RLV+iivNnPo7e3VRsfxt5zewshHLtah3l4cG5uTX37z3T++/OKTv9UVZDS9fBgSdd+vZ9VneD9WiSL3kM8U/V3y6JXiraiUyuHS3PaibFFrbVNZUVtl8fL0gFExv70hLs1Op+aFw72tAcVt3kKV55jpRv50UU6JaW1E2F7GbyitL86+ExB0/LzLaa9ki8cFhenPhEX5Do+rLufJb+StWxXwHYaZW31gOcieC11pxt+/Un8RUPuxH//I4/XD0eojMYb3AGL1n0aqP43c//7Fxg/RNXfG9S7T4DpFXCbAQQjXW+AW1+RVKRlTUqwDlGhGDQ2CkUMHB/2IgJphujflvo2Lj2ZMbyh2jTdg3ITQDDJtA+pGVD0Al2BzPYA6gEaGts0s5dRt20S++CK4+dOQKZfg27uTfsLyC8yWIyicxPVX+c9t255Hn//mi82lWZVq5+WL53qd7qefrv3uN3/mNVdhjQQ0YtDMYO2sYW+K3p8W9+QKKx7Xvgif4LWEPwj19PC4fu3mL37x65NffPPZX/9amZ38KOOZe/v87UkNC2AXeY5SX8WLbNIHB8sqp6renPv6yz/9/oN/+92//scf/3j6qxOjvc2MdqW/q3pF3MsopG1tDZ45Lb5CzfWqVZ/IYt3yyO7isJvNzT//4YO//Omv/+s3//pvf/rog78cOXfxRm9pfmFCgtXThuuvNm6+aHcZYSz52FqIbQfx9VZ0ukD3eXD7J/78ww9XjkZpjsYaj8SajsQaj8YaPo3UfPp0//QL+enYZt+uVdcJ4EyA8zg4jRDrHrjeBDfebb8UbUnVaE7FZiEWAGFP+sGEsUxvuN8iCRkx5GuZGmxO/RjXA27ETAuiG8zFoN7cCx2cAjVT1OW0Wotm6vPAd5/dH/84RHrV47ZyNqTl5UlmgwMKh/Ve26Zkx7u21+LCHyOkq+fWnTtnYe/g+cvffPib3x5NjY9lHaCRgHqK6CSglYCONQRopSbF/Jxk0sHe3kQZX2bn/uo3f/nF//yt3U2rLz/+2+2kDM/OtTtibbSaebLDuIiM38QI7kSU8LOfffO3D//tgz/6ePsHBoSc+v7Cl//47vG9u5RyidEujA026BRipJJ0tDd5ZFR6tcuvxbcKulpepjwNC/Tjt7cfPnzi93/46F9++8m//OrDX/zL7z7+y1+4qQklqVm28X3XslrdJhiLPmQ1iG2G8PUO5nSJ4cuHgsO+vYfDlo9Fqw8AHI2jjsUZP4nUfBq+/026/PKrSduSfrdJ7DxOnMeI8yg4DsGtDrjVhG3L5vp2TXMqWqLCjYLRQ2DufDYYKkMgC+Crnu3Q1cDmGS6Geszm/XrADYC5ZhgsAIJaMdOIUQuDrifWfxGzeCR05pN7smN+3TctL+5MBZfHfa2VuoHCQTPFqYu3z316N/rRfYR0sXEJHx21+MWvP/39n7/882GLqKcRWH0AYPo9AK2EaMVEK2b065kZ6Z988mVEZOIf//zh//jlX3/x6w/v+PrbXL18KyTct2czSKqLVaNHmzRHaDwd3RMXGtmQnPCn332QnJyWl5vHcXb77e+P/Pp3x27dsKZUy6CbRdoFWi0DrRRpZqUzwgeZxZ7ZfdHphcO9jUblSkpSfMnrt6KRiX//6NQv/3DmF7859qc//v0fH/198HVh+KME29Rqz0nKsg8fALjRzfzwxvDPyImPvToOhy2ZG1DDkVjTZwnMsXjTp1HaT8OVx+N2Ld9uXEmvdx9nOOPgMgYuY8h9nHYYgOstyKJeH965zB5Wq3GjcOwQECxHiLu05dO0Gj1rqka4FlhxzaE/AMBlyy/LgCVhNkEdQANCnBcNR4KGPwme//jO6Pc2t8cHH413OXe8ujpRfxXtOlNLnP4in/1Bbv2bHIbRPgmP+ebCw1/98fQvfvv515fD09Keg0ZGWAdME/PaJxrJgRj9xu2Au7//85lff/DF//jlv/33X37yH59c4ji6FOdkWz2I8e7dvTuri9EwYZu0k8Bw6mlrdeYr/2uXrl+5rlIqnz55qlSo/vTXf/z6g69+/4dPFsQjoJ0FzRzRydiaz86lJtVcR3d7Ulrq9FAno9twcHDS6XV6g/Hzby798o/nf/Wv5//296/OfnXSz+L64Otij7hXXlOUZT+yGgLrIWzBRz++M36fMPuha+PhB8vHY1SfxRuPxpsBJJgOx+iORKqOxexdKtm5mFTjM0G7jYPzKHDGsNs4ch4GOz5YtGLL8rWmFZ1Mi5oGxw7pAY9odbfrp+6KDG+NqN7c57DpHnD9f616gHpATZjuRUwjprkYagHqEIpqExy/3fGR/8QJl9KXLz1MhifDPE530U1R2a1VoQVedxWW2ZukrYZlEWOU5xWVnbFO5TxscArOiSkcbWlpxeZVSdTToBWD9n30iUaM9Gvevvc+/If/nz6x/Z+/+c//7/fffv1jSFBwWENtjd2jeI/eveA5bYyaebBOO/Sbzjxu9nNw+/rjT3Jz8rRa9eHDn9+45fa7P3z+69//8zcffFZT/uY9AK2MaKREe4BBhrVSpJ5DmjmdYu3wse/cAmJv2fn+8ref/ubPl37571a//cPn4aFPjv37X/uLCu6kFfqwABhrMwDLfnyuwnQ6dfk/nKs/Dl04Eaf+LMF4LIH+LJH+LMF0NNZwNEp7LEZ55tXWD9GVAZPGAwAuo4QzwspxCKx7iWUzc7thYULDNA+OHZo16JMHJP48ZdY2xQWmEbM73jqE2FVvBlD3vgHFTdg0iZlWoOsxrgOoxbhOrf3Cr/Dvbj0XOUErc48w3F+ReXBfXJDzPAZKrqzwrBQjQeKmJCzl0Wtj6+tLLgEvUys33/LlIRGFqp1F+HnJsz44mKslRC1G2qWYuKwzVoU3vat/9adzZ2/GnbeMK31TWVlSGJD4zL1PGTyvj1WhsHXaps/47YPm+4HhFqdP8To7FUrFH/504oMPLf7lg69/9a9ffXjc8sWzLNDME/PCB7WEaNmkx5JgJQO1dH9r4T8On/33r+/+5sPrv/7j5//rbzc++OzuB38+WfSy4JM//2f6gweBGcV+05TlAG0tApaBAH6qok+nr/+nc+XHIbMn4tTHE03Hk5jjSSyAY/HGYzG6Y9HKUy/kJyPqA4T7bpPgOkZcRsEMABxFYC8Eiy5sUb37dk7ZKBo91LSx614jDRebahHDAgCoZxmwDvg54WBz88PerAeaxQMMuxXAmH0ZUNn5lUdxYrKXTvUIQyCt9GwpPK0QcBZqLXvzL0sa7SRNocxUCx5pNcz0ddTVJCUVZqSXjAp7kGqWqCWgFr/PPGoxMUefqGewWirs7wuOaE8u10Tmb4Qkiq/YPtnb3ZrlNacWvvboV91bMMSqcNgmsuIbvwrrOmf1qLOstKOhVmfQ/e3E9ZPOr3/zkeXvP7a66JmXk5OPNWzagYO1/z70UvNnSUAt0SlWP/788kc/xf7HyaBf//nrv3zl/9HF3L8cuT7Y3ff13/7emffKPyXXb8poO0BZi8BGhG2E5EINOp259XdO+UfBshOxqs+T6OPJ6LMk+liC8bNE07E4/ZGo/W/Sds+nCxzrpt0mwW2CuJorAWeEbYfsh4jNAFh1YJ/6uSrh6KHQ+tF7g7oSDdv2mHseYJc8BvabiJjuwgwXUD052IWxxflAVewuDNcyuEGrO+/lO8gPMf/Gtg9SOmvXXFVie0Wn9XqLXW/OxYqYq0u1Gai/AfHrqcFmZnaAWR/Du1OgnAHVDFaLsUryc+glRCUmKjGopmnFXElpTdKr/pisGQe39NbGFv38KDXa/ayw1FOoDlnQx6jYb+Ba8o2fh3V/wynsKH87PyEwUXorj0j3NIlj4ohT/Ojt5xNdPP57AGzHJWVNoDbnOvMEVGJavWLpePcn7wrv9PEPT952jun71qPlql3YdB+/ITVe09se8vKN3wxlLaCsRzALYBAu1KFTz+Ufubz9JER6PEb5eTJ1PJk5nsx8lkgdT6JZE0SrPk/YsSxZvvqyw30Cu00StwniNgYuI8AZJfYiYj8IVnxsXbeVWtd5yL12PWnZVM8GmqlHmPt+/4V7GaaPoVowwyWsA/5LbIkmqBIzNRjXIlxLUenlqbvrDwHfRXo3Ru2I1c5401VaeVkndDIKPXpygnT8KtzLZbprjT31IO6F1SHYnSLKGaKcwYqZvWUBqMSgnAZz9IlSTJQzsDNOr03MCvnNr99I2lr0Q50Mv4Hpb83JL/EeVIcsGGLU6MkutuyhPn/I/zawO/ZRFL0uNul239W0Bad0Pa3cTWyWJ5Xyd9YXzUE3FwCNeeGzo9j8WRKimkEqaW19e1TBXFytIrZWEVOjcInsrqxqlgt5pu4GJa85ILfKT0zZCigbMwBrIQvgdLbiY9fyT0LEx6IVJ5KpEynoeAp6DyDBeDRWfTR+59ab7QvJ9SyACeI2AV7jTKCYdh0Dp2GwNyciBx7tkF5xKGxc/9bIRp9tbzDUsfkddyBqH5gFjJoxexB0EH22I2L3B2yPVA2oCuMahm426J4V3zXqQoH2oxROWOMMWg4lcxws+IkadadF7ryCUCzm4dF2LOqA8W6YE8DGCOxNmh0wzcadJSFmLxXshJ0rpmFrDM8P4EkeHmpXt5Qrq4uYlkrEa8h/les3pA6Z18ep4OkutugxfvGk7+tggYtbGJoSoA2ZSbmVmJLjHfTiUWwxl1tPqxdBLSXqg6TPOoCFbRb7WYoZ2J9R7yw8jMlOfT3zsmHnSTovJ79qc7DH2FUPXXXLjdV+pa1+EsZaYLIfwbbD2EYIl+rRmVz1J24Vn4bMHI36GUAq+sxcCY4lmI7Faj+LV57P2zwXW+M1SrtNEM44uI8zPhMml1HsOgYOIuwwTOz42KGw/1DWhpGLmUZiPnEz5/1GDK2Ynka0EFFNgBvZkzjEJiJAXMKqBtgvY9XRVAxv+PzdpOJiV8wEY4OnYccedBxQu+pGbfk5Z9A4hxa48fLv4vVhWBDCnBAWh2B9hOxOEMU0VorpfbFpV4z2JFghxQoJZjGYrbA/RbbH8YIApnpA1IH7mnFvE/CbUW9jQeaLgEFV8KwhVoUi9rBlr/HLCMEXIaIrdg8pQRcS9VCjfWvdDTvD/cZNGVLMYhVbaQmbfNhzJ3aiYosN+0H702R/Gvan0O64cnG8Jr8wP+nZCLdpq62Wbq+BthrorB0pKw5uHvVnAVD2I9hOhFgAXHw2T3/Ys/rTkOmjUfufJ5uOp6DjqeizZOZoInUskToWrz+epDv1cvNUeJXfuMl1gri83w2wewLOGLiOg/MwuzXjvB05VG1kD9rMYt/3NgFwMRv0Jsw0AdPEHoUyjcRMiLBlucG8P6hFKGtG8nXgm284KaMjYdjoSyvc9LtOoHdFWxx1v2N//o/MsIORZ9+Y4sBsT5KdCdgah50JsjdFFNNsklFKyrKiE+xuND0M6oh9OJCdMllbtCfrQ+yqnAb5BFoVwZwAi3vxZA9M8vBkt2mgsTAxOWhgP1iqj1OgqD1s02/8Klr0RcjQWetog4CHeS2mtnqqrRaP8GB1HPbfJ32slMyPtSKFFFQSs8/YkQWwNw3ySbw1hqV9aKgN9TbTrTWqt4WmiiLgvsUtVQ1ZWY8Eq7cltN0g7TAKtkMsgMv16Gye8Yh33eGQmcMRe58nUydSWQDHU9CxJPoY2w4ZTiQYzrzc/fZxtV+/ynXM3AiNgevoe7mMgssocR4hnPKRQw0YNWL8fqUDZi/Nq74RcLP5DUyD+TVA48GhtHmsJ+yBaL3BcDHi3WlOyP5eJKNw1W9ysMYD9O4msa2s8sYc1xKN2Bu7bJrjrsr6q9A+m20O8vv7LK8SNxak9T8O3X6WaqooRbXvjLVvRouz0L6YKNiFydJaH4HlIVgUsm6YH9B1VJXExwX1bgeK9TFKFLGHHISGk0mTn98VfGeXqhb0Qn8n09eORd0g7YfNcdifAaWYqGRELaP3xVjJis1v+++XPwtgdxI2xtBED/S3QXcj01LD1FcAtwIaq+nGijdpGY/G931mTPbDyJ4FYE5BXOZMnumYX8PhkOnDEfITKWwNOJGKP09Dx5OYz5Lo40nGzxL03z/b+yGhi9M07zIObmwnil1HiSsbevbSbZS4joFb1dihZsw0m3M9m2oOEs7PhmhmJwwXWIuYW1Km0WwCtg3FuA4xqcJha19LSvdkf95Rv8lhf+Pu/+fqrZ/iWtaw0fwn9/7wnXP22RF0sJAgwSW4jMDg7i4hCdGdBIfgEAgkENzd3W1wC0GCDgOMr9Xd61YPObdu3aquqTWTVFH1PP0+r3Yvnrd4ijn5xfJkwBnOsAUd9LlcZkuyv2B/CvDWEW+NuvqzEG9lb7Z7Mf09bPyBGqpQfRXRULXSUAqwNC9jjbpYRKcL1PEsOpyFh5OSxZ6bhq+1KcnB7bvBS/x35+D1KXSfFhlmrSuFDDx0KfzV1wXnh+HqONyZhIezGNnLFYT/3Dq82iAv165/Th3P916uj5KnK+T5MrxYRRcc/N9+z5PLQ2iiBw63w/5W1N8K+5vRUIu4pbowqyBygee7JGHPQPYsZE4C5iSyaCKfFEqUgtvko5YeJJ6rpEiVUwEtDdLSsAopJZO0ZKnSR6Fm6plN0Zp98YjnPPSax9Bj0DH6yGuB8pqnvBaQZ/38nQ4AOiBsxxsf3NqBzALw3FU7gm0ItFGYhls7aL61gNs8AME2QpxcFEzyovbmHMTn7kjkTfxki8ddOjP0RXNuYMb5pl3UkGEAACAASURBVNVxPtthvySoMzXwamcEXmER+BNrXq8B7sZcRY6koQJWfwc133e/FZyvDUMuB3E51CUHcZewPpzOw9M5yebwZUflUWV2FMPcq2I2cF7w+hy8Oofei2Kzwn2FoK5HwZ39lT/A+hjam8Nad87BCoNd+jq6WgdXW3UpifMfXmwlvV1P/7BcmLHV8UN8OC8jANMMf07B1RG4MIDm+tFMH5ruASOtN42VmXnlIRy+N0fiNAfYc4gxgVhTlEUzqVMoUQppV4jiPHh5pppKqKRCWjpUSQdKKaRSClBJxQSoJJ0yqk7NU5s9F0gPDDqGXkYDwujPI8855NWweKcDwHYAOjDcoBOhDgQ7EGrDKTFow84Aoy+zAIAngmSrCWcDuCLdTgprO+Mut3x/LTGhwA/xfaXzjNMe14E8I7DgJh13vmllz2c5XHzxOioO6kvxXh8oJy9X0dU6xVunrnBAIjpa2O/6wW+t5rZXncx2Q+4qxOjLJOiCgy6W4NmCcG3gsrdqISdh6LXjKxcj1ueOwClB4gl4fYoCliVmpceKAW3q0YuJb/KkmxPgaAGeLsJzjsyfr1JX+A+Jz1a/BrrzCjJBSzXqrEdttTet1ev9tZins3l0tohO59HBNPo5iYOFjVG4NCjuqr9pq0vIqQhdl/pwJM6zkD2P6BPIaZqyaCK1C8TKtwQknqukSlXSkEoGVM2QeYJUoJJKKH0QKX84p//gmb6r8p0jvXAUhHH3nqO85vCz9wLODLwaFu60kWQHBB0QE9AOYQeu+dz6ZAx9G9acW9zJWw982xKQpWOwTXDWNxi5Pmx/feiFJH7kb0/pOGO82GKvyxnMO4sHmMfVzI1C1lmxz0WR72mJ//Andkd2xP58G7hcw3EnVps17HJPl6mzZXi+gi5W8K7EwckSvFiS/prmTjfNFL7qTXSeeW/3K4+ZFWjJ+KfcZ+TmxQlMPAch24RJ6YlaWIdC9IZBcN1waxt5MAdPl9D5MsVbg5fL8HIF8VYgb6M15SXsboDdDai7DnXW71YUnnL60cUidb6A0T+ZpY5m0MEk+jUJNseEQ02C5qr5b+X+hU1hu8CLI3WeRex55DiJWNPU02ZCO1+sFNyuGLUk9+qCliJRTccEqGQCWhqJyUgFih9FtE+8p1+5Jm9++C8Q3ovIZx55zyP/eegzD7znkPfCrQUs3GkHZCfEFiBbmACZ9N8qEtmBUKuMD1ksBJsh+F9PBleKvm2Mby7GznZaS3i+lMhfssiQjLn2ZZmdDznBBRdBu8NirhW3OuC80O+8wO+0yG8ny2m/2H801aMrJ2prrBpccKAsEMQul8uhzpew7FwsguN58e7E7kBlf2ZUc7zDzAfGykf7zVSr38Wu5VEWrOefXbsunh+CV2cw6icwKT9Tj+xSjlqlxW2bBxQOtbUQhxx4vowuVtHlGpagyzV4ucrpqeK2V8OOetheJ+6smyjJIE+XqLMFdLZInS1QJ3Po9ww6mAY748LR1pv2mtlv38wtPD1q58J/Qc8lCXsOuswj+iRiTaGnTYRWnlg5qE0xmiP/+lIlWaKaAdUykWomUEkHmIA0TIBqMt8w/9zg5Y/geYnvEvJboHwXyPxdYem+JGBe6o1DI5kPwOhjNwBk3hh2yIygVeaW/8cEid2AbLXgsTicEDQC2ASlub0lq8MBewuuUBpIHnsQ446Xg56tn3SIJXcwy75pYczn2l189z4t9j8v9Dou9N7KcD4r8eKW+J19DVrI8ulJDxirTj2Y6ZD8mgZH88T+DJfTP1df3JWZ2PgmosDbaC2DvZpsu5Fsv5Zse5Brt1fg3Pjcyj32g1PrafSONPEcxB4Ai2quelSXauy64rNfalGT2h55qVlfuFvz5AkHcVcgNjXs/Enu2u5w/VVv/UHt18UfhcK9GXixhLf/+RI6m4fHs+hoFuyMi8ZaLjprv37KfMJM+a9tasDg78gD5L4kZc8jl3nckpQRIH2cK1QOalKI5si/4akki9SyoPpnqJYJVDMgJiAdKCeJ1VNFOtnnOs+rwxdEvhzku4j8l0D5oajuWBq0IPVaoDxvnXAnBJgDjDtsx/0v0CHLhJcQ0Quk2DNjFSL/LEpWGpK1hduBOL/q5Vgj/eq3DxIFSJeZ5LjTSK75ThMDrLhIhpl73xx+lrlelnodfwn4/cX7sMhnJ4t9UuRx8cWHW+J9WeJzURrQ+9ptKutV95uIztfh7S9Del6FL6e/Osj9sJ7+qivWajvNYSXZejPNYTPF7rTIYSHFeuAfB+/gKHbbRSBH8OIExh5C68Yrtdh+tWfrSi8PVeM594PHVcJHrQKzy8uqLnfmiZMldL4Kcdy5Crgr6GQZHC6RxxxwsYznYs4X4dkCzgMOp4m1oauR9s78XFevd6puP/7jMfQvx7zw2avofeCxSLjMI9cFyJiELBwFSR/nCGiBjQrRS3JveCopYo3PSD0bqWUCtUwkIwAqJYnVUiVamVztF40h41xfDuXHoXw5KGgBBi6SPguYD+wVGhbudEIsQTI/jGSBP9mOQDcgVhHZC/+4X+yHsQWQLZgA3I5vgrD2aOtd4tPVSQ8kDgGnHmDKUTzpXftKhzvMhssugg76Up7dWV3wcLJD63vr7jTbzlSLgUy73mS7ujirnhes2SS3/S9Bg+98iOavoKGUqPtC1n2R1n0hq4uIyvydnA89cVZbabarH203Ux23MuzOiu2nPljMpDFd2Sz3Fq7PlPD5MYw9grbtNxovxtWfr9MSj9US1u8FT92N2paLWNKMHDf2z/uUXrQw2iM+5oCTBVwBPOOA8yUM/cUiOuOg4zlwOCNZHzseaiz/55WL50sNl5J7XgP/9p76j9fEfdeiSI4g6gB4ckjXpT8WIAtDJVo5fEX/Ovkozi0BD7ORRjZSz4ZqmZCWhsMh5WSxSopYM42r98+AZ8eW/zIKWEYBHOS/hGnwXaJ8lyifJeTdiAnA4oOlH5Jt8DYSBS34gWyTIY7TYwq0UDgSbUZ4QLEZkk2k9FVpSt03Fu93IBIHEetMcooxX2Y9mGkMlt3IWfZZnVNPimPDR7PdXk/Rpi+x50Xu+W510K9nw8dyowQdFec1BRMpUfNZL0FjCWwoQQ0lsL4ENpSS9SWS2i+XFTldcfYrac6cZLv1NNvtTJvTL1ZjH54uZDDcHCycK366jwriD0HsIbTvvtH5wFGNW6C9PlF/uXMvePp+9O69iFW5mJ0HkStq0QsPvb8zglNS0vNGuxt429PEwTy5N0f8muWtji60VVQnPS+KCSgP94vyCFXxaPiP9+R/fWb/8pn7y2tc2bc8el0U8YvwXsYEOM8hxiTFnEAWjRKtz1eKfrXyUZwHr7kqKeKHOehhLtLIBmrYD2MhoiWLVZJEGimXppnLzMop/2UYsIwCl6kAbAdUwDLlu4R8lpBP4/ydDgjaACbgT+FBlhg3Y8eLS0CyzIu8zQNk41lkMwRNJFF/vJfw0n6XEwiJCOmJu2TeQTzlWf/i0WEbA6y6CPuYvSmmY98Y0g0feOACTvFkOeS6r3VZiFYCF3+ESCea4WgrOdgA+uvI1u9E01fQWEo2fhW3fCW7qtFwK9FZvVbwrsjf6o29XEWYfusz/eVs2+EPtrMpdoEMC7ucKac+fuQuGbMP6INCvbRt1dhJ5den6om7fwdP3Y/+eS9iUz5690Hk2v3o3fuRG/KRq8oREyq+9VrM1z4O9q89Wc/Zdu/dGVXPwpbzM05L8k7yM9ODolQ9qv/rM/0f77m/fBb+9prQCPsRsyMO25N6rQDXReQ8i50wcxw+bRBrf+bJeVcrRC3Lv76kJYse5aGHeUgjB6hlkSrpJPYEKVLaJ4FaMte65Ld1Xrf/CgxakRGwjPyWkf+tKWAC5u604ywM+942CFqw5uBQB6fEFF632x9bACL+FOwgaCdFL3Nf9bd6EcJoIAwVrrGkM06zX6x6kwykS17knNNaufV8rQOx64EOnNCpi/SIiW7ckcCTt+W2N+TAm/dfaogRz7fBxW642IdmOuFoMxhpRGPtcLINjLcQk42n7XmcsvDlMqeTDlf+oMdFh+tsvuWPOL3JTw7hTlYWH7rsWy4DlqWxB4A9LtZO3dF4NqH89lTt1d7doKn70b/uR24/wHawdi9m927UlmLMyj3/nr88mmmOKTXvP55/Lzr/Xnhensf7mnNVmnf1JZdXmj+UmqKs6/4fq4y7rm33fab+6zOi86whdk8S9lPiswLdFpHTLHbC9DFo3iDSzb564PVDHhPAoyUJtYqQZh56mAvUP5Oq6aRqOlJNI5U+ClSTuDblvKcZLQGrMHiVClyBASt/CAjAXuF/BLQBnAHcloBaMA1/KqO32x+7X/zLnzNJzaS0dKI7J9uRz4uDRIxg11m6xOQNerS/MVyrtIErHmedjhPlTyVH/vDMg/zJku6zEM8d34Ei8QA33scL9NMZJn85YLnNf7Xl/eVUDbHUAThdxHzreX/JSuWrpbKwxVKXvUbm9RBLOEEnl5yJJbp0xkEyQr/sdK6N1wt0snn6st2h6dJ1WBC3T3otAu3MXw/jx2ivz1Rf/bwbPHUvZv9e1JZ89I582Oxdz55/0cv/Y5uuaPWC7RSan5A4nvL2CkOfe1mawy3J5pYXiBuqQFuDsKmi83PmI1Ofu4yv/7Yu/JfVZ6PXdfEH0rCfUu/VPwQwZQSY1GIC7nl8k4tcln9zpZwk0i6GjwqgZh5U/0yqZQAZAYD2UaiWzDMu4lqmNQevksGrVNAKCljB+oO1SOYVfBtnsQXI9r5McyAuxmGtl3224I2PA1Cci1GgBZDNJNF0spv4gX7Ne42IBOFvb+kqWzzp3PPRaCbHRjLvIxx1Gi4yufkZAMVBkgOPs2k7TIDAG99AI/VGEk/I9+btsH5P296suApWAnY6PVZr3ThVLks/nH+1utyM+VyPsHhjdsJFOrntBI5c0IUrunRHlx7g0F0yRT/vcGJbPzaIanRu59t38CJ3SZ8V0qj4VDVmgPb2VC1x9++gqbuxe3KBw/+2L7rLLJP3alWPGFcOamcEpP2uq7ipLePXfRPUfedXfRXVVUhbqmBnPexolHY0XrQ3Z3wo0vCvux+9/FfghFrUuFX2UMIxGbZLeK9AtyXEmkasCcQcRSbVoic5l3ddyx5EcBTfygj4Ah4XgccF6JYAlTSomgaVPwk10vi6OedmKS1ha9LgNRS8ioKwHVBBMgL8Ocivae5O6/93+/+pNuNYU+ZyZSWg20xYJlAdwstXmb7nZ68hiBWfB4o3PcTT9OVvDlPpDr/qHcg5j9Uq681RNhCHQZHfzrDD0QiDPHVDEm9K4oOkvpTEW0aDN7zxvv7pdLbkcDzt8Hvc7njC7mTc/mzK4XzOXrTtDE/dMejXrujGneJ7IgG+SwVeeoqX6aIhx9fhFroRzcw6rm3ztf+8OGCdsPhxSYsaUH97ovpy+++gyfs+PffdalQSNpRfHSv/c63yz/ndqBmDF32NRaXCrgayvR601+HVUU921gs7Gw87Wss+l7LCv2jGTspFL9+NmFdO+KWSsGpXtpBwAoK3JN5r2AKYk4gxhhgj0LhKpJ/N/cu55F7YotI7nnKSWOsLoV0CtAqwBahmAJV0pJqOaB9FD9OEWp/P9T60Ra2KQzao4FUUvEoFrCLsD1ZRwCryaZy5gxNd8KcE3SKzgP+142UbXxb54GxAJv1vS17u7ryGZLzkMki04yqZYxx3srtem6yX2JLTPlf9TiMVlmJuIAKRJNdzpPLp0YQrPmyNofelpH4U4Sd78KWkPpgSkS/i+6MrX8j1glxPeO6GLnE/BzuMGzd8bpLvRQk8ocATXHnyNx3AnrN4ivEmysLs7Zhx7ppl0xV7TBi4Qdg0C1TihtXfH6q/2Lzv1aAY0EF7+1spSaCcJFRIFdFSb+Seb2oknxm/Hnr1oaSr+Otue/Ov9tbF+vqKvLK49+VmkdXaLzkqL7flEjaUX+wpJ54ovD5RfrbEbtp6dgYCtyTeq8B1ATqOIcYYxRiGhhVCvc+n/2YV/h22hAlIFj3+ItUpI7WKcCQqy8WgahqifRJrpAkfZ19q/9MVzbkJ2kKhm1TQOoY+eJ0KXEOBa8i3aVZGwK3u44YwFp9G2QAWDjfxeKhMhTD64ozWoqmZF4B8Lr0OEe46E8vsmwG3lkT9g2/O5y1O0hHnmW/mF5u+kAwHYn/poW//d3PJaQiSBGDcCX+KCKDIQBkNARThj275wPcC+VESH0rsgwSeiO+B+PhcPHXjhq7dKYEXusF7n7/qSO4y0RlbNOuQGGlpkbKk+2HCpvHapv06cJ207xFqvJhQS9xVi53+m1mkkrirnCxUShXR0iS0DKlqFqmeylf557d2rsA4l2uUtKYb36sX06n9bFjv05bmhyOlN/tKb36pfzhVS76iJfNVkoVKH69oCXN+QyfPzkHAhsh7lXRdgPQRmQUMwydfBU/S9v/NLPw7jKP05lIpSfyoWPrkG6n95X+pAO4NQFqSRC1Z+DiLq5c8EjJ6FLqJwjapkHUUuEYFrVNYi9aQb+PMHVzyxCj/bwgF94Tx19uSA/4dx6lEyXRXR280IN8R/EjBrgu5wr4ZYrW/Mfr5hX1Sa0eOuF72OC+02JI3ARQMJW/857vteLuBUBSAcScCkNQPkYGIDEJkAEUEISKIIoPwrUxEIF5Sf9l5edzPoQReFN+TuvG8PR1PnLmeL9KvFuzRGZs6Z4sW6M8ibczS1rUSR1g9IoPKE89FKXNcrPXPolLckkJAl2r4sOKbU5UMsXKGhJYlUS0UP/pG6FYShhUSrbxzrZzTR1lHmhmHD1N+qSftqX7cV/t0rJlx9TCT//Cz5GEOqZ5D0DKFj/JEqgnjkQs3CWfAf03ktQJc5pD9IEUfRYwhqF3M1/m09R9W6f3wFYU3FwpJosfFhF4loV0CH+ZAjSyokor7M6rJUpVPQs1Mrnn+mkcrJ2SHwgdj1zH6AavYDv5YAI5/ZFH/bfQp6/3CJvinCdwCYAsga36tltVGEsQ/pCj+ZtcFrLnc9DPb3hptF7MvaxzJEVdxP3Om3EK474eIQEQE/Vx025nzBPwAhPGVcfAH/SAKhFAgGIFgCoQgMkhGgP8fAvC1EHghvhe69kQ8N3jqfslhb7SYgn0XxGWhE2fBjGNUON0gbUvz5ahLp9C87tKh/8ZtBuhkbCmFDcl51tLiVpXfXyon81SyhDqVUrMOZN6LnvYjsz5k3o3MO5BlO7JsRVbN6GkjNG1ARvXQsB7pV0PdSqBdSehVkQa10Lia1HozFrsuTDghfZeFXhzAnkX2AxRjlHLsR48LhQ9fL/zt/O1BxJpi4plikvhxMTCsInRLgWYu1MiEuD2QClVTCOUPfI00rnX5b0bZUPgWCttCIZtU0AYKxBJEBa4j36aZO62AxD7gdu7zD+632/92NBq08y8yy+Mk4jRAvLr+5QG23fgDjLpEvbnPDF41U9TrRI4yD5sd13vp8NoPkQHETdB4ux1xHYzwTg+k8GYP/gM6DKNAOAXCZAs/IDJYRkMAJQ2gJP6U2BfhWzu80JUbPGcL1lnL9RYSDhuds9EFCx6xBFP0oBC2fsYvrfcLjnXHVs08qza+ywyhX3SqFtwu71mjGLut8olLe/tLu0xg2YcsR5DlGLIeRRYjyHIYWeAbRqmnXZRFNzLthma9EBMzgCwGkeUgZTFAmXYho3ZkXE/ofxp+viOOOyD8VoQeHOA0hez6EX0U2ffCx/lC1WcTf7Mr5SJXFV4eK34SPyoCRnVAt5x8lA/VM6BqKvYBOBX450Y9lWdZfu6Q3xm+BcI2bgnAniBo/ZaAaSxBLZgA1IyXbCIRkY2QxKO4JOwgxanVH6+u0iH58fKXL7nnLhhltKRYtr+zvqphX7c4oCm2oIc1VW5OHPoiiQ8U+m5NMk63AoA4CJF4j8sgDsGIQxn0MIKCEZgGMowCoX8WEYgl6PbOFOwMvNC5CzxwOui33W22gXtOiOuMzphwnymccvYM9H6ScWiU9fNp4YpN67Vp7ZXzBGFUfqkRUKng3agQ/0sz6Vw+fvppm8RmElpPIdspZDeFrGQ0WA0hq0FkNYAshpHNFGU7TdnOIJsZ/GA1SVmOUSadyKQVmdaIzDNGXvwUxe1JAlcl7kuQOY5se/FZSZtOUjNPpBTZe9e1Ti5iRf7FkdIn8aNCYFxH6lWQjwuBRiZUTcOpgEoaqfyer57GNy4+s89oidwkIjap0C0sQcEbMjewgXwbp++0AYAJkCXAeAYd4tk37AAgbAVEyUwHZ/09JD9xD4Kl+x6iWafaT8aN6W7HP9xuGulgzJkYY2xV2RyNusBLTyT2IHk+c10OxHUIIsMRiJTt9wiKjKCADHQQRpHhsiVDnwyh8ArGVoI5kFmA0AtducNDFth2nvtmIF1wRafOFNeJOmOQO3TBlCvdL1g369S44Ew/ZYLeL9b/fu7QJzGv5ct5FquGDzyIP1B/uan0bNRhDDjMI4cFvBwXkMMccpil7KYxGXZTyGEWOcwj+3nKbp6ym0X2s5TNLGU9QZl1YQJMqgWWOcOvDiRRO6KADeCxBBkjlIwAaNVKPMoTPQhsvuve8iB8RS5hn5Yk0SwkjWtJo2pSq5jU+AzUMiHuz6STyv/w1VP5eoWXFkkt8ZvS8C0Yto2NIHgDhcqWf+MUJgAbgYwAPHUrO5rRBEAzAO3XJ9Wd8STx6fo8XrDvIV52bvhs+SPLk1PKvql3kg46wWknfjdz9ps5uY/jRSR0Fx95c/qY6CYQCQOQKJCShCBpGEWEYw7IcBkNsgf8HCYzkRCKCKGkgXjJCKD4XujcFe7SJUtOK5WmYJONuC7owgmdMsl1R96Yu7lfgs7nC6Minta7UWav0KqJb1p3ZV4nVAqufxgzrvD8WC6g16x403EG0BcRg4MYHEq2EJOD6EuQvgSZ+Ef8u+MS5biIHBcph3mZKUwgsw5k3IDMvvNsC0de/5ZEbgkDNqDHInLoR3a9iDkGzerFj3NFd71+3PXsfBC+LPfsl2qS5FEBaVhDmNaTOiV/CFDNgKrpgPZBoPrx6knB9ZO3LS/WheFbMEIWjIZtUrKFMAEyB4BPIzVj9GEDDkMx+m2kpLA7VyD4IBa+5O15STZdOkqdUt+5LVb5Xze4C7tZcJpNjDIWSs3PZ9wR1xMJ3BHfXbjjsd5uCX66wGMP6twbXfmhG38kCESiYCQJpqSh+Io+IowiQzHueOMH/28FURJ/PFdx7QEPnNAm/XKYsddsAfedqUtXdO6ETlgkx2Gvi20clqmTyzMsuVF9NujaJ7LrEJtW8UxrhRrPBtXDh5SeH98PbGEPCRkLiMVBzGWKtUI5rVDMFcRaRaxV/JW1jFgrlIwDxFhCjEXkuEDZziKbCfS0A+qXi02KfjuUTSQeE+FbooBN6LGA7HqhXR9kjiHjGrFWrvA/zl/uevfdD1u+H7enniJ6XEgY1hDmTaRuKamZg2ui2AjSgUqKWOHtpVaeQPfjUPzMafg2ithC4X/Qp8I3UUDT5B8LwOovOxPZiMv9uNrc9HttYv45JBO4+37kvs9wJf3VS5/FtmeHFc6CViaYZsNp5mkbY7bShtz3kgXvLtS12w3HZavVCs47wlUm3GSAbRbYcwYHLuC3Bzz1gOdeiOuLrjEllDiQkgQiiT8l/Z8R4PuF3NC5K9ikwxXW6g9T/rgzOnahuC7olIV+s4gFu/EKR/NXjboF14ZfBWrPJlnNh/Y9fJMK3tNakda7eeXgXtqzLcP0WccJgrmIGEvQaRlfzSrjADmtUk6rMvSXZZQsU8xlbBZMbATIbg5ZTSCzTqSVf2OQuubRyHl2KAnbEvpvAPc5ZNuFCaCPQP3vYp3sq/9j//me7+C9sOW7sT/VkwWPC6TGdeTTZqleOXiUi1tjapmIlg5U0wj515ean2/M89a9G+cidmDkNgrfRJHbKGKTithEgU2Td9oBaAewDVsA3vv4ZAAJW0lpRV8mIXkhvg4mTrx3+10/JEU2V75aKWXyW5yIMRc0wxIOOg3lGotW/BAPX3ZF3bDRBZs/x95oskJzdGqJgTgMtMxAKwy0JlsbTLjJgpvOcNsJ7bmgY3fE9UbXPojvA/nef0JPrie5zUAcB7DgtFhiRCyw0akbdeGKjp3QgbNk2q4izcouc87wK9+gTPj4/aZF3gxrWGr49cKqTqqXviPv23o/ZITRcuo4CxiYAOS8jJxWEEtmB7d7n7ks+3oLvUyLmEt/nITVODLtQI8yzx+9GAke+RW7Lw1Z5/utky7T0LoD2A1Ah2GgXSrQyzr/vyyS7voO3wvl3Iv7pfrx6lG+1KwBWrZJDSrJR/m3FoAbk6pppMLbK430a5vvXLu87thtgKHfpiK2UMQWJiCoeepOBwnaSewGbj0wPhpGgubTzcnpCCiJIC/8rpZcsjKCPpemb7WEn9c4iXvYcIYNxllrFXbrbUxw5EUJvKlrV3TFhkdOohk2p8oMzdHRAp1adEQcOlpjYgJWGWiVhVZYaJlFLeNPtMwCa0yw4wz3XeCRKzx0AXtOxJojXHBE8w6iMdZyqRnJcUJnrtS5K/XbGew5SaYdP76wsys+NPsh1C0V6iTt675tZwyJzKuvTCtvTHJ/y3s1KIZ3s4bFdnMYU+YiYi5RrCXktIzliMXB25/JoZjLWH+YHMRaovDvi4i+gOxmcKhq2orUPh0phzXEcy5j9iRBqzc+q4A1Aazbod0gdBiEmvl87Q+b//fT5Hv+Y3dDl+7FH9DeX2jmSswaoE0nYVwjfVwA1DNI9UxMgEoaqfDuSiPt2qz8yjy5MXabjNpBkTuYg0gZDcEtsigIO2FZl7ERo4+3f/lAnkSQAG4CRVtuTSXsL319zcWRP0sdBe3OYNIVTrJ4Pa59OabSbR8k9EN8D+rKBXGdwRZTMuU+UmQMZxjYCBbomIBV0zPo+wAAIABJREFUBtxkoR1ntMOGW05og4VWmYjDopaY1BITLTLhoiNadICLDmjeAc3ZUXMOaIbO66BvfntKrjmjM1ckI4DcZommWBEx3k9LLy1qhTrFYp3kE5XQ786DQod2iVHpuWkBV8GzWvvlwNNOse04cphGjjPIcQ4v+zkcBdHncEREn0f0BQqvRcpxAUNPn0e208hygjIfRLrVUP3Dkbx3UcKWIOanOGid77UCGKPQqh3ZDQLbXqCZy1eJHvyPfc69wMl7IUv344+U3p5p5oifYgJI80apVjEhkyCAq3JpQOmDQDWZZ1hyrfu6MXFdErONorAnoCK38ENw8+SddpJol23/elkVqAnCLjG3azgWiCPAWcB6h0tuQ1lyUfreD7/rBmfpoCucZouHWX2fTS/mfeBVAJL4oWs3iueCjp3IRYfDdvpmsx1YZYAVOlxyRBhcOuI4wlU6WmeiTSdqy5naZKN1Z2wNS3S0yKQWGdQ8Hc3TqXk6NeeIZhzIIbvtcovTFga54YzOXdC5C3XkRGwwryc8GWGxpmUCyyaxdpFEK/nsb9d8l74beq/UuPTEqOhK2ada//2wQa1EtxYYNZMmHaR5L7AYABaDwGoYWI5AixFoPQqtxqD1KLIehdZj0GoUWo1AyyFk2guN25BGEUF7c6Aa+CV+Wxy+xQ/dknguQYcBaNUmu7OpXfo4ly/nX3eP9fVe0PTd0OX78UdyiaeaOWKzBtKqg7RqJ3RKCbUsUgMPSeCKkGqyROkfrsEXgc777rip4+gdFL1LRWELuCVg6k4nIDsgbAI4/sFJACArON1Hx8/BdSB33qOsKKR682dlivfhN0dhhzOacpeOsRe/2y+3M4gzP0SG4urNlRu6cIHbDmCGMVZkIpnHYoIuPNCJK/zlDHed4BYLbjDx3l9nwhU6WGJADpNaYaEVJ7QgQ3+Oji1mjo6m6WDEXtprP52pL+h3BTsu6JwNT9noyIlYoa+2upvF5hl/F1q3inUKycefTu67lTCq9uy7xSalpwZFN8q+1bovBw2+izRL4eOv4FE5ePwdaFWQj38ArR/gcSV49APersc/4J9VCR9XwscV8NF3qFEClDPFii/3niTUR+9IwtZvwnaA2zyw7YVWndgBmNSLtHKu5bzK7zlX3A2Yuhuy8iD++P7zk8c5EvM60rKVtO0k9L5JNbLBw89A/bY/nEoqvr3Uyb9+mr/uXjkevQOjdzD0UdsoBluAjIB28L9jwCRoAeLy3mQgfkb8DlhsYhf0Nib/KNn57ndV5yYZcCMnXX42swe/2kh++yFpKC423HiiK1d0xIKrjpJJp+lyU7BMp07dKJ4HrudceaIrL+rSkzr3QKeu8MgF/WKDLWewQgcLjmCWDqfpaJKBJuloHC9i2IEcpIs66SOpJuJBNtrH1KIzNjpyJpcYlZn2tpnjRpUi63aJbhHU+nR037PaKn/OtltkXHqqX8yn+dfTwjpMKm8efYHqBaR6EVAtItW/AI0SqP4FqhUDjS9IvRhpFEN1/ADVi5FaMf5FrQiqFpC0XFI5VST/bNskqS9qhwhZ54duQ9dZaNUFrbuRwxDQq+DrZPPuuZbdda645z9xL2xVLv73vRdnmllCk1rCsp206ZIYVhGauVA9C6jjCQmsQgrvrjSzuHY/uJZpjTG7ROQOit6hondQ5BYKaZ260wHINgCaAJ61aoSwS8pr6Ysi+aHns561pUEtZxdFGZGHX+nCVlcw4XHW696Z/1R8FASlYYgIwXWbG0904Qq2sNqcdjr+6rCFWwx06YGu3KkrD+rGB/G9Kb43deMlI8MD/9OFBzp1QweucMuJXHCUjtsTQ/Zg0JEccASDDKLPgdfqVPdMVzzkhH674yzs3AUdOIsXnOITnOzKflvUC61aCN1C+PD1upx/h8GbXod2sfGXU4NivrJ/o3xgl1nh3sMCQj2fVMsnVW5XAaGST6gWkGoFhFoBqV4AVPNJ1QKomg/VCpBqAVLJg0rZUuUsQumTQD5u/WnWWOQ2EbR6E7gOnaegRQdp2wftB4FWyY1e2v7fnjX/da7822/8ftiaXNyh3EuueuqN8Q+JVRtp1y01qZE+KoBqmTgjw+MRaUD5H+HDNJ5ZGdfwTc3zTemt+ERvYyEKbsFhKJ5GacaTbnjgp+bn9PJKBHEestHh8rW9uGx5oT/b4+K7s7DT9WrIq6voqeggCInDgSQIif1w+M9zg78YcM0BTNlzvptKF9jwwAXxPNG1B3Xlga690I03deND3XhhDq490Y0X5kPgTfF9qGtvTMaRG1hhSUcdpH32cIgp6XSYz7NpeK4tGWOgU3fEZaNzZ7jnzJ/zcYqOtai8tm4Rm9aTukVQJXZGNWpGI7qF0S0xKDkz/MJXDmikRU4/Suh7mC9UyyNVc0mVXFIlD+DPXFI1j1TJJWg50tuvNPwjUMmBtGygnE0qZxGKaRL5dzzFWI5N8Wz4Dhm4eu27Cpjj0KId2A+Sdv3kwwK++vNxxdCR/7Kr/vYbvR++Jhe3/yCRp5Jypf9dYtVF2PfhbOBxEaGaSapnYz9MSwW0ZJHKJ65BCVf3Tcvz5auoXRSzQ0VtUdgJt/4vCpKhj+8mKOovEl8l8tZ9+0qZVdsr6TXFu2Vegno3QZ9PV6HF9X4QEIZCSQgU+SGRD3XlAY+c4IY9WnQkRhizX82IJRY686SuvNG1N8b32ov6/xEg+wULF98L3Zb+r9zRoQuYZxD9DqCPwW9jlYc+nsq2kE4x0Jk7umCjMza5zZxodLf7UGNZJ7XrFBtWk3oFUqWwflrUkpxvjWuXRL/4zLCYr+grG5byrnmSc6GeC1RzMfS3uKvmETL0CVoOoZwtVc4mlHNI2TOGHqOfScgnCeVfc1WeLdC/L0bskn5LPE8OScf3JQLHYdKyQ/ooX6AY0kqLW/mLXfW379CDiE25mF/yiVdK//D0v0psOoBdF2HXQWp9kahlAXVZUQgTkCKVf3OuV8CzyJv3b5yP2sEE4HBoCwW1yGpBLbK9Xw9gCykt604ir+O2+lhNFWGNNzf5xa/3y1jXjV69eVYX2yGkrMoGRX6U2A/3Dk9dsPhwHIkRm5NmxmyZBVhjIa43de0j2/U+GH2+D97yfC+MODYCGSV8L9xuxF/dIc8NHbmSc47SXjvQS98pd6wI0dmpdCAWGOjCHV24UKdsYpP9zxtHZsWvpw0ix37SoBLo594oBHUpxWzc96ljt17pF58bF13Le9c+iFx74NOq82FNPZtQyyWxHWBnQD4sQRpfoHoRwLqE7YCg5ZCYhtu9nyGRTxPLJ4vk3nJVn82wG9YjdkmfRZ7LPGE3AKy7gMMIMG0S6Rfw73rXKsZt/de15m/fwQeRm/ejdxRe8eTe8vRKJNadpE0PYdst1f0mVc/BbuB2TouWKlV4x9PMvGDVX9hlN8Vsg+gtGIsJgEGt03eaAYmDH9nh9zYgqe16TpxFrjQ61ranNwqEZZmBJ9/Yg9mWh8uBUl4grm4SoUjkj8WH6wp26GiVDqccJf2snmSDy35XsOqELjzRlYwAvi9GH6vNn4UVSWYZMrOQKdKlG7pwhbsscpoh7aFLuhhtL/T6X5px25ykyyx04QbPndER+3rZzy0+1rL60qpdbNtN6pcD3aRtxaBexZhteb9mRvVvg8Jzk4JLOfeqe2HLCsFjmi+nHuaJtb4CnQqo/QPqVEHtavD4Bw51NMuA+hdStRCo5AFaLknLAcqfScUMQj5NKp8iefCGqxY34d35M3KH8Jq7cp4hbXqgbS+gD5OGVWL9z8f/9WyUTzi4515316dPLnLzXtSOfOKl/Dv+41y+RQtp1wNtu6TGVVLNPKCWScoIgLQUoPT+5mEq1/L7xdPk+oRNnA3E7VAxtxaAJ91uMwCE2oGwqTtC9Ct8vZ71rf9bzcVZ+Sub6Xy7rQk/3GonwyARjKQh+CzYlQfcd4JrDDDnSAw4TuZa7LWzwZqbeJmO3zJy7I0usQqhG28s+rfqjyMiT+pGpktXXtSVJ3YSXDd06grW6cS4A9HreN3u8iNYa+i1gWjQjdh0wjMpZ2x0wO6rYTtnNVs3S217JBZtwKAM0KIGNOI4SjG7SkHtNl+39QrOTHJO77nX3I9YVwydevRq7Ml3YNCADFvwu1+MOvFbp4w6oUE7NGxFek1Qtw5pVULNMqhWiIMfpc+EYjohnyKVe3uhEjsWMHwcsiH2WeKzJkmbbmQ/AO0HCd2vfM1X8/IhIwovT+/7tNz16X4QuX4velfu5YXCO6FG1o1prdS2D9r1EWaNhGY+npJTyyJp6aQy7g+LaB/OjUt5ZqntzxcvYnZR7A4OQ4NaJu/gOqjsXo5GArYTgtbu0N/TbgdtvqUTLV/WV0peW6/0e4gvAhAZicgQCoQDgT9WkmNXuM5A847kCJ3z1W6+gkGuuaETd3TuRuw5i9cY0i0W/O2GLjwonifF80Rcd+rSHcPN86BuF9ed4rrhRPeADTgM6bAD0eOwkG/bk2A29t5IOupBbrugc9wXI/Y8Et+yXRp+W7RKHQelJvXAuPiGFtSpHLepGLOjEtJpUbSum3+mn7R1z6vlQfiGUuiU5qtRozqIuys9yKQXmfRB4wFo0o+MepFxD2XYiQza0JNm9KSJ0qmjHlUgtWLsihVSpfJvLpRjhkOnLgM41wHLEsYYadsLHYegTY9Uu+BKKbRDOW5d/jX3vn/nXa/2e2HL96P35BJOFd4JVVNvDL6J7PuwuVh1gEdFhFomqZ4FaOl4WlQlVSr/+ky/4IpZtelVNRK3DeN2MAeBzRN3mgDOwhoBaICogxC0dYb+GnQ+7worm2rPG6jva3CSXIYiEEOBUESGIUkYlG1buM2Ci0wwRt+tZ46X2BHb3ujCG126UTw24rmiS0/ytws8ciEPnMgDZ/TblTp2Qyeu6NQFnbnBUxd47AJ/s+GBE9xlwg0GmHWQDtgTPfTm5zor6fTJJBPpmAv86YYJOHM9XPDz/CfJtvHGplvqMACMqoBO0qZy8ADt2S+l2J+0oFaLL5vauWeaCePKocNyEZuKIeOPXo8b1QHTTmTai4z7KZMBZDyATPsp0z7Mh3E3Muqk9NspvTbqSTOlXUc9LIdKuaRiGiH35lw5ZjB87tpnkee/QtJHoX0/ZAyDp80ig/xLOZ86pYQDxbc3iqGDd90a/w5alIv9JRd/rPBWoPSJr/9VatdJ2vQB6y6oU0aof8Z+mJYh4yBVqvSWp5l+5tx8ZZPVkLAtjdtGsdt/CLjNAFADQG2EsLEt/Fcf86orrPD7u8YmDxEvHME4hKJw1E+Gw2uMMjxwxpXOKSav36W/4CnY80U8HySUtRLxQIMHHiq5csMJ2qU7uHAjjpyIfRa5xwR7THKXDnYcwRaD3KCTqw6Q4wDm7MkxB2mfnaSbWROpvZVhP5fxlBh1QXue8MyNPPYozLEPaeTYtwOHAalVF2FUJlIM6dB4tkJ7cawcs6vgW2NTeaidc6IS2q4avfQgfE0hcEDr/bRhDTDrQKY9yLSHMpNxYDKATPqhcT+SvQePMuxChh2UXgvSbUSPq5FqEVTKIOXfXijHDEQvCbzneL4cwnEI2Pfjv2taLzBI3/uvV5NS4oXCO75K7Mxf7Kr/BszKxe49ePZb/hVP+ZNQK19k2UrY9BJWXdDgB/Ewh9TIwmdmaBlAOU1K+yh4mHRuVnZhltL0ev3mDwFNk3caSbIZwHoS1pOohZTWtL04HHXfa/Cv+Gwm4EYhGINQJMKtxDAoDMB7/LcLXHVEswwwTe/JMObORvM3g252gwX7AaIjH+mpF7jyQ4IA3BET+mJWcPAjm7W6cqN4bojrgv3qKRsdO6NDJ/TTiVi0J0btJX22om5WS5zuTqbNUrYVMcJGe57ozJ2/HxLzIcKp6cK6U8ocI80bpIbZR4p+jbRnW7Tnx7TYzQc+3xjN19qZe7SABuWYjQdhnPu+bXpJS4ZV0KyVMuuizHooU6xClGk/ZSIbjzDtQYZdlHE3hQloQzqNSKsGq5ByJin/7kIppi96ReQzx/NYIO0HoUM/6TBAGn4XaCSMKoSNKb6+VnjH10xc/xfr21/+03Ixe3K4HHRBSxI+zBaY1Ers+oBNNzRrhA/zsQqpZZIqOBsg1VKlim9OjUuuGRUrgQ0zz3Zh7C4KaJq400CCehLWkQDflUWCkvaso4WYmjQj/kkUgs8oFEnBcCgNwe804uEMFqw5wnkGnGVcjfokhtk5eobqs2MN2DF2nqFhcSGpST4N5S4bo95X2wGiA2/iwg8KAqBI9hIGkTcl9sZvyRN7USJZC/PCGR5jDiRTduSgo6jLuSNeZyfDmpNvQ4w4oz13cOLeWs2Mqep06JTa90scB0mjMr5yWJdm9IRSwoHSsyPFqDnViDrHVrFe6oaCV61SzM79kMW/XaqM0rcMKqFJEzJuwa+/M+tEZr3IrI8y7aNMeykTmQQZdyGDDixEui1IqxapfUHKWUDhHZcW0xu9JvKeuXSbJe0HoOMwadcj1S24UA7rUIxeVngjVHgvePzu57+ZpX/5TcrF/HwQf/jg+ZnKJ6FqKt/oh9i2l7TuAhYt6FGRVDUDc6CSTqikkaqphNLbS63PF+zWG4ukypc7kvhdFCgjAG//WgI2EKBOCivnBiqKHG6OYxBIQDASwVDcuSVCIW7VupHrDDjHkMy6j9d4+YREavs3yAcMyYfOygXPyPsN3XVrv8uqkHfMUbZ8Zkr3THgZ8D3fbXPcV/DTjzj1gHxfnDyLff83AOGDLYPnhk5cwAqDGLIXdTv1Jer9zLReKbAhRlzgtof4ICjxk2dA15FtF8EYJSybpU8yDuS9ajWerau8PFaI278f3GOWOmFRfa35fFgpeEA5Zvd+8Ox/WCVGWb+1iyTG1ci4kTJspIwaoXELMumAZt3IrJsy6aaMuyjjTmTUTum3ylxxLaVaSKpkQsX3XKXo7ug1idfUpes0sB8EzFHCrFFkkHsi59uoEP9T/q2IlizSfLd/16nsL59h+ZhdTEDCsdIHvtLHa4MyiXUnYdNFWraBJ+Wk2mcp9sNpUnx6MlWqkiRUTz43+35t9KH+7eJF3A4Mapq8U0eAOhLWkrCeAA2E9Mdk9eFutOztR9G42EmGQGkQEgdArjuxziDmnX+PBb+McdB1fq7sU3PPv+Oef4984KBC2KJc1Pa9yE35iDX5kCWlwMG/XWrlvTrl6fnqtgnu/v7FGR6bI/6CHR/i2BN3wUQyn3GDKxno3AVsOROjDpJe9sh7491Mm/ViO2LUCWx6THZ5PiuvYHYI7QekjsOkQemNasyISmivUvxPlVfHCvHbj+IHTHKXdfP31EKbNJ6tK0Wvy4dM3mUVGmYfaxUJHxVJdL9B/R/IoAYa1AH9etKwgTRqBKatyKQdGbdTRq2UfgulV4c0y6FqAVDOgorvL2nRPbHrUvdJLnsK4Ft9BgmTKsmT5PV7fp0Kz0/k3wsf5hHq74/uuXz9t2ffvYgNLEEJpwpvrpU/iR4XCixapDY9wKoLGlYDjRxCPYNUTZOqppG0VEI9jVR8e2b49ZpZvetS0P5sB+B+QC0BqwlYQ8BaKewSXTV1RwFxJAKRUBoOJcFQGowkQZDrRWy4CBZ8q4o8bfxiDBJHn2ScGXyRGJSRusVi9Q/78mET8mFTCvH78gknCs9/yYeNPXp7qBC/pRS5pBDBkfcbVHare0h/5R3o/z3HfW/MX7TpSRy54FyB54ENa9uZGGNK+9jTKWY/s2zXimzJYVcpJyA52S164JdDD8kchRYtEq3kwwceVZrP15SfHyg9P5CLmtVJ7GU08XU+zij6N9Ke7cmFLSoH9moE1xkW80zrpGZNpHEdMKwh9arIJz9I/Sqo9wPoVwCdMkLrq1SrjHj0Var2RaJWKFHJI5SyCaUsQunjtXJUV9ya1HXswnkC0IegXa/E6JtILaZPPnxGPvFSKU2sVQIffjp/4Pr9L6+ev4M58vEHcs9P5F5dKn8QPcwWmtZIbLuBdQc0b4SPCgnVdKl6OtYflRRCJZ1Uen+tk33h0HCj/6zo3YYkSEYAqCZAtRTWkKDu98bsfAiUBkJphJTvj3VfEoAufSUbbiu9AYHRIRZvevRyLp+UQv0KpFeN9GuRfg3Sq4BauTdyYVMK/mNycXtyYXNqcZz7kRzFqHn1hHVa7KJK9IxyxJRqxLhq2OijoBYdtw8R8cFtX10vpr1Ea07kTxdynU2Os4gBp6Vsy5+f7VZL7Ihh9k6vb3xRnlPnNX0I0AeAXvGVZsKsckC7cvyWSuKRQsK2SnS/ed6SdcWpgv8PzYRFWvzeg+CJB+wyi88/TWqhaR0yrgEmDcCsBZi3QbMWaNoMjRpJk0Zo2AAN65BeHXxSC3VqgU4N0KqCmt+gWgFQSrqmRXXErUrcxi5YY4A+RNp2E0/yz9Qje+5FrMi/vnpURGiVQY3Uqwfedf/1avsrYEYu7pdcwvGDlxf4lpoUvkmNxKqTtOqAFi1QuxSoZRHqmYRyipiWStDSCNUUscrHU+PvNxafh4IbF0KbRu/USmENgWolsE6Kag435hejrw98rw78gCAQK8+5z82Gf3GOp01sjkn2kX6x1OA7MqxFRg3IoAHp10P9JmjYDPUqiUfJZ8pB/fI+9f91KlTya6KF92vEj6q9nFV/s6jxblntzZL66zm1F9NyIX1ywQPKIQPaYU3W/s8L0rx+D3hJRphgmE0OOS3lWf7Mtl8rspUOuRYkO8f17zj2EawxYF7D1007vudWrfmco/R8XynxSDlm9vHLLuc2gfbrAVpgi9qLPcXoddWgQRXPsqflQpMa0qiGMKmDxnXApB6YNELzZmjWgkxbkHELNG6Fxq3IoAno1QGdalKrknz4lVTOkyimihTeXiiHN8evSF1HLlijwHGAsGqVGmXvyQd0PIjdU/iHr/sd6lQgzQwhLazvP87V//GZVIjZexB/JPfiXPH9jeJHgU6JwLKVsG6Hlm1IvwI+zCHVMghaigifGkvFB5gU33GfFF46t9zoRBc+axy+UyMG1WJQK4Z1Ith1c1la5jLRaLM+yIa8UOmhz9qwf1BcsMXHUcN8vlEZaVQDzZpkb9ZtBqZtyKSeeFIq0Eo/UXm5pBI/9vj1hEHqgmXZuVW11LQGmNYD40b0/y79GlKvUqpXJtLJ4z58t6YQOX0/bFIzctDG71Vznrd4yIXoZy4X2vzMpW98sT/t9YrL/ODcdeU0Cuw7Jbr5XNWoMfWwPlrCLu3lAe35ump0m3MjzyxnScH7m+abbdUX2+oRIzT7d8bu8SZROU8Tvz191/I0fdwif8Os+LdZ+bXpd6FxhVS/QmJQSRhUAb1qUr8G6lSARyWkRgFBy5YqpIoUP/LlX58qhzTGL4tcR7jMUUDvJy0bCI2XYwph4/LPT9UyJXq16EkV0vgs0Xy5+C9m2b+8R+Witx7EH8i9OJN/w1P6JHxUIDCtl1h3Qss2aFqLtIugarpUJVWCz02mEKqpBO2TQD2da1J1Y/hPW2BmxZ0GEayVwGoJaJGIq6Zqjw8TiNNgTrvt/oB7TaGPdXiSadaRfqnUuBpaNOOhAfNmaN6G787Uzrt++HGf9nxa++OCeelv22aJWTMwaYFmbci0DZq1I9MOaNKGSzEGrZR+M9JrQXpN2G50a4BuueRR+rFKAud+xJx85Ky2T2FJcoBowH2p0H43l7FaYFmVQX/Wu0zvlTJHSL2SyydJv+Q8atSfrSg+/6X+alM9tsu18dyhZPGh1zv9oDTryI+BL8I/fXKvKqJPtjmvDbmPN7Dqs2zKkuyT4hxCgl3M3X0fe77Rivph9HFcP21VJ3PfoIBrUCTQyhdp5gk1ciWq2RK1zxLlZIFS4rFSYG3M3I3rMJc5DBx6xE8r+KqR3XIRK3KJF9olwLAJ6dchjWyJxpvt/zqV/dtr6G7oilzcnvzzE/lXl7SPIloa36RaYtFKmDcTFk3oyVeg8VmqnkYo4zhVqppGqKUSCu/On5TwXFoutd0T7/TzyY4bokNI1M72bu8kAmEw+dvrYj7k9Stfs2eNennX+t+AeRMwa0dmLdC8HZrUE9oFNw8//FRPnNHP3rJuFVt2QZNOaNoBzTuheRcw7wDm+CuONMzakHErMmyREdCMP580oSeNSKea0iqHj7O4is+W5WI4D0Jndb2zZ2piRlOtf+UxVoqYnzLiPLovnEeBWRVfL/v0vme9ZswULX5VO35Myy/T61NuXNbb7OKAgS6/g41I0XGI5NiXOHYHJ67wxA2dsNGRK7HpvttkufPDhtfpdNLqUvfBtiLF5VOiY0SsOyMgTNs1XsMr/VFUk9GnlSeZx4+yrjXSBEofecpvThX8qiPHz1yGLhyHSNtOsWH+gWrkwIOYXVrSjW4lNGpFhg3oURHxMOn4LvbDfX8HL8rH7z54diCXeK70ka+QxNcqFWH028DTFmhYibQLSdUUidL7a7UUsWqqVC1dqpIkeJR1+bT2Wjem8M5PAbF7QxxIiOofWTsToZJd351BH7+IANN/Zg1KxQY/wNMOaNYOTduQeSvULRVrZ1zQXszof962bhI//X96+g6vprZ9Xf+UN96795x7lCpFDCUBpKng3vsovTcVsaGAgh0LYqP3jkjvhN577y2EdNJD+lopK2uuN2bc546xRgZCGEN+36/P75sZB9fH8YAhPGAMBIyYboyAgFFIr7w2CK4OElcHwdUB4NWLX+kGXt3ElS7CsxO4twFyC4An4A3gcqXJ+duZXfqO5cNVp/TlBzE3l3JuCUpDOt4GZHUNBo7qQsYwjxKJ09Mx13sdrgllfyUmf/4WNzP1QCHKNMjvm5QJuCIOKOPNeqY4yGk8i8UlkUAcCQThOCMM2wkTj0au1/if9kf1ffaZLf9rtNhzotpvofnmcmfMeGN044+b75/9df9Jwq1H78kP6zzerNq/PLFLanswyojozIAdAAAgAElEQVSclgRPYf+mol4/9qwfzli9OHUu0Xt3mv2pG7jXY655ass73efj+v95b9U2g2mVwbZ+LbLLVl38ipJKUb8OfUCf6XqP6WoH8KjFSfmGSzmI7TvR5VyDU67xcp7RPvvMp05+48fYOY7aoNJj+9vr9Nlh+kh5a25EWFJKQCHL+xfm04b9MQiuDZiXKl0YpVxL+kS//Hbtz1ZtwAAeMAH8x8ymHzX9OQFujIPrQ/hv01+jwhHUuwd4dgD3TrhxdG+DXk9pI9zM1ndpAJdrcIdyE6kAtc04sHq6RcrYyHoUf1oWJKwJzXp4I2NWEDaBeZaLfbPXL/nHpL9NmJt9iChSjerbkISKxAIkDj7aOEKbAIV8qgRCEYeLY3FBgpF3W7AWvtzpP9N0Y7r5z5muW2sjQez1RBX7PiZ5iEvv4ZI7uCgeCBJMrFj9XpRsIZg9ErLSFl2SE/H4VaZ/fNrdzuWIaVnwJHazF3XKmLRL372YJaHUYX69BAzobsKrFScVaC9nrv53WOO/khZs0mnWmWyrlwIbeIkiYpeLXPll8O81BvSbrnUDr0ZAKcedvqMXMk6cv8qd8g1OBfpL3xG3AonPh4FzSiPGYhzvjXZie7Mn00NBD79fLxH4tZi8Ok3XB/Cr/eaM32r0KEOcMpev5Oz4txv8B8CNMQC9ftx0Ywz/9zQIGDE7/gBxtR/49sFE79mOe7QBj3aC0gYorYDcDMhNwK0RuNYD50o4Ijrl652+qqwyjy+k7Fg/OXTJXM17GiMoucWpiXmXmx1GVfzxS3r1zcDj13eOdtKMqoc4kgh0iYQ+Ab6itwkkkUAS4Kegq+KBPMEkvq2g3V7o8p9o+/dMf+DhSqxW9MSoeGxSJsEFIlSfJcDFFJoIkASoP9DAIw1wFmPmasRgzCj9drhmMeR0JGjqZ1xSZWvEhCx0CrvVpbV92G+Twbicq/ZoxPz6gF8/4dNN+HYQpGKd83v6/0TUn78zY5mya5PBsszkWr+V2H1RwyAoR692Gm7049e7gW8r8KgDpAK9Y7bM6tk2KR+9lGdwLsKccmTer4bOyVTyxb5Gw9Y4d2ow8lGuf5nYpxXz7jRd6zVd7YdV17fN6FGOOj2b88rZDGjDrvdB6/tP4DfGwR+T4OYUCBg2W58KrvYRPl3gSifw7ASeHYR7K6A04W6N5qcBuNaYSOVGh1zE/ova8bPC/i3P+vmh1ZNt2zTahSeH5NTR1tdR4tKb8z/C3/XOBbZK/dO+9Q6n61QpJvQeJK//Lee7C+V8aAJU0Zgd3yRO5K1Fjrf8sTgSoeCnYuoUXJ30t8W1iThyG2ox9ffgJ+bq70IFDhzC70IAVInwZEIWhwujTbwIwIs2nUTr14OFE4kP8wrDRqVhs9itFoVVcp/NSy653HClFfeFHSDh3UX4dgFylYn0RXQhpulC4qDlw42LmSyrTI7VK75dttL+m86hUO/VbPDvwa734Ve7gMdP2A45fddYpe9e/sC9nGcgFZpI+TrHh33nFoa70LXhs6XR2ynfrhWe+rSYvLtMXl2mq1Tg24v7dGCUCtTlzYb7u1n/JtS3Ew8YNrv/KPhzAvw1RfgPg+uDUNTg2wu8OnCvduDVTni0AnIjTm7AXatNzhVGxyK9ww/U/hti/wVx/KS4+Ipj8+zAOnXHKm3fJp1ukXJokX5EiS/cLkgQlQTWZ8Vk9B9Fvn5LZ7wxapPhKRD2HwGBIRno7sAHgR6Ny+O1J3dm2v84WL1n1Lww8wQSzdkpERpdlwz0cJQBaDL8J3KXQO4S2ruE5i5QmwkZSnhMBKRx8OhfGINzIwE7CtsJ08zFvv7yMpwqjJw3/tUgsX4waP9eTKkyebUDn76/49unG1AacOc8je2DkX+ENpy/t2Dz7Ng6k2OZybHOkjp809n9MDhX6Pza9QF94Fo34d0CKHWwd3LKFv0redYtVwWPa0pMpNTxczvtNcKRlg9Zede/0b3qMW/YIEPf9+7CfbtxSo3RM1/qcL/1Rv2Zb5vJf4Dwh4UXYnBjzBRgFvRcpcKdl3cn8GoDV1rg/8ylynS5zHgpX2f/DYXuAHkZasf3ItuXJ+a+c9smbcc6g26ZzriQsnf+0a7988PI2BROaYSoIrQq9/Xdj28Fgs8Y8sqEfQfYcwKeg5qVBIYHkM6O3sU1Cbg0XrAaOdERgiq/4PonkCGpv03oEoHujtnN7wLtHUJ7B4r0IS0DUjQI5W1CmQjkCUCeQJwlELJ4II0jJHGQpCSKAfxoyF7dCtXPR/7ISUoY5EfPG/+oFdo/Hrv8TUWpxb07gXcv8IW1DS74rrTgl/K1pLcH/wgst7gzbZWyY53BsshkWb4R2n/RXjQHwZUmw7Uu07Ue4NsJg8C9Gr/0TWX/fN/m6aJLPnK5ACc9nzq3VZ+33NZ448UQpRj1qMO8m/ArLVA16NsBPBtxcqnB9n7/9dyday34tV7o/v5jBDzlGDYFjOJ+fbhfL+HbDXw6gVcr8Kg3kcqMjvk6h+/oRXhfC+LwWWX/XnTx5YlF6qb1k3Xrpxu2GTTbTLpVGs0i/cTyydH5hxtWTw8d7/YmhCXOfr1zVHEv7WHIEb3IhDwEhs84VogbUs1SjkcQA30yTCCaBFwRz1+JXBqMwQw5wPCUMDwk4E/vwVSDmo2ugYQMXJ6An8WDs7/NTUjjCGkcPCWVJRLSBEIST0DTR+OCaIIfDbiROC0c7IRiy2GV2UEPBthRs7obtQKHp5Mu+Si5BvPqJHz6YBfk3QN8egjvDsK53ED6LPxnaPX5uB6L+8s2GQyL50yLF1zbD3LoeXmGy+U671b9tV78Wg/h3Uq41wOXUoPjJ5HV/SnSm93LhUan9MlzY62ND9OzvXOF7mUYpdrk+RNQGoDnL3DlF3CvNJG/iS8m1N+olfu14depkD7vP0oEjMLMc5UKBZ6URpNzudrzp5FciznmIfbftPZftQ6fFfYfRDaZdOv0XZu0bavUXbsXJzYvmDaZLMu04/NpNKu0Y8sntH/d37J8vOv4dMPO/3NScvPz+4XPE+81VD8wqJ9CTzc+Br+1G1DD9ADo7hFoEoHcxhWJyEn8bGeoSd8ADM+BMR0Y0qD2Rn8fllkzDQkoE/H/2BqeVMMD0ThCFAfE5leh+YhUGAuZd7x4wIrHj+Pw/VjjZox8OpTd9Vfd26sPfs2Fjmtu1IsvpU27FugpNSbvLgiATx/w7iV8YCUAbrXGy99VtveH/+tmhWXSnHXqgcUzhkUG0+qN2OEL6vDN6Fig82gw+LVjsBR3Eh6NBKUGv5ynsc2kX0wacM3hkNInzwnk6rB3reQClVupyb0KeNQCj3rg2Ui418KNh3Pmqlt6v+9PzK8TLlKuDUEd4bUhmPf9+qDXk6AuUEP6JriUI3L4rHD8ILTKpFk937dM3bV5TrN9xbJ+xbV9cWr5nH0h3Wz61GPLpzSLJ/vn729YPt69lLZ98a+ipMf9OV8OsnP2U55kS08/4+p4mEngZ80n/60dg66dRGhvA0WcSZC41HxdI3yJGz4CLAsYcwD2iTBmEOh9QpVAKGGGgWldHIuLYuB1H/x4QhgDRLFAkAD48YCfiHPvGk5uyzcjT6dvcYb+zez7g9bpf9Dke9jqzei5we+/edQaffdDbtCA4s9fMqdnM66FOnI17tNF+PYRvv2Edy/w7gde3cD9F+aQr3V6e/hff+WeTxi2fLBmlUa3eEa3yOTavpfbf9E55BpJpajXL8PVbvxqN7jSDNzrCedSg/0nqfWDebt7VK/0vnNChSbgZadbvta1CCNXAEoNBIBcAygVgPRVcSH619Wv++RK7EoT7teFXx8g4GqhD/j04L49BLkBd6kA9l/OLr/auvhwzDJt/ULKmm0G7eIrju0Lrt0LnlUG+0IazeLpkcXTI+tUmvXTA4uHO1YPdv71aN0iZc85dcvuZknCo95v3w9rq7jF+XvFuR/14mTIm0NuQ8U2eofQ/0c/jNwhVAm4MBrZi5tv8MO1yUCfDIxvAfYVGLIA+gTWWEU8IY8jZLCu4vwoIIgFp7dx7l3tcZxwNYi/cEs4H8oe/+tk6C/m0B/C6WDlSqR2JVq7GGrciMDWIrDVUGw1CJsPUg+Hfsh6GNYp+Heb2iltnFygo1Sa/LrNKajf7P79wKsH7oOdinWXc6T/DC4/H9pombRgnXZgkU63eM60eiV0yNHafzM65OkoNXqfZuP1LuDbQbj/BJQ63KkQsX/DI6UvWt96f45+KvF5PehSiLoV424VOKUGd68DLuW4S4HB6aP4f0LLrxULnEswci3wbMJhuu8F3l2EVztwbwKutcClHFz8JHZMXbSKabV7vm/znG7znGWVfmKZdmz19NgqlWaTfmzz5MDy4faF++sXkjcsH25bPtpySt12ujPsFFR8O6U/O2ensoz1s5bX3sTqbXmPieJgl/JbMg8797tAdw82jop4IIgCnCjm4J/c2XCguUPokoEO3oYAgwNNgvQvRRxxFkdIYoAwBnBj9Qexkvkw9tBfwqlgzWYUshWh247W70Rg+9Gm3QjTTphpMwiHDO1gfDXItByEL4WYFgIN04HoYHDT59DYus0/O1FSxig5X+laafDrgZ7nRyV8+s2VoBdc6QAulYZL39UOj8b/7/WcCwmjFx6sQrpcGu1CJsv2/Zl9js4h13CpQOv90+jbarzahXs1A0o9cC4zOH5TWj3doCTXnVvdO/Z7P++Sh7oVmyiVuHsNDALnEtzpm9bxLdcissKrSOZShLtVArca4PmLIP8E5Abg/hOQ64BLNXApxa3fcp1fbFrE9Fo82rRK2bNKObBOObR+cmD1ZN/iwfb55HXLpDXr5GX7B0sO9xYckiZI8R3RGUPJz3pff5grKaF1NIupXbKB7jNqD3O4M9UkioOkUm0S0N79zaqDlN6zOEIQSbDCcHrkZqOX7igeqBMgSEgSDBTEzL+Db4slzmIJEeRyGfeiOf23pCPB+sVIfDsKiqJ2Q8FuGNgOBVuhYDMUrIfhK8GmxRB8KRRbDMHmQ01zIYbpQMNUkHYwmN4SFfrsx58tiMvrOcpXAbnceK0T9+0Dvv2E798AEF4dwL0et/+uJmUx/o/v+/8O/GWROGfz5MAilWaRdmL1ku+Yo3XI0V36oXc1JyLfVsyvHXaJ7nXgciF66YPgfHDBuaG5Vf/vu875OucCI6UCp1ThlCpAKsIu5Wjs37CsY2s8ilUuMDgIl3LcvRbCQKmD1nerxZ2rcFIJZpt1ej6x3zZx4ML9Devk9YsP1+wfLNokjlnF9FtFdNhHd7nEdfsk9aV82cqpPPmQv97cfdrScjw9JpoZV8yPa+ZHkfkx9dyEcnzweHYwBRZMWSx+FksoEqHdZbGQQsEPJ7jhBDPMuBu63eyHncTiZ3Gwo0eSCO0dArkDmaYKM89OGo3zI03HUchi7MQ3snEuHl8MAevh+GoovhICVsLAUhi+GIbPh8JnLgSfDwPz4aaZEONUsH4ySDcWqBsJ1FCDzjqDXz2J/6uS7VXAtH+27FZh9G3DfKnAmwpP+f0GYDW+0gU8m8DlAvTSJ9F//fHj/1379q+YgfPJSxZPDi1TaRbPTuyyxI5fEPuvqEOu2r1G59lohENuMzSdWwXm9EPtkNx1rmlw2j+f5Zyrdy7A3CtwtwoTpRKQCo2OOSr7Vwzr+Fr3Yq1rMe5WTriUA3I1cKnCXatxMwCAVI6TijDbdzzbhxNWIdUWQXWXgiuuxjfnVB9kfFm9/2rh2aed9/n0H2Un9S3C0THlzKRqdVG9viBnHurEPAP7GN1dVa7PqkY6BRMDwq5fwyvDyeA0FpZQSQzx+xFFE8Io4jSC4EYSzAjDZuh++zWcFoGLYgnVXUJ7j4AM1ER40dBZDFQSiKNwbqRhO4re9Odaub9uMso0HQLmwsBsGJgJwyZDTJPBpskQbCIYmwjBJkKME8HG8VDDaDA6fAsduoUMBiLUQE1/kLIrsPpVYEBmc0Ct2u7hiEcR4v3TeK0PnuT4UYFXH4wGzw6Ylp2LdHYfpBfCa857pf33H6XnY8cs7m+Yy96hdSbT8bP84me1wzetU76aXIV6NBi9mk2Unyb3Gty5CCGlDp+r7Bq7XixwzjM452NuZSZyhYlcDpwLTPbZaofXDNuEekox4lJsci0DrhWEWzXhXAWcq4FbLeFSBS6X4c4QAK7jk1lKYG5C2ujH78dfcpnl5bzSUl5Dw1lb69nMjGJ3R7O0INvZ1LLpRi5dzz3WibkGudiokhlUEux4V7W9qh3t2+mqKNifvovzosw9TBwhigGiGEIUSwijidNIghMBGBG6ldCdFl9wGI5zY4E0ASjuAuUdII8nZLGExAyVIBKwovTLYft1f26UBqDDEaaJUON4kHE0GBsNwUaCjCO3jEOBhqFA/UCQbiBQN3hTRw1CB4J0g4HoYIhuJFxNDRV3hi+XxqQnRFwJe3M979jl1TIlh+tRY/DvN28HeoEPFY7EcLnbjDsVaOzeiy6E1Qbfa7zg++EfN6v+FTtqcX/TOpVmmUq3e8279EV1MVtl/1XtUqxxrdRRajE4YNWZyNWY87PRc/lNw1cLxc65epcCjFxmIpdDqbFzPmafrXZ6w7BNbHQvRiEA5bDeulYB50rgXEW41QCXCoJUjDvnGy++5ZLSlmIe9mV/Y+TnsRrrztqb5L0ditFBzey0jMsy8Lk6Md/IOzHwGQYhyyjmGqSnOhEXkYv1SolBJtTvrjDGf/46GaxlLSTjnFgghgQkAnbrMYQIalTh7YvsCHAcjiyE7rb4gcNIEwPqZ3B+FGQ5CiPhSCWIJPgRkOzFjFBNB9Eb/topvYYOhRpGQlX9wcreUFlPuKw7UtIbLe2NO+uLUfVHqvrDVAOhmoEY9XCccjiB2fXwsDNzuuZj5afPH16W33tUfyP4s3tCjX8u2yFlyrME9W/H/cxcIwhAL7jSjnv8xC9/ldmmb1v/lZfyaj4kqev8lTf/8+/Kf0QOWCRv2KbSLdNotm/4l7JV8G6tLwq3Eq1bhY5SjZFrjORqzOPt7LnsugGfIolz3m8AcLcy3LUYdy40OnzRXHp9YnO72b1E51piTkEVwAUCAIPAtZpwriAuFeOkAsPFd1y3jPWktKHqalltpbCpQULtlk+Pq9aWVXyOkXWM8Jg6MV8vExr5bFTMQ5Qyg1ZlVEhQmRAVcpXHu4yZ9lZ0dfK4v4K/+RjwouC4BAGIBjD/xABBNHEaRTDDwWGYeibksN0PRsBJGMGPhXmJFwV+009PI4jTSMANB6xIzWwYve7aUeU1enN0/9eI4mcRHx/EPL8dm5p45+vLDxlJz948fPbp8dPcZ4+rP2ZONzXuT4zNdbb1/6Q2VU5S2zktDUxqt2yIqm5qEn0u2PJJGSBnzLq+PfSq1V/tMF1pNXk0mcj1mEsV5l1lsE9dtQyvD7rb8Dnv5NWHnfgng7Y+7/8RkPfP8C7Le8s2aUc2qXT716cOH6T2n87sc+RuxVr3SsS9Wu9eafR4t3DuRVmvZ8EZKVfnVoS5lZrIZbhzocm5AHPI0Vx6fWxzu9W9RP8bALcKwrWScKkkXKsItyrgXAGcikyX83R277iUl1tJqYP1tbLWRnlft2J2Sr27hRzuafhsjMfUM7lnPLmUIeLL5Fqd2qSR66VC7fbS2trU2NrE8OHssPFwRTs3svzru/wgFXBigDAOZh5I6TU3lIJoghcJGGGm3WDlZPB+21VwEAHl9rwYghNJcKIgDKdRBC+C4EXgnDDAjEAXI9lNN4a+3sxIfvLkcU3a8/6XbybfZU3n/lhvqmPWltHKCw/qKg47m46Ge9jrc4qdZTl9R80+QBYmJPMjsvlx8d6WeqRfMtCp7GxXvc/bD0gfd7zbTX5P865EyBVQDu9aqncrltumLVjGdVyMqknK6C0s5VRU87/k0p+9XbgeWXXxRv6F4HrrxEmbJzs2aYeOL9mOH0QOnxQOOQrXAqV7qcazCvV4N3/uwfdWj0Klcz7qVmx0LcXcynBSAeZSiNnnqB1fHdrc6XAv1buUmsiVgFINXCuAGwQA5iJSGaTAX85F7d+x3V9t337SV1khaG+RTo4qdne0PC7CPlGfMlEJX88RSeaYczP0Wb5UqlEY1XKjXISOtnXRh/vkSxOKhYmt9vay11/f3r+tZT4neNGwERLGEPxoQgipqPALbiRghGPbgYqJ4L1WGAGAEQG40QQXWh9wIwheFMGNILhhODuUYEfoVyIP6/zLMqJeZPRlvJr5kr1enLfb8VPQ80vQ85PbVstqr6UtTcmPt3T7y2rmnlYhNJ0JMAnHyIMlynDK0okFxrUF5cyIZrhX09Eq+17KCHkxdSllGArTsk4cPrFJX7mkH6eeZXLXXCnpPef6vaYfRUfFZczSUnZuPi2viP4uZ+NuxrjjrTLrqHa7+4sXn+1dzDwmZYkcPkkdsmUu32TuJQrKy4lz0R8aKYVq1wIdpchELjG5lUKlPQQgW33pxf7FpC7PMoNbGe5eRVCqgVsFIFcRrpWwADiX41CD8EPrkMV2f7OT9Gyork42PKg6OkBPeYhUopVLDWdiTCUxiIUITyThSsR6nUmPYDotppZh9C3xcPN8a2l/U2H/r9KZ+vKd/OwfBu5z4jQGCGIJYRzBhxsbaH1elLkChxk3AuVjwbvNfvDWA1YE4ETCfMWB10jARAQBCCc4YWayacRBnX/t64QP7ya+ZS/XlzNbq1hddZyRdtFoJ3+8l3e4hjB2UNq6hrmLCJnomQhVSvVKISbnG8WnRjYdZRwix3uG3RX90rR6bEjR26POzJ71ypxx+cLzKFX4/DT4tuFeXbAR8qgzOeXIHW/3fM7fKS5lNtTxWpq5g1Rxe+tpQwOnuPLo9Y8tv9ttdlGtTo+XLj4/dMzi2GcJnD5ILn+WkNNHz918UeNepCUX6sgl0P1dSiAAroUmu2zVpRc7tsk9HhVG13LMvRq4VwNy5d+PWyVwgbFidPqudXzPcX+9ez9ztLZWNkA929+Ti4R6AVd/vK/hnOikQr3yzKBDTIjaaEBxnQZDlCal2Mg5QA43VJvziqF29mA7f7xPWluYbzp9SvDjYHLnxxKCOLhL4JkdnBMB6GHG1VvykZC93wBwogA3kuBDbGD+4UaaIyACQsWK0K+G79fdaMqKryxYry48bKpg9P/kd9YyBzvZg/1HOxtnJ7saPs3APUTZxyo6l7vD29tk7QkkSgZHeMw7pZ2ID/bltF30YB053FVyWLqRIVntT+71+7/cv3PdqzS+naYr3eBKu/mYrw4n5an8Xs4n3Cuvq+d3d6pGBs82VzWbK+rtdd34sKq1RVbdIHyVsx7+dMT34YjjoznSS/rld6f27/mkxwPnrj0tdy9C3IoMriVG1zIo8SYVmFwKMAhAxqZdcp9nucGtEk5ncEiuJFwrcHIVcK2AlCMIwDc1BODV9pNX442N8qUFlVRsOKGpaHv64z2dhI9pVZhRjxtQTKfGMNSolRsUQoNCaBSzDKd0PWNPf7CuWxqVzY2IO+sKMe4juDvjxxKnv/NPLOBGEZwowI6CACwHng2F7jf7QYk9TP1R8G2cCIIXaU5E4YAdZgYgUrccvlt7ozUruq54vbbwqLGY1lJJ62w5GJ3eG15enVhfPzgQcI9RFl2xc0Jf5i1P8kbHWYNrnK09Pn1PtL8r2KexuIJTFPoQ3cA6QVh04+aaNrdizSt9nJTNudpi9GjBrzSZvH4Zr1eJA1/2FuYUjVTX1BRT25r4XW2SkX7F6iKyMK0Zo6oHujX9PdrOdmVDo7S8XhCVQvVJaLGPbr30YMLlduM5n5RKjxIduchILjW5wb7e5FxgIuUZ7D+pHJ+tXbzX51lhIFfiFPOKgmI2PcxCMFYwUr7B6avK4R2b8mLj2duplmbV7IySto+cHBno+9jJkU4i0GmUmEKqV5yhGo1OpzPoNJiEq5NwUDEHZR8gtA0NbQOhbej2VtTU5lwT9z4hiIdm5Zuf02jAhgdVBCsS0MINC4GywZD9Jj+wb/4mJwJA3/9fACIAOxywIwhmBLoUsl3j3/kxsrZgsSb/sLmMNtEvmp1jTG9tjq4vLGzv0GhCJk15eMxaYMxOsAaGGO0TrIH5/SUmT3QqVJ5weUKJzKjHZUKDgKPjsVDWMbK/iYwM80OSKr1ShwMKaH9WMW98HEv53FJbWM2ZoOo3JozrQ/TB1tbiX70ttGGqYmJEMzFwNjmiGaFqJke1EyPaYaqmp0tV/1NUVs37lHeYlD7hG/jpnE9ag3uxjlKEUUpxcinuXGByzjeScvX2n5QOz1Zsk6me5QZKBcw/MAKqoPuTK4FrKe5abCLlGx1zlI5ZbErGWmbW7M862cSoZm0JOdhE9ze1extaPhv+ASyG8oh7uszcXuPs0EVsHl+mVRiUEgOfiZ7sqGibqpMd3eG6crglG+PcJQRxMKXwza0nzwwAKxIwI8BhmHEuUNIffNB0Hd+H0m3o++YibM4/kYATDtjhBCucYISjiyGb1QE9nyJqC+brimiddcwpqmRlTrS+wdk75J5y1GcCnfRULxAoDvhHa9yVbcnqMmNRLFOJT7ViHiLmI3KJTi3XKWSIWm5QnhkEbIRFQw62NQPU0xdv2n5k/ZioqeGM9CkXh4yH8xhzzXQyj9HmNFtjrImO5oLiwc61uQnFwox6ZQ6ZGFTOT2mnx5RTo6rRAUVft7yxQVhbKyouF9xJrj3n+7zZo/h3AYAVmFRgci40kvL1dp8U9qlLNskDnmUQABgBtfBxq4Y9qGs57lxighyjz3LHLI7bs8VX7+d+Nkh6O6Tjw8q9HeSUq9vZ0Bxu6452tGymbpfNnaQtLjC2drlMOoevVZjOBHoRF1FKMAHLQN9FjrYUE+3vTOxEQhD7NwCwsfkNAJyBwX6YcTZQ1HvrsNkf7MFx1wxANIAwRMJXdgS0PjOMYIQji8Gb1fGE3c8AAAiVSURBVAHUz+HVPyYaimk9DZyRDt7eikLE1SklBo0Mk/IRKQ85E+m4HCmPLxXLFGKJQsJHZXydQqSX8FCJANEhmEFnOhOjMqH+9ETLpev31lVTw6KOVnbll59n86M4Z9Uk3FKfzGqOZzH2Ms5Z4a9TOQvd6v2ZhY7GnrqOlUn+3Jh6cli9sWxYnNYuTOvmp7Vzk/ruNllTk7SqWpzxouvc1RddlGIdpQS6vxu8d9TkXISR8nQXP8rtnszb3h/yLDO4VwByNU6pBe61ONkcBHAwLsOd8owOn6ROH09dU2ezPi8NDqrmZ5DxEWRiVMpkaDhM7eGWdnsV2dvSrG/yd4/F+4dnJ8daLlN3ykD5TJR+qGAeqrlHuv0V5co0b6oj08SCqnkIgLmuwvzDjIAj2AncZRpng047b9Kab4DdCAgAO4Lg/A0ABOO3+zPDAD1MOx+yXX1j4Et4xfeRxgoa9RdvaVTGO9YrREaVxChiI1KeTik2qCRGuVCnOcPUckwlNcj4OpVUr5Lo5EKDlKeXnqIijpZLQw42lKxDlEVD6dsaxj66t4EMUgUNJb3MqSHd8bKJu7pHrVn9VSCZ7VWtDiI7Y9zZttPFLvESdaSudrBtaWJENT6sHqMqxgc1YwPKwV5lf6equVFSWyd59ab33PVXVHKRjlyGuZXAGgArcJHJOVdn90Fu93jW5t6IR6nBvdy8pq4D5FqcXI27wc8rgDOzU64BAvCB75wy8Tl3k0pVTo6q56a04yOK2Wn54uzZ1MjZ8rRme1mzuXK2u6E+2NLQdhHmkf74ULV9xFje29/eEe0uq1an5DMjjJn2x/hJLMGLhX0nrKtR0PqMCMAIB8dhYAcCwGv/99EvfwgA0+zvHHiWC63/HwAAMwwch6LzodtVAf05oVW5o3XFe/3N3PUZJWNHwzlSS9hG7hEiYOqFLFTM1sn4eoVQr5QaZXwEUWA6lQmRY3KhXsozCjkaHkdBP1TStjRcmp7P1PNOEAFbzzpCDzbRwb7T2pLh0bpm5kAnd6S99FVGdtrnhu8Vk1Wl0uke5UIPrbeMN9rU8K1kdlw2M66en9DOTaJTo1pqt7y/U97ZImuok7x63XPu2psRciFKKTW6lcAb/5wLMNciE+kHagZgxvbesEep3r3CBAtALe5eA2vA7xRkBkDv8ElG+sh3fjTyo2S/p087QlXNTWnnptBBqmJqFBmjKmfGlPtbesa+cX9Ne7Ch2VtXMo4MR/vKhd3tud31qVn61Ih0alA+0ne00P0YMGPMaR1OWIATBRgRBCOSYITjR6H4VohxJpDXevOg8Rq+HQ6YkbDe/q/1ORAPwAqHAByFIvNhm+X+fdlhdYVTbXWsvib+9IDg9ETPPtCeHqNcmpa1r+bSUPahRsA0iNk6Cdcg4xkkXPRMoFeKDBIOKuKiR2zOyvHGxsERk6bmHuv4DEQqROVimI4Yewj72Li7iSzMSIY61oZbRn9VDLT9pNeWHTSULFXkNHXll883NtS8yyr8UDs7Ltla1u2soqtzms0VdHEanZ9AJ0YU7S2yN297z119M0nO17qXGd2KMZcSuIdwLTKSfmjt3p/ZPZq2vTfkUaJzLzdRqnGPOpPH7xQEJzLcrdR06YfO4aPM6SP/0t3OvIr9+iZpa4u4r1vS1i2paRV29MqovfLhXtnCpGp7EdldRg7W0c1F7fayen1Rvrx8urQkmB6T9XcJe5r5PU3r632PAT2WYEcT7ChoXBacfglmBHEShh+E4Bsh2HQQu+mv/QZ/fMcMAMtchOE2whwBzHBYq5nwSjRkNnq73L/zfWhz1XJHA6e7gTtFFTAPNNwjLZeuVYqNfCbCYshox6dMuop7hDJ2VYxdNfcIEbHMeLD0Ao52g0abP1xe2tnZ3RbC36Wrz4QGucggPkWFbD3nBJVL9Sd7atYhyqHp9tbVW0vI8pR6ZRodpyqGOgTU1pP2nwcdP5mzY4rpUcXanGZ9UUk/1GwsqjeXtAtTSmqv8sP7vnN+WbNu+RpPCIDJrRh3LTK5FBlI37X2H6T2j6ds7w1cKdG5l5k8q4FHDe5Zaw6FGpxSiZNLMadcveNHyaWPpw53OnziK26mDObUcLPKTwJfTHk8nbiWMvyxilHVIhoYVM6Oa2ZHFQsTyokJ1eKUbHHybGLibGhY0tsj6WgX/ao77Wpc3B9JwU+i4IWKrEjAiiRYUcRJOExB9FDTfhC+HoTNBtPr/9hr8Me3QgAzCr4Hmj7S3H1G4r8BYITju8Gq6cj9Cv/md9EN5et1RQed9SxqK2duiMc70h9vKk92lLS9s809+uzmyt4en03TnR6j/GOUva+FaWofZe9oD9bkuzuitQ0G7VB2tK3kHWulfD2XphWxEY0cE3HhUp1D0/KOEe4JerSh3VtSH64ih+ua/RXl5pJ6a8GwMo0sTCEL49qVGe3qrHp2VDM3pZqfPhsfko8NKHvbpX2dypzP/ed83y+TCzQeEACjazHmUmR0KTI4fdfA7d2jKZukAY8ilFKKedRAlu+VengoZu5HAbkMI+XqHT+IL388tU1otYlusLw/7/R0yfn52uX0ZacXa5T3234f9wI+rt8rOCjpEBc3nFR2iKI/LX5rPHxfvhWfPXf760LEy+GEdxMpn+Zyf7SxZlMAPRowowErmmBFEowogm6uAcdhpr1Q02oQNhO0V359ty4A3wo1v82cpv4XMGYkTFknEfhWiGQohFFz81fWvbrSjebq49FOwWinYGFYuj17tr+koK1rdpbkW6vi/W0Jh4ay9rRCBsrcUzJ2tUdrmv0FJX1Fc7SC7C7JD9cUrF3dyRbC2EZYe1rWPso+0LIP1Sc7WsaO7ngT4R7paVuazTnV7pJ2f1VztKmUnOqFXPR4B6XvGA429FvLyOYyMjuqHKHKRwfkfZ2SnnbxxIiqq03c0SLJyaH+f9WSWOJh63luAAAAAElFTkSuQmCC);background-size:cover;background-position:center;border:none;padding:0;';

        // Apply icon to button
        mainBtn.style.cssText = 'background-image:' + _iconBg;
        mainBtn.dataset.iconBg = _iconBg.substring(0, _iconBg.indexOf(');') + 1);
        if (_settings.useEmojiIcon) {
            mainBtn.style.backgroundImage = 'none';
            mainBtn.textContent = '😢';
            mainBtn.style.fontSize = '20px';
        }

        // Dropdown
        const dropdown = document.createElement('div');
        dropdown.className = 'dropdown-menu absolute left-0 mt-2 flex flex-col gap-2 transition-all duration-150 ease-out opacity-0 scale-95 hidden';

        // ── Theme-aware color tokens for the dropdown UI ──
        // Use CSS custom properties so colors follow GeoPixels++ theme changes live
        const C = {
            pillBg:      'var(--color-white, #fff)',
            pillHover:   'var(--color-gray-100, #f3f4f6)',
            pillText:    'var(--color-gray-700, #374151)',
            flyBg:       'var(--color-white, #fff)',
            flyHover:    'var(--color-gray-100, #f3f4f6)',
            flyText:     'var(--color-gray-700, #374151)',
            flyMuted:    'var(--color-gray-500, #6b7280)',
            inputBg:     'var(--color-white, #fff)',
            inputBorder: 'var(--color-gray-300, #d1d5db)',
            inputText:   'var(--color-gray-900, #111827)',
            shadow:      '0 1px 3px rgba(0,0,0,.12)',
            activeBg:    'var(--color-green-100, #bbf7d0)',
            activeText:  'var(--color-green-800, #166534)',
            teBtnBg:     'var(--color-purple-500, #7c3aed)',
            teBtnText:   'var(--color-purple-50, #f3e8ff)',
            teActiveBg:  'var(--color-purple-400, #c4b5fd)',
            teInactiveBg:'var(--color-white, #fff)',
            teHover:     'var(--color-purple-100, #ede9fe)',
            navBg:       'var(--color-white, #fff)',
            navText:     'var(--color-gray-500, #6b7280)',
        };
        dropdown.id = 'geopixelconsDropdown';

        function openDropdown() {
            // Refresh debug button label with current log count (only when debugging is on)
            if (_settings.enableDebug) {
                const _dbgLabelSpan = document.querySelector('#gpc-debug-dropdown-btn span:nth-child(2)');
                if (_dbgLabelSpan) _dbgLabelSpan.textContent = `Debug Logs (${dbgCount()})`;
            }
            dropdown.classList.remove('hidden');
            setTimeout(() => {
                dropdown.classList.remove('opacity-0', 'scale-95');
            }, 10);
        }
        function closeDropdown() {
            dropdown.classList.add('opacity-0', 'scale-95');
            setTimeout(() => {
                dropdown.classList.add('hidden');
            }, 150);
        }

        mainBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            // Close other dropdowns using the site's function if available
            if (typeof closeAllDropdowns === 'function') {
                closeAllDropdowns();
            } else {
                document.querySelectorAll('.dropdown-menu').forEach(d => {
                    if (d !== dropdown && !d.classList.contains('hidden')) {
                        d.classList.add('opacity-0', 'scale-95');
                        setTimeout(() => d.classList.add('hidden'), 150);
                    }
                });
            }
            const isOpen = !dropdown.classList.contains('hidden');
            if (isOpen) closeDropdown(); else openDropdown();
        });

        function makeSubBtn(icon, label, onClick) {
            const btn = document.createElement('button');
            btn.className = 'gpc-pill-btn';
            btn.title = label;
            btn.style.cssText = 'position:relative;width:40px;height:40px;border-radius:9999px;background:'+C.pillBg+';box-shadow:'+C.shadow+';display:flex;align-items:center;justify-content:flex-start;border:none;cursor:pointer;overflow:hidden;transition:width .25s cubic-bezier(.4,0,.2,1);padding:0;font-size:16px;flex-shrink:0;';
            const iconSpan = document.createElement('span');
            iconSpan.style.cssText = 'width:40px;min-width:40px;text-align:center;flex-shrink:0;line-height:40px;';
            iconSpan.textContent = icon;
            const labelSpan = document.createElement('span');
            labelSpan.style.cssText = 'white-space:nowrap;font-size:12px;font-weight:600;color:'+C.pillText+';opacity:0;transition:opacity .2s .05s;padding-right:12px;pointer-events:none;';
            labelSpan.textContent = label;
            btn.appendChild(iconSpan);
            btn.appendChild(labelSpan);
            btn.addEventListener('mouseenter', () => {
                const textW = labelSpan.scrollWidth + 12;
                btn.style.width = (40 + textW) + 'px';
                labelSpan.style.opacity = '1';
                btn.style.background = C.pillHover;
            });
            btn.addEventListener('mouseleave', () => {
                btn.style.width = '40px';
                labelSpan.style.opacity = '0';
                btn.style.background = C.pillBg;
            });
            btn.addEventListener('click', (e) => {
                e.stopPropagation();
                btn.style.width = '40px';
                labelSpan.style.opacity = '0';
                btn.style.background = C.pillBg;
                closeDropdown();
                onClick();
            });
            return btn;
        }

        // ─── Shared flyout close registry (only one open at a time) ──
        const _flyoutClosers = [];
        function closeAllFlyouts() { for (const fn of _flyoutClosers) fn(); }

        // ─── Shared flyout builder for Screenshot / Highscore ─────────
        function buildFeatureFlyout(opts) {
            // opts: { id, icon, title, featureKey, getModule, color }
            const group = document.createElement('div');
            group.style.cssText = 'position:relative;';

            const mainBtn = document.createElement('button');
            mainBtn.className = 'gpc-pill-btn';
            mainBtn.title = opts.title;
            mainBtn.style.cssText = 'position:relative;width:40px;height:40px;border-radius:9999px;background:'+C.pillBg+';box-shadow:'+C.shadow+';display:flex;align-items:center;justify-content:flex-start;border:none;cursor:pointer;overflow:hidden;transition:width .25s cubic-bezier(.4,0,.2,1);padding:0;font-size:16px;flex-shrink:0;';
            mainBtn.id = 'gpc-' + opts.id + '-sub';
            const mainIcon = document.createElement('span');
            mainIcon.style.cssText = 'width:40px;min-width:40px;text-align:center;flex-shrink:0;line-height:40px;';
            mainIcon.textContent = opts.icon;
            const mainLabel = document.createElement('span');
            mainLabel.style.cssText = 'white-space:nowrap;font-size:12px;font-weight:600;color:'+C.pillText+';opacity:0;transition:opacity .2s .05s;padding-right:12px;pointer-events:none;';
            mainLabel.textContent = opts.title;
            mainBtn.appendChild(mainIcon);
            mainBtn.appendChild(mainLabel);
            mainBtn.addEventListener('mouseenter', () => {
                if (flyoutOpen) return;
                const textW = mainLabel.scrollWidth + 12;
                mainBtn.style.width = (40 + textW) + 'px';
                mainLabel.style.opacity = '1';
                mainBtn.style.background = C.pillHover;
            });
            mainBtn.addEventListener('mouseleave', () => {
                mainBtn.style.width = '40px';
                mainLabel.style.opacity = '0';
                mainBtn.style.background = C.pillBg;
            });
            function collapsePill() {
                mainBtn.style.width = '40px';
                mainLabel.style.opacity = '0';
                mainBtn.style.background = C.pillBg;
            }

            const flyout = document.createElement('div');
            flyout.id = 'gpc-' + opts.id + '-flyout';
            Object.assign(flyout.style, {
                position: 'absolute', left: 'calc(100% + 8px)', top: '0',
                transform: 'scale(0.95)',
                display: 'flex', flexDirection: 'column', gap: '4px',
                transition: 'all 0.15s ease-out',
                opacity: '0', pointerEvents: 'none', zIndex: '21',
            });
            let flyoutOpen = false;

            const flyBtnStyle = 'display:flex;align-items:center;gap:6px;min-width:200px;padding:5px 10px;background:'+C.flyBg+';box-shadow:'+C.shadow+';border-radius:6px;border:none;cursor:pointer;font-size:11px;font-weight:500;color:'+C.flyText+';white-space:nowrap;height:28px;transition:background .12s;';
            const flyBtnActiveStyle = flyBtnStyle.replace('background:'+C.flyBg,'background:'+C.activeBg).replace('color:'+C.flyText,'color:'+C.activeText);

            function makeFlyBtn(label, emoji, onClick, extraId) {
                const btn = document.createElement('button');
                btn.style.cssText = flyBtnStyle;
                btn.innerHTML = emoji + ' ' + label;
                if (extraId) btn.id = extraId;
                btn.addEventListener('mouseenter', () => { if (!btn.dataset.active) btn.style.background = C.flyHover; });
                btn.addEventListener('mouseleave', () => { if (!btn.dataset.active) btn.style.background = C.flyBg; });
                btn.addEventListener('click', (e) => { e.stopPropagation(); closeFly(); closeDropdown(); onClick(); });
                return btn;
            }

            function closeFly() {
                flyoutOpen = false;
                flyout.style.opacity = '0';
                flyout.style.pointerEvents = 'none';
                flyout.style.transform = 'scale(0.95)';
            }

            // ── 1) Select Area (ad-hoc drag) ──
            flyout.appendChild(makeFlyBtn('Select Area', '🔲', () => {
                const triggerBtn = document.getElementById('gpc-' + opts.id + '-trigger');
                if (triggerBtn) triggerBtn.click();
            }));

            // ── 2) Pick Points (click two corners) ──
            flyout.appendChild(makeFlyBtn('Pick Points', '📌', () => {
                startPickPointsMode(opts);
            }));

            // ── 3) Input Coords ──
            const coordForm = document.createElement('div');
            Object.assign(coordForm.style, {
                display: 'flex', flexDirection: 'column', gap: '4px',
                minWidth: '200px', padding: '6px 10px',
                background: C.flyBg, boxShadow: C.shadow,
                borderRadius: '6px', fontSize: '11px',
            });
            const cached = loadCachedCoords();
            const _inputSt = 'width:60px;padding:2px 4px;border:1px solid '+C.inputBorder+';border-radius:4px;font-size:11px;background:'+C.inputBg+';color:'+C.inputText+';';
            coordForm.innerHTML =
                '<div style="font-weight:600;color:'+C.flyText+';margin-bottom:2px;">📝 Input Coords</div>' +
                '<div style="display:flex;gap:4px;align-items:center;">' +
                '  <span style="width:22px;color:'+C.flyMuted+';font-size:10px;">NW</span>' +
                '  <input id="gpc-' + opts.id + '-nw-x" type="number" placeholder="X" style="'+_inputSt+'" value="' + (cached ? cached.minX : '') + '">' +
                '  <input id="gpc-' + opts.id + '-nw-y" type="number" placeholder="Y" style="'+_inputSt+'" value="' + (cached ? cached.maxY : '') + '">' +
                '</div>' +
                '<div style="display:flex;gap:4px;align-items:center;">' +
                '  <span style="width:22px;color:'+C.flyMuted+';font-size:10px;">SE</span>' +
                '  <input id="gpc-' + opts.id + '-se-x" type="number" placeholder="X" style="'+_inputSt+'" value="' + (cached ? cached.maxX : '') + '">' +
                '  <input id="gpc-' + opts.id + '-se-y" type="number" placeholder="Y" style="'+_inputSt+'" value="' + (cached ? cached.minY : '') + '">' +
                '</div>' +
                '<button id="gpc-' + opts.id + '-coord-go" style="margin-top:2px;padding:4px 8px;background:' + (opts.color || '#3b82f6') + ';color:white;border:none;border-radius:4px;font-size:11px;font-weight:600;cursor:pointer;">Go</button>';
            coordForm.addEventListener('click', (e) => e.stopPropagation());
            flyout.appendChild(coordForm);

            // Wire the Go button after appending
            setTimeout(() => {
                const goBtn = document.getElementById('gpc-' + opts.id + '-coord-go');
                if (goBtn) goBtn.addEventListener('click', () => {
                    const minX = parseInt(document.getElementById('gpc-' + opts.id + '-nw-x').value);
                    const maxY = parseInt(document.getElementById('gpc-' + opts.id + '-nw-y').value);
                    const maxX = parseInt(document.getElementById('gpc-' + opts.id + '-se-x').value);
                    const minY = parseInt(document.getElementById('gpc-' + opts.id + '-se-y').value);
                    if ([minX, maxY, maxX, minY].some(isNaN)) return;
                    const bounds = { minX: Math.min(minX, maxX), maxX: Math.max(minX, maxX), minY: Math.min(minY, maxY), maxY: Math.max(minY, maxY) };
                    saveCachedCoords(bounds);
                    closeFly(); closeDropdown();
                    const mod = opts.getModule();
                    if (mod && mod.processWithBounds) mod.processWithBounds(bounds);
                });
            }, 0);

            // ── 4) Auto-screenshot toggle (screenshot only) ──
            if (opts.id === 'screenshot') {
                const autoBtn = document.createElement('button');
                autoBtn.id = 'gpc-auto-screenshot-btn';
                const isOn = isAutoScreenshotEnabled() && loadCachedCoords();
                autoBtn.style.cssText = isOn ? flyBtnActiveStyle : flyBtnStyle;
                autoBtn.innerHTML = '📷 Auto-save on paint';
                if (isOn) autoBtn.dataset.active = '1';
                autoBtn.title = 'Takes a screenshot of the cached area every time you paint. Requires Input Coords.';
                autoBtn.addEventListener('mouseenter', () => { if (!autoBtn.dataset.active) autoBtn.style.background = C.flyHover; });
                autoBtn.addEventListener('mouseleave', () => { if (!autoBtn.dataset.active) autoBtn.style.background = isAutoScreenshotEnabled() && loadCachedCoords() ? C.activeBg : C.flyBg; });
                autoBtn.addEventListener('click', (e) => {
                    e.stopPropagation();
                    const coords = loadCachedCoords();
                    if (!coords) {
                        _gpcNotify('Set coords first (Input Coords or Pick Points).', true);
                        return;
                    }
                    const nowOn = !isAutoScreenshotEnabled();
                    setAutoScreenshot(nowOn);
                    autoBtn.style.cssText = nowOn ? flyBtnActiveStyle : flyBtnStyle;
                    if (nowOn) { autoBtn.dataset.active = '1'; autoBtn.innerHTML = '📷 Auto-save on paint ✅'; }
                    else { delete autoBtn.dataset.active; autoBtn.innerHTML = '📷 Auto-save on paint'; }
                    _gpcNotify(nowOn ? 'Auto-screenshot ON' : 'Auto-screenshot OFF');
                });
                flyout.appendChild(autoBtn);
            }

            _flyoutClosers.push(closeFly);

            mainBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                collapsePill();
                const wasOpen = flyoutOpen;
                closeAllFlyouts();
                if (!wasOpen) {
                    flyoutOpen = true;
                    // Refresh cached coord values in inputs
                    const cc = loadCachedCoords();
                    const setVal = (suffix, val) => { const el = document.getElementById('gpc-' + opts.id + '-' + suffix); if (el) el.value = val ?? ''; };
                    setVal('nw-x', cc?.minX); setVal('nw-y', cc?.maxY); setVal('se-x', cc?.maxX); setVal('se-y', cc?.minY);
                    flyout.style.opacity = '1'; flyout.style.pointerEvents = 'auto'; flyout.style.transform = 'scale(1)';
                    // Refresh auto-screenshot button state
                    const ab = document.getElementById('gpc-auto-screenshot-btn');
                    if (ab) {
                        const isOn = isAutoScreenshotEnabled() && loadCachedCoords();
                        ab.style.cssText = isOn ? flyBtnActiveStyle : flyBtnStyle;
                        ab.innerHTML = isOn ? '📷 Auto-save on paint ✅' : '📷 Auto-save on paint';
                        if (isOn) ab.dataset.active = '1'; else delete ab.dataset.active;
                    }
                }
            });
            document.addEventListener('click', (e) => { if (flyoutOpen && !group.contains(e.target)) closeFly(); });

            group.appendChild(mainBtn);
            group.appendChild(flyout);
            return group;
        }

        // ─── Shared "Pick Points" mode ──────────────────────────────
        let _pickState = null;

        function startPickPointsMode(opts) {
            if (_pickState) cleanupPickPoints();
            const map = _getMapRef();
            if (!map) { _gpcNotify('Map not ready.', true); return; }

            _pickState = { opts, step: 0, markers: [], handler: null, keyHandler: null };
            _gpcNotify('Click top-left corner…');
            document.body.style.cursor = 'crosshair';

            _pickState.handler = function(e) {
                const gSize = (typeof gridSize !== 'undefined') ? gridSize : 25;
                const merc = turf.toMercator([e.lngLat.lng, e.lngLat.lat]);
                const gx = Math.round(merc[0] / gSize);
                const gy = Math.round(merc[1] / gSize);

                if (_pickState.step === 0) {
                    _pickState.p1 = { x: gx, y: gy };
                    // Add marker
                    const el = _createPickMarker('NW', '#ef4444');
                    const marker = new maplibregl.Marker({ element: el }).setLngLat(e.lngLat).addTo(map);
                    _pickState.markers.push(marker);
                    _pickState.step = 1;
                    _gpcNotify('Click bottom-right corner…');
                } else {
                    _pickState.p2 = { x: gx, y: gy };
                    const el = _createPickMarker('SE', '#3b82f6');
                    const marker = new maplibregl.Marker({ element: el }).setLngLat(e.lngLat).addTo(map);
                    _pickState.markers.push(marker);

                    const p1 = _pickState.p1, p2 = _pickState.p2;
                    const bounds = {
                        minX: Math.min(p1.x, p2.x), maxX: Math.max(p1.x, p2.x),
                        minY: Math.min(p1.y, p2.y), maxY: Math.max(p1.y, p2.y),
                    };
                    saveCachedCoords(bounds);
                    cleanupPickPoints();
                    _gpcNotify('Coords applied! (' + bounds.minX + ',' + bounds.maxY + ') → (' + bounds.maxX + ',' + bounds.minY + ')');
                }
            };

            _pickState.keyHandler = function(e) {
                if (e.key === 'Escape') { cleanupPickPoints(); _gpcNotify('Cancelled.'); }
            };

            map.on('click', _pickState.handler);
            document.addEventListener('keydown', _pickState.keyHandler);
        }

        function cleanupPickPoints() {
            if (!_pickState) return;
            const map = _getMapRef();
            if (map) {
                if (_pickState.handler) map.off('click', _pickState.handler);
            }
            for (const m of _pickState.markers) m.remove();
            if (_pickState.keyHandler) document.removeEventListener('keydown', _pickState.keyHandler);
            document.body.style.cursor = '';
            _pickState = null;
        }

        function _createPickMarker(label, color) {
            const el = document.createElement('div');
            el.style.cssText = 'display:flex;flex-direction:column;align-items:center;pointer-events:none;';
            el.innerHTML =
                '<svg width="28" height="40" viewBox="0 0 24 36"><path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" fill="' + color + '"/><circle cx="12" cy="11" r="4.5" fill="white"/></svg>' +
                '<span style="font-size:10px;font-weight:700;color:' + color + ';text-shadow:0 0 2px white,0 0 2px white;">' + label + '</span>';
            return el;
        }

        function _getMapRef() {
            try { const m = (0, eval)('map'); if (m && typeof m.setStyle === 'function') return m; } catch {}
            if (typeof unsafeWindow !== 'undefined') { try { const m = unsafeWindow.eval('map'); if (m && typeof m.setStyle === 'function') return m; } catch {} }
            return null;
        }

        function _gpcNotify(msg, isError) {
            const existing = document.getElementById('gpc-flyout-toast'); if (existing) existing.remove();
            const toast = document.createElement('div'); toast.id = 'gpc-flyout-toast'; toast.textContent = msg;
            Object.assign(toast.style, { position:'fixed',top:'70px',left:'50%',transform:'translateX(-50%)',background:isError?'#fca5a5':'#bbf7d0',color:isError?'#7f1d1d':'#166534',padding:'8px 18px',borderRadius:'8px',fontSize:'13px',fontWeight:'600',zIndex:'100001',boxShadow:'0 4px 12px rgba(0,0,0,.2)',transition:'opacity .3s',fontFamily:"system-ui,sans-serif" });
            document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 2500);
        }

        // Screenshot button with flyout (only if enabled)
        if (_settings.regionScreenshot) {
            dropdown.appendChild(buildFeatureFlyout({
                id: 'screenshot', icon: '📸', title: 'Region Screenshot',
                featureKey: 'regionScreenshot', color: '#10b981',
                getModule: () => _regionScreenshot,
            }));
        }

        // Highscore button with flyout (only if enabled)
        if (_settings.regionsHighscore) {
            dropdown.appendChild(buildFeatureFlyout({
                id: 'highscore', icon: '🏆', title: 'Region Highscore',
                featureKey: 'regionsHighscore', color: '#3b82f6',
                getModule: () => _regionsHighscore,
            }));
        }

        // Theme Editor button with right-expanding flyout (only if enabled)
        // Note: _themeEditor is populated later by the feature module, so we
        // only check it lazily (on click), not at dropdown-build time.
        if (_settings.themeEditor) {
            const teGroup = document.createElement('div');
            teGroup.style.cssText = 'position:relative;';

            const teBtn = document.createElement('button');
            teBtn.className = 'gpc-pill-btn';
            teBtn.title = 'Theme Editor';
            teBtn.style.cssText = 'position:relative;width:40px;height:40px;border-radius:9999px;background:'+C.pillBg+';box-shadow:'+C.shadow+';display:flex;align-items:center;justify-content:flex-start;border:none;cursor:pointer;overflow:hidden;transition:width .25s cubic-bezier(.4,0,.2,1);padding:0;font-size:16px;flex-shrink:0;';
            teBtn.id = 'gpc-theme-sub';
            const teIcon = document.createElement('span');
            teIcon.style.cssText = 'width:40px;min-width:40px;text-align:center;flex-shrink:0;line-height:40px;';
            teIcon.textContent = '🎨';
            const teLabelSpan = document.createElement('span');
            teLabelSpan.style.cssText = 'white-space:nowrap;font-size:12px;font-weight:600;color:'+C.pillText+';opacity:0;transition:opacity .2s .05s;padding-right:12px;pointer-events:none;';
            teLabelSpan.textContent = 'Theme Editor';
            teBtn.appendChild(teIcon);
            teBtn.appendChild(teLabelSpan);
            teBtn.addEventListener('mouseenter', () => {
                if (teFlyoutOpen) return;
                const textW = teLabelSpan.scrollWidth + 12;
                teBtn.style.width = (40 + textW) + 'px';
                teLabelSpan.style.opacity = '1';
                teBtn.style.background = C.pillHover;
            });
            teBtn.addEventListener('mouseleave', () => {
                teBtn.style.width = '40px';
                teLabelSpan.style.opacity = '0';
                teBtn.style.background = C.pillBg;
            });
            function teCollapsePill() {
                teBtn.style.width = '40px';
                teLabelSpan.style.opacity = '0';
                teBtn.style.background = C.pillBg;
            }

            const teFlyout = document.createElement('div');
            teFlyout.id = 'gpc-theme-flyout';
            Object.assign(teFlyout.style, {
                position: 'absolute', left: 'calc(100% + 8px)', top: '0',
                transform: 'scale(0.95)',
                display: 'flex', flexDirection: 'column', gap: '3px',
                transition: 'all 0.15s ease-out',
                opacity: '0', pointerEvents: 'none', zIndex: '21',
            });

            let teFlyoutOpen = false;
            let teSubPage = 0;
            const TE_PER_PAGE = 4;

            function _escHTML(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

            function renderThemeFlyout() {
                teFlyout.innerHTML = '';
                if (!_themeEditor) return;
                const themes = _themeEditor.loadThemes();
                const activeName = _themeEditor.getActiveThemeName();
                const allNames = Object.keys(themes).sort((a, b) => {
                    const pa = a === 'Default' ? 0 : a === 'Default Dark' ? 1 : 2;
                    const pb = b === 'Default' ? 0 : b === 'Default Dark' ? 1 : 2;
                    return pa !== pb ? pa - pb : a.localeCompare(b);
                });
                const totalPages = Math.max(1, Math.ceil(allNames.length / TE_PER_PAGE));
                if (teSubPage >= totalPages) teSubPage = 0;
                const start = teSubPage * TE_PER_PAGE;
                const page = allNames.slice(start, start + TE_PER_PAGE);

                for (const name of page) {
                    const theme = themes[name];
                    const isActive = name === activeName;
                    const btn = document.createElement('button');
                    Object.assign(btn.style, {
                        display: 'flex', alignItems: 'center', gap: '6px',
                        minWidth: '130px', maxWidth: '190px', padding: '4px 8px',
                        background: isActive ? C.teActiveBg : C.teInactiveBg,
                        boxShadow: C.shadow,
                        borderRadius: '6px', border: 'none', cursor: 'pointer',
                        fontSize: '11px', fontWeight: isActive ? '700' : '500',
                        color: isActive ? C.teBtnText : C.flyText,
                        whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                        transition: 'background .12s', height: '28px',
                    });
                    const bgColor = theme.overrides?.['background::background-color'] || theme.overrides?.['water::fill-color'] || '#808080';
                    btn.innerHTML = '<span style="width:14px;height:14px;border-radius:3px;border:1px solid rgba(0,0,0,.15);flex-shrink:0;background:' + bgColor + '"></span>' + _escHTML(name);
                    btn.title = name;
                    btn.addEventListener('click', async (e) => {
                        e.stopPropagation();
                        await _themeEditor.applyThemeByName(name);
                        renderThemeFlyout();
                    });
                    btn.addEventListener('mouseenter', () => { if (!isActive) btn.style.background = C.teHover; });
                    btn.addEventListener('mouseleave', () => { if (!isActive) btn.style.background = C.teInactiveBg; });
                    teFlyout.appendChild(btn);
                }

                if (totalPages > 1) {
                    const nav = document.createElement('button');
                    Object.assign(nav.style, {
                        display: 'flex', alignItems: 'center', justifyContent: 'center',
                        minWidth: '130px', maxWidth: '190px', padding: '3px 8px',
                        background: C.navBg, boxShadow: C.shadow,
                        borderRadius: '6px', border: 'none', cursor: 'pointer',
                        fontSize: '10px', color: C.navText, height: '22px', transition: 'background .12s',
                    });
                    nav.textContent = '▸ ' + (teSubPage + 1) + '/' + totalPages;
                    nav.title = 'Next page';
                    nav.addEventListener('click', (e) => {
                        e.stopPropagation();
                        teSubPage = (teSubPage + 1) % totalPages;
                        renderThemeFlyout();
                    });
                    teFlyout.appendChild(nav);
                }

                // "Editor" button to open full modal
                const editorBtn = document.createElement('button');
                Object.assign(editorBtn.style, {
                    display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px',
                    minWidth: '130px', maxWidth: '190px', padding: '4px 8px',
                    background: C.teBtnBg, boxShadow: C.shadow,
                    borderRadius: '6px', border: 'none', cursor: 'pointer',
                    fontSize: '11px', fontWeight: '600', color: C.teBtnText,
                    whiteSpace: 'nowrap', height: '28px', transition: 'filter .12s',
                });
                editorBtn.textContent = '⚙️ Editor';
                editorBtn.title = 'Open full Theme Editor';
                editorBtn.addEventListener('mouseenter', () => { editorBtn.style.filter = 'brightness(1.1)'; });
                editorBtn.addEventListener('mouseleave', () => { editorBtn.style.filter = ''; });
                editorBtn.addEventListener('click', (e) => {
                    e.stopPropagation();
                    closeDropdown();
                    closeFlyout();
                    if (_themeEditor) _themeEditor.toggleModal();
                });
                teFlyout.appendChild(editorBtn);
            }

            function closeFlyout() {
                teFlyoutOpen = false;
                teFlyout.style.opacity = '0';
                teFlyout.style.pointerEvents = 'none';
                teFlyout.style.transform = 'scale(0.95)';
            }

            _flyoutClosers.push(closeFlyout);

            teBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                teCollapsePill();
                const wasOpen = teFlyoutOpen;
                closeAllFlyouts();
                if (!wasOpen) {
                    teFlyoutOpen = true;
                    renderThemeFlyout();
                    teFlyout.style.opacity = '1';
                    teFlyout.style.pointerEvents = 'auto';
                    teFlyout.style.transform = 'scale(1)';
                }
            });

            document.addEventListener('click', (e) => {
                if (teFlyoutOpen && !teGroup.contains(e.target)) closeFlyout();
            });

            teGroup.appendChild(teBtn);
            teGroup.appendChild(teFlyout);
            dropdown.appendChild(teGroup);
        }

        // Map Markers button (only if enabled)
        if (_settings.mapMarkers) {
            dropdown.appendChild(makeSubBtn('📌', 'Map Markers', () => {
                if (_mapMarkers) _mapMarkers.openModal();
            }));
        }

        // Settings button (always visible)
        dropdown.appendChild(makeSubBtn('⚙️', 'Settings...', createSettingsModal));

        // Changelog button (always visible)
        dropdown.appendChild(makeSubBtn('📋', 'Changelog', showChangelog));

        // Debug Logs button — only shown when Enable Debugging is on.
        // Always exports, even with zero timestamped entries — dbgExport()
        // appends a live state snapshot regardless, which is often the more
        // useful half of the file when something failed too early for any
        // dbgPush() call to have run yet.
        if (_settings.enableDebug) {
            const _dbgBtn = makeSubBtn('🔶', `Debug Logs (${dbgCount()})`, dbgExport);
            _dbgBtn.id = 'gpc-debug-dropdown-btn';
            dropdown.appendChild(_dbgBtn);
        }

        group.appendChild(mainBtn);
        group.appendChild(dropdown);

        // Ensure GeoPixelcons++ is always after GeoPixels++ in controls-left
        function positionAfterGeoPixelsPP() {
            const gppGroup = controlsLeft.querySelector('#geopixels-plusplus')?.closest('.relative');
            if (gppGroup && gppGroup.nextSibling !== group) {
                gppGroup.after(group);
                return true;
            }
            return false;
        }

        controlsLeft.appendChild(group);
        if (!positionAfterGeoPixelsPP()) {
            // GeoPixels++ may not be loaded yet — watch for it
            const obs = new MutationObserver(() => {
                if (positionAfterGeoPixelsPP()) obs.disconnect();
            });
            obs.observe(controlsLeft, { childList: true, subtree: true });
            // Stop watching after 30s to avoid leaks
            setTimeout(() => obs.disconnect(), 30000);
        }
    });

    // ============================================================
    //  FEATURE MODULES
    //  Each wrapped in a try/catch to set status
    // ============================================================


    // ============================================================
    //  GHOST++ CORE (indexed template data-model + ingest worker)
    // ============================================================
    //  Ported verbatim from the standalone Ghost++ userscript
    //  (scripts/geopixels-ghost-template-overhaul/1.0.0.js, lines 37-632).
    //  createCore() / ingestWorkerMain() are renamed to their gpp-prefixed
    //  top-level equivalents so they live safely in GPC++'s single shared
    //  IIFE scope; internal logic is unchanged.
    /**
     * Pure production core. It deliberately has no DOM dependencies so the
     * monolithic userscript can be tested without regex-extracting source.
     */
    function gppCreateCore() {
        'use strict';

        const ALPHA_THRESHOLD = 128;
        const MAX_EXACT_COLORS = 65534;
        const MAX_U8_COLORS = 254;
        const RGB444_BUCKETS = 4096;
        const EMPTY_U8 = 0xFF;
        const EMPTY_U16 = 0xFFFF;
        const POSITION_OFFSET = 0x80000000;
        const POSITION_MARKER = Object.freeze([71, 80, 88, 255]);
        // Native ghost22.js's own "Group Noise" clustering threshold —
        // squared Euclidean RGB distance below which two colors are merged
        // into the same group (see groupPaletteColors below). Kept as a
        // named, exported constant so it can be tuned/verified against the
        // live native site later rather than buried as a magic number.
        const GROUPING_THRESHOLD_SQUARED = 2;
        const ERROR_STATE = Object.freeze({
            UNCHECKED: 0,
            CORRECT: 1,
            WRONG: 2,
            MISSING: 3,
            UNKNOWN: 4,
        });

        function abortError() {
            const error = new Error('Operation cancelled.');
            error.name = 'AbortError';
            return error;
        }

        function assertDimensions(rgba, width, height) {
            if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
                throw new TypeError('Image dimensions must be positive integers.');
            }
            if (!rgba || typeof rgba.length !== 'number' || rgba.length !== width * height * 4) {
                throw new TypeError('RGBA length does not match image dimensions.');
            }
        }

        function packRgb(r, g, b) {
            return ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF);
        }

        function unpackRgb(packed) {
            return Object.freeze({
                r: (packed >>> 16) & 0xFF,
                g: (packed >>> 8) & 0xFF,
                b: packed & 0xFF,
            });
        }

        function packedToHex(packed) {
            return `#${(packed & 0xFFFFFF).toString(16).padStart(6, '0').toUpperCase()}`;
        }

        function packedToRgbaString(packed) {
            const { r, g, b } = unpackRgb(packed);
            return `rgba(${r},${g},${b},1)`;
        }

        function hexToPacked(hex) {
            const match = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
            return match ? parseInt(match[1], 16) : null;
        }

        function emptyValue(indexType) {
            if (indexType === 'u8') return EMPTY_U8;
            if (indexType === 'u16') return EMPTY_U16;
            throw new TypeError(`Unsupported index type: ${indexType}`);
        }

        function makeIndexArray(indexType, length, buffer) {
            if (indexType === 'u8') return buffer ? new Uint8Array(buffer) : new Uint8Array(length);
            if (indexType === 'u16') return buffer ? new Uint16Array(buffer) : new Uint16Array(length);
            throw new TypeError(`Unsupported index type: ${indexType}`);
        }

        function makeFullMask(colorCount, counts) {
            const mask = new Uint32Array(Math.ceil(colorCount / 32));
            for (let index = 0; index < colorCount; index++) {
                if (!counts || counts[index] > 0) mask[index >>> 5] |= (1 << (index & 31));
            }
            return mask;
        }

        function normalizeMask(maskLike, colorCount, counts) {
            const expectedLength = Math.ceil(colorCount / 32);
            const mask = new Uint32Array(expectedLength);
            if (maskLike) {
                const source = maskLike instanceof Uint32Array ? maskLike : new Uint32Array(maskLike);
                mask.set(source.subarray(0, expectedLength));
            } else {
                mask.set(makeFullMask(colorCount, counts));
            }
            const trailing = colorCount & 31;
            if (trailing && mask.length) mask[mask.length - 1] &= (0xFFFFFFFF >>> (32 - trailing));
            return mask;
        }

        function maskHas(mask, index) {
            return index >= 0 && (index >>> 5) < mask.length && (mask[index >>> 5] & (1 << (index & 31))) !== 0;
        }

        function maskSet(mask, index, enabled) {
            if (index < 0 || (index >>> 5) >= mask.length) return false;
            const wordIndex = index >>> 5;
            const bit = 1 << (index & 31);
            const before = mask[wordIndex];
            mask[wordIndex] = enabled ? (before | bit) : (before & ~bit);
            return before !== mask[wordIndex];
        }

        function maskToggle(mask, index) {
            if (index < 0 || (index >>> 5) >= mask.length) return false;
            mask[index >>> 5] ^= (1 << (index & 31));
            return maskHas(mask, index);
        }

        function maskOnly(colorCount, index) {
            const mask = new Uint32Array(Math.ceil(colorCount / 32));
            maskSet(mask, index, true);
            return mask;
        }

        function exactIndexSync(rgba, width, height, options) {
            const total = width * height;
            const provisional = new Uint16Array(total);
            provisional.fill(EMPTY_U16);
            const colors = [];
            const countList = [];
            const colorToIndex = new Map();
            let opaquePixelCount = 0;

            for (let pixel = 0; pixel < total; pixel++) {
                if (options.isCancelled && options.isCancelled()) throw abortError();
                const offset = pixel * 4;
                if (rgba[offset + 3] <= ALPHA_THRESHOLD) continue;
                opaquePixelCount++;
                const packed = packRgb(rgba[offset], rgba[offset + 1], rgba[offset + 2]);
                let paletteIndex = colorToIndex.get(packed);
                if (paletteIndex === undefined) {
                    if (colors.length >= MAX_EXACT_COLORS) return null;
                    paletteIndex = colors.length;
                    colors.push(packed);
                    countList.push(0);
                    colorToIndex.set(packed, paletteIndex);
                }
                provisional[pixel] = paletteIndex;
                countList[paletteIndex]++;
                if (options.onProgress && (pixel & 0x3FFFF) === 0) {
                    options.onProgress(Math.min(0.48, (pixel / total) * 0.48), 'Indexing exact colours');
                }
            }

            const palette = Uint32Array.from(colors);
            const counts = Uint32Array.from(countList);
            if (colors.length <= MAX_U8_COLORS) {
                const indices = new Uint8Array(total);
                indices.fill(EMPTY_U8);
                for (let pixel = 0; pixel < total; pixel++) {
                    const value = provisional[pixel];
                    if (value !== EMPTY_U16) indices[pixel] = value;
                }
                return { indices, indexType: 'u8', palette, counts, opaquePixelCount, quantized: false, poorMatchPixelCount: 0 };
            }
            return { indices: provisional, indexType: 'u16', palette, counts, opaquePixelCount, quantized: false, poorMatchPixelCount: 0 };
        }

        function binnedIndexSync(rgba, width, height, options) {
            const total = width * height;
            const bucketCounts = new Uint32Array(RGB444_BUCKETS);
            const sumR = new Float64Array(RGB444_BUCKETS);
            const sumG = new Float64Array(RGB444_BUCKETS);
            const sumB = new Float64Array(RGB444_BUCKETS);
            let opaquePixelCount = 0;

            for (let pixel = 0; pixel < total; pixel++) {
                if (options.isCancelled && options.isCancelled()) throw abortError();
                const offset = pixel * 4;
                if (rgba[offset + 3] <= ALPHA_THRESHOLD) continue;
                const r = rgba[offset];
                const g = rgba[offset + 1];
                const b = rgba[offset + 2];
                const bucket = ((r >>> 4) << 8) | ((g >>> 4) << 4) | (b >>> 4);
                bucketCounts[bucket]++;
                sumR[bucket] += r;
                sumG[bucket] += g;
                sumB[bucket] += b;
                opaquePixelCount++;
                if (options.onProgress && (pixel & 0x3FFFF) === 0) {
                    options.onProgress(0.48 + (pixel / total) * 0.24, 'Reducing a high-colour image');
                }
            }

            const bucketToIndex = new Int32Array(RGB444_BUCKETS);
            bucketToIndex.fill(-1);
            const colors = [];
            const counts = [];
            for (let bucket = 0; bucket < RGB444_BUCKETS; bucket++) {
                const count = bucketCounts[bucket];
                if (!count) continue;
                const paletteIndex = colors.length;
                bucketToIndex[bucket] = paletteIndex;
                colors.push(packRgb(
                    Math.round(sumR[bucket] / count),
                    Math.round(sumG[bucket] / count),
                    Math.round(sumB[bucket] / count)
                ));
                counts.push(count);
            }

            const indices = new Uint16Array(total);
            indices.fill(EMPTY_U16);
            let poorMatchPixelCount = 0;
            for (let pixel = 0; pixel < total; pixel++) {
                if (options.isCancelled && options.isCancelled()) throw abortError();
                const offset = pixel * 4;
                if (rgba[offset + 3] <= ALPHA_THRESHOLD) continue;
                const r = rgba[offset];
                const g = rgba[offset + 1];
                const b = rgba[offset + 2];
                const bucket = ((r >>> 4) << 8) | ((g >>> 4) << 4) | (b >>> 4);
                const paletteIndex = bucketToIndex[bucket];
                indices[pixel] = paletteIndex;
                if (colors[paletteIndex] !== packRgb(r, g, b)) poorMatchPixelCount++;
                if (options.onProgress && (pixel & 0x3FFFF) === 0) {
                    options.onProgress(0.72 + (pixel / total) * 0.27, 'Writing indexed cells');
                }
            }

            return {
                indices,
                indexType: 'u16',
                palette: Uint32Array.from(colors),
                counts: Uint32Array.from(counts),
                opaquePixelCount,
                quantized: true,
                poorMatchPixelCount,
            };
        }

        function indexRgba(rgba, width, height, options = {}) {
            assertDimensions(rgba, width, height);
            const exact = exactIndexSync(rgba, width, height, options);
            const indexed = exact || binnedIndexSync(rgba, width, height, options);
            indexed.width = width;
            indexed.height = height;
            indexed.mask = makeFullMask(indexed.palette.length, indexed.counts);
            if (options.onProgress) options.onProgress(1, 'Ready');
            return indexed;
        }

        async function indexRgbaAsync(rgba, width, height, options = {}) {
            assertDimensions(rgba, width, height);
            const total = width * height;
            const yieldEvery = Math.max(4096, options.yieldEvery || 65536);
            const yieldControl = options.yieldControl || (() => new Promise(resolve => setTimeout(resolve, 0)));
            const provisional = new Uint16Array(total);
            provisional.fill(EMPTY_U16);
            const colors = [];
            const countList = [];
            const colorToIndex = new Map();
            let opaquePixelCount = 0;
            let exceeded = false;

            for (let start = 0; start < total; start += yieldEvery) {
                if (options.isCancelled && options.isCancelled()) throw abortError();
                const end = Math.min(total, start + yieldEvery);
                for (let pixel = start; pixel < end; pixel++) {
                    const offset = pixel * 4;
                    if (rgba[offset + 3] <= ALPHA_THRESHOLD) continue;
                    opaquePixelCount++;
                    const packed = packRgb(rgba[offset], rgba[offset + 1], rgba[offset + 2]);
                    let paletteIndex = colorToIndex.get(packed);
                    if (paletteIndex === undefined) {
                        if (colors.length >= MAX_EXACT_COLORS) { exceeded = true; break; }
                        paletteIndex = colors.length;
                        colors.push(packed);
                        countList.push(0);
                        colorToIndex.set(packed, paletteIndex);
                    }
                    provisional[pixel] = paletteIndex;
                    countList[paletteIndex]++;
                }
                if (options.onProgress) options.onProgress(Math.min(0.48, (end / total) * 0.48), 'Indexing exact colours');
                if (exceeded) break;
                await yieldControl();
            }

            let indexed;
            if (!exceeded) {
                const palette = Uint32Array.from(colors);
                const counts = Uint32Array.from(countList);
                if (colors.length <= MAX_U8_COLORS) {
                    const indices = new Uint8Array(total);
                    indices.fill(EMPTY_U8);
                    for (let start = 0; start < total; start += yieldEvery) {
                        if (options.isCancelled && options.isCancelled()) throw abortError();
                        const end = Math.min(total, start + yieldEvery);
                        for (let pixel = start; pixel < end; pixel++) {
                            if (provisional[pixel] !== EMPTY_U16) indices[pixel] = provisional[pixel];
                        }
                        if (options.onProgress) options.onProgress(0.48 + (end / total) * 0.51, 'Finalizing compact indices');
                        await yieldControl();
                    }
                    indexed = { indices, indexType: 'u8', palette, counts, opaquePixelCount, quantized: false, poorMatchPixelCount: 0 };
                } else {
                    indexed = { indices: provisional, indexType: 'u16', palette, counts, opaquePixelCount, quantized: false, poorMatchPixelCount: 0 };
                }
            } else {
                const bucketCounts = new Uint32Array(RGB444_BUCKETS);
                const sumR = new Float64Array(RGB444_BUCKETS);
                const sumG = new Float64Array(RGB444_BUCKETS);
                const sumB = new Float64Array(RGB444_BUCKETS);
                opaquePixelCount = 0;
                for (let start = 0; start < total; start += yieldEvery) {
                    if (options.isCancelled && options.isCancelled()) throw abortError();
                    const end = Math.min(total, start + yieldEvery);
                    for (let pixel = start; pixel < end; pixel++) {
                        const offset = pixel * 4;
                        if (rgba[offset + 3] <= ALPHA_THRESHOLD) continue;
                        const r = rgba[offset], g = rgba[offset + 1], b = rgba[offset + 2];
                        const bucket = ((r >>> 4) << 8) | ((g >>> 4) << 4) | (b >>> 4);
                        bucketCounts[bucket]++;
                        sumR[bucket] += r;
                        sumG[bucket] += g;
                        sumB[bucket] += b;
                        opaquePixelCount++;
                    }
                    if (options.onProgress) options.onProgress(0.48 + (end / total) * 0.24, 'Reducing a high-colour image');
                    await yieldControl();
                }
                const bucketToIndex = new Int32Array(RGB444_BUCKETS);
                bucketToIndex.fill(-1);
                const reducedColors = [];
                const reducedCounts = [];
                for (let bucket = 0; bucket < RGB444_BUCKETS; bucket++) {
                    const count = bucketCounts[bucket];
                    if (!count) continue;
                    bucketToIndex[bucket] = reducedColors.length;
                    reducedColors.push(packRgb(Math.round(sumR[bucket] / count), Math.round(sumG[bucket] / count), Math.round(sumB[bucket] / count)));
                    reducedCounts.push(count);
                }
                const indices = new Uint16Array(total);
                indices.fill(EMPTY_U16);
                let poorMatchPixelCount = 0;
                for (let start = 0; start < total; start += yieldEvery) {
                    if (options.isCancelled && options.isCancelled()) throw abortError();
                    const end = Math.min(total, start + yieldEvery);
                    for (let pixel = start; pixel < end; pixel++) {
                        const offset = pixel * 4;
                        if (rgba[offset + 3] <= ALPHA_THRESHOLD) continue;
                        const r = rgba[offset], g = rgba[offset + 1], b = rgba[offset + 2];
                        const bucket = ((r >>> 4) << 8) | ((g >>> 4) << 4) | (b >>> 4);
                        const paletteIndex = bucketToIndex[bucket];
                        indices[pixel] = paletteIndex;
                        if (reducedColors[paletteIndex] !== packRgb(r, g, b)) poorMatchPixelCount++;
                    }
                    if (options.onProgress) options.onProgress(0.72 + (end / total) * 0.27, 'Writing indexed cells');
                    await yieldControl();
                }
                indexed = {
                    indices,
                    indexType: 'u16',
                    palette: Uint32Array.from(reducedColors),
                    counts: Uint32Array.from(reducedCounts),
                    opaquePixelCount,
                    quantized: true,
                    poorMatchPixelCount,
                };
            }
            indexed.width = width;
            indexed.height = height;
            indexed.mask = makeFullMask(indexed.palette.length, indexed.counts);
            if (options.onProgress) options.onProgress(1, 'Ready');
            return indexed;
        }

        function indexedToRgba(indices, indexType, palette) {
            const empty = emptyValue(indexType);
            const rgba = new Uint8ClampedArray(indices.length * 4);
            for (let pixel = 0; pixel < indices.length; pixel++) {
                const index = indices[pixel];
                if (index === empty) continue;
                const packed = palette[index];
                const offset = pixel * 4;
                rgba[offset] = (packed >>> 16) & 0xFF;
                rgba[offset + 1] = (packed >>> 8) & 0xFF;
                rgba[offset + 2] = packed & 0xFF;
                rgba[offset + 3] = 255;
            }
            return rgba;
        }

        function computeGridBounds(position, width, height) {
            if (!position || !Number.isFinite(position.gridX) || !Number.isFinite(position.gridY)) return null;
            return Object.freeze({
                left: Math.round(position.gridX),
                top: Math.round(position.gridY),
                right: Math.round(position.gridX) + width - 1,
                bottom: Math.round(position.gridY) - height + 1,
            });
        }

        function transformIndexed(indices, indexType, width, height, operation) {
            let nextWidth = width;
            let nextHeight = height;
            if (operation === 'rotate-cw' || operation === 'rotate-ccw') {
                nextWidth = height;
                nextHeight = width;
            }
            const output = makeIndexArray(indexType, nextWidth * nextHeight);
            output.fill(emptyValue(indexType));
            for (let y = 0; y < height; y++) {
                for (let x = 0; x < width; x++) {
                    let nx = x, ny = y;
                    if (operation === 'flip-x') nx = width - 1 - x;
                    else if (operation === 'flip-y') ny = height - 1 - y;
                    else if (operation === 'rotate-cw') { nx = height - 1 - y; ny = x; }
                    else if (operation === 'rotate-ccw') { nx = y; ny = width - 1 - x; }
                    else throw new TypeError(`Unsupported transform: ${operation}`);
                    output[ny * nextWidth + nx] = indices[y * width + x];
                }
            }
            return { indices: output, width: nextWidth, height: nextHeight };
        }

        function templateHash(indices, palette, width, height) {
            let hash = 0x811C9DC5;
            function feed(value) {
                hash ^= value & 0xFF;
                hash = Math.imul(hash, 0x01000193) >>> 0;
            }
            feed(width); feed(width >>> 8); feed(height); feed(height >>> 8);
            const bytes = new Uint8Array(indices.buffer, indices.byteOffset, indices.byteLength);
            for (let index = 0; index < bytes.length; index++) feed(bytes[index]);
            for (let index = 0; index < palette.length; index++) {
                const value = palette[index];
                feed(value); feed(value >>> 8); feed(value >>> 16);
            }
            return hash.toString(16).padStart(8, '0');
        }

        function decodePositionHeader(rgba, width, height) {
            assertDimensions(rgba, width, height);
            if (height < 2 || width < 5) return { position: null, rgba, width, height };
            const xVotes = new Map();
            const yVotes = new Map();
            for (let x = 0; x + 4 < width; x += 5) {
                const marker = x * 4;
                if (rgba[marker] !== POSITION_MARKER[0] || rgba[marker + 1] !== POSITION_MARKER[1] ||
                    rgba[marker + 2] !== POSITION_MARKER[2] || rgba[marker + 3] !== POSITION_MARKER[3]) continue;
                const p1 = (x + 1) * 4;
                const p2 = (x + 2) * 4;
                const p3 = (x + 3) * 4;
                const p4 = (x + 4) * 4;
                const encodedX = (((rgba[p1] << 24) >>> 0) | (rgba[p1 + 1] << 16) | (rgba[p2] << 8) | rgba[p2 + 1]) >>> 0;
                const encodedY = (((rgba[p3] << 24) >>> 0) | (rgba[p3 + 1] << 16) | (rgba[p4] << 8) | rgba[p4 + 1]) >>> 0;
                xVotes.set(encodedX, (xVotes.get(encodedX) || 0) + 1);
                yVotes.set(encodedY, (yVotes.get(encodedY) || 0) + 1);
            }
            if (!xVotes.size || !yVotes.size) return { position: null, rgba, width, height };
            const winner = votes => {
                let bestValue = null, bestCount = -1;
                for (const [value, count] of votes) {
                    if (count > bestCount) { bestValue = value; bestCount = count; }
                }
                return bestValue;
            };
            const encodedX = winner(xVotes);
            const encodedY = winner(yVotes);
            const position = {
                gridX: Number(BigInt(encodedX) - BigInt(POSITION_OFFSET)),
                gridY: Number(BigInt(encodedY) - BigInt(POSITION_OFFSET)),
            };
            const stripped = new Uint8ClampedArray(width * (height - 1) * 4);
            stripped.set(rgba.subarray(width * 4));
            return { position, rgba: stripped, width, height: height - 1 };
        }

        function encodePositionHeader(rgba, width, height, position) {
            assertDimensions(rgba, width, height);
            if (!position || !Number.isInteger(position.gridX) || !Number.isInteger(position.gridY)) {
                throw new TypeError('Integer grid coordinates are required for position export.');
            }
            const output = new Uint8ClampedArray(width * (height + 1) * 4);
            output.set(rgba, width * 4);
            const encodedX = Number((BigInt(position.gridX) + BigInt(POSITION_OFFSET)) & 0xFFFFFFFFn) >>> 0;
            const encodedY = Number((BigInt(position.gridY) + BigInt(POSITION_OFFSET)) & 0xFFFFFFFFn) >>> 0;
            for (let x = 0; x + 4 < width; x += 5) {
                const marker = x * 4;
                output.set(POSITION_MARKER, marker);
                const p1 = (x + 1) * 4, p2 = (x + 2) * 4, p3 = (x + 3) * 4, p4 = (x + 4) * 4;
                output[p1] = (encodedX >>> 24) & 0xFF; output[p1 + 1] = (encodedX >>> 16) & 0xFF; output[p1 + 3] = 255;
                output[p2] = (encodedX >>> 8) & 0xFF; output[p2 + 1] = encodedX & 0xFF; output[p2 + 3] = 255;
                output[p3] = (encodedY >>> 24) & 0xFF; output[p3 + 1] = (encodedY >>> 16) & 0xFF; output[p3 + 3] = 255;
                output[p4] = (encodedY >>> 8) & 0xFF; output[p4 + 1] = encodedY & 0xFF; output[p4 + 3] = 255;
            }
            return { rgba: output, width, height: height + 1 };
        }

        function compareRealityPixel(expectedIndex, mapR, mapG, mapB, mapAlpha, paletteLookup, tileKnown, groupOfIndex) {
            if (!tileKnown) return ERROR_STATE.UNKNOWN;
            if (mapAlpha <= ALPHA_THRESHOLD) return ERROR_STATE.MISSING;
            const actualIndex = paletteLookup.get(packRgb(mapR, mapG, mapB));
            if (actualIndex === expectedIndex) return ERROR_STATE.CORRECT;
            // Group Noise: a placed pixel matching any OTHER colour in the
            // same near-duplicate group as the expected colour still counts
            // as correct — mirrors native ghost22.js's fuzzy progress/error
            // matching. groupOfIndex is null/undefined whenever
            // template.groupNoise is off, so this branch never executes and
            // behaviour is byte-identical to before this parameter existed.
            if (groupOfIndex && actualIndex !== undefined && groupOfIndex[actualIndex] === groupOfIndex[expectedIndex]) {
                return ERROR_STATE.CORRECT;
            }
            return ERROR_STATE.WRONG;
        }

        // Greedy nearest-neighbour colour clustering, matching native
        // ghost22.js's "Group Noise" definition exactly: walk the palette in
        // order; for each colour, join the closest EXISTING group (by
        // squared RGB distance to that group's original representative —
        // never an average) if within thresholdSquared, else start a new
        // group with itself as representative.
        //
        // Optimization note: a brute-force "compare against every existing
        // group's representative" is O(n * g) — worst case O(n^2) against a
        // palette that can legitimately reach MAX_EXACT_COLORS (65534) for a
        // photo-derived source image. Since any two colours within
        // thresholdSquared must have each of |dr|,|db|,|dg| bounded by
        // floor(sqrt(thresholdSquared)) (a single channel differing by more
        // already exceeds the threshold on its own), only representatives in
        // that small integer neighbourhood around the current colour can
        // ever be a match. Indexing representatives by their exact (r,g,b)
        // in a Map and only probing that neighbourhood finds the exact same
        // minimum-distance match a brute-force scan would, in O(n) instead.
        function groupPaletteColors(palette, thresholdSquared) {
            if (typeof thresholdSquared !== 'number') thresholdSquared = GROUPING_THRESHOLD_SQUARED;
            const length = palette.length;
            const groupOfIndex = new Int32Array(length);
            const groups = [];
            const groupByRepresentative = new Map();
            const radius = Math.max(0, Math.floor(Math.sqrt(thresholdSquared)));
            const repByBucket = new Map(); // exact (r,g,b) key -> representative palette index
            const bucketKey = (r, g, b) => (r * 256 + g) * 256 + b;

            for (let i = 0; i < length; i++) {
                const packed = palette[i];
                const r = (packed >>> 16) & 0xFF;
                const g = (packed >>> 8) & 0xFF;
                const b = packed & 0xFF;

                let bestRep = -1;
                let bestDist = Infinity;
                for (let dr = -radius; dr <= radius; dr++) {
                    const nr = r + dr;
                    if (nr < 0 || nr > 255) continue;
                    for (let dg = -radius; dg <= radius; dg++) {
                        const ng = g + dg;
                        if (ng < 0 || ng > 255) continue;
                        for (let db = -radius; db <= radius; db++) {
                            const nb = b + db;
                            if (nb < 0 || nb > 255) continue;
                            const rep = repByBucket.get(bucketKey(nr, ng, nb));
                            if (rep === undefined) continue;
                            const dist = dr * dr + dg * dg + db * db;
                            if (dist <= thresholdSquared && dist < bestDist) {
                                bestDist = dist;
                                bestRep = rep;
                            }
                        }
                    }
                }

                if (bestRep !== -1) {
                    groupOfIndex[i] = bestRep;
                    groupByRepresentative.get(bestRep).memberIndices.push(i);
                } else {
                    groupOfIndex[i] = i;
                    const group = { representativeIndex: i, memberIndices: [i] };
                    groups.push(group);
                    groupByRepresentative.set(i, group);
                    repByBucket.set(bucketKey(r, g, b), i);
                }
            }

            return Object.freeze({ groupOfIndex, groups, groupByRepresentative });
        }

        return Object.freeze({
            constants: Object.freeze({
                ALPHA_THRESHOLD,
                MAX_EXACT_COLORS,
                MAX_U8_COLORS,
                EMPTY_U8,
                EMPTY_U16,
                POSITION_OFFSET,
                POSITION_MARKER,
                ERROR_STATE,
                GROUPING_THRESHOLD_SQUARED,
            }),
            packRgb,
            unpackRgb,
            packedToHex,
            packedToRgbaString,
            hexToPacked,
            emptyValue,
            makeIndexArray,
            makeFullMask,
            normalizeMask,
            maskHas,
            maskSet,
            maskToggle,
            maskOnly,
            indexRgba,
            indexRgbaAsync,
            indexedToRgba,
            computeGridBounds,
            transformIndexed,
            templateHash,
            decodePositionHeader,
            encodePositionHeader,
            compareRealityPixel,
            groupPaletteColors,
        });
    }

    function gppIngestWorkerMain(createCore) {
        'use strict';
        const core = createCore();
        self.onmessage = async event => {
            const message = event.data || {};
            if (message.type !== 'ingest') return;
            const operationId = message.operationId;
            let bitmap = null;
            try {
                self.postMessage({ type: 'progress', operationId, fraction: 0.02, label: 'Decoding image' });
                bitmap = await createImageBitmap(message.file);
                const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
                const context = canvas.getContext('2d', { willReadFrequently: true, colorSpace: 'srgb' });
                context.drawImage(bitmap, 0, 0);
                let rgba = context.getImageData(0, 0, bitmap.width, bitmap.height).data;
                let width = bitmap.width;
                let height = bitmap.height;
                const decoded = core.decodePositionHeader(rgba, width, height);
                rgba = decoded.rgba;
                width = decoded.width;
                height = decoded.height;
                const indexed = core.indexRgba(rgba, width, height, {
                    onProgress: (fraction, label) => self.postMessage({
                        type: 'progress',
                        operationId,
                        fraction: 0.05 + fraction * 0.94,
                        label,
                    }),
                });
                const payload = {
                    type: 'complete',
                    operationId,
                    width,
                    height,
                    position: decoded.position,
                    indexType: indexed.indexType,
                    indices: indexed.indices.buffer,
                    palette: indexed.palette.buffer,
                    counts: indexed.counts.buffer,
                    mask: indexed.mask.buffer,
                    opaquePixelCount: indexed.opaquePixelCount,
                    quantized: indexed.quantized,
                    poorMatchPixelCount: indexed.poorMatchPixelCount,
                };
                self.postMessage(payload, [payload.indices, payload.palette, payload.counts, payload.mask]);
            } catch (error) {
                self.postMessage({
                    type: 'error',
                    operationId,
                    name: error && error.name ? error.name : 'Error',
                    message: error && error.message ? error.message : String(error),
                });
            } finally {
                if (bitmap) bitmap.close();
            }
        };
    }

    // Builds the source string for the Ghost++ ingest Worker's Blob URL.
    // Mirrors the original 1.0.0.js mechanism exactly (see its use of
    // `new Blob([workerSource], { type: 'text/javascript' })`): the
    // ingest-worker IIFE is invoked with the core-factory function's
    // source passed in as its literal `createCore` argument, so the
    // worker never needs a free/global reference to resolve it.
    function gppBuildIngestWorkerSource() {
        return ';(' + gppIngestWorkerMain.toString() + ')(' + gppCreateCore.toString() + ');';
    }

    // ── Ghost++ page-realm bridge ─────────────────────────────────────
    // GeoPixels keeps map/grid/palette/native-ghost state in top-level lexical
    // `let` bindings, which are invisible to `window`/`unsafeWindow` property
    // access. Every other GPC++ feature that needs one of these bindings goes
    // through the same two techniques already proven in this codebase:
    //   - READ: `(0, eval)('name')` first (works when Tampermonkey runs us with
    //     enough page-realm visibility), falling back to `unsafeWindow.eval('name')`.
    //     This is exactly core.js's `_getMapRef()` pattern, generalized.
    //   - WRITE (and any statement more complex than a read): inject a `<script>`
    //     tag whose textContent runs the assignment in the page's own realm, then
    //     remove it. This is ghost-palette-search.js's `commitGhostPaletteChanges`
    //     technique. Direct `unsafeWindow.eval('x = 1')` writes are NOT used here
    //     because they are unreliable across browsers/Tampermonkey configurations;
    //     script injection is the cross-browser-tested approach in this codebase.
    //
    // Ghost++ itself runs entirely in the normal Tampermonkey sandbox, like every
    // other GPC++ feature — it does not serialize its own UI/renderer/state into
    // the page realm the way the original standalone 1.0.0.js did. Only these
    // narrow, named touch points cross the realm boundary.

    function gppEvalPageExpr(expr) {
        try {
            const value = (0, eval)(expr);
            if (value !== undefined) return value;
        } catch (_) { /* fall through */ }
        if (typeof unsafeWindow !== 'undefined') {
            try { return unsafeWindow.eval(expr); } catch (_) { /* fall through */ }
        }
        return undefined;
    }

    function gppGetMap() {
        const m = gppEvalPageExpr('map');
        return (m && typeof m.project === 'function') ? m : null;
    }

    function gppGetTurf() {
        const t = gppEvalPageExpr('turf');
        return (t && typeof t.toWgs84 === 'function') ? t : null;
    }

    // Single round-trip read of every small page-realm value Ghost++ needs on a
    // steady-state basis. Cheap to call often; callers should still avoid calling
    // it inside a per-pixel/per-frame loop.
    function gppReadGridConstants() {
        const raw = gppEvalPageExpr(
            '({' +
            'gridSize: (typeof gridSize !== "undefined" ? gridSize : 25),' +
            'halfSize: (typeof halfSize !== "undefined" ? halfSize : 12.5),' +
            'minZoom: (typeof minZoom !== "undefined" ? minZoom : 10.5),' +
            'tileSize: (typeof SYNC_TILE_SIZE !== "undefined" ? SYNC_TILE_SIZE : 1000),' +
            'offsetMetersX: (typeof offsetMetersX !== "undefined" ? offsetMetersX : 0),' +
            'offsetMetersY: (typeof offsetMetersY !== "undefined" ? offsetMetersY : 0)' +
            '})'
        );
        return raw || { gridSize: 25, halfSize: 12.5, minZoom: 10.5, tileSize: 1000, offsetMetersX: 0, offsetMetersY: 0 };
    }

    // Returns the account's current palette as [{hex, index, owned}]. `Colors`
    // is replaced wholesale on login with the account's purchased colours
    // (index148.js), so this must be re-read each time it's needed rather than
    // cached at startup.
    function gppReadGamePalette() {
        const raw = gppEvalPageExpr(
            '(function(){' +
            'if (typeof Colors === "undefined" || !Array.isArray(Colors)) return null;' +
            'var active = (typeof activeColors !== "undefined" && Array.isArray(activeColors)) ? activeColors : [];' +
            'var activeSet = {};' +
            'for (var i = 0; i < active.length; i++) activeSet[active[i]] = true;' +
            'var out = [];' +
            'for (var j = 0; j < Colors.length; j++) out.push({ hex: Colors[j], index: j, active: !!activeSet[j] });' +
            'return out;' +
            '})()'
        );
        return raw || [];
    }

    // One bulk read of every "gridX,gridY" key currently reserved in the
    // native queue (`queuedPixels`, a page-realm `Map` — same realm-mismatch
    // reasoning as the rest of this file: a bare `let` binding, never a
    // `window` property). A single round-trip returning a plain string array
    // rather than one gppEvalPageExpr call per on-screen cell, which the
    // error-crosshair redraw loop (gpp-scan.js) would otherwise need — that
    // loop can iterate thousands of cells per redraw.
    function gppReadQueuedPixelKeys() {
        const raw = gppEvalPageExpr(
            '(typeof queuedPixels !== "undefined" && queuedPixels && typeof queuedPixels.keys === "function")' +
            ' ? Array.from(queuedPixels.keys()) : []'
        );
        return Array.isArray(raw) ? raw : [];
    }

    // Direct bitmap access into the live map's tile cache for progress/error
    // scanning. Returns null if the tile hasn't synced. `tileImageCache` is a
    // page-realm `Map`; ImageBitmap values cross the sandbox boundary by
    // reference the same way `map` itself does.
    function gppGetTileBitmap(tileX, tileY) {
        const bitmap = gppEvalPageExpr(
            '(function(){' +
            'if (typeof tileImageCache === "undefined") return null;' +
            'var entry = tileImageCache.get("' + tileX + ',' + tileY + '");' +
            'return (entry && entry.colorBitmap) ? entry.colorBitmap : null;' +
            '})()'
        );
        return bitmap || null;
    }

    // Returns the native guild menu's `userGuildData` object (or null),
    // freshly re-read every call — same "must be re-read, never cached"
    // reasoning as gppReadGamePalette, since `userGuildData` is reassigned
    // wholesale (not mutated) whenever the guild modal repopulates or the
    // user leaves/joins a guild (js/index148.js). Used by
    // gpp-native-shim.js's guild "Set as Ghost" rewire, which previously
    // read this via `unsafeWindow.userGuildData` (a `let` binding, so that
    // property access always silently returned undefined — the actual
    // cause of its "Could not find the selected project." error).
    function gppReadNativeGuildData() {
        return gppEvalPageExpr('(typeof userGuildData !== "undefined" ? userGuildData : null)') || null;
    }

    // Returns the account's auth token + numeric user id, freshly re-read
    // every call (same realm-mismatch reasoning as gppReadNativeGuildData —
    // `tokenUser`/`userData` are both top-level `let` bindings in
    // js/index148.js). Used for authenticated fetches Ghost++ itself needs
    // to make (e.g. POST /GetMyGuildProjects for the Guild Templates
    // section), mirroring the exact { Token, UserId } shape js/index148.js's
    // own fetchGuildProjects() sends.
    function gppReadNativeAuth() {
        const raw = gppEvalPageExpr(
            '({' +
            'token: (typeof tokenUser !== "undefined" ? tokenUser : null),' +
            'userId: (typeof userData !== "undefined" && userData ? userData.id : null)' +
            '})'
        );
        return raw || { token: null, userId: null };
    }

    // Runs arbitrary statements in the page realm via a throwaway injected
    // <script> element. `code` must be a complete, self-contained statement
    // list (no return value is captured — for reads, use gppEvalPageExpr).
    function gppRunInPageRealm(code) {
        const script = document.createElement('script');
        script.textContent = '(function(){ "use strict"; ' + code + ' })();';
        (document.head || document.documentElement).appendChild(script);
        script.remove();
    }

    // ── Native ghost state snapshot / restore (for the compatibility shim) ──
    // Ghost++ mirrors only its focused template into these bindings so native
    // consumers (cursor sampling, error crosses, location readout, guild
    // actions) keep working while Ghost++ owns the overlay. See
    // gpp-runtime.js's compatibility-shim section for the mirror-building side;
    // this file only provides the raw read/write primitives.

    function gppReadNativeGhostSnapshot() {
        return gppEvalPageExpr(
            '(function(){' +
            'return {' +
            'hasGhostImage: (typeof ghostImage !== "undefined" && !!ghostImage),' +
            'ghostImageTopLeft: (typeof ghostImageTopLeft !== "undefined" ? ghostImageTopLeft : null),' +
            'isColorFilterDisabled: (typeof isColorFilterDisabled !== "undefined" ? isColorFilterDisabled : null)' +
            '};' +
            '})()'
        );
    }

    // Clears whatever is currently painted onto the native ghost overlay
    // canvas (id="ghost-canvas"). Native `drawGhostImageOnCanvas()` always
    // clearRects that canvas before deciding whether to redraw (see
    // js/ghost22.js), so once Ghost++ replaces it with a no-op
    // (gppNativeDrawReplacement), whatever the native tool — or the legacy
    // Ghost Template Manager driving that same native tool — had already
    // painted stays frozen on screen forever, since nothing ever clears it
    // again. Call once when the shim first takes over the overlay slot; the
    // no-op keeps it that way afterwards so no repeated clearing is needed.
    function gppClearNativeGhostCanvasPaint() {
        gppRunInPageRealm(
            'if (typeof ghostCanvasCtx !== "undefined" && ghostCanvasCtx && typeof ghostCanvas !== "undefined" && ghostCanvas) {' +
            'ghostCanvasCtx.clearRect(0, 0, ghostCanvas.width, ghostCanvas.height);' +
            '}'
        );
    }

    // Writes the focused Ghost++ template's RGBA ImageData, dominant-colour
    // map, active-colour set, top-left position, and filter-disabled flag into
    // the native lexical bindings so native consumers read the focused
    // template instead of stale or empty state. `payload` fields are all
    // optional; only provided fields are written. Large/complex values
    // (ImageData, Map, Set) are passed as page-realm-constructible descriptors
    // rather than serialized wholesale, since ImageData does not survive a
    // naive string round-trip.
    function gppWriteNativeGhostMirror(descriptorCode) {
        gppRunInPageRealm(descriptorCode);
    }

    // ── Native function suppression ───────────────────────────────────────
    // `drawGhostImageOnCanvas`, `regenerateGhostCanvas`, and
    // `initializeGhostFromStorage` are top-level `function` DECLARATIONS in
    // ghost22.js, which — unlike `let` bindings — attach to `window` normally,
    // so direct assignment (through `unsafeWindow` for reliability) is enough
    // to monkey-patch them; no script injection is needed for this specific
    // case. This mirrors GTM's `patchGhostModalToggleForRemembering`. No
    // restore path exists: the shim is unconditional for as long as Ghost++
    // itself is enabled (see gpp-native-shim.js), so these patches are never
    // handed back.
    const GPP_NATIVE_FN_NAMES = ['drawGhostImageOnCanvas', 'regenerateGhostCanvas', 'initializeGhostFromStorage'];

    function gppSetNativeFunction(name, replacement) {
        const target = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        if (GPP_NATIVE_FN_NAMES.indexOf(name) === -1) throw new Error('gppSetNativeFunction: unexpected name ' + name);
        target[name] = replacement;
    }

    // ── Ghost++ legacy history bridge ───────────────────────────────────
    // Unconditional (not a setting — by explicit product decision, always
    // on) integration with the legacy Ghost Template Manager's
    // (ghost-template-manager.js) own IndexedDB template history —
    // 'GP_Ghost_History' v3, store 'images' ({id (autoincrement), blob,
    // name, date, hash, templateId, groupNoise}). Ghost++ reads its
    // template list FROM that store (merged with anything still sitting in
    // its own OLD private gpp-runtime.js store, from before this was
    // unconditional — see gppLoadTemplateLibrary) and writes new/deleted
    // templates back into it, so switching between the legacy ghost menu
    // and the new Ghost++ panel is seamless in both directions: a template
    // created in either shows up in the other, and deleting it from either
    // removes it from both.
    //
    // Schema coupling risk: this file opens 'GP_Ghost_History' at a
    // HARDCODED version (GPP_LEGACY_DB_VERSION) matching
    // ghost-template-manager.js's own DB_VERSION as of this writing. If
    // that file's schema/version ever changes again, this constant must be
    // updated to match — IndexedDB refuses to open a connection at a
    // version lower than the database's current on-disk version.
    //
    // Position-header compatibility: gpp-core.js's decodePositionHeader/
    // encodePositionHeader already implement the exact same 5-pixel-packet,
    // marker-byte-71/80/88, POSITION_OFFSET=0x80000000 format
    // ghost-template-manager.js's encodeRobustPosition/decodeRobustPosition
    // use (confirmed by direct comparison of both implementations), so
    // blobs written by either feature decode identically in the other.
    //
    // Public surface (the only names other Ghost++ files should call):
    //   gppLegacyListImages()               -> Promise<Array<record>>
    //   gppLegacyAddImage(blob,name,noise)  -> Promise<{id,hash,templateId,name,date,groupNoise}>
    //   gppLegacyDeleteImage(id)            -> Promise<void>
    //   gppLegacyComputeTemplateId(blob)    -> Promise<string>  (exposed for tests)

    const GPP_LEGACY_DB_NAME = 'GP_Ghost_History';
    const GPP_LEGACY_DB_VERSION = 3;
    const GPP_LEGACY_STORE_NAME = 'images';

    let gppLegacyDbPromise = null;

    // Mirrors ghost-template-manager.js's own onupgradeneeded exactly
    // (idempotent contains-checks rather than oldVersion branching), so
    // whichever feature happens to be the first to ever open this database
    // on a given profile creates the identical schema the other expects.
    function gppLegacyOpenDatabase() {
        if (!gppLegacyDbPromise) {
            gppLegacyDbPromise = new Promise((resolve, reject) => {
                const request = indexedDB.open(GPP_LEGACY_DB_NAME, GPP_LEGACY_DB_VERSION);
                request.onupgradeneeded = () => {
                    const db = request.result;
                    const txn = request.transaction;
                    let store;
                    if (!db.objectStoreNames.contains(GPP_LEGACY_STORE_NAME)) {
                        store = db.createObjectStore(GPP_LEGACY_STORE_NAME, { keyPath: 'id', autoIncrement: true });
                    } else {
                        store = txn.objectStore(GPP_LEGACY_STORE_NAME);
                    }
                    if (!store.indexNames.contains('hash')) store.createIndex('hash', 'hash', { unique: false });
                    if (!store.indexNames.contains('templateId')) store.createIndex('templateId', 'templateId', { unique: false });
                };
                request.onsuccess = () => resolve(request.result);
                request.onerror = () => reject(request.error);
            }).catch(err => {
                gppLegacyDbPromise = null;
                throw err;
            });
        }
        return gppLegacyDbPromise;
    }

    async function gppLegacyComputeFileHash(blob) {
        const buffer = await blob.arrayBuffer();
        const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
        const bytes = Array.from(new Uint8Array(hashBuffer));
        return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
    }

    // Mirrors ghost-template-manager.js's computeTemplateId(): hashes the
    // position-header-STRIPPED image content, so the same template is
    // recognised as "the same" regardless of where it's been placed —
    // required for cross-feature duplicate detection to behave the same
    // way it already does inside the legacy menu alone. Uses gpp-core.js's
    // own decodePositionHeader (byte-compatible, see file banner) rather
    // than re-implementing GTM's separate canvas-based decoder.
    async function gppLegacyComputeTemplateId(blob) {
        try {
            const core = gppCreateCore();
            const bitmap = await createImageBitmap(blob);
            const canvas = document.createElement('canvas');
            canvas.width = bitmap.width;
            canvas.height = bitmap.height;
            const ctx = canvas.getContext('2d', { willReadFrequently: true });
            ctx.drawImage(bitmap, 0, 0);
            bitmap.close();
            const rgba = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
            const decoded = core.decodePositionHeader(rgba, canvas.width, canvas.height);
            if (!decoded.position) return await gppLegacyComputeFileHash(blob);
            const cleanCanvas = document.createElement('canvas');
            cleanCanvas.width = decoded.width;
            cleanCanvas.height = decoded.height;
            cleanCanvas.getContext('2d').putImageData(
                new ImageData(new Uint8ClampedArray(decoded.rgba), decoded.width, decoded.height), 0, 0
            );
            const cleanBlob = await new Promise(resolve => cleanCanvas.toBlob(resolve, 'image/png'));
            return await gppLegacyComputeFileHash(cleanBlob);
        } catch (_) {
            // On any decode failure, fall back to hashing the raw blob —
            // matches GTM's own fallback exactly.
            return await gppLegacyComputeFileHash(blob);
        }
    }

    function gppLegacyListImages() {
        return gppLegacyOpenDatabase().then(db => new Promise((resolve, reject) => {
            const tx = db.transaction(GPP_LEGACY_STORE_NAME, 'readonly');
            const request = tx.objectStore(GPP_LEGACY_STORE_NAME).getAll();
            request.onsuccess = () => resolve(request.result || []);
            request.onerror = () => reject(request.error);
        }));
    }

    // Update-in-place on a templateId match (same numeric id kept) rather
    // than ghost-template-manager.js's own delete-then-recreate — a
    // deliberate difference: Ghost++ keys its OWN per-template state
    // (gpp-runtime.js's GPP_STATE_STORE) by 'legacy_'+id, so recreating a
    // matched record under a fresh id would silently orphan the user's
    // mask/position/opacity on every re-ingest of a duplicate image. The
    // only user-visible effect of this choice is GTM's own history list
    // ordering (newest-first, by insertion) — an updated record keeps its
    // original position in that list instead of jumping to the top, which
    // degrades gracefully.
    async function gppLegacyAddImage(blob, name, groupNoise) {
        const db = await gppLegacyOpenDatabase();
        const hash = await gppLegacyComputeFileHash(blob);
        const templateId = await gppLegacyComputeTemplateId(blob);
        const finalName = name || ('Image_' + Date.now());
        const date = Date.now();
        return new Promise((resolve, reject) => {
            const tx = db.transaction(GPP_LEGACY_STORE_NAME, 'readwrite');
            const store = tx.objectStore(GPP_LEGACY_STORE_NAME);
            const lookup = store.index('templateId').get(templateId);
            let resultId = null;
            lookup.onsuccess = () => {
                const existing = lookup.result;
                const item = { blob, name: finalName, date, hash, templateId, groupNoise: !!groupNoise };
                if (existing) {
                    item.id = existing.id;
                    resultId = existing.id;
                    store.put(item);
                } else {
                    const addRequest = store.add(item);
                    addRequest.onsuccess = () => { resultId = addRequest.result; };
                }
            };
            tx.oncomplete = () => resolve({ id: resultId, hash, templateId, name: finalName, date, groupNoise: !!groupNoise });
            tx.onerror = () => reject(tx.error);
        });
    }

    function gppLegacyDeleteImage(id) {
        return gppLegacyOpenDatabase().then(db => new Promise((resolve, reject) => {
            const tx = db.transaction(GPP_LEGACY_STORE_NAME, 'readwrite');
            tx.objectStore(GPP_LEGACY_STORE_NAME).delete(id);
            tx.oncomplete = () => resolve();
            tx.onerror = () => reject(tx.error);
        }));
    }

    // ── Ghost++ runtime core ──────────────────────────────────────────
    // Settings, IndexedDB persistence, template state, and the ingestion
    // pipeline (file/drag/paste -> Worker -> indexed template -> stored
    // record). This is the shared state every other Ghost++ module
    // (renderer, native shim, placement, scan, palette/library UI) reads
    // and writes through the small `gppState` surface at the bottom of
    // this file — no other file should declare its own copy of `templates`,
    // `settings`, or `database`.

    const GPP_SETTINGS_KEY = 'gpGhostPlusPlusSettings.v1';
    const GPP_DB_NAME = 'GP_Ghost_Plus_Plus';
    const GPP_DB_VERSION = 1;
    const GPP_TEMPLATE_STORE = 'templates';
    const GPP_STATE_STORE = 'templateState';

    const GPP_DEFAULT_SETTINGS = Object.freeze({
        gapRatio: 0.6,         // 0-1; cell fill/gap ratio, global (all templates)
        // No modal-position memory setting — the modal always re-centers on
        // open (see gpp-init.js's open()), by explicit product decision.
        focusedTemplateId: null,
        showErrors: true,
        autoscanEnabled: false,
        hideQueuedCrosses: true,  // crosshairs over an already-queued pixel stop drawing, defaults on
        errorShape: 'x',         // 'x' | 'circle' | 'square'
        errorColor: '#dc2626',
        errorOpacity: 1,          // 0-1
        errorSizeScale: 1,        // multiplier on the base marker size
        autoHideUnfocused: true, // when true, focusing a template hides every other one — see gppApplyAutoHideUnfocused
        grayDisabledSwatches: true, // when false, a disabled palette swatch only gets the diagonal slash, no grayscale/opacity dimming — see gpp-palette.js's .gpp-palette-gray-disabled
        paletteViewMode: 'grid', // 'grid' | 'list' — see gpp-palette.js's gpp-palette-list-mode
    });

    let gppSettings = gppLoadSettings();
    let gppDatabase = null;
    let gppTemplates = [];          // in-memory list of { id, name, core:{indices,indexType,palette,counts,mask,width,height}, position, opacity, locked, order, scanSummary }
    let gppFocusedTemplateId = gppSettings.focusedTemplateId;
    // Guild project templates (gpp-guild-templates.js) — deliberately a
    // SEPARATE array from gppTemplates, never merged into it: they must
    // never appear in the main Templates grid or Manage Templates modal
    // (both iterate gppState.templates directly), and every template
    // object in here carries `ephemeral: true`, which gppPersistTemplateState/
    // gppPersistTemplateCore both check and refuse to write — guild
    // projects change frequently server-side and must be re-fetched fresh
    // every page load, never cached in IndexedDB. Repopulated once per
    // page session by gpp-guild-templates.js's one-shot fetch; never
    // touched anywhere else.
    let gppGuildTemplates = [];
    let gppIngestOperation = null;  // { worker, operationId, cancel() } while an import is in flight
    let gppIngestToken = 0;

    function gppLoadSettings() {
        try {
            const raw = localStorage.getItem(GPP_SETTINGS_KEY);
            if (!raw) return { ...GPP_DEFAULT_SETTINGS };
            return { ...GPP_DEFAULT_SETTINGS, ...JSON.parse(raw) };
        } catch (_) {
            return { ...GPP_DEFAULT_SETTINGS };
        }
    }

    function gppSaveSettings() {
        localStorage.setItem(GPP_SETTINGS_KEY, JSON.stringify(gppSettings));
    }

    // ── IndexedDB v2-style split: immutable core payload vs mutable state ──
    // `templates`: id, name, width, height, indexType, indices/palette/counts/mask
    // buffers, source Blob, opaquePixelCount, quantized, poorMatchPixelCount,
    // hash. Written once on import or transform (flip/rotate rewrites the core).
    // `templateState`: id, position, opacity, visible, locked, order, mask
    // (the live filter bitset — distinct from the core's initial full mask).
    // Written on every ordinary edit (toggle a colour, move, rename) without
    // touching the core payload.
    function gppOpenDatabase() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(GPP_DB_NAME, GPP_DB_VERSION);
            request.onupgradeneeded = () => {
                const db = request.result;
                if (!db.objectStoreNames.contains(GPP_TEMPLATE_STORE)) {
                    db.createObjectStore(GPP_TEMPLATE_STORE, { keyPath: 'id' });
                }
                if (!db.objectStoreNames.contains(GPP_STATE_STORE)) {
                    db.createObjectStore(GPP_STATE_STORE, { keyPath: 'id' });
                }
            };
            request.onsuccess = () => resolve(request.result);
            request.onerror = () => reject(request.error);
        });
    }

    function gppDbGetAll(storeName) {
        return new Promise((resolve, reject) => {
            const tx = gppDatabase.transaction(storeName, 'readonly');
            const request = tx.objectStore(storeName).getAll();
            request.onsuccess = () => resolve(request.result || []);
            request.onerror = () => reject(request.error);
        });
    }

    function gppDbPut(storeName, record) {
        return new Promise((resolve, reject) => {
            const tx = gppDatabase.transaction(storeName, 'readwrite');
            tx.objectStore(storeName).put(record);
            tx.oncomplete = () => resolve();
            tx.onerror = () => reject(tx.error);
        });
    }

    function gppDbDelete(storeName, id) {
        return new Promise((resolve, reject) => {
            const tx = gppDatabase.transaction(storeName, 'readwrite');
            tx.objectStore(storeName).delete(id);
            tx.oncomplete = () => resolve();
            tx.onerror = () => reject(tx.error);
        });
    }

    function gppMakeTemplateId() {
        return 'gpp_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
    }

    // Decoded-fields cache for shared-library images, keyed by the shared
    // record's own `hash` — avoids re-running the indexing Worker on every
    // library (re)load for an image whose content hasn't changed. Never
    // explicitly evicted; a full page reload naturally clears it, matching
    // gpp-library.js's thumbnail-cache lifetime assumption.
    const gppLegacyDecodeCache = new Map();

    async function gppLegacyDecodeCached(image) {
        if (image.hash && gppLegacyDecodeCache.has(image.hash)) return gppLegacyDecodeCache.get(image.hash);
        const decoded = await gppDecodeImageBlob(image.blob);
        if (image.hash) gppLegacyDecodeCache.set(image.hash, decoded);
        return decoded;
    }

    // Loads every image from the legacy Ghost Template Manager's shared
    // GP_Ghost_History store and decodes each into Ghost++'s indexed
    // template model, joined against Ghost++'s OWN per-template state
    // (GPP_STATE_STORE, keyed 'legacy_'+numeric-id — the SAME store private
    // templates use, just a different id namespace) for mask/position/
    // opacity/order/visible/locked. A record with no matching state yet
    // (never touched from Ghost++ before) falls back to the position
    // decoded straight from its own image header, if any, and otherwise
    // sane defaults — exactly like a freshly-ingested private template.
    // One bad/corrupt record is logged and skipped rather than failing the
    // whole library load.
    async function gppLoadSharedLibraryTemplates(stateById) {
        const images = await gppLegacyListImages();
        // Serial, not parallel: gppIngestViaWorker's cancellation bookkeeping
        // (gppIngestToken/gppIngestOperation) is a SINGLE shared module-level
        // slot, designed for "at most one in-flight ingest, cancellable via
        // one Cancel button" — not per-call. Firing several decodes
        // concurrently (tried once, reverted) makes every earlier call's
        // captured token stop matching gppIngestToken the moment a later
        // call increments it, so its worker's onmessage guard
        // (`if (gppIngestToken !== token) return;`) silently drops every
        // message for every decode but the last one — their promises never
        // resolve, and this whole function (and therefore Ghost++'s init)
        // hangs forever whenever the shared library has more than one
        // template. Loading templates one at a time is the correctness
        // requirement here, not just an incidental original choice.
        const results = [];
        for (const image of images) {
            const templateId = 'legacy_' + image.id;
            const state = stateById.get(templateId) || {};
            try {
                const decoded = await gppLegacyDecodeCached(image);
                const core2 = gppCreateCore();
                results.push({
                    id: templateId,
                    legacySourceId: image.id,
                    name: state.name || image.name || 'Untitled template',
                    width: decoded.width,
                    height: decoded.height,
                    indexType: decoded.indexType,
                    indices: decoded.indices,
                    palette: decoded.palette,
                    counts: decoded.counts,
                    opaquePixelCount: decoded.opaquePixelCount,
                    quantized: decoded.quantized,
                    poorMatchPixelCount: decoded.poorMatchPixelCount,
                    sourceBlob: image.blob,
                    mask: state.mask ? new Uint32Array(state.mask) : core2.makeFullMask(decoded.palette.length, decoded.counts),
                    // 'position' in state (not truthiness) distinguishes "never
                    // touched by Ghost++ yet" (fall back to the image's own
                    // decoded header) from "explicitly unset" (state.position
                    // persisted as null) — a truthy check alone would let an
                    // explicit Unset silently resurrect the decoded position
                    // on the next reload, since null/undefined are equally
                    // falsy but mean very different things here.
                    position: ('position' in state) ? state.position : (decoded.position || null),
                    opacity: typeof state.opacity === 'number' ? state.opacity : 1,
                    locked: !!state.locked,
                    groupNoise: !!state.groupNoise,
                    // Shared items default to after every private template on their very
                    // first appearance (no state.order yet); an explicit drag-reorder (see
                    // gpp-library.js's gppLibraryReorderTemplates) persists a real order
                    // value afterward, same as any private template.
                    order: typeof state.order === 'number' ? state.order : (1000000 + image.id),
                    scanSummary: null,
                });
            } catch (err) {
                console.error('[GeoPixelcons++] Ghost++: failed to decode shared history image "' + (image.name || image.id) + '".', err);
            }
        }
        return results;
    }

    async function gppLoadTemplateLibrary() {
        const [cores, states] = await Promise.all([
            gppDbGetAll(GPP_TEMPLATE_STORE),
            gppDbGetAll(GPP_STATE_STORE),
        ]);
        const stateById = new Map(states.map(s => [s.id, s]));
        const templates = cores.map(core => {
            const state = stateById.get(core.id) || {};
            const core2 = gppCreateCore();
            return {
                id: core.id,
                name: state.name || core.name || 'Untitled template',
                width: core.width,
                height: core.height,
                indexType: core.indexType,
                indices: core2.makeIndexArray(core.indexType, 0, core.indices),
                palette: new Uint32Array(core.palette),
                counts: new Uint32Array(core.counts),
                opaquePixelCount: core.opaquePixelCount,
                quantized: core.quantized,
                poorMatchPixelCount: core.poorMatchPixelCount,
                sourceBlob: core.sourceBlob || null,
                mask: state.mask ? new Uint32Array(state.mask) : core2.makeFullMask(core.palette.length, core.counts),
                position: state.position || null,
                // No separate "visible" flag — opacity<=0 IS hidden, opacity>0
                // IS visible; a single mechanism, not two (see gpp-library.js's
                // vis toggle, which sets opacity directly rather than a
                // parallel boolean that could drift out of sync with it).
                opacity: typeof state.opacity === 'number' ? state.opacity : 1,
                locked: !!state.locked,
                groupNoise: !!state.groupNoise,
                order: typeof state.order === 'number' ? state.order : 0,
                scanSummary: null,
            };
        });

        // Always merged with the legacy Ghost Template Manager's shared
        // history — not an opt-in setting (see gpp-legacy-bridge.js). `templates`
        // above (this template++'s own private store) stays only as a
        // read path for anything ingested before this was unconditional;
        // every new ingest writes to the shared store instead (see
        // gppIngestImageFile/gppIngestJsonFile).
        let sharedTemplates = [];
        try {
            sharedTemplates = await gppLoadSharedLibraryTemplates(stateById);
        } catch (err) {
            // Never let a broken/unreachable shared history take the whole
            // library down — degrade to whatever private templates exist.
            console.error('[GeoPixelcons++] Ghost++: failed to load the shared legacy history.', err);
        }
        const merged = templates.concat(sharedTemplates);
        merged.sort((a, b) => a.order - b.order);
        return merged;
    }

    // Re-runs the full load (private + shared) and swaps it into the live
    // gppTemplates array.
    async function gppReloadTemplateLibrary() {
        gppTemplates = await gppLoadTemplateLibrary();
        gppNormalizeTemplateOrder();
        if (!gppTemplates.some(t => t.id === gppFocusedTemplateId)) {
            gppFocusedTemplateId = gppTemplates[0] ? gppTemplates[0].id : null;
            gppSettings.focusedTemplateId = gppFocusedTemplateId;
            gppSaveSettings();
        }
        if (typeof gppRendererSchedule === 'function') gppRendererSchedule();
        return gppTemplates;
    }

    function gppNormalizeTemplateOrder() {
        gppTemplates.forEach((template, index) => { template.order = index; });
    }

    // Both this function and gppPersistTemplateCore below are the two
    // choke points every write path in the whole file funnels through to
    // reach IndexedDB — a single `template.ephemeral` guard here is what
    // guarantees NO code path (now or added later) can ever accidentally
    // persist a guild template, rather than needing every individual
    // caller (visibility toggle, drag-reorder, rename, Template Settings,
    // etc.) to remember to check it themselves. See gpp-guild-templates.js
    // for where ephemeral templates come from and why they must never
    // reach the database: guild projects change frequently server-side and
    // are meant to be re-fetched fresh each page load, never cached here.
    async function gppPersistTemplateState(template) {
        if (template.ephemeral) return;
        await gppDbPut(GPP_STATE_STORE, {
            id: template.id,
            name: template.name,
            position: template.position,
            opacity: template.opacity,
            locked: template.locked,
            groupNoise: !!template.groupNoise,
            order: template.order,
            mask: Array.from(template.mask),
        });
    }

    // A core-rewriting change (e.g. core.transformIndexed — no current UI
    // caller since the flip/rotate buttons were removed, but kept as a
    // generic building block) genuinely changes a shared-origin template's
    // pixel content. The shared store is content-
    // addressed (gppLegacyAddImage dedupes by hash), so writing the new
    // pixels there is necessarily a NEW record, not an update to the old
    // one — unlike a plain private-store template, which can just overwrite
    // its own row in place. Re-encoding into GPP_TEMPLATE_STORE under the
    // same id instead (the private-store path below) would silently create
    // a stale, orphaned duplicate the next time the library reloads (both
    // the real shared record and this leftover would carry the same id).
    // So: write the new shared record, migrate this template's identity
    // (id/legacySourceId) and its gppTemplates/focus bookkeeping onto it,
    // and clean up the now-superseded old shared record.
    async function gppPersistTemplateCore(template) {
        if (template.ephemeral) return; // see gppPersistTemplateState's identical guard
        if (typeof template.legacySourceId === 'number') {
            const oldId = template.id;
            const oldLegacyId = template.legacySourceId;
            const blob = await gppEncodeTemplateToPngBlob(template);
            const record = await gppLegacyAddImage(blob, template.name, !!template.groupNoise);
            template.sourceBlob = blob;
            template.id = 'legacy_' + record.id;
            template.legacySourceId = record.id;
            if (record.hash) {
                gppLegacyDecodeCache.set(record.hash, {
                    width: template.width, height: template.height, indexType: template.indexType,
                    indices: template.indices, palette: template.palette, counts: template.counts,
                    opaquePixelCount: template.opaquePixelCount, quantized: template.quantized,
                    poorMatchPixelCount: template.poorMatchPixelCount, position: template.position,
                });
            }
            if (record.id !== oldLegacyId) {
                await Promise.all([
                    gppLegacyDeleteImage(oldLegacyId),
                    gppDbDelete(GPP_STATE_STORE, oldId),
                ]);
            }
            // The old state row (position/mask/opacity/etc, keyed by oldId)
            // was just deleted above — re-persist it under the new id right
            // away rather than leaving it live only in memory until some
            // later, unrelated action happens to save it.
            await gppPersistTemplateState(template);
            const idx = gppTemplates.findIndex(t => t.id === oldId);
            if (idx !== -1) gppTemplates[idx] = template;
            if (gppFocusedTemplateId === oldId) {
                gppFocusedTemplateId = template.id;
                gppSettings.focusedTemplateId = template.id;
                gppSaveSettings();
            }
            return;
        }
        await gppDbPut(GPP_TEMPLATE_STORE, {
            id: template.id,
            name: template.name,
            width: template.width,
            height: template.height,
            indexType: template.indexType,
            indices: template.indices.buffer,
            palette: Array.from(template.palette),
            counts: Array.from(template.counts),
            opaquePixelCount: template.opaquePixelCount,
            quantized: template.quantized,
            poorMatchPixelCount: template.poorMatchPixelCount,
            sourceBlob: template.sourceBlob,
        });
    }

    async function gppDeleteTemplate(template) {
        // A shared-origin template (id 'legacy_'+numeric-id, see
        // gppLoadSharedLibraryTemplates) has no GPP_TEMPLATE_STORE record to
        // begin with — its core data lives entirely in the legacy
        // GP_Ghost_History store instead. Per explicit product decision,
        // deleting it from Ghost++ deletes it there too (true shared
        // delete), not just Ghost++'s own state — so it disappears from the
        // legacy Ghost Template Manager's history as well, matching a
        // single unified library rather than a partial/one-way mirror.
        const deletions = [gppDbDelete(GPP_STATE_STORE, template.id)];
        if (typeof template.legacySourceId === 'number') {
            deletions.push(gppLegacyDeleteImage(template.legacySourceId));
        } else {
            deletions.push(gppDbDelete(GPP_TEMPLATE_STORE, template.id));
        }
        await Promise.all(deletions);
        gppTemplates = gppTemplates.filter(t => t.id !== template.id);
        gppNormalizeTemplateOrder();
        if (gppFocusedTemplateId === template.id) {
            gppFocusedTemplateId = gppTemplates[0] ? gppTemplates[0].id : null;
            gppSettings.focusedTemplateId = gppFocusedTemplateId;
            gppSaveSettings();
        }
        await Promise.all(gppTemplates.map(gppPersistTemplateState));
    }

    // ── Ingestion ──────────────────────────────────────────────────────
    // Accepts PNG/JPEG/JFIF/WebP (Worker path) and JSON exports (direct
    // deserialize, no Worker needed). Decode/index always runs off the main
    // thread via the Worker built from gppBuildIngestWorkerSource(); a
    // cancellable, yielding main-thread fallback covers browsers where
    // Worker/OffscreenCanvas construction fails.
    const GPP_ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/jfif', 'image/webp'];

    function gppCancelIngest() {
        if (gppIngestOperation) {
            gppIngestToken++;
            try { gppIngestOperation.worker && gppIngestOperation.worker.terminate(); } catch (_) {}
            gppIngestOperation = null;
        }
    }

    function gppIngestViaWorker(file, onProgress) {
        return new Promise((resolve, reject) => {
            let worker;
            try {
                const source = gppBuildIngestWorkerSource();
                const blob = new Blob([source], { type: 'text/javascript' });
                const url = URL.createObjectURL(blob);
                worker = new Worker(url);
                URL.revokeObjectURL(url);
            } catch (err) {
                reject(err);
                return;
            }
            const operationId = 'op_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
            const token = ++gppIngestToken;
            gppIngestOperation = { worker, operationId, cancel: () => { gppIngestToken++; worker.terminate(); } };
            worker.onmessage = event => {
                if (gppIngestToken !== token) return;
                const message = event.data || {};
                if (message.operationId !== operationId) return;
                if (message.type === 'progress') {
                    onProgress && onProgress(message.fraction, message.label);
                } else if (message.type === 'complete') {
                    gppIngestOperation = null;
                    worker.terminate();
                    resolve(message);
                } else if (message.type === 'error') {
                    gppIngestOperation = null;
                    worker.terminate();
                    const error = new Error(message.message);
                    error.name = message.name;
                    reject(error);
                }
            };
            worker.onerror = err => {
                if (gppIngestToken !== token) return;
                gppIngestOperation = null;
                worker.terminate();
                reject(err && err.message ? new Error(err.message) : new Error('Worker failed to start.'));
            };
            worker.postMessage({ type: 'ingest', operationId, file });
        });
    }

    // Cancellable, yielding main-thread fallback used only when the Worker
    // path itself fails to construct (e.g. blob-URL Workers blocked by CSP).
    async function gppIngestOnMainThread(file, onProgress) {
        const core = gppCreateCore();
        const bitmap = await createImageBitmap(file);
        const canvas = document.createElement('canvas');
        canvas.width = bitmap.width;
        canvas.height = bitmap.height;
        const context = canvas.getContext('2d', { willReadFrequently: true, colorSpace: 'srgb' });
        context.drawImage(bitmap, 0, 0);
        bitmap.close();
        let rgba = context.getImageData(0, 0, canvas.width, canvas.height).data;
        let width = canvas.width, height = canvas.height;
        const decoded = core.decodePositionHeader(rgba, width, height);
        rgba = decoded.rgba; width = decoded.width; height = decoded.height;
        const token = ++gppIngestToken;
        const indexed = await core.indexRgbaAsync(rgba, width, height, {
            isCancelled: () => gppIngestToken !== token,
            onProgress: (fraction, label) => onProgress && onProgress(0.05 + fraction * 0.94, label),
        });
        return {
            width, height, position: decoded.position, indexType: indexed.indexType,
            indices: indexed.indices, palette: indexed.palette, counts: indexed.counts, mask: indexed.mask,
            opaquePixelCount: indexed.opaquePixelCount, quantized: indexed.quantized, poorMatchPixelCount: indexed.poorMatchPixelCount,
        };
    }

    // Decode-only half of ingest — shared between the normal file-upload
    // path below and gpp-legacy-bridge.js-sourced images (see
    // gppLoadSharedLibraryTemplates), neither of which needs to duplicate
    // the Worker/main-thread fallback logic. `blob` need not be a real
    // File (a plain Blob decodes identically); the caller supplies its own
    // name/id/persistence semantics.
    async function gppDecodeImageBlob(blob, onProgress) {
        let result;
        try {
            result = await gppIngestViaWorker(blob, onProgress);
            result.indices = gppCreateCore().makeIndexArray(result.indexType, 0, result.indices);
            result.palette = new Uint32Array(result.palette);
            result.counts = new Uint32Array(result.counts);
            result.mask = new Uint32Array(result.mask);
        } catch (workerError) {
            console.warn('[GeoPixelcons++] Ghost++: Worker ingest failed, falling back to main thread.', workerError);
            result = await gppIngestOnMainThread(blob, onProgress);
        }
        return result;
    }

    // Cross-namespace dedup: a template ingested privately (id 'gpp_...')
    // BEFORE shared-library mode was ever turned on has no record in
    // GP_Ghost_History yet, so gppLegacyAddImage's own templateId lookup
    // (scoped to that store) can never find it. Without this check,
    // re-ingesting the identical image after enabling shared mode would
    // create an independent 'legacy_...' duplicate instead of migrating the
    // existing private entry — two visible copies with independently
    // editable state, and deleting one silently leaves the other behind.
    // Only private templates that still have their raw sourceBlob can be
    // checked this way; own-JSON-format private imports (sourceBlob: null)
    // have nothing to hash and are skipped, same as before this fix existed.
    async function gppFindMatchingPrivateTemplate(contentTemplateId) {
        for (const candidate of gppTemplates) {
            if (candidate.id.indexOf('legacy_') === 0 || !candidate.sourceBlob) continue;
            try {
                const candidateId = await gppLegacyComputeTemplateId(candidate.sourceBlob);
                if (candidateId === contentTemplateId) return candidate;
            } catch (_) {
                // An unhashable candidate just isn't checked — never fails the ingest.
            }
        }
        return null;
    }

    // Deletes a private template's own core+state records and drops it from
    // the in-memory list — used only when gppFindMatchingPrivateTemplate
    // finds it's being superseded by a shared-mode re-ingest of the exact
    // same image content (see callers below), never as a general-purpose
    // delete (that's gppDeleteTemplate, which also handles focus reassignment
    // and order renumbering — unnecessary here since the caller immediately
    // inserts a replacement in the same spot).
    async function gppRemoveSupersededPrivateTemplate(privateTemplate) {
        await Promise.all([
            gppDbDelete(GPP_TEMPLATE_STORE, privateTemplate.id),
            gppDbDelete(GPP_STATE_STORE, privateTemplate.id),
        ]);
        gppTemplates = gppTemplates.filter(t => t.id !== privateTemplate.id);
    }

    // Builds a lightweight, UNDECODED "stub" for a guild project — just
    // enough shape (id/name/position/opacity) for gpp-guild-templates.js to
    // list it and for the renderer's visibility check to safely skip it
    // (opacity 0, no indices/palette yet) — no fetch, no decode, no worker
    // round-trip. `guildProject` retains the raw API object (needed by
    // gppDecodeGuildTemplateEphemeral below AND by the card's own <img
    // src="project.image">, which reads the thumbnail straight off this —
    // never through Ghost++'s own indexed-decode pipeline, deliberately:
    // see that file for why). `guildDecoded` distinguishes a stub from a
    // fully-decoded template without needing a truthy-vs-undefined check on
    // `indices` scattered through every caller.
    //
    // Reported production scale (39 templates, up to 3276x3276 each) is
    // exactly why this exists as a separate step from decoding: building
    // all 39 stubs is nearly free (no network, no CPU), where decoding all
    // 39 up front — the ORIGINAL design — meant 39 full worker round-trips
    // and, via gpp-renderer.js's old unconditional reconcile, 39 real GPU
    // texture uploads (~10MB+ each at that resolution) for templates the
    // user likely never even looks at.
    function gppBuildGuildTemplateStub(project) {
        const hasPosition = Number.isFinite(project.imageGridX) && Number.isFinite(project.imageGridY);
        return {
            id: 'guild_' + project.id,
            name: 'Guild project ' + project.id,
            width: 0,
            height: 0,
            indexType: null,
            indices: null,
            palette: null,
            counts: null,
            opaquePixelCount: 0,
            quantized: false,
            poorMatchPixelCount: 0,
            sourceBlob: null, // never written anywhere — nothing needs to retain this
            mask: null,
            position: hasPosition
                ? { gridX: Math.round(project.imageGridX), gridY: Math.round(project.imageGridY) }
                : null,
            opacity: 0, // hidden by default — see gpp-guild-templates.js
            locked: false,
            groupNoise: false,
            order: 0,
            scanSummary: null,
            ephemeral: true,
            guildProject: project,
            guildDecoded: false,
        };
    }

    // Fetches + decodes a stub's full pixel data IN PLACE (mutates and
    // returns the SAME object gppBuildGuildTemplateStub produced) — true
    // lazy loading: only ever called once a user actually wants to VIEW a
    // specific guild template (a card click in gpp-guild-templates.js, or
    // the guild menu's "Set as Ghost"), never eagerly for the whole batch.
    // A no-op (besides returning the stub) if already decoded — cheap to
    // call unconditionally from a "show this" code path. Reuses
    // gppDecodeImageBlob directly, the exact same pure decode step
    // gppIngestImageFile itself calls before any of ITS persistence work
    // begins — genuinely the same decode, just never followed by a
    // database write (this object's own `ephemeral: true`, set by
    // gppBuildGuildTemplateStub, is what gppPersistTemplateState/
    // gppPersistTemplateCore both check and refuse to write, as defense in
    // depth if this ever reaches a normal-template code path).
    //
    // NOT safe to call directly with more than one in flight — always go
    // through gppState.decodeGuildTemplate (gppQueueGuildDecode below),
    // never this function by name, from outside this file. See that
    // wrapper's own comment for why.
    async function gppDecodeGuildTemplateRaw(stub) {
        if (stub.guildDecoded) return stub;
        const project = stub.guildProject;
        const response = await fetch(project.image);
        const blob = await response.blob();
        const result = await gppDecodeImageBlob(blob);
        stub.width = result.width;
        stub.height = result.height;
        stub.indexType = result.indexType;
        stub.indices = result.indices;
        stub.palette = result.palette;
        stub.counts = result.counts;
        stub.opaquePixelCount = result.opaquePixelCount;
        stub.quantized = result.quantized;
        stub.poorMatchPixelCount = result.poorMatchPixelCount;
        stub.mask = result.mask;
        if (!stub.position) stub.position = result.position || null;
        stub.guildDecoded = true;
        return stub;
    }

    // Serializes every on-demand guild-template decode (gpp-guild-templates.js's
    // per-card lazy decode when a user clicks to view one, AND
    // gpp-native-shim.js's "Set as Ghost") through one shared queue.
    // gppDecodeImageBlob ultimately goes through gppIngestViaWorker, whose
    // single shared cancellation token (gppIngestToken/gppIngestOperation)
    // is not safe for two decodes in flight at once (see that function's
    // own header comment) — before lazy loading, nothing could trigger two
    // independent guild decodes close enough together to matter; a grid of
    // 39 individually-clickable cards is exactly the kind of overlapping
    // trigger that didn't exist before. Chains onto a resolved-or-rejected
    // placeholder either way so one failed decode never wedges every decode
    // after it.
    let gppGuildDecodeQueue = Promise.resolve();
    function gppQueueGuildDecode(stub) {
        const result = gppGuildDecodeQueue.then(() => gppDecodeGuildTemplateRaw(stub));
        gppGuildDecodeQueue = result.then(() => {}, () => {});
        return result;
    }

    // Appends one decoded (or stub) guild template to gppGuildTemplates and
    // asks the renderer to redraw. Callers that want "reuse if one already
    // exists for this project" (i.e. everyone — see
    // gppGetOrCreateGuildTemplate below) should go through that function
    // instead of calling this directly with a freshly-built stub, to avoid
    // ever pushing a second entry with an id already present.
    function gppAddGuildTemplate(template) {
        gppGuildTemplates.push(template);
        if (typeof gppRendererSchedule === 'function') gppRendererSchedule();
    }

    // THE single entry point for turning a guild project into a
    // gppGuildTemplates entry — both gpp-guild-templates.js's Guild
    // Templates section (fetching the whole list on first expand) and
    // gpp-native-shim.js's "Set as Ghost" (a single deliberate project) call
    // this instead of gppBuildGuildTemplateStub+gppAddGuildTemplate
    // directly. Returns the EXISTING entry for this project's id if one is
    // already in gppGuildTemplates (whether still an undecoded stub, or
    // already fully decoded from an earlier "Set as Ghost"/card view) —
    // never creates a second object sharing an id that's already present.
    //
    // This fixes a real, previously-shipped bug: both call sites used to
    // build a brand-new stub unconditionally, so viewing the same guild
    // project from both the section AND "Set as Ghost" (or clicking "Set as
    // Ghost" for a project the section had already stub-listed) produced
    // TWO objects with the identical id ('guild_' + project.id) sitting in
    // gppGuildTemplates side by side. gppGetFocusedTemplate's
    // `gppGuildTemplates.find(t => t.id === gppFocusedTemplateId)` then
    // returns whichever of the two array insertion order happens to put
    // first — often the OLDER, still-undecoded stub (indexType/indices/
    // palette all null) even though a fully-decoded sibling object also
    // existed — which is exactly what made gppShimSyncFocusedTemplate's
    // gppWriteFullNativeMirror -> core.indexedToRgba throw "Unsupported
    // index type: null", and made the Guild Templates section's own card
    // clicks appear to do nothing (the renderer's per-id resource map in
    // gpp-renderer.js also collides on a duplicate id, so which of the two
    // objects' pixels actually end up on screen becomes non-deterministic).
    function gppGetOrCreateGuildTemplate(project) {
        const id = 'guild_' + project.id;
        const existing = gppGuildTemplates.find(t => t.id === id);
        if (existing) return existing;
        const stub = gppBuildGuildTemplateStub(project);
        gppAddGuildTemplate(stub);
        return stub;
    }

    async function gppIngestImageFile(file, onProgress) {
        onProgress && onProgress(0, 'Starting');
        const result = await gppDecodeImageBlob(file, onProgress);
        const name = (file.name || 'Untitled template').replace(/\.[a-z0-9]+$/i, '');

        // Shared-library mode: the raw uploaded file itself (position header
        // and all, if it decoded one above) is exactly what
        // ghost-template-manager.js's own HistoryManager.add() would store,
        // so it is written through unchanged rather than re-encoded from the
        // now-decoded indices. gppLegacyAddImage() dedupes against any
        // existing record with the same clean-content templateId (see that
        // file), returning the SAME numeric id on a match — reuse an
        // already-loaded in-memory template with that id instead of pushing
        // a visual duplicate, mirroring the legacy menu's own "duplicate
        // detected, updating entry" behaviour.
        const contentTemplateId = await gppLegacyComputeTemplateId(file);
        // A private-store template ingested from a build before this was
        // unconditional has no shared record yet — migrate it (state carries
        // over, its own private record is removed) rather than create a
        // visible duplicate for the same image content.
        const privateMatch = await gppFindMatchingPrivateTemplate(contentTemplateId);
        const record = await gppLegacyAddImage(file, name, false);
        const id = 'legacy_' + record.id;
        const legacySourceId = record.id;
        if (record.hash) gppLegacyDecodeCache.set(record.hash, result);

        const template = {
            id,
            legacySourceId,
            name,
            width: result.width,
            height: result.height,
            indexType: result.indexType,
            indices: result.indices,
            palette: result.palette,
            counts: result.counts,
            opaquePixelCount: result.opaquePixelCount,
            quantized: result.quantized,
            poorMatchPixelCount: result.poorMatchPixelCount,
            sourceBlob: file,
            mask: result.mask,
            position: result.position || null,
            opacity: 1,
            locked: false,
            groupNoise: false,
            order: gppTemplates.length,
            scanSummary: null,
        };
        // Two distinct dedup sources can supersede this brand-new template
        // object with an EXISTING one's state: (1) a same-namespace match —
        // gppLegacyAddImage() itself returned an id already present in
        // gppTemplates (a shared template re-ingested), or (2) a cross-
        // namespace match — privateMatch, a template still living in the
        // old private store from before this was unconditional. Either way,
        // the existing entry's order/mask/position/etc carries over and its
        // old record (private-store row for case 2) is removed so the
        // library shows exactly one entry, never two, for the same image.
        const existingIndex = gppTemplates.findIndex(t => t.id === id);
        const supersedes = existingIndex !== -1 ? gppTemplates[existingIndex] : privateMatch;
        if (supersedes) {
            template.order = supersedes.order;
            template.opacity = supersedes.opacity;
            template.locked = supersedes.locked;
            template.groupNoise = supersedes.groupNoise;
            template.position = supersedes.position || template.position;
            template.mask = supersedes.mask;
        }
        if (existingIndex !== -1) {
            gppTemplates[existingIndex] = template;
        } else {
            if (privateMatch) await gppRemoveSupersededPrivateTemplate(privateMatch);
            gppTemplates.push(template);
        }
        await gppPersistTemplateState(template);
        gppFocusedTemplateId = template.id;
        gppSettings.focusedTemplateId = template.id;
        gppSaveSettings();
        gppTriggerLoadTimeScan(template);
        return template;
    }

    function gppBase64ToBlob(base64, mimeType) {
        const binary = atob(base64);
        const bytes = new Uint8Array(binary.length);
        for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
        return new Blob([bytes], { type: mimeType });
    }

    // JSON import accepts two distinct shapes:
    //   1. Our own export (gppExportTemplateAsJson below) — pre-indexed
    //      template data, `{kind:'ghost-plus-plus-template', ...}`.
    //   2. GeoPixelcons++ Ghost Template Manager's history-export format —
    //      `{version, exportDate, images:[{name, imageData (base64 PNG),
    //      mimeType, ...}]}`. Each embedded PNG already carries GTM's own
    //      position-header packets (the same 5-pixel-packet marker format
    //      gpp-core.js's decodePositionHeader/POSITION_MARKER implements),
    //      so once decoded back to a real image file it runs through the
    //      ordinary image-ingest pipeline unmodified — no separate parsing
    //      needed for position data. A single GTM export can bundle several
    //      images, so this always returns an ARRAY of created templates
    //      (length 1 for our own format).
    async function gppIngestJsonFile(file, onProgress) {
        const text = await file.text();
        const parsed = JSON.parse(text);

        if (parsed && Array.isArray(parsed.images)) {
            const created = [];
            const skipped = [];
            for (const entry of parsed.images) {
                if (!entry || typeof entry.imageData !== 'string') { skipped.push(entry && entry.name); continue; }
                const mimeType = entry.mimeType || 'image/png';
                const blob = gppBase64ToBlob(entry.imageData, mimeType);
                const ext = mimeType.split('/')[1] || 'png';
                const imageFile = new File([blob], (entry.name || 'Imported template') + '.' + ext, { type: mimeType });
                const createdTemplate = await gppIngestImageFile(imageFile, onProgress);
                // GTM's own history-export format bundles a per-image groupNoise
                // flag (ghost-template-manager.js's exportToZip/exportSelectedToJson)
                // — apply it here rather than leaving every re-imported template
                // at gppIngestImageFile's fresh-ingest default of false.
                if (typeof entry.groupNoise === 'boolean' && createdTemplate.groupNoise !== entry.groupNoise) {
                    createdTemplate.groupNoise = entry.groupNoise;
                    await gppPersistTemplateState(createdTemplate);
                }
                created.push(createdTemplate);
            }
            if (!created.length) throw new Error('That export did not contain any recognisable images.');
            return created;
        }

        if (!parsed || parsed.kind !== 'ghost-plus-plus-template' || !parsed.width || !parsed.height) {
            throw new Error('That JSON file is not a Ghost++ template export or a GeoPixelcons++ ghost history export.');
        }
        const name = parsed.name || 'Imported template';
        const template = {
            id: gppMakeTemplateId(),
            name,
            width: parsed.width,
            height: parsed.height,
            indexType: parsed.indexType,
            indices: gppCreateCore().makeIndexArray(parsed.indexType, 0, new Uint8Array(parsed.indices).buffer),
            palette: Uint32Array.from(parsed.palette),
            counts: Uint32Array.from(parsed.counts),
            opaquePixelCount: parsed.opaquePixelCount,
            quantized: !!parsed.quantized,
            poorMatchPixelCount: parsed.poorMatchPixelCount || 0,
            sourceBlob: null,
            mask: parsed.mask ? Uint32Array.from(parsed.mask) : gppCreateCore().makeFullMask(parsed.palette.length, parsed.counts),
            position: parsed.position || null,
            opacity: typeof parsed.opacity === 'number' ? parsed.opacity : 1,
            locked: false,
            groupNoise: typeof parsed.groupNoise === 'boolean' ? parsed.groupNoise : false,
            order: gppTemplates.length,
            scanSummary: null,
        };
        // This format carries no raw source blob (it's already-decoded
        // indices/palette) — re-encode it to a real PNG (position header
        // baked in when placed) before writing it into the shared store, so
        // it round-trips through the legacy menu identically to any other
        // history entry.
        const blob = await gppEncodeTemplateToPngBlob(template);
        const contentTemplateId = await gppLegacyComputeTemplateId(blob);
        const privateMatch = await gppFindMatchingPrivateTemplate(contentTemplateId);
        const record = await gppLegacyAddImage(blob, name, template.groupNoise);
        template.id = 'legacy_' + record.id;
        template.legacySourceId = record.id;
        template.sourceBlob = blob;
        if (record.hash) {
            gppLegacyDecodeCache.set(record.hash, {
                width: template.width, height: template.height, indexType: template.indexType,
                indices: template.indices, palette: template.palette, counts: template.counts,
                opaquePixelCount: template.opaquePixelCount, quantized: template.quantized,
                poorMatchPixelCount: template.poorMatchPixelCount, position: template.position,
            });
        }
        // See gppIngestImageFile's identical comment: either a same-namespace
        // (existingIndex) or cross-namespace (privateMatch) supersede carries
        // the old entry's state over and removes its now-superseded record.
        const existingIndex = gppTemplates.findIndex(t => t.id === template.id);
        const supersedes = existingIndex !== -1 ? gppTemplates[existingIndex] : privateMatch;
        if (supersedes) {
            template.order = supersedes.order;
            template.opacity = supersedes.opacity;
            template.locked = supersedes.locked;
            template.groupNoise = supersedes.groupNoise;
            template.position = supersedes.position || template.position;
            template.mask = supersedes.mask;
        }
        if (existingIndex !== -1) {
            gppTemplates[existingIndex] = template;
        } else {
            if (privateMatch) await gppRemoveSupersededPrivateTemplate(privateMatch);
            gppTemplates.push(template);
        }
        await gppPersistTemplateState(template);
        gppFocusedTemplateId = template.id;
        gppSettings.focusedTemplateId = template.id;
        gppSaveSettings();
        gppTriggerLoadTimeScan(template);
        return [template];
    }

    async function gppExportTemplateAsJson(template) {
        const payload = {
            kind: 'ghost-plus-plus-template',
            name: template.name,
            width: template.width,
            height: template.height,
            indexType: template.indexType,
            indices: Array.from(template.indices),
            palette: Array.from(template.palette),
            counts: Array.from(template.counts),
            mask: Array.from(template.mask),
            opaquePixelCount: template.opaquePixelCount,
            quantized: template.quantized,
            poorMatchPixelCount: template.poorMatchPixelCount,
            position: template.position,
            opacity: template.opacity,
            groupNoise: !!template.groupNoise,
        };
        const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = url;
        link.download = (template.name || 'template') + '.ghostplusplus.json';
        link.click();
        URL.revokeObjectURL(url);
    }

    // Reverses ingest: indices+palette(+position) -> a real PNG Blob, with
    // GTM's own position-header packets baked in when placed — used both by
    // the base64 JSON export below and by the shared-library write-back
    // path for Ghost++'s own JSON-format import (gpp-legacy-bridge.js has
    // no raw source blob for that shape, only decoded fields).
    async function gppEncodeTemplateToPngBlob(template) {
        const core = gppCreateCore();
        let rgba = core.indexedToRgba(template.indices, template.indexType, template.palette);
        let width = template.width;
        let height = template.height;
        if (template.position) {
            const encoded = core.encodePositionHeader(rgba, width, height, template.position);
            rgba = encoded.rgba;
            width = encoded.width;
            height = encoded.height;
        }
        const canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d');
        ctx.putImageData(new ImageData(rgba, width, height), 0, 0);
        return new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
    }

    // Blob -> base64 via the browser's own native FileReader encoder rather
    // than a manual arrayBuffer()+Uint8Array+chunked-String.fromCharCode+
    // btoa() pipeline — this is exactly what the legacy Ghost Template
    // Manager's own exportToZip/exportSelectedToJson
    // (ghost-template-manager.js's blobToBase64) has always done, and is
    // the real fix for "export is slow": the manual chunked-loop approach
    // spends real main-thread time building up a giant intermediate JS
    // string one 32K chunk at a time before btoa() even starts, where
    // FileReader hands the whole conversion to the browser's own
    // (non-JS-interpreter) implementation in one call.
    function gppBlobToBase64(blob) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onloadend = () => resolve(reader.result.split(',')[1]);
            reader.onerror = reject;
            reader.readAsDataURL(blob);
        });
    }

    async function gppTemplateToBase64Png(template) {
        const blob = await gppEncodeTemplateToPngBlob(template);
        return gppBlobToBase64(blob);
    }

    // Small batches, not a single unbounded Promise.all — each template's
    // PNG re-encode (gppEncodeTemplateToPngBlob) has no shared mutable
    // state (its own <canvas>, its own FileReader), so running a few at
    // once is safe unlike gppIngestViaWorker's single-shared-token design
    // (see gppLoadSharedLibraryTemplates' own comment on why THAT one must
    // stay serial) — but encoding dozens of large templates fully in
    // parallel would still spike memory/GPU pressure for no real gain past
    // a handful at a time.
    const GPP_EXPORT_BATCH_SIZE = 4;

    // Bulk export (Manage Templates modal's "Export selected") — ONE file
    // covering every selected template, in the same
    // {version, exportDate, images:[...]} shape gppIngestJsonFile already
    // accepts on import (see its GTM-compatibility branch), rather than one
    // download per template. Each image is re-encoded back to a real PNG
    // (with a position header when placed) so the file is a genuine,
    // portable image export, not just a dump of internal index buffers.
    // `onProgress(fraction, label)` is optional (same shape as
    // gppIngestImageFile's own callback) — the Manage Templates modal uses
    // it to drive a real progress bar instead of a blank wait.
    async function gppExportTemplatesAsJson(templates, filename, onProgress) {
        const core = gppCreateCore();
        const images = new Array(templates.length);
        let completed = 0;
        for (let start = 0; start < templates.length; start += GPP_EXPORT_BATCH_SIZE) {
            const batch = templates.slice(start, start + GPP_EXPORT_BATCH_SIZE);
            const encoded = await Promise.all(batch.map(template => gppTemplateToBase64Png(template)));
            batch.forEach((template, i) => {
                const hash = core.templateHash(template.indices, template.palette, template.width, template.height);
                images[start + i] = {
                    id: start + i + 1,
                    name: template.name || 'Untitled template',
                    date: Date.now(),
                    hash,
                    templateId: hash,
                    imageData: encoded[i],
                    mimeType: 'image/png',
                    groupNoise: !!template.groupNoise,
                };
                completed++;
                if (onProgress) onProgress(completed / templates.length, 'Exporting ' + (template.name || 'template'));
            });
        }
        const payload = {
            version: '1.0',
            exportDate: new Date().toISOString(),
            images,
        };
        const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = url;
        link.download = filename || ('ghost-plus-plus-export-' + images.length + '.json');
        link.click();
        URL.revokeObjectURL(url);
    }

    function gppGetFocusedTemplate() {
        return gppTemplates.find(t => t.id === gppFocusedTemplateId)
            || gppGuildTemplates.find(t => t.id === gppFocusedTemplateId)
            || null;
    }

    // Focusing a template also treats it as "most recently worked on" — bump
    // it to the very front of the in-memory list (immediate effect, no reload
    // needed for the grid to reflect it) and give it an order value lower
    // than every other template's own current order, so the same ordering
    // survives the next reload/sort too (gppLoadTemplateLibrary sorts by
    // `order` ascending). Only the newly-focused template's state is
    // persisted — everyone else's `order` is left untouched, no renumbering
    // pass (unlike gppNormalizeTemplateOrder, which a full reload/explicit
    // drag-reorder still runs to compact values back down eventually).
    async function gppBumpFocusedTemplateToTop(id) {
        const index = gppTemplates.findIndex(t => t.id === id);
        if (index === -1) return;
        const template = gppTemplates[index];
        const others = gppTemplates.filter(t => t.id !== id);
        if (!others.length) return;
        const minOrder = Math.min(...others.map(t => t.order));
        const alreadyOnTop = index === 0 && template.order < minOrder;
        gppTemplates.splice(index, 1);
        gppTemplates.unshift(template);
        if (alreadyOnTop) return;
        template.order = minOrder - 1;
        await gppPersistTemplateState(template);
    }

    // View Settings' "Auto-hide unfocused templates" checkbox
    // (gppSettings.autoHideUnfocused, default off): when on, focusing a
    // template hides every OTHER template in gppState.templates (opacity
    // 0, persisted like any other visibility change) and makes sure the
    // newly-focused one is itself visible — but only forces it to opacity 1
    // if it was exactly 0 before; a deliberately-set partial opacity (e.g.
    // 50%) is left alone rather than clobbered on every refocus. This is
    // the "only ever look at one template at a time" mode the setting's own
    // name promises, and composes directly with gpp-renderer.js's
    // gppRendererIsTemplateVisible fix (opacity 0 = zero GPU/canvas
    // resources, not just skipped drawing) — with this on, at most one
    // template's resources are ever held at a time, which is exactly the
    // performance case a library full of large templates benefits from.
    // Scoped to gppState.templates only: guild templates (gpp-guild-templates.js)
    // have their own independent, click-to-preview visibility model that
    // never calls gppFocusTemplate at all, so they're naturally unaffected.
    async function gppApplyAutoHideUnfocused(focusedId) {
        const toPersist = [];
        for (const t of gppTemplates) {
            if (t.id === focusedId) {
                if (t.opacity <= 0) { t.opacity = 1; toPersist.push(t); }
            } else if (t.opacity > 0) {
                t.opacity = 0;
                toPersist.push(t);
            }
        }
        if (!toPersist.length) return;
        await Promise.all(toPersist.map(t => gppPersistTemplateState(t)));
        if (typeof gppRendererSchedule === 'function') gppRendererSchedule();
    }

    async function gppFocusTemplate(id) {
        const previousId = gppFocusedTemplateId;
        gppFocusedTemplateId = id;
        // A guild/ephemeral template (gpp-guild-templates.js, or the guild
        // menu's "Set as Ghost") never touches localStorage, the recency
        // order, or an auto-scan — none of those make sense for something
        // that will not exist on the next page load anyway, and
        // gppSaveSettings() writing its id to gppSettings.focusedTemplateId
        // would otherwise be a (harmless but avoidable) trace of it outside
        // gppGuildTemplates itself.
        if (gppGuildTemplates.some(t => t.id === id)) return;
        gppSettings.focusedTemplateId = id;
        gppSaveSettings();
        if (id) await gppBumpFocusedTemplateToTop(id);
        if (id && id !== previousId) {
            gppTriggerLoadTimeScan(gppTemplates.find(t => t.id === id));
            if (gppSettings.autoHideUnfocused) await gppApplyAutoHideUnfocused(id);
        }
    }

    // Fires an immediate (fire-and-forget) "Scan progress" run whenever a
    // template is loaded into view — either by focusing an existing one or
    // by finishing an ingest — so the progress bar/color breakdown render
    // right away instead of staying blank until the user manually clicks
    // Scan progress themselves. Deliberately allowed to be inaccurate (the
    // relevant map tiles may not have synced yet): the user can pan to the
    // template's location and re-run the scan for a correct result. Never
    // awaited by its callers — must not block focus/ingest on a full scan.
    function gppTriggerLoadTimeScan(template) {
        if (!template || !template.position || gppScanRunning) return;
        if (typeof gppScanTemplate !== 'function') return;
        // Fire-and-forget, unlike the manual Scan button's own click handler
        // (gpp-scan.js), which calls onChange() both immediately AND again
        // once its promise resolves. gppTriggerLoadTimeScan's caller
        // (gppFocusTemplate, right above) usually triggers its own refresh
        // around the same time anyway, but calling gppRequestUiRefresh()
        // here too makes this self-sufficient rather than implicitly
        // depending on that — and without the .finally() below, nothing
        // would ever re-render the Progress section once THIS scan actually
        // finished: gppScanRunning/gppScanningTemplateId flip back correctly
        // in memory, but the Scan/error/missing/nearest/clear buttons stayed
        // frozen showing "Scanning…"/disabled until some UNRELATED action
        // (e.g. toggling a palette color, which does call onChange())
        // happened to trigger a refresh — read as "stuck, but only
        // sometimes" from the outside. gppRequestUiRefresh() (gpp-init.js)
        // is the cross-file-safe equivalent of that same onChange() call.
        // gppScanTemplate sets gppScanRunning/gppScanningTemplateId
        // SYNCHRONOUSLY before its first await (see its own comment), so the
        // refresh below correctly sees the new busy state — calling it
        // BEFORE gppScanTemplate would refresh against the still-false
        // flag and never show Scanning… at all.
        const pending = gppScanTemplate(template);
        if (typeof gppRequestUiRefresh === 'function') gppRequestUiRefresh();
        pending.catch(err => {
            console.error('[GeoPixelcons++] Ghost++ load-time scan failed:', err);
        }).finally(() => {
            if (typeof gppRequestUiRefresh === 'function') gppRequestUiRefresh();
        });
    }

    // Single entry point every UI file (shell/palette/library/scan) reads
    // and calls through — the one place that "knows" what the runtime is
    // made of, so no other file needs its own copy of this state.
    const gppState = {
        get settings() { return gppSettings; },
        get templates() { return gppTemplates; },
        get guildTemplates() { return gppGuildTemplates; },
        get focusedTemplateId() { return gppFocusedTemplateId; },
        get runtimeReady() { return gppRuntimeReady; },
        buildGuildTemplateStub: gppBuildGuildTemplateStub,
        getOrCreateGuildTemplate: gppGetOrCreateGuildTemplate,
        decodeGuildTemplate: gppQueueGuildDecode,
        addGuildTemplate: gppAddGuildTemplate,
        applyAutoHideUnfocused: gppApplyAutoHideUnfocused,
        getFocusedTemplate: gppGetFocusedTemplate,
        focusTemplate: gppFocusTemplate,
        saveSettings: gppSaveSettings,
        ingestImageFile: gppIngestImageFile,
        ingestJsonFile: gppIngestJsonFile,
        exportTemplateAsJson: gppExportTemplateAsJson,
        exportTemplatesAsJson: gppExportTemplatesAsJson,
        deleteTemplate: gppDeleteTemplate,
        persistTemplateState: gppPersistTemplateState,
        persistTemplateCore: gppPersistTemplateCore,
        normalizeTemplateOrder: gppNormalizeTemplateOrder,
        cancelIngest: gppCancelIngest,
        reloadLibrary: gppReloadTemplateLibrary,
        acceptedImageTypes: GPP_ACCEPTED_IMAGE_TYPES,
    };

    // Guards against concurrent callers (the modal's own open() flow and
    // gpp-renderer.js's independent self-mount both call this) racing two
    // separate gppLoadTemplateLibrary() reads, where the second resolution
    // would silently overwrite gppTemplates and drop anything the first
    // caller had already appended in between.
    let gppRuntimeInitPromise = null;

    // False from page load until gppInitRuntime()'s decode pass genuinely
    // finishes — gpp-init.js's open() now renders the modal shell and calls
    // refreshAll() BEFORE awaiting ensureRuntime() (a faster-feeling first
    // open), which means gpp-library.js's Templates grid can render with
    // gppTemplates still empty purely because decoding hasn't finished yet,
    // not because the user genuinely has zero templates. Exposed via
    // gppState.runtimeReady so that section can tell the two apart and show
    // a "Loading templates..." placeholder instead of looking like the
    // library was wiped. Never reset back to false once true (a later,
    // separate reload — e.g. reloadLibrary() — updates gppTemplates in
    // place; the FIRST decode having finished once is all this flag means).
    let gppRuntimeReady = false;

    // Whatever template is focused at the moment this runs was restored
    // from a PREVIOUS page session (either gppInitRuntime()'s own
    // no-longer-exists fallback, or the plain `gppFocusedTemplateId =
    // gppSettings.focusedTemplateId` assignment at this file's own
    // module-load time) — never through gppFocusTemplate() itself, so that
    // function's own `id !== previousId` auto-scan trigger never got a
    // chance to fire for it. Without this, a template that was already
    // loaded before the page refresh would sit with stale/no progress
    // until the user manually clicked Scan or switched templates away and
    // back. Split out from gppInitRuntime() below so it can be called
    // directly in tests without needing to force a full, expensive
    // re-run of that function's own database/library-loading work.
    function gppTriggerScanForRestoredFocus() {
        const alreadyFocusedTemplate = gppTemplates.find(t => t.id === gppFocusedTemplateId);
        if (alreadyFocusedTemplate) gppTriggerLoadTimeScan(alreadyFocusedTemplate);
    }

    function gppInitRuntime() {
        if (!gppRuntimeInitPromise) {
            gppRuntimeInitPromise = (async () => {
                gppDatabase = await gppOpenDatabase();
                gppTemplates = await gppLoadTemplateLibrary();
                gppNormalizeTemplateOrder();
                if (!gppTemplates.some(t => t.id === gppFocusedTemplateId)) {
                    gppFocusedTemplateId = gppTemplates[0] ? gppTemplates[0].id : null;
                }
                gppRuntimeReady = true;
                // Runtime becoming ready is the one guaranteed moment a
                // native-ghost-mirror sync (gpp-native-shim.js's
                // gppShimSyncFocusedTemplate, reached via refreshAll()) can
                // correctly reflect the real restored focus/mask.
                // gppShimEnable() now runs immediately at Ghost++ init (see
                // gpp-ui-shell.js's gppReplaceNativeOpener), well BEFORE this
                // runtime is ready — so its own first sync call correctly
                // clears the native mirror to empty (nothing is focused yet
                // at that point), but nothing was guaranteed to re-sync it
                // afterward. gppTriggerScanForRestoredFocus() below only
                // refreshes when it actually finds a positioned template to
                // scan, silently skipping the refresh (leaving the mirror
                // stuck empty) whenever there's no restored focus, or the
                // restored template isn't positioned yet. Other addons that
                // read the native mirror directly instead of going through
                // Ghost++'s own UI (e.g. a third-party "queue selected
                // colors" tool reading the bare ghostActivePaletteColors
                // binding) would then see "no colors selected" even though
                // Ghost++ itself correctly shows colors enabled, until some
                // OTHER action (opening Ghost++'s own modal, toggling a
                // color) happened to trigger a real refreshAll() later.
                // refreshAll() safely no-ops for anything not yet built by
                // ensureShellBuilt() (each render call is individually
                // guarded on its own container existing), so this is safe to
                // call even before the modal has ever been opened.
                gppRequestUiRefresh();
                gppTriggerScanForRestoredFocus();
                return gppState;
            })().catch(err => {
                gppRuntimeInitPromise = null;
                throw err;
            });
        }
        return gppRuntimeInitPromise;
    }

    // ── Ghost++ UI shell ──────────────────────────────────────────────
    // Opener button (replaces the native "Overlay Image" button in place) and
    // the modal skeleton: two-column layout, fully collapsible right panel,
    // drag from any edge, resize from any corner. Palette/library/scan panels
    // are populated by gpp-runtime.js; this file owns layout/chrome only.

    const GPP_IDS = Object.freeze({
        opener: 'gpp-opener',
        modal: 'gpp-modal',
        style: 'gpp-shell-style',
        left: 'gpp-modal-left',
        right: 'gpp-modal-right',
        rightContent: 'gpp-modal-right-content',
        editingLabel: 'gpp-editing-label',
    });

    // Rewrites the tag's content every call rather than no-op-ing once it
    // exists — t2()/isDarkMode() are evaluated fresh each time this runs, so
    // re-calling it (see gpp-init.js's theme-change observer) is how the UI
    // picks up a live dark/light toggle instead of staying frozen at
    // whatever theme was active on first mount.
    function gppInjectShellStyle() {
        let style = document.getElementById(GPP_IDS.style);
        if (!style) {
            style = document.createElement('style');
            style.id = GPP_IDS.style;
            document.head.appendChild(style);
        }
        style.textContent = `
            #${GPP_IDS.modal} {
                position: fixed; left: 24px; top: 90px; z-index: 2000;
                display: flex; flex-direction: row;
                width: min(92vw, 800px); height: 75vh; min-width: 480px; min-height: 320px;
                border-radius: .75rem; overflow: hidden;
                background: ${t2('#ffffff', '#1e1e2e')}; color: ${t2('#111827', '#f5f5f5')};
                border: 1px solid ${t2('#d1d5db', '#45475a')};
                box-shadow: 0 12px 32px ${t2('rgba(15,23,42,.28)', 'rgba(0,0,0,.62)')};
                font: 13px system-ui, sans-serif;
            }
            #${GPP_IDS.modal}.gpp-hidden { display: none; }
            /* Only present for the duration of a collapse/expand toggle (see
               the toggle-right handler below) — ports ghost-template-manager.js's
               own .gpc-preview-animating: without a matching width transition
               on the MODAL itself, right's own width animates smoothly while
               the modal's outer edge snaps instantly, which reads as a janky,
               uncoordinated "weird animation" since the two related widths
               move at different paces. Scoped to a temporary class (not a
               permanent rule) so it never fights gppWireModalResize's own
               drag-to-resize, which needs instant, untransitioned width
               updates while dragging.
               Duration/easing MUST match #${GPP_IDS.right}'s own width
               transition exactly (.15s ease, below) — #${GPP_IDS.left} is
               flex:1 1 0 with no width transition of its own, so at every
               frame its rendered width is just "modal's current width minus
               right's current width". If the two transitions run for
               different durations, that difference is momentarily nonzero
               partway through (one side finishes shrinking before the
               other), which reads as the left panel visibly bulging and
               settling instead of staying rock-steady while only the right
               side moves. Keeping both transitions identical keeps that
               difference constant (= left's true width) at every frame. */
            #${GPP_IDS.modal}.gpp-modal-animating-width { transition: width .15s ease; overflow: hidden; }
            #${GPP_IDS.modal} .gpp-head {
                display: flex; align-items: center; gap: 8px; padding: 8px 10px; cursor: move;
                background: ${t2('#f8fafc', '#181825')}; border-bottom: 1px solid ${t2('#d1d5db', '#45475a')};
            }
            #${GPP_IDS.modal} .gpp-head .gpp-spacer { flex: 1; }
            #${GPP_IDS.modal} .gpp-head button {
                border: none; background: transparent; color: inherit; cursor: pointer; font-size: 14px;
            }
            #${GPP_IDS.editingLabel} {
                font-size: 11px; font-weight: 600;
                color: ${t2('#475569', '#a6adc8')};
            }
            /* No overflow-y/scrollbar-gutter here — #gpp-left-body (its
               child, below) is the one that actually scrolls (.gpp-head
               plus #gpp-left-body's own flex:1 exactly fill this
               container's height between them), so this element itself
               never shows a scrollbar. It used to carry both properties
               anyway — pure dead weight that silently reserved ~15px of
               scrollbar-gutter space on the right for a scrollbar that
               could never appear, on top of the horizontal padding bug
               above; together they were the "invisible padding" that kept
               .gpp-head and every divider from reaching gpp-panel-splitter. */
            #${GPP_IDS.left} {
                flex: 1 1 0; min-width: 0; display: flex; flex-direction: column;
                padding: 10px 0;
            }
            /* Horizontal inset intentionally does NOT live on the container
               above — .gpp-head is a direct child of it and needs to span
               the full panel width, flush to .gpp-panel-splitter (so does
               every details.gpp-collapsible section's own border-top
               divider, below). Each piece that actually needs breathing
               room insets ITSELF instead: .gpp-head via its own padding
               (below), #gpp-drop-zone/#gpp-ingest-status via their own
               margin, and details.gpp-collapsible via its own padding
               (further down) — none of which affect where a border drawn
               on that same element's box actually sits. */
            #gpp-drop-zone, #gpp-ingest-status { margin-left: 12px; margin-right: 12px; }
            /* Reserves the scrollbar's own width whether or not it's
               currently needed, on every scrollable region below — per
               explicit product feedback, content (Progress/Error Settings/
               palette/etc.) was visibly shifting left/right as filtering or
               scanning made a container's content just barely cross the
               scroll threshold. */
            #gpp-left-body { scrollbar-gutter: stable; }
            #${GPP_IDS.right} {
                position: relative;
                width: 280px; flex-shrink: 0; display: flex; flex-direction: column; overflow: hidden;
                border-left: 1px solid ${t2('#d1d5db', '#45475a')};
                background: ${t2('#f8fafc', '#181825')};
                transition: width .15s ease;
            }
            #${GPP_IDS.right}.gpp-collapsed { width: 34px; }
            #${GPP_IDS.right}.gpp-collapsed #${GPP_IDS.rightContent} { opacity: 0; pointer-events: none; }
            #${GPP_IDS.right}.gpp-collapsed .gpp-panel-splitter { display: none; }
            #${GPP_IDS.rightContent} { flex: 1; overflow-y: auto; padding: 10px; scrollbar-gutter: stable; }
            .gpp-panel-splitter {
                position: absolute; left: -4px; top: 0; bottom: 0; width: 8px;
                cursor: ew-resize; z-index: 20; background: transparent; transition: background .15s;
            }
            .gpp-panel-splitter:hover, .gpp-panel-splitter.gpp-ps-dragging { background: rgba(99,102,241,.25); }
            .gpp-collapse-btn {
                width: 20px; height: 20px; border-radius: 9999px; border: 1px solid ${t2('#d1d5db', '#45475a')};
                background: ${t2('#ffffff', '#313244')}; color: inherit; cursor: pointer; font-size: 10px;
                display: flex; align-items: center; justify-content: center; flex-shrink: 0;
            }
            .gpp-edge { position: absolute; z-index: 5; cursor: move; }
            .gpp-edge.n, .gpp-edge.s { left: 12px; right: 12px; height: 6px; }
            .gpp-edge.n { top: 0; } .gpp-edge.s { bottom: 0; }
            .gpp-edge.e, .gpp-edge.w { top: 12px; bottom: 12px; width: 6px; }
            .gpp-edge.e { right: 0; } .gpp-edge.w { left: 0; }
            .gpp-corner { position: absolute; z-index: 6; width: 14px; height: 14px; }
            .gpp-corner.nw { top: 0; left: 0; cursor: nwse-resize; }
            .gpp-corner.ne { top: 0; right: 0; cursor: nesw-resize; }
            .gpp-corner.sw { bottom: 0; left: 0; cursor: nesw-resize; }
            .gpp-corner.se { bottom: 0; right: 0; cursor: nwse-resize; }
            /* Horizontal padding lives HERE (not on the ambient #gpp-modal-left
               container above) so the summary heading and .gpp-body content
               stay inset, while border-top itself — drawn on this element's
               own border box, which padding never moves — still spans the
               full, true panel width flush to .gpp-panel-splitter. */
            details.gpp-collapsible { border-top: 1px solid ${t2('#e5e7eb', '#313244')}; padding: 8px 12px; }
            details.gpp-collapsible > summary {
                cursor: pointer; font-weight: 600; list-style: none; display: flex; align-items: center; gap: 6px;
            }
            details.gpp-collapsible > summary::-webkit-details-marker { display: none; }
            details.gpp-collapsible > summary::before { content: '\\25B8'; transition: transform .1s; }
            details.gpp-collapsible[open] > summary::before { transform: rotate(90deg); }
            details.gpp-collapsible .gpp-body { padding: 8px 2px 2px; }
            #gpp-drop-zone {
                border: 2px dashed ${t2('#cbd5e1', '#45475a')}; border-radius: 8px;
                padding: 14px; text-align: center; cursor: pointer;
                color: ${t2('#334155', '#cdd6f4')};
                transition: border-color .1s, background .1s;
            }
            #gpp-drop-zone:hover, #gpp-drop-zone.gpp-dragging {
                border-color: ${t2('#2563eb', '#89b4fa')};
                background: ${t2('rgba(37,99,235,.06)', 'rgba(137,180,250,.08)')};
            }
            #gpp-drop-zone .gpp-muted { color: ${t2('#64748b', '#a6adc8')}; }
            #gpp-url-upload-btn {
                display: inline-block; margin-top: 6px; padding: 0; border: none; background: none;
                font: inherit; font-size: 11px; text-decoration: underline; cursor: pointer;
                color: ${t2('#64748b', '#a6adc8')};
            }
            #gpp-url-upload-btn:hover { color: ${t2('#2563eb', '#89b4fa')}; }
        `;
    }

    // Theme helper local to this feature (mirrors core.js's isDarkMode()/t(),
    // reused directly rather than reimplemented) — aliased to a short name so
    // the template-literal CSS above stays readable.
    function t2(light, dark) { return t(light, dark); }

    // `loadGhostImageBtn` ("Overlay Image") lives inside `#imageGroupDropdown`
    // alongside `toggleDitherer` (Pixelate Image) and, when the legacy Ghost
    // Palette Search feature is active, `gpc-sync-ghost-btn`. Per explicit
    // clarification, the opener belongs INSIDE that dropdown too — as a
    // sibling of those buttons, positioned right after the div wrapping
    // loadGhostImageBtn — not out in the always-visible toolbar row (an
    // earlier attempt moved it there and got the placement wrong).
    // Assumes exactly one call site (gpp-init.js's own readyState-gated
    // startup) — it creates a fresh #gpp-opener unconditionally, so a
    // second call would produce a visible duplicate button. Not guarded
    // against here since there is only ever the one call site today.
    function gppReplaceNativeOpener(onOpen) {
        const opener = document.createElement('button');
        opener.type = 'button';
        opener.id = GPP_IDS.opener;
        opener.title = 'Ghost++ template overlay';
        opener.setAttribute('aria-label', 'Open Ghost++');
        opener.textContent = '👻';
        // Match the native icon buttons' own classes exactly (they carry no
        // dark: variant themselves — these floating toolbar icons render the
        // same in both themes on this site) rather than custom CSS, so the
        // new button is visually indistinguishable from its siblings.
        opener.className = 'w-10 h-10 bg-white shadow rounded-full flex items-center justify-center hover:bg-gray-100 cursor-pointer';
        opener.style.fontSize = '16px';
        opener.addEventListener('click', onOpen);

        const refs = { opener, native: null };

        function attach(native) {
            refs.native = native;
            native.style.display = 'none';
            // The site's own Tailwind "hidden" convention (see e.g. Janitor
            // View's "removes the hidden class" pattern) AND a pre-emptive
            // 'data-gpc-pill' marker — ext-pill-hover-labels.js (the Hover
            // Labels extension, default-on) re-scans #controls-left on every
            // DOM mutation (including this very insertion below) and
            // explicitly sets style.display='flex' on any of its target
            // buttons that lacks the .hidden class, reviving a plain inline-
            // style hide. 'data-gpc-pill' is that feature's own "already
            // processed" marker (its PROCESSED_ATTR) — setting it here short-
            // circuits its transformButton() before it ever touches this
            // button's display style, regardless of the .hidden class too.
            native.classList.add('hidden');
            native.setAttribute('data-gpc-pill', '1');
            // Sibling of loadGhostImageBtn (and toggleDitherer) in the same
            // toolbar row, right below loadGhostImageBtn — per explicit user
            // correction, NOT a sibling of some enclosing wrapper div.
            if (native.parentElement) {
                native.insertAdjacentElement('afterend', opener);
            } else if (!opener.isConnected) {
                document.body.appendChild(opener);
            }
            // Hiding the native button alone does NOT stop it from firing —
            // display:none doesn't block a programmatic .click() (e.g. the
            // site's own keyboard-shortcut handler, performShortcutAction's
            // 'ghost' case, calls loadGhostImageBtn?.click() directly), and
            // that would still pop the native ghost modal even though
            // Ghost++'s own opener is right there. gppShimEnable() (which
            // installs the click-blocking capture for exactly this button)
            // used to only run lazily on the user's FIRST manual open of
            // Ghost++ (via ensureRuntime()) — leaving a real window, before
            // that first open, where a keyboard shortcut (or anything else
            // that calls .click() on the hidden button) still summoned the
            // old native UI. A user's own exported debug log showed init
            // completing successfully (native button found, opener mounted)
            // yet them still reporting "still don't see the menu" — this is
            // the gap that explained it. gppShimEnable() is idempotent
            // (guarded by gppShimActive) and every one of its pieces
            // (native function patching, control-capture installation,
            // snapshot/mirror reads) only touches page-realm globals and
            // gppState's own already-safe-when-empty accessors — none of it
            // needs gppInitRuntime() to have resolved first — so it's safe
            // to run right here, the moment the native button is confirmed
            // to exist, instead of waiting for the modal's first open.
            if (typeof gppShimEnable === 'function') {
                gppShimEnable();
                dbgPush('Ghost++ opener/native-button: native-control click captures installed immediately (blocks the hidden native ghost button from still opening the old UI via a keyboard shortcut or other programmatic click, before the user has ever opened Ghost++ once).', { uiComponent: 'Ghost++ Template Overlay' });
            }
        }

        const native = document.getElementById('loadGhostImageBtn');
        if (native) {
            dbgPush('Ghost++ opener/native-button: #loadGhostImageBtn found on the first synchronous check — replaced immediately.', { uiComponent: 'Ghost++ Template Overlay' });
            attach(native);
        } else {
            // The native toolbar button may not exist yet if Ghost++'s own init
            // runs before the site's own JS has finished mounting it -- a
            // client-rendered element, not guaranteed present merely because
            // DOMContentLoaded already fired. User-reported (Firefox) as
            // 'toggled Ghost++ on, still see the old menu': without this,
            // the fully-functional native button silently stayed untouched
            // and clickable, and this opener button landed at document.body
            // instead of the toolbar, unnoticed. Watch for it to actually
            // appear instead of giving up after one synchronous check.
            dbgPush('Ghost++ opener/native-button: #loadGhostImageBtn NOT found on the first synchronous check — this is the exact condition behind the reported "toggled Ghost++ on, still see the old menu" bug. Watching for it to appear instead of giving up.', { uiComponent: 'Ghost++ Template Overlay' });
            const watchStartedAt = Date.now();
            document.body.appendChild(opener); // visible in the meantime, better than nothing
            const watchRoot = document.getElementById('controls-left') || document.body;
            const observer = new MutationObserver(() => {
                const found = document.getElementById('loadGhostImageBtn');
                if (!found) return;
                observer.disconnect();
                clearTimeout(giveUpTimer);
                dbgPush('Ghost++ opener/native-button: #loadGhostImageBtn appeared ' + (Date.now() - watchStartedAt) + 'ms after Ghost++ started watching for it — replaced now.', { uiComponent: 'Ghost++ Template Overlay' });
                attach(found);
            });
            observer.observe(watchRoot, { childList: true, subtree: true });
            // 15s matches the same give-up duration ext-map-movement-lock.js
            // and ext-log-out-button.js already use for their own late-
            // mounting-element watchers, not an arbitrary pick.
            const giveUpTimer = setTimeout(() => {
                observer.disconnect();
                dbgPush('Ghost++ opener/native-button: gave up after 15s — #loadGhostImageBtn was never found. The native ghost tool will keep working alongside the Ghost++ button instead of being hidden.', { uiComponent: 'Ghost++ Template Overlay' });
                console.error('[GeoPixelcons++] Ghost++: never found the native #loadGhostImageBtn to replace -- it will keep working alongside the Ghost++ button instead of being hidden.');
            }, 15000);
        }

        return refs;
    }

    function gppRestoreNativeOpener(refs) {
        if (refs && refs.opener) refs.opener.remove();
        if (refs && refs.native) {
            refs.native.style.display = '';
            refs.native.classList.remove('hidden');
            refs.native.removeAttribute('data-gpc-pill');
        }
    }

    function gppBuildModalShell() {
        gppInjectShellStyle();
        const modal = document.createElement('div');
        modal.id = GPP_IDS.modal;
        modal.className = 'gpp-hidden';
        modal.setAttribute('role', 'dialog');
        modal.setAttribute('aria-label', 'Ghost++ template overlay manager');
        modal.innerHTML = `
            <div class="gpp-edge n" data-gpp-drag></div>
            <div class="gpp-edge s" data-gpp-drag></div>
            <div class="gpp-edge e" data-gpp-drag></div>
            <div class="gpp-edge w" data-gpp-drag></div>
            <div class="gpp-corner nw" data-gpp-resize="nw"></div>
            <div class="gpp-corner ne" data-gpp-resize="ne"></div>
            <div class="gpp-corner sw" data-gpp-resize="sw"></div>
            <div class="gpp-corner se" data-gpp-resize="se"></div>
            <div id="${GPP_IDS.left}">
                <div class="gpp-head" data-gpp-drag>
                    <strong>Ghost++</strong>
                    <span id="${GPP_IDS.editingLabel}"></span>
                    <span class="gpp-spacer"></span>
                    <button type="button" data-gpp-action="close" aria-label="Close">✕</button>
                </div>
                <div id="gpp-left-body" style="flex:1; overflow-y:auto;"></div>
            </div>
            <div id="${GPP_IDS.right}">
                <div class="gpp-panel-splitter" data-gpp-splitter title="Drag to resize"></div>
                <div style="display:flex; align-items:center; padding:6px 8px; gap:6px;">
                    <button type="button" class="gpp-collapse-btn" data-gpp-action="toggle-right" title="Collapse">◀</button>
                    <span class="gpp-muted" style="font-size:11px;">Preview &amp; library</span>
                </div>
                <div id="${GPP_IDS.rightContent}"></div>
            </div>
        `;
        document.body.appendChild(modal);
        gppWireModalDrag(modal);
        gppWireModalResize(modal);
        gppWirePanelSplitter(modal);
        modal.addEventListener('transitionend', event => {
            if (event.target === modal && event.propertyName === 'width') {
                modal.classList.remove('gpp-modal-animating-width');
            }
        });
        modal.querySelector('[data-gpp-action="toggle-right"]').addEventListener('click', () => {
            const right = document.getElementById(GPP_IDS.right);
            // Ports ghost-template-manager.js's own .gpc-panel-splitter
            // collapse behaviour (see gppWirePanelSplitter below) up to the
            // whole-modal level: shrink gpp-modal's own width by the same
            // amount right shrinks by, so no empty gap is left where right
            // used to sit — merely freezing left's width (an earlier, now-
            // superseded fix) only stopped left from swallowing that space,
            // it didn't reclaim it.
            //
            // On EXPAND, derive the target from the modal's CURRENT
            // (collapsed) width rather than blindly restoring a remembered
            // total — that earlier approach snapped back to a stale size
            // whenever the user resized the modal (drag a corner/edge)
            // WHILE it was collapsed, ignoring that resize entirely. Instead,
            // treat "current collapsed width minus the 34px stub" as the
            // left panel's live width (exactly GTM's own leftW = currentW -
            // 34) and add back only the right panel's remembered width.
            modal.classList.add('gpp-modal-animating-width');
            const currentModalWidth = Math.round(modal.getBoundingClientRect().width);
            const willCollapse = !right.classList.contains('gpp-collapsed');
            if (willCollapse) {
                const rightWidth = Math.round(right.getBoundingClientRect().width);
                if (rightWidth > 38) modal.dataset.gppRightWidth = rightWidth + 'px';
                modal.style.width = Math.max(320, currentModalWidth - rightWidth + 34) + 'px';
            } else {
                const leftWidth = currentModalWidth - 34;
                const rememberedRightWidth = parseInt(modal.dataset.gppRightWidth, 10) || 280;
                modal.style.width = Math.max(480, leftWidth + rememberedRightWidth) + 'px';
            }
            const collapsed = right.classList.toggle('gpp-collapsed');
            // gppWirePanelSplitter's drag handler sets an INLINE width style,
            // which (inline always outranks a stylesheet class rule) silently
            // defeated the .gpp-collapsed rule's width:34px below the moment
            // the user had ever dragged the splitter — the content faded out
            // but the panel itself never actually shrank. Clear that inline
            // override on collapse (remembering it first) and restore it on
            // expand, rather than fighting it every time.
            if (collapsed) {
                if (right.style.width) right.dataset.gppExpandedWidth = right.style.width;
                right.style.width = '';
            } else if (right.dataset.gppExpandedWidth) {
                right.style.width = right.dataset.gppExpandedWidth;
            }
            modal.querySelector('[data-gpp-action="toggle-right"]').textContent = collapsed ? '▶' : '◀';
            modal.querySelector('[data-gpp-action="toggle-right"]').title = collapsed ? 'Expand' : 'Collapse';
        });
        return modal;
    }

    // Ports ghost-template-manager.js's .gpc-panel-splitter behaviour exactly:
    // an 8px strip straddling the right panel's left border, drag-left widens
    // the right panel, clamped to 5%-95% of the MODAL's width (not the right
    // panel's own width).
    function gppWirePanelSplitter(modal) {
        const splitter = modal.querySelector('[data-gpp-splitter]');
        const right = document.getElementById(GPP_IDS.right);
        splitter.addEventListener('pointerdown', event => {
            if (event.button !== 0 || right.classList.contains('gpp-collapsed')) return;
            event.preventDefault();
            event.stopPropagation();
            const startX = event.clientX;
            const startW = right.getBoundingClientRect().width;
            right.style.transition = 'none';
            splitter.classList.add('gpp-ps-dragging');
            splitter.setPointerCapture(event.pointerId);
            const onMove = ev => {
                const delta = startX - ev.clientX; // drag left = wider right panel
                const modalW = modal.getBoundingClientRect().width;
                const minW = Math.max(34, Math.floor(modalW * 0.05));
                const maxW = Math.max(minW, Math.floor(modalW * 0.95));
                const newW = Math.min(Math.max(minW, startW + delta), maxW);
                right.style.width = newW + 'px';
            };
            const onUp = () => {
                splitter.classList.remove('gpp-ps-dragging');
                right.style.transition = '';
                splitter.removeEventListener('pointermove', onMove);
                splitter.removeEventListener('pointerup', onUp);
            };
            splitter.addEventListener('pointermove', onMove);
            splitter.addEventListener('pointerup', onUp);
        });
    }

    // Drag (move) is wired to the header AND all 4 edges — grabbing any edge
    // moves the whole modal, matching the explicit "draggable on all edges"
    // requirement. Resize is corners-only (see gppWireModalResize) — edges
    // deliberately do not resize.
    function gppWireModalDrag(modal) {
        modal.querySelectorAll('[data-gpp-drag]').forEach(handle => {
            let drag = null;
            handle.addEventListener('pointerdown', event => {
                if (event.button !== 0 || event.target.closest('button')) return;
                const rect = modal.getBoundingClientRect();
                drag = { id: event.pointerId, dx: event.clientX - rect.left, dy: event.clientY - rect.top };
                handle.setPointerCapture(event.pointerId);
                event.stopPropagation();
            });
            handle.addEventListener('pointermove', event => {
                if (!drag || drag.id !== event.pointerId) return;
                modal.style.left = Math.max(0, event.clientX - drag.dx) + 'px';
                modal.style.top = Math.max(0, event.clientY - drag.dy) + 'px';
                modal.style.right = 'auto';
            });
            handle.addEventListener('pointerup', event => { if (drag && drag.id === event.pointerId) drag = null; });
        });
    }

    // Corners only — each `data-gpp-resize` value encodes which two sides
    // move (nw/ne/sw/se), so a single pointer handler covers all 4.
    function gppWireModalResize(modal) {
        modal.querySelectorAll('[data-gpp-resize]').forEach(handle => {
            const sides = handle.dataset.gppResize;
            let drag = null;
            handle.addEventListener('pointerdown', event => {
                if (event.button !== 0) return;
                const rect = modal.getBoundingClientRect();
                drag = { id: event.pointerId, startX: event.clientX, startY: event.clientY, rect };
                handle.setPointerCapture(event.pointerId);
                event.stopPropagation();
            });
            handle.addEventListener('pointermove', event => {
                if (!drag || drag.id !== event.pointerId) return;
                const dx = event.clientX - drag.startX;
                const dy = event.clientY - drag.startY;
                const minW = 480, minH = 320;
                if (sides.includes('e')) modal.style.width = Math.max(minW, drag.rect.width + dx) + 'px';
                if (sides.includes('s')) modal.style.height = Math.max(minH, drag.rect.height + dy) + 'px';
                if (sides.includes('w')) {
                    const width = Math.max(minW, drag.rect.width - dx);
                    modal.style.width = width + 'px';
                    modal.style.left = (drag.rect.left + drag.rect.width - width) + 'px';
                }
                if (sides.includes('n')) {
                    const height = Math.max(minH, drag.rect.height - dy);
                    modal.style.height = height + 'px';
                    modal.style.top = (drag.rect.top + drag.rect.height - height) + 'px';
                }
            });
            handle.addEventListener('pointerup', event => { if (drag && drag.id === event.pointerId) drag = null; });
        });
    }


    // ── Ghost++ viewport renderer ────────────────────────────────────────
    // Draws the ghost overlay on the live map. One canvas is created once and
    // appended to the map container; WebGL2 is preferred (per-cell dot shader
    // sampling small index/palette/mask textures) with an automatic Canvas2D
    // fallback (stepped per-visible-cell fillRect loop) when WebGL2 is
    // unavailable or a template exceeds the GPU's texture size limit.
    // Ported from the standalone prototype's createRenderer()
    // (scripts/geopixels-ghost-template-overhaul/1.0.0.js, lines ~1385-1884):
    // every direct `map`/`turf`/`gridSize`/`halfSize`/`offsetMeters*` access
    // is replaced with gpp-bridge.js's gppGetMap()/gppGetTurf()/
    // gppReadGridConstants(), since this runtime is sandboxed rather than
    // injected into the page realm like the original. The original's
    // per-cell error-cross overlay is intentionally NOT ported — error/scan
    // rendering belongs to a later Story-07-style subsystem and is out of
    // this file's scope.
    //
    // There is no automatic zoom-based "completed template" preview swap —
    // that system was removed in favour of a manual "Preview" button
    // (gpp-placement.js) that just drives template.opacity to 100%, the same
    // field the visibility eye-icon/Opacity slider already control. The
    // per-cell dot overlay stays visible at every zoom down to
    // gppReadGridConstants().minZoom (a separate, lower-level cutoff) by
    // applying gapRatio's proportional inset UNCONDITIONALLY, regardless of
    // how small a cell renders on screen — there is deliberately no small-
    // cell special case that snaps to a solid/100%-fill look, since that
    // made a zoomed-out overlay indistinguishable from Preview being on,
    // which the native ghost overlay never does either ("the cells appear no
    // matter your zoom level, only the render level matters" — a tiny cell
    // just fades out via the canvas/GPU's own antialiasing, same as any
    // other small shape would, rather than being artificially forced solid
    // or discarded outright).
    //
    // Also fixed relative to the original during the port: the original's
    // Canvas2D dot path floored each drawn cell to `Math.max(0.5, ...)`
    // pixels, which meant gapRatio===0 still left a faint 0.5px speckle
    // instead of a fully invisible cell. Both render paths here treat
    // gapRatio<=0 as an unconditional "draw nothing" instead (see
    // gppRendererDrawWebGl's fragment shader and gppRendererDrawCanvas2d),
    // matching gpp-view-settings.js's explicit "no artificial floor" contract
    // for this same setting.
    //
    // Public surface (the only names other Ghost++ files should call):
    //   gppRendererMount()    — idempotent; creates the canvas and wires
    //                           listeners once the map/turf become available.
    //                           Self-invoked at the bottom of this file,
    //                           gated on _settings.ghostPlusPlus — the
    //                           overlay must keep drawing whether or not the
    //                           Ghost++ modal is open, so it does not wait
    //                           for gpp-init.js's open(). Also kicks off
    //                           gppInitRuntime() itself (see
    //                           gppRendererEnsureRuntime()) if nothing has
    //                           loaded the template library from IndexedDB
    //                           yet, so templates painted before a user ever
    //                           opens the modal still show up.
    //   gppRendererSchedule() — coalesced (one rAF per burst) redraw request;
    //                           call after any state change the overlay must
    //                           reflect (mask toggle, position/opacity edit,
    //                           template add/remove). Already called by
    //                           gpp-view-settings.js / gpp-palette.js /
    //                           gpp-placement.js via a `typeof` guard, so it
    //                           is safe for those files to load before this
    //                           one. A no-op before gppRendererMount() has
    //                           run.
    //   gppRendererDestroy()  — full teardown (feature disable / cleanup).

    const GPP_RENDERER_CANVAS_ID = 'gpp-renderer-canvas';
    const GPP_RENDERER_MAP_POLL_MS = 100;
    const GPP_RENDERER_MAP_POLL_MAX_ATTEMPTS = 300; // ~30s

    // One shared, stateless core instance — cheap to build, but no reason to
    // rebuild it per template per frame when a single instance works for the
    // life of the module (function declarations across files are hoisted
    // through the shared IIFE scope, so calling gppCreateCore() here is safe
    // regardless of this file's position in build.js's SRC_ORDER).
    const gppRendererCore = gppCreateCore();

    let gppRendererState = null;       // module singleton; null until gppRendererMount() attaches
    let gppRendererRuntimeReady = null; // promise cache for gppRendererEnsureRuntime()

    // ── small helpers ──────────────────────────────────────────────────

    function gppRendererClamp(value, min, max) {
        return Math.min(max, Math.max(min, value));
    }

    // Cheap non-cryptographic checksum over a template's live mask bitset,
    // used purely to detect "did the mask change since we last uploaded/
    // cached this template's GPU mask texture or preview thumbnail" without
    // needing an explicit change-notification hook from gpp-palette.js.
    function gppRendererMaskSignature(mask) {
        let sig = 0x9e3779b9 | 0;
        for (let i = 0; i < mask.length; i++) sig = (Math.imul(sig ^ mask[i], 0x85ebca6b)) | 0;
        return sig >>> 0;
    }

    function gppRendererCreateCanvasElement() {
        const element = document.createElement('canvas');
        element.id = GPP_RENDERER_CANVAS_ID;
        element.setAttribute('aria-hidden', 'true');
        Object.assign(element.style, {
            position: 'absolute',
            inset: '0',
            width: '100%',
            height: '100%',
            pointerEvents: 'none',
            imageRendering: 'pixelated',
            zIndex: '1',
        });
        return element;
    }

    // Loads gpp-runtime.js's template library exactly once for the whole
    // page, whether that happens because the renderer mounted first or
    // because the user opened the Ghost++ modal first (gpp-init.js's own
    // ensureRuntime() sets gppDatabase via gppInitRuntime() too). Checking
    // the shared `gppDatabase` binding (gpp-runtime.js) is a best-effort
    // guard against redundant IndexedDB reads if both paths race — never
    // destructive either way, since gppInitRuntime() only reads.
    function gppRendererEnsureRuntime() {
        if (!gppRendererRuntimeReady) {
            gppRendererRuntimeReady = (gppDatabase ? Promise.resolve(gppState) : gppInitRuntime())
                .catch(err => {
                    gppRendererRuntimeReady = null;
                    throw err;
                });
        }
        return gppRendererRuntimeReady;
    }

    // ── mount / lifecycle ─────────────────────────────────────────────

    function gppRendererMount() {
        if (gppRendererState) return gppRendererState.handle;
        const state = {
            destroyed: false,
            attached: false,
            canvas: null,
            map: null,
            turf: null,
            gl: null,
            ctx2d: null,
            program: null,
            vertexBuffer: null,
            mode: 'pending',
            resources: new Map(), // templateId -> gl/canvas2d resource (see gppRendererUploadTemplate*)
            resizeObserver: null,
            frameRequest: 0,
            moveHandler: null,
            zoomHandler: null,
            contextLostHandler: null,
            pollTimer: 0,
            pollAttempts: 0,
        };
        state.handle = Object.freeze({
            get mounted() { return state.attached; },
            get mode() { return state.mode; },
            schedule: gppRendererSchedule,
            destroy: gppRendererDestroy,
        });
        gppRendererState = state;
        gppRendererWaitForMap(state);
        return state.handle;
    }

    function gppRendererWaitForMap(state) {
        if (state.destroyed) return;
        const map = gppGetMap();
        const turf = gppGetTurf();
        if (map && turf) {
            try {
                gppRendererAttach(state, map, turf);
            } catch (err) {
                console.error('[GeoPixelcons++] Ghost++ renderer failed to attach:', err);
            }
            return;
        }
        if (state.pollAttempts++ >= GPP_RENDERER_MAP_POLL_MAX_ATTEMPTS) {
            console.warn('[GeoPixelcons++] Ghost++ renderer: map never became available; giving up.');
            return;
        }
        state.pollTimer = setTimeout(() => gppRendererWaitForMap(state), GPP_RENDERER_MAP_POLL_MS);
    }

    function gppRendererAttach(state, map, turf) {
        if (state.destroyed || state.attached) return;

        // Defensive cleanup of any stray leftover from a prior mount whose
        // destroy() didn't complete (e.g. threw mid-teardown).
        const stray = document.getElementById(GPP_RENDERER_CANVAS_ID);
        if (stray) stray.remove();

        const canvas = gppRendererCreateCanvasElement();
        map.getContainer().appendChild(canvas);
        state.canvas = canvas;
        state.map = map;
        state.turf = turf;

        let gl = null;
        try {
            gl = canvas.getContext('webgl2', {
                alpha: true,
                antialias: false,
                premultipliedAlpha: true,
                preserveDrawingBuffer: false,
            });
        } catch (_) { gl = null; }
        if (gl) {
            state.gl = gl;
            try {
                gppRendererInitWebGl(state);
                state.mode = 'webgl2';
            } catch (err) {
                console.warn('[GeoPixelcons++] Ghost++ renderer: WebGL2 unavailable, falling back to Canvas2D.', err);
                gppRendererSwitchToCanvas2d(state, err);
            }
        }
        if (!state.gl) {
            state.ctx2d = state.canvas.getContext('2d', { alpha: true, colorSpace: 'srgb' });
            if (!state.ctx2d) {
                console.error('[GeoPixelcons++] Ghost++ renderer: no supported canvas backend is available.');
                return;
            }
            state.ctx2d.imageSmoothingEnabled = false;
            state.mode = 'canvas2d';
        }

        state.resizeObserver = new ResizeObserver(() => gppRendererScheduleInternal(state));
        state.resizeObserver.observe(map.getContainer());

        if (state.gl) {
            state.contextLostHandler = event => {
                event.preventDefault();
                try {
                    gppRendererSwitchToCanvas2d(state, new Error('WebGL context lost'));
                    gppRendererScheduleInternal(state);
                } catch (err) {
                    console.error('[GeoPixelcons++] Ghost++ renderer: context lost and Canvas2D fallback failed.', err);
                }
            };
            state.canvas.addEventListener('webglcontextlost', state.contextLostHandler);
        }

        // Requirement: redraws are triggered by map move/zoom but coalesced
        // through one rAF per burst — never drawn synchronously here.
        state.moveHandler = () => gppRendererScheduleInternal(state);
        state.zoomHandler = () => gppRendererScheduleInternal(state);
        map.on('move', state.moveHandler);
        map.on('zoom', state.zoomHandler);

        state.attached = true;

        gppRendererEnsureRuntime()
            .then(() => gppRendererScheduleInternal(state))
            .catch(err => console.error('[GeoPixelcons++] Ghost++ renderer: failed to load the template library.', err));

        gppRendererScheduleInternal(state); // initial paint (draws nothing until the library above resolves)
    }

    function gppRendererDestroy() {
        if (!gppRendererState) return;
        const state = gppRendererState;
        state.destroyed = true;
        gppRendererState = null;

        if (state.pollTimer) clearTimeout(state.pollTimer);
        if (state.frameRequest) window.cancelAnimationFrame(state.frameRequest);
        if (state.resizeObserver) { try { state.resizeObserver.disconnect(); } catch (_) {} }
        if (state.map) {
            try {
                if (state.moveHandler) state.map.off('move', state.moveHandler);
                if (state.zoomHandler) state.map.off('zoom', state.zoomHandler);
            } catch (_) {}
        }
        if (state.canvas && state.contextLostHandler) {
            try { state.canvas.removeEventListener('webglcontextlost', state.contextLostHandler); } catch (_) {}
        }
        try {
            for (const resource of state.resources.values()) gppRendererDeleteResource(state, resource);
            state.resources.clear();
            if (state.gl) {
                if (state.vertexBuffer) state.gl.deleteBuffer(state.vertexBuffer);
                if (state.program) state.gl.deleteProgram(state.program);
            }
        } catch (_) {}
        if (state.canvas && state.canvas.parentNode) state.canvas.remove();
    }

    // ── scheduling ─────────────────────────────────────────────────────

    function gppRendererScheduleInternal(state) {
        if (!state || state.destroyed || !state.attached) return;
        if (state.frameRequest) return; // already coalesced into the pending frame
        state.frameRequest = window.requestAnimationFrame(() => gppRendererDraw(state));
    }

    function gppRendererSchedule() {
        if (!gppRendererState) return; // not mounted yet (or already destroyed) — nothing to draw
        gppRendererScheduleInternal(gppRendererState);
    }

    // ── WebGL2 backend ────────────────────────────────────────────────

    function gppRendererCompileShader(gl, type, source) {
        const shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            const message = gl.getShaderInfoLog(shader);
            gl.deleteShader(shader);
            throw new Error('Ghost++ renderer shader failed: ' + message);
        }
        return shader;
    }

    function gppRendererInitWebGl(state) {
        const gl = state.gl;
        const vertexSource = `#version 300 es
            in vec2 a_position;
            uniform vec4 u_rect;
            uniform vec2 u_viewport;
            out vec2 v_uv;
            void main() {
                vec2 pixel = u_rect.xy + (a_position * u_rect.zw);
                vec2 clip = vec2((pixel.x / u_viewport.x) * 2.0 - 1.0,
                                 1.0 - (pixel.y / u_viewport.y) * 2.0);
                gl_Position = vec4(clip, 0.0, 1.0);
                v_uv = a_position;
            }`;
        // gapRatio<=0 is an unconditional discard so it can reach a truly
        // invisible cell; above that, cells too small on screen to resolve a
        // fractional gap without shimmer/aliasing (<2.5px) just draw solid
        // instead of discarding, so the overlay stays visible at any zoom
        // (down to the native minZoom cutoff already applied before this
        // draw is ever issued — see gppRendererDraw).
        const fragmentSource = `#version 300 es
            precision highp float;
            precision highp int;
            precision highp usampler2D;
            in vec2 v_uv;
            uniform usampler2D u_indices;
            uniform usampler2D u_palette;
            uniform usampler2D u_mask;
            uniform ivec2 u_template_size;
            uniform int u_palette_width;
            uniform uint u_empty;
            uniform float u_gap_ratio;
            uniform float u_opacity;
            out vec4 out_color;
            void main() {
                vec2 safe_uv = min(v_uv, vec2(0.9999999));
                ivec2 cell = ivec2(floor(safe_uv * vec2(u_template_size)));
                uint palette_index = texelFetch(u_indices, cell, 0).r;
                if (palette_index == u_empty) discard;
                int word_index = int(palette_index >> 5u);
                uint word = texelFetch(u_mask, ivec2(word_index, 0), 0).r;
                if ((word & (1u << (palette_index & 31u))) == 0u) discard;
                if (u_gap_ratio <= 0.0) discard;
                if (u_gap_ratio < 1.0) {
                    vec2 within = fract(safe_uv * vec2(u_template_size));
                    float inset = (1.0 - u_gap_ratio) * 0.5;
                    if (within.x < inset || within.x > 1.0 - inset || within.y < inset || within.y > 1.0 - inset) discard;
                }
                int palette_x = int(palette_index) % u_palette_width;
                int palette_y = int(palette_index) / u_palette_width;
                uint packed = texelFetch(u_palette, ivec2(palette_x, palette_y), 0).r;
                vec3 rgb = vec3(float((packed >> 16u) & 255u), float((packed >> 8u) & 255u), float(packed & 255u)) / 255.0;
                out_color = vec4(rgb, u_opacity);
            }`;
        const vertex = gppRendererCompileShader(gl, gl.VERTEX_SHADER, vertexSource);
        const fragment = gppRendererCompileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
        const program = gl.createProgram();
        gl.attachShader(program, vertex);
        gl.attachShader(program, fragment);
        gl.linkProgram(program);
        gl.deleteShader(vertex);
        gl.deleteShader(fragment);
        if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
            throw new Error(gl.getProgramInfoLog(program) || 'Could not link Ghost++ renderer shaders.');
        }
        state.program = program;

        const vertexBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW);
        state.vertexBuffer = vertexBuffer;

        gl.useProgram(program);
        const positionLocation = gl.getAttribLocation(program, 'a_position');
        gl.enableVertexAttribArray(positionLocation);
        gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
        gl.uniform1i(gl.getUniformLocation(program, 'u_indices'), 0);
        gl.uniform1i(gl.getUniformLocation(program, 'u_palette'), 1);
        gl.uniform1i(gl.getUniformLocation(program, 'u_mask'), 2);
        gl.enable(gl.BLEND);
        gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
        gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
    }

    function gppRendererSwitchToCanvas2d(state, reason) {
        if (!state.gl) return;
        try {
            for (const resource of state.resources.values()) gppRendererDeleteResource(state, resource);
            if (state.vertexBuffer) state.gl.deleteBuffer(state.vertexBuffer);
            if (state.program) state.gl.deleteProgram(state.program);
        } catch (_) {}
        state.resources.clear();
        state.vertexBuffer = null;
        state.program = null;

        const previousCanvas = state.canvas;
        const nextCanvas = gppRendererCreateCanvasElement();
        if (previousCanvas && previousCanvas.parentNode) previousCanvas.parentNode.replaceChild(nextCanvas, previousCanvas);
        else if (state.map) state.map.getContainer().appendChild(nextCanvas);
        state.canvas = nextCanvas;
        state.gl = null;
        state.ctx2d = nextCanvas.getContext('2d', { alpha: true, colorSpace: 'srgb' });
        if (!state.ctx2d) throw new Error('Canvas2D fallback could not be created.');
        state.ctx2d.imageSmoothingEnabled = false;
        state.mode = 'canvas2d';
        if (reason) console.warn('[GeoPixelcons++] Ghost++ renderer: using Canvas2D.', reason);
    }

    function gppRendererCreateTexture(gl, unit) {
        const texture = gl.createTexture();
        gl.activeTexture(gl.TEXTURE0 + unit);
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        return texture;
    }

    // ── per-template GPU/Canvas2D resource management ───────────────────
    // `resources` holds one entry per currently visible+positioned template,
    // keyed by template.id. Rebuilt only when the template's core data
    // (indices/palette/dimensions) actually changed — see
    // gppRendererReconcile() — never per frame. Mask changes (far more
    // frequent — every colour toggle in gpp-palette.js) update just the
    // small mask texture via gppRendererSyncMaskGl(), also detected lazily
    // rather than through an explicit change hook.

    function gppRendererDeleteResource(state, resource) {
        if (!state.gl || !resource) return;
        const gl = state.gl;
        ['indexTexture', 'paletteTexture', 'maskTexture'].forEach(key => {
            if (resource[key]) gl.deleteTexture(resource[key]);
        });
    }

    function gppRendererRemoveTemplateResource(state, id) {
        const resource = state.resources.get(id);
        gppRendererDeleteResource(state, resource);
        state.resources.delete(id);
    }

    function gppRendererUploadTemplateGl(state, template) {
        const gl = state.gl;
        gppRendererRemoveTemplateResource(state, template.id);
        const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
        if (template.width > maxTextureSize || template.height > maxTextureSize) {
            throw new Error((template.name || 'Template') + ' exceeds this browser\'s ' + maxTextureSize + 'px WebGL texture limit.');
        }

        const indexTexture = gppRendererCreateTexture(gl, 0);
        if (template.indexType === 'u8') {
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8UI, template.width, template.height, 0, gl.RED_INTEGER, gl.UNSIGNED_BYTE, template.indices);
        } else {
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16UI, template.width, template.height, 0, gl.RED_INTEGER, gl.UNSIGNED_SHORT, template.indices);
        }

        const paletteTexture = gppRendererCreateTexture(gl, 1);
        const paletteWidth = Math.max(1, Math.min(maxTextureSize, template.palette.length || 1));
        const paletteHeight = Math.max(1, Math.ceil(Math.max(1, template.palette.length) / paletteWidth));
        const paletteData = new Uint32Array(paletteWidth * paletteHeight);
        paletteData.set(template.palette);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32UI, paletteWidth, paletteHeight, 0, gl.RED_INTEGER, gl.UNSIGNED_INT, paletteData);

        const maskTexture = gppRendererCreateTexture(gl, 2);
        const maskData = template.mask.length ? template.mask : new Uint32Array(1);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32UI, maskData.length, 1, 0, gl.RED_INTEGER, gl.UNSIGNED_INT, maskData);

        state.resources.set(template.id, {
            indexTexture,
            paletteTexture,
            maskTexture,
            maskSig: gppRendererMaskSignature(template.mask),
            paletteWidth,
            indicesRef: template.indices,
            paletteRef: template.palette,
            width: template.width,
            height: template.height,
        });
    }

    function gppRendererUploadTemplateCanvas2d(state, template) {
        gppRendererRemoveTemplateResource(state, template.id);
        state.resources.set(template.id, {
            colorStrings: Array.from(template.palette, packed => gppRendererCore.packedToHex(packed)),
            indicesRef: template.indices,
            paletteRef: template.palette,
            width: template.width,
            height: template.height,
        });
    }

    // Adds/refreshes resources for every currently visible+positioned
    // template and drops resources for templates no longer in that set
    // (deleted, hidden, or un-positioned). Runs once per draw — cheap,
    // since it only does real GPU/canvas work when a resource is
    // missing or its indices/palette reference (transform/flip/rotate
    // creates a fresh array) or dimensions changed.
    function gppRendererReconcile(state, templates) {
        const ids = new Set(templates.map(t => t.id));
        for (const id of Array.from(state.resources.keys())) {
            if (!ids.has(id)) gppRendererRemoveTemplateResource(state, id);
        }
        for (const template of templates) {
            const existing = state.resources.get(template.id);
            const stale = !existing
                || existing.indicesRef !== template.indices
                || existing.paletteRef !== template.palette
                || existing.width !== template.width
                || existing.height !== template.height;
            if (!stale) continue;
            try {
                if (state.gl) gppRendererUploadTemplateGl(state, template);
                else gppRendererUploadTemplateCanvas2d(state, template);
            } catch (err) {
                if (state.gl) {
                    console.warn('[GeoPixelcons++] Ghost++ renderer: WebGL2 upload failed, falling back to Canvas2D.', err);
                    gppRendererSwitchToCanvas2d(state, err);
                    for (const fallbackTemplate of templates) {
                        try { gppRendererUploadTemplateCanvas2d(state, fallbackTemplate); } catch (err2) {
                            console.error('[GeoPixelcons++] Ghost++ renderer: could not prepare template for Canvas2D.', err2);
                        }
                    }
                    return;
                }
                console.error('[GeoPixelcons++] Ghost++ renderer: could not prepare template.', err);
            }
        }
    }

    function gppRendererSyncMaskGl(state, template, resource) {
        const sig = gppRendererMaskSignature(template.mask);
        if (resource.maskSig === sig) return;
        resource.maskSig = sig;
        const gl = state.gl;
        gl.activeTexture(gl.TEXTURE2);
        gl.bindTexture(gl.TEXTURE_2D, resource.maskTexture);
        const maskData = template.mask.length ? template.mask : new Uint32Array(1);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32UI, maskData.length, 1, 0, gl.RED_INTEGER, gl.UNSIGNED_INT, maskData);
    }

    // ── grid -> screen projection ─────────────────────────────────────
    // mercX = gridX*gridSize + offsetMetersX (edges offset by ±halfSize),
    // mercY similarly; grid Y is up-positive and a template's rows extend
    // DOWNWARD from its top-left position row 0, so the top edge is
    // position.gridY*size + half (larger Y / further north) and the bottom
    // edge is (position.gridY - height + 1)*size - half (smaller Y / further
    // south) — screen Y then comes out increasing downward via map.project,
    // matching normal screen conventions without an extra flip.

    function gppRendererProjectTemplate(map, turf, grid, template) {
        // gpp-placement.js's gppPlacementPreview (shared top-level scope —
        // see build.js's SRC_ORDER banner) overrides the committed position
        // while a "Place on map" capture is tracking the pointer, so the
        // template visibly follows the cursor before the click commits it.
        const preview = gppPlacementPreview;
        const position = (preview && preview.templateId === template.id) ? preview.position : template.position;
        if (!position) return null;
        try {
            const size = grid.gridSize;
            const half = grid.halfSize;
            const offsetX = grid.offsetMetersX;
            const offsetY = grid.offsetMetersY;
            const topLeftEdge = [position.gridX * size - half + offsetX, position.gridY * size + half + offsetY];
            const bottomRightGridX = position.gridX + template.width - 1;
            const bottomRightGridY = position.gridY - template.height + 1;
            const bottomRightEdge = [bottomRightGridX * size + half + offsetX, bottomRightGridY * size - half + offsetY];
            const topLeft = map.project(turf.toWgs84(topLeftEdge));
            const bottomRight = map.project(turf.toWgs84(bottomRightEdge));
            if (![topLeft.x, topLeft.y, bottomRight.x, bottomRight.y].every(Number.isFinite)) return null;
            return {
                x: topLeft.x,
                y: topLeft.y,
                width: bottomRight.x - topLeft.x,
                height: bottomRight.y - topLeft.y,
            };
        } catch (_) {
            return null;
        }
    }

    function gppRendererViewportIntersects(rect, cssWidth, cssHeight) {
        return rect && rect.x + rect.width >= 0 && rect.x <= cssWidth && rect.y + rect.height >= 0 && rect.y <= cssHeight;
    }

    // ── frame lifecycle ──────────────────────────────────────────────

    // Only resizes the backing store when the observed CSS/device-pixel size
    // actually changed — reassigning canvas.width/height every frame
    // reallocates the backing store and is the known GPC++ perf bug this
    // file must not repeat (see requirement 1).
    function gppRendererEnsureSize(state) {
        const rect = state.map.getContainer().getBoundingClientRect();
        const cssWidth = Math.max(1, rect.width);
        const cssHeight = Math.max(1, rect.height);
        const dpr = Math.max(1, window.devicePixelRatio || 1);
        const backingWidth = Math.max(1, Math.round(cssWidth * dpr));
        const backingHeight = Math.max(1, Math.round(cssHeight * dpr));
        if (state.canvas.width !== backingWidth || state.canvas.height !== backingHeight) {
            state.canvas.width = backingWidth;
            state.canvas.height = backingHeight;
        }
        if (state.gl) {
            state.gl.viewport(0, 0, backingWidth, backingHeight);
        } else if (state.ctx2d) {
            state.ctx2d.setTransform(backingWidth / cssWidth, 0, 0, backingHeight / cssHeight, 0, 0);
            state.ctx2d.imageSmoothingEnabled = false;
        }
        return { cssWidth, cssHeight, dpr };
    }

    // A template is "visible" for resourcing/drawing purposes if it's
    // actively being placed (previewTemplateId — must render regardless of
    // its own opacity so the user can see what they're positioning), or if
    // its EFFECTIVE opacity (accounting for gpp-placement.js's Preview
    // button override, same as the WebGl/Canvas2d draw functions' own
    // `previewing` check) is above 0. Used to keep gppRendererReconcile
    // from building a GPU texture / Canvas2D resource for a hidden
    // template at all — see gppRendererDraw's own comment on why this
    // matters for large templates.
    function gppRendererIsTemplateVisible(template, previewTemplateId) {
        if (template.id === previewTemplateId) return true;
        const effectiveOpacity = gppForcedVisibleTemplateIds.has(template.id) ? 1 : template.opacity;
        return effectiveOpacity > 0;
    }

    function gppRendererDraw(state) {
        state.frameRequest = 0;
        if (state.destroyed || !state.attached) return;

        let turf = state.turf;
        if (!turf) {
            turf = gppGetTurf();
            if (turf) state.turf = turf;
        }

        const viewport = gppRendererEnsureSize(state);
        if (state.gl) {
            state.gl.clearColor(0, 0, 0, 0);
            state.gl.clear(state.gl.COLOR_BUFFER_BIT);
        } else if (state.ctx2d) {
            state.ctx2d.clearRect(0, 0, viewport.cssWidth, viewport.cssHeight);
        }
        if (!turf) return; // can't project without turf; next schedule() retries

        let zoom;
        try { zoom = state.map.getZoom(); } catch (_) { zoom = -Infinity; }

        const grid = gppReadGridConstants();
        if (zoom < grid.minZoom) return; // requirement 6: hide entirely below native minZoom

        // A template with no committed position yet still needs to be drawn
        // while gppPlacementPreview (gpp-placement.js) is tracking it — it
        // hasn't been placed, but the "Place on map" click-to-place capture
        // still projects it at the cursor's current grid cell.
        const previewTemplateId = gppPlacementPreview ? gppPlacementPreview.templateId : null;
        // Guild templates (gpp-guild-templates.js) are never part of
        // gppState.templates (see that array's own header comment in
        // gpp-runtime.js — they must never persist or appear in the main
        // Templates grid), but they still need to draw on the map exactly
        // like a real template whenever a card's visibility toggle sets
        // its opacity above 0 — concat them in here, at the one place this
        // whole file turns "templates" into "what gets drawn".
        //
        // Filtered by gppRendererIsTemplateVisible BEFORE reaching
        // gppRendererReconcile, not just inside the WebGl/Canvas2d draw
        // loops below (which still separately check effectiveOpacity too,
        // for the placement-preview edge case) — per explicit product
        // feedback, an opacity-0 template must get ZERO GPU/canvas
        // resources built for it, not just skip being drawn. This matters a
        // lot for large templates (guild projects reported up to 3276x3276
        // in production — a single WebGL index texture that size is ~10MB+
        // on its own): previously EVERY positioned template got a full
        // texture upload in gppRendererReconcile regardless of visibility,
        // so 30-40 large hidden guild templates could mean hundreds of MB
        // of avoidable GPU uploads. Excluding invisible templates here
        // means gppRendererReconcile's own existing stale-resource cleanup
        // (it already drops any resource whose id isn't in the current
        // `templates` set) now also frees a template's resource the moment
        // it becomes hidden, not just skips creating one.
        const templates = gppState.templates.concat(gppState.guildTemplates)
            .filter(t => (t.position || t.id === previewTemplateId) && gppRendererIsTemplateVisible(t, previewTemplateId))
            .slice()
            .sort((a, b) => a.order - b.order);

        gppRendererReconcile(state, templates);

        const settings = gppSettings;

        if (state.gl) gppRendererDrawWebGl(state, viewport, templates, grid, turf, settings);
        else if (state.ctx2d) gppRendererDrawCanvas2d(state, viewport, templates, grid, turf, settings);
    }

    function gppRendererDrawWebGl(state, viewport, templates, grid, turf, settings) {
        const gl = state.gl;
        gl.useProgram(state.program);
        gl.bindBuffer(gl.ARRAY_BUFFER, state.vertexBuffer);
        const loc = name => gl.getUniformLocation(state.program, name);
        gl.uniform2f(loc('u_viewport'), viewport.cssWidth, viewport.cssHeight);

        for (const template of templates) {
            // gpp-placement.js's "Preview" button — a transient view aid,
            // deliberately independent of template.opacity/gapRatio (see
            // gppForcedVisibleTemplateIds' own comment there).
            const previewing = gppForcedVisibleTemplateIds.has(template.id);
            const effectiveOpacity = previewing ? 1 : template.opacity;
            if (effectiveOpacity <= 0) continue; // requirement 4: 0 opacity is fully invisible — skip the draw entirely
            const resource = state.resources.get(template.id);
            if (!resource) continue;
            const rect = gppRendererProjectTemplate(state.map, turf, grid, template);
            if (!gppRendererViewportIntersects(rect, viewport.cssWidth, viewport.cssHeight)) continue; // requirement 5: cull

            gl.uniform4f(loc('u_rect'), rect.x, rect.y, rect.width, rect.height);
            gl.uniform1f(loc('u_opacity'), effectiveOpacity);
            gl.uniform1f(loc('u_gap_ratio'), previewing ? 1 : settings.gapRatio);

            gppRendererSyncMaskGl(state, template, resource);
            gl.uniform2i(loc('u_template_size'), template.width, template.height);
            gl.uniform1i(loc('u_palette_width'), resource.paletteWidth);
            gl.uniform1ui(loc('u_empty'), gppRendererCore.emptyValue(template.indexType));
            gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, resource.indexTexture);
            gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, resource.paletteTexture);
            gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, resource.maskTexture);
            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        }
    }

    function gppRendererDrawCanvas2d(state, viewport, templates, grid, turf, settings) {
        const ctx = state.ctx2d;
        for (const template of templates) {
            // gpp-placement.js's "Preview" button — a transient view aid,
            // deliberately independent of template.opacity/gapRatio (see
            // gppForcedVisibleTemplateIds' own comment there).
            const previewing = gppForcedVisibleTemplateIds.has(template.id);
            const effectiveOpacity = previewing ? 1 : template.opacity;
            if (effectiveOpacity <= 0) continue; // requirement 4
            const resource = state.resources.get(template.id);
            if (!resource) continue;
            const rect = gppRendererProjectTemplate(state.map, turf, grid, template);
            if (!gppRendererViewportIntersects(rect, viewport.cssWidth, viewport.cssHeight)) continue; // requirement 5: cull

            ctx.globalAlpha = effectiveOpacity;

            const effectiveGapRatio = previewing ? 1 : settings.gapRatio;
            if (effectiveGapRatio <= 0) { ctx.globalAlpha = 1; continue; } // exactly 0 = fully invisible, no artificial floor

            const cellWidth = rect.width / template.width;
            const cellHeight = rect.height / template.height;
            const absCellWidth = Math.abs(cellWidth);
            const absCellHeight = Math.abs(cellHeight);
            const stepX = Math.max(1, Math.ceil(1 / Math.max(absCellWidth, 0.0001)));
            const stepY = Math.max(1, Math.ceil(1 / Math.max(absCellHeight, 0.0001)));
            const minX = gppRendererClamp(Math.floor((0 - rect.x) / cellWidth), 0, template.width - 1);
            const maxX = gppRendererClamp(Math.ceil((viewport.cssWidth - rect.x) / cellWidth), 0, template.width);
            const minY = gppRendererClamp(Math.floor((0 - rect.y) / cellHeight), 0, template.height - 1);
            const maxY = gppRendererClamp(Math.ceil((viewport.cssHeight - rect.y) / cellHeight), 0, template.height);
            const startX = Math.max(0, Math.floor(minX / stepX) * stepX);
            const startY = Math.max(0, Math.floor(minY / stepY) * stepY);
            const empty = gppRendererCore.emptyValue(template.indexType);

            // Only visible cells (clamped range above) are ever visited, and
            // for very zoomed-out views stepX/stepY skip-sample rather than
            // touching every cell — never a full template-sized pass.
            for (let y = startY; y < maxY; y += stepY) {
                for (let x = startX; x < maxX; x += stepX) {
                    const sampleX = Math.min(template.width - 1, x + Math.floor(stepX / 2));
                    const sampleY = Math.min(template.height - 1, y + Math.floor(stepY / 2));
                    const index = template.indices[sampleY * template.width + sampleX];
                    if (index === empty || !gppRendererCore.maskHas(template.mask, index)) continue;

                    const drawX = rect.x + x * cellWidth;
                    const drawY = rect.y + y * cellHeight;
                    const drawWidth = cellWidth * stepX;
                    const drawHeight = cellHeight * stepY;
                    // Same proportional inset at every zoom level, however
                    // small the cell renders on screen — matching the native
                    // ghost overlay, which never snaps to a solid/100%-fill
                    // look just because you've zoomed out; only the actual
                    // render resolution determines what's visible. For any
                    // gapRatio in (0,1] this inset is always < half the cell,
                    // so drawnWidth/drawnHeight are always positive (down to
                    // sub-pixel, which the canvas naturally antialiases away
                    // rather than needing an artificial size floor here).
                    const insetX = drawWidth * (1 - effectiveGapRatio) * 0.5;
                    const insetY = drawHeight * (1 - effectiveGapRatio) * 0.5;
                    const drawnWidth = drawWidth - insetX * 2;
                    const drawnHeight = drawHeight - insetY * 2;
                    if (drawnWidth <= 0 || drawnHeight <= 0) continue; // no floor to a visible speckle — see file banner

                    ctx.fillStyle = resource.colorStrings[index];
                    ctx.fillRect(drawX + insetX, drawY + insetY, drawnWidth, drawnHeight);
                }
            }
            ctx.globalAlpha = 1;
        }
    }

    // ── auto-mount ───────────────────────────────────────────────────
    // Mirrors gpp-init.js's own `if (_settings.ghostPlusPlus) { try {...} }`
    // gating, but mounts independently of the modal: the overlay must draw
    // on the map whether or not the Ghost++ manager window is ever opened.
    // gppRendererMount() itself tolerates the map/turf not being ready yet
    // (it polls — see gppRendererWaitForMap()), so this is safe to run at
    // top-level script-load time regardless of where this file sits in
    // build.js's SRC_ORDER relative to gpp-bridge.js/gpp-runtime.js/
    // gpp-init.js (function declarations across files in the shared IIFE are
    // hoisted; the only state this touches — gppSettings/gppState/
    // gppDatabase — is only ever read later, asynchronously, by which point
    // the whole script's synchronous top-level pass has already run).
    if (_settings.ghostPlusPlus) {
        try {
            gppRendererMount();
        } catch (err) {
            console.error('[GeoPixelcons++] Ghost++ renderer failed to mount:', err);
        }
    }

    // ── Ghost++ native-ghost compatibility shim ───────────────────────
    // Lets Ghost++ own the overlay slot while keeping the *native* ghost
    // consumers (cursor sampling, error-cross rendering, the location
    // readout, guild fill/auto-place) working against the focused Ghost++
    // template instead of stale/empty state. Everything here goes through
    // gpp-bridge.js's narrow, named touch points — this file never reads or
    // writes `map`/`turf`/`Colors`/native `let` bindings directly.
    //
    // Unconditional (not a setting — assumed whenever Ghost++ itself is
    // enabled, by explicit product decision; there is no UI to turn it off
    // independently, and no restore/disable path — see gpp-bridge.js's
    // gppSetNativeFunction comment).
    //
    // Lifecycle contract:
    //   gppShimEnable()              — patch native draw fns to no-ops,
    //                                   install native-control capture
    //                                   listeners, mirror the focused template.
    //                                   Call this once Ghost++'s runtime is
    //                                   ready; re-entrant calls just resync.
    //   gppShimSyncFocusedTemplate() — refresh the mirror for whichever
    //                                   template is currently focused. Safe to
    //                                   call any time (no-ops unless the shim
    //                                   is active); call it after ANY state
    //                                   change another Ghost++ module makes
    //                                   that affects the focused template:
    //                                   focus change, mask/colour toggles,
    //                                   position edits, ingest/import of a new
    //                                   template, and core-rewriting transforms
    //                                   (flip/rotate). It internally skips
    //                                   rebuilding the (potentially large) RGBA
    //                                   mirror when the focused template's core
    //                                   (id + indices + palette identity)
    //                                   hasn't changed since the last sync, so
    //                                   rapid mask/position edits stay cheap.
    //
    // INTEGRATION NOTE FOR WHOEVER WIRES THIS UP: this file intentionally has
    // no side effects at load time (consistent with every other gpp-*.js
    // file) — nothing here runs until one of the two functions above is
    // called. The call sites below still need to be wired from elsewhere
    // (gpp-init.js / the palette+position+library modules), since this file
    // may not edit any other file:
    //   - Call `gppShimEnable()` once Ghost++'s runtime has initialized
    //     (after `gppInitRuntime()` resolves).
    //   - Call `gppEnsureGuildProjectHook()` from the same spot (it needs
    //     `gppState` to exist) to rewire the guild menu's "Set as Ghost"
    //     button (`setProjectAsGhost`) into Ghost++'s own ingestion pipeline.
    //   - Call `gppShimSyncFocusedTemplate()` from `gppFocusTemplate`'s caller
    //     after a focus change, from the palette panel after any mask/colour
    //     toggle, from the position/transform panel after a move/flip/rotate,
    //     and from the ingest flow after a new template is imported/focused.
    //
    // Native ghost state ownership: while the shim is active, Ghost++ is the
    // sole writer of `ghostImage`, `ghostImageTopLeft`, `ghostImageOriginalData`,
    // `ghostImageCanvas`, `ghostPaletteColors`, `ghostActivePaletteColors`,
    // `ghostAllImageColors`, `paletteToImageColorMap`,
    // `imageColorToDominantColorMap`, `isColorFilterDisabled`, and
    // `ghostImageFileObject` (see js/ghost22.js for their native declarations).
    // `gppReadNativeGhostSnapshot()`'s bridge surface only exposes three small
    // fields (hasGhostImage / ghostImageTopLeft / isColorFilterDisabled), so
    // this shim does not attempt to snapshot-and-restore whatever arbitrary
    // native ghost state may have existed before Ghost++ claimed the slot —
    // `gpp-ui-shell.js`'s `gppReplaceNativeOpener()` already hides the native
    // entry point for the whole time Ghost++ is enabled, so there should be no
    // independent native ghost session to preserve, and (being unconditional
    // now) nothing ever hands the slot back.
    //
    // Native localStorage (`ghostImageData` / `ghostImageCoords`) IS mirrored
    // by this file (in addition to the in-memory globals above) — third-party
    // userscripts that piggyback on the *native* ghost tool (e.g.
    // queue-ghost-color) read their ghost position/image from these
    // two keys instead of (or in addition to) the in-memory globals, exactly
    // like the legacy Ghost Template Manager's own `applyCoordinatesToGame`
    // (see ghost-template-manager.js) already writes them. Written on every
    // full mirror rebuild (`gppWriteFullNativeMirror`) and on every light
    // resync (`gppWriteLightNativeMirror`, coords only — the image data URL
    // is unchanged when only the mask/position moved), and removed on
    // `gppClearNativeMirror` so no stale snapshot lingers once Ghost++
    // releases the slot or has no focused template.
    //
    // `setProjectAsGhost` (the guild menu's "Set as Ghost" button,
    // html-snippets/guildTemplates.html) IS patched — see
    // gppInstallGuildProjectHook()/gppGuildProjectHookInstalled below — but
    // NOT through gpp-bridge.js's `gppSetNativeFunction`, since that helper's
    // `GPP_NATIVE_FN_NAMES` allowlist only covers `drawGhostImageOnCanvas`,
    // `regenerateGhostCanvas`, `initializeGhostFromStorage`. `setProjectAsGhost`
    // is invoked via an inline `onclick="setProjectAsGhost(id)"` attribute
    // rather than addEventListener on a stable element, so wrapping the
    // global function directly (same "function DECLARATION -> attaches to
    // window normally" reasoning gpp-bridge.js documents for the other three)
    // is the only viable interception point. The remaining guild automation
    // functions from the original prototype (`addGhostAsProject`,
    // `updateGuildProject`, `applyGuildImageAsGhost`) are still NOT patched —
    // out of scope for this change.

    // Page-realm temp bridge key used to hand large/complex payloads (raw RGBA
    // bytes, palette arrays) to an injected <script> so it can reconstruct
    // real page-realm `Uint8ClampedArray`/`ImageData`/`Set`/`Map` instances via
    // their own constructors — see gpp-bridge.js's `gppWriteNativeGhostMirror`
    // doc comment ("page-realm-constructible descriptors ... since ImageData
    // does not survive a naive string round-trip"). The property is written
    // right before injecting the script and deleted by that same script before
    // it finishes, so it never lingers on `window`.
    const GPP_NATIVE_MIRROR_GLOBAL = '__gppNativeMirrorPayload';

    // The native/GPC++ controls that must not be allowed to silently replace
    // Ghost++'s state while it owns the overlay slot. Matches the original
    // prototype's `installNativeControlCaptures()` list exactly (see
    // scripts/geopixels-ghost-template-overhaul/1.0.0.js ~1971-1991).
    // `loadGhostImageBtn` is already display:none'd by
    // `gppReplaceNativeOpener()`, but a hidden button can still be triggered
    // programmatically, so it stays in this list for defense in depth.
    const GPP_NATIVE_CONTROL_TARGETS = [
        ['loadGhostImageBtn', 'click'],
        ['ghostImageInput', 'change'],
        ['initiatePlaceGhostBtn', 'click'],
        ['clearGhostImageBtn', 'click'],
    ];

    let gppShimActive = false;
    let gppNativeControlCaptures = [];
    let gppGuildProjectHookInstalled = false;

    // Identity of the template core (id + the exact `indices`/`palette` typed
    // array references) currently reflected in the native RGBA mirror. Used
    // to skip the expensive per-pixel RGBA rebuild when only the mask or
    // position changed — mirrors the original design's distinction between
    // "focus changes/imports/transforms rebuild the RGBA mirror" and cheaper
    // in-place updates for everything else (see ARCHITECTURE.md's "Native
    // coexistence" section).
    let gppMirroredTemplateId = null;
    let gppMirroredIndices = null;
    let gppMirroredPalette = null;

    function gppNativeBridgeTarget() {
        return (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
    }

    // Plain per-origin Web Storage — shared across the page and every
    // userscript sandbox regardless of realm, unlike the in-memory globals
    // above (see ghost-template-manager.js's `applyCoordinatesToGame`, which
    // already relies on this same direct-call working across the sandbox
    // boundary). No injected-script bridge needed here.
    function gppWriteNativeGhostLocalStorage(coords, dataUrl) {
        try {
            if (coords) localStorage.setItem('ghostImageCoords', JSON.stringify(coords));
            if (dataUrl) localStorage.setItem('ghostImageData', dataUrl);
        } catch (_) { /* storage may be unavailable (private mode, quota) */ }
    }

    function gppClearNativeGhostLocalStorage() {
        try {
            localStorage.removeItem('ghostImageCoords');
            localStorage.removeItem('ghostImageData');
        } catch (_) { /* ignore */ }
    }

    // Builds a PNG data URL from the focused template's decoded RGBA — the
    // same format native `ghost22.js` writes into `ghostImageData` (an
    // `<img>`-loadable data URL, not a raw buffer).
    function gppEncodeRgbaToDataUrl(rgba, width, height) {
        const canvas = document.createElement('canvas');
        canvas.width = width;
        canvas.height = height;
        const ctx = canvas.getContext('2d');
        const imageData = ctx.createImageData(width, height);
        imageData.data.set(rgba);
        ctx.putImageData(imageData, 0, 0);
        return canvas.toDataURL('image/png');
    }

    // ── Native function replacements ──────────────────────────────────
    // Assigned via gppSetNativeFunction, which writes them onto the page's
    // `window`/`unsafeWindow`. Because these are ordinary JS function VALUES
    // (not source-text injected into the page), calling them from native code
    // still executes this closure in Ghost++'s own sandboxed realm — the same
    // technique gpp-bridge.js's doc comment calls out as GTM's
    // `patchGhostModalToggleForRemembering` pattern — so they can freely call
    // other gpp* functions directly.

    function gppNativeDrawReplacement() {
        // No-op: the expensive native canvas overlay must not render
        // underneath Ghost++'s own renderer while the shim owns the slot.
    }

    function gppNativeRegenerateReplacement() {
        // No-op: nothing native ever reads `ghostImageCanvas` again once
        // `drawGhostImageOnCanvas` itself is neutralized, so rebuilding the
        // offscreen filtered canvas would just be wasted work.
    }

    function gppNativeInitializeReplacement() {
        // The native page-load hook that would normally repopulate ghost
        // state from `localStorage`. Ghost++ never persists to that storage
        // (see file header), so instead of loading anything, just reassert
        // the current mirror — this is the "patched initializer reasserting
        // its takeover snapshot if invoked while ownership is active" behavior
        // ARCHITECTURE.md's Native coexistence section calls for.
        gppShimSyncFocusedTemplate();
    }

    // ── Native control interception ───────────────────────────────────

    function gppBlockNativeGhostControl(event) {
        if (!gppShimActive) return;
        event.preventDefault();
        event.stopImmediatePropagation();
        if (event.target && event.target.id === 'ghostImageInput') {
            try { event.target.value = ''; } catch (_) { /* ignore */ }
        }
        console.warn('[GeoPixelcons++] Ghost++: native ghost control blocked while Ghost++ owns the overlay slot. Use the Ghost++ (👻) panel instead.');
        const opener = document.getElementById(GPP_IDS.opener);
        if (opener) opener.click();
    }

    function gppInstallNativeControlCaptures() {
        gppRemoveNativeControlCaptures();
        GPP_NATIVE_CONTROL_TARGETS.forEach(pair => {
            const id = pair[0];
            const type = pair[1];
            const element = document.getElementById(id);
            if (!element) return;
            element.addEventListener(type, gppBlockNativeGhostControl, true);
            gppNativeControlCaptures.push({ element, type });
        });
    }

    function gppRemoveNativeControlCaptures() {
        gppNativeControlCaptures.forEach(entry => {
            entry.element.removeEventListener(entry.type, gppBlockNativeGhostControl, true);
        });
        gppNativeControlCaptures = [];
    }

    // ── Guild "Set as Ghost" compatibility rewire ──────────────────────
    // Native setProjectAsGhost(projectId) (js/index148.js) looks up
    // userGuildData.projects, loads project.image into the NATIVE ghost tool
    // (ghostImageOriginalData/extractAndMapColors/drawGhostImageOnCanvas), and
    // positions it at project.imageGridX/imageGridY. While Ghost++ owns the
    // overlay slot, that whole native pipeline is neutralised (see this
    // file's draw-fn replacements/control captures above), so letting it run
    // would just fight Ghost++'s own state for nothing visible. Full
    // replacement, not call-through: decode the same project data via
    // Ghost++'s EPHEMERAL path (gppState.decodeGuildTemplate,
    // gpp-guild-templates.js's own architecture) instead of a real,
    // persisted ingest — a guild project loaded this way must never be
    // written to IndexedDB either, same "changes frequently, load
    // dynamically" reasoning as the Guild Templates section itself.
    async function gppLoadGuildProjectIntoGhostPlusPlus(projectId) {
        const target = gppNativeBridgeTarget();
        // `userGuildData` is a top-level `let` in js/index148.js (see
        // gpp-bridge.js's header comment) — `target.userGuildData` (a
        // window PROPERTY read) always silently returns undefined, which is
        // the actual cause of this having always shown "Could not find the
        // selected project.": `guildData` was undefined, not merely missing
        // the requested project. gppReadNativeGuildData() reads the real
        // live binding via the page-realm eval bridge instead.
        const guildData = gppReadNativeGuildData();
        const project = guildData && Array.isArray(guildData.projects)
            ? guildData.projects.find(p => p.id === projectId)
            : null;
        if (!project) {
            if (typeof target.showAlert === 'function') target.showAlert('Error', 'Could not find the selected project.');
            return;
        }
        try {
            // gppGetOrCreateGuildTemplate (NOT gppBuildGuildTemplateStub
            // directly) — reuses the Guild Templates section's own stub for
            // this project if one already exists (e.g. the section was
            // expanded earlier), rather than creating a second object with
            // the same id sitting alongside it in gppGuildTemplates. Two
            // objects sharing an id used to be exactly how this crashed:
            // gppGetFocusedTemplate's .find() could resolve back to the
            // OTHER (still-undecoded) duplicate — see that function's own
            // comment for the full explanation.
            const template = gppState.getOrCreateGuildTemplate(project);
            await gppState.decodeGuildTemplate(template); // no-op if this entry was already decoded
            // Unlike the Guild Templates section's own opacity-0 browse
            // default, a deliberate single "Set as Ghost" click means the
            // user wants to see this NOW.
            template.opacity = 1;
            await gppState.focusTemplate(template.id); // resolves via gppGetFocusedTemplate's gppGuildTemplates fallback; never persists (see gppFocusTemplate's own ephemeral guard)
            gppShimSyncFocusedTemplate();
            const opener = document.getElementById(GPP_IDS.opener);
            if (opener) opener.click();
        } catch (err) {
            console.error('[GeoPixelcons++] Ghost++: failed to load guild project ' + projectId + ' as a Ghost++ template.', err);
            if (typeof target.showAlert === 'function') target.showAlert('Error', 'Could not load that guild project into Ghost++.');
        }
    }

    // Installed once, from the same lifecycle point as gppShimEnable() (after
    // gpp-runtime.js's runtime is ready — this wrapper needs gppState to
    // exist). Re-entrant-safe like gpp-scan.js's gppEnsureAutoscanHook().
    function gppEnsureGuildProjectHook() {
        if (gppGuildProjectHookInstalled) return;
        const target = gppNativeBridgeTarget();
        if (typeof target.setProjectAsGhost !== 'function') return; // not ready yet — next init call retries
        gppGuildProjectHookInstalled = true;
        target.setProjectAsGhost = function gppSetProjectAsGhostReplacement(projectId) {
            gppLoadGuildProjectIntoGhostPlusPlus(projectId);
        };
    }

    // ── Native ghost mirror builders ──────────────────────────────────

    // Resets every native ghost binding Ghost++ owns back to its empty
    // default (mirrors js/ghost22.js's own `clearGhostImage()` shape) and
    // forgets what was mirrored, so the next sync always does a full rebuild.
    function gppClearNativeMirror() {
        gppWriteNativeGhostMirror(
            'ghostImage = null;' +
            'ghostImageTopLeft = null;' +
            'ghostImageOriginalData = null;' +
            'ghostImageCanvas = null;' +
            'ghostPaletteColors = [];' +
            'ghostActivePaletteColors = new Set();' +
            'ghostAllImageColors = new Map();' +
            'paletteToImageColorMap = new Map();' +
            'imageColorToDominantColorMap = new Map();' +
            'isColorFilterDisabled = false;' +
            'ghostImageFileObject = null;'
        );
        gppClearNativeGhostLocalStorage();
        gppMirroredTemplateId = null;
        gppMirroredIndices = null;
        gppMirroredPalette = null;
    }

    // Builds the small, page-realm-safe describable pieces shared by both the
    // full and light mirror writers: one object per palette entry plus the
    // list of currently-enabled entries' rgba-string keys (the same key format
    // js/ghost22.js's native code already keys its Maps/Sets by).
    function gppBuildPaletteMirrorData(core, template) {
        const paletteColors = [];
        const activeRgba = [];
        for (let index = 0; index < template.palette.length; index++) {
            const packed = template.palette[index];
            const rgb = core.unpackRgb(packed);
            const rgbaString = core.packedToRgbaString(packed);
            const hex = core.packedToHex(packed);
            const count = template.counts[index] || 0;
            paletteColors.push({ r: rgb.r, g: rgb.g, b: rgb.b, rgba: rgbaString, hex: hex, count: count, totalCount: count });
            if (core.maskHas(template.mask, index)) activeRgba.push(rgbaString);
        }
        return { paletteColors: paletteColors, activeRgba: activeRgba };
    }

    // Full rebuild: reconstructs ghostImageOriginalData (an ImageData built
    // from the template's indexed cells + palette), ghostImageTopLeft, and
    // enough of ghostActivePaletteColors/imageColorToDominantColorMap/
    // ghostAllImageColors/isColorFilterDisabled that native cursor sampling,
    // error-cross rendering, and the location readout keep working. Used on
    // focus change, import, and any core-rewriting transform (flip/rotate).
    function gppWriteFullNativeMirror(template) {
        const core = gppCreateCore();
        const rgba = core.indexedToRgba(template.indices, template.indexType, template.palette);
        const paletteData = gppBuildPaletteMirrorData(core, template);

        const allColorsEntries = [];
        const dominantEntries = [];
        for (let index = 0; index < paletteData.paletteColors.length; index++) {
            const entry = paletteData.paletteColors[index];
            allColorsEntries.push([entry.rgba, entry.count]);
            dominantEntries.push([entry.rgba, entry.rgba]);
        }

        const target = gppNativeBridgeTarget();
        target[GPP_NATIVE_MIRROR_GLOBAL] = {
            width: template.width,
            height: template.height,
            rgbaBuffer: rgba.buffer,
            topLeft: template.position ? { gridX: template.position.gridX, gridY: template.position.gridY } : null,
            paletteColors: paletteData.paletteColors,
            activeRgba: paletteData.activeRgba,
            allColorsEntries: allColorsEntries,
            dominantEntries: dominantEntries,
        };

        gppWriteNativeGhostMirror(
            'var _p = window["' + GPP_NATIVE_MIRROR_GLOBAL + '"];' +
            'if (!_p) return;' +
            'var _bytes = new Uint8ClampedArray(_p.rgbaBuffer);' +
            'var _imageData;' +
            'try {' +
            '  _imageData = new ImageData(_bytes, _p.width, _p.height, { colorSpace: "srgb" });' +
            '} catch (e) {' +
            '  var _scratch = document.createElement("canvas").getContext("2d");' +
            '  _imageData = _scratch.createImageData(_p.width, _p.height);' +
            '  _imageData.data.set(_bytes);' +
            '}' +
            'var _paletteColors = [];' +
            'for (var _i = 0; _i < _p.paletteColors.length; _i++) {' +
            '  var _pc = _p.paletteColors[_i];' +
            '  _paletteColors.push({ r: _pc.r, g: _pc.g, b: _pc.b, rgba: _pc.rgba, hex: _pc.hex, count: _pc.count, totalCount: _pc.totalCount });' +
            '}' +
            'var _activeSet = new Set();' +
            'for (var _j = 0; _j < _p.activeRgba.length; _j++) _activeSet.add(_p.activeRgba[_j]);' +
            'var _allColors = new Map();' +
            'for (var _k = 0; _k < _p.allColorsEntries.length; _k++) _allColors.set(_p.allColorsEntries[_k][0], _p.allColorsEntries[_k][1]);' +
            'var _dominant = new Map();' +
            'for (var _m = 0; _m < _p.dominantEntries.length; _m++) _dominant.set(_p.dominantEntries[_m][0], _p.dominantEntries[_m][1]);' +
            'ghostImage = { width: _p.width, height: _p.height };' +
            'ghostImageTopLeft = _p.topLeft ? { gridX: _p.topLeft.gridX, gridY: _p.topLeft.gridY } : null;' +
            'ghostImageOriginalData = _imageData;' +
            'ghostImageCanvas = null;' +
            'ghostPaletteColors = _paletteColors;' +
            'ghostActivePaletteColors = _activeSet;' +
            'ghostAllImageColors = _allColors;' +
            'paletteToImageColorMap = new Map();' +
            'imageColorToDominantColorMap = _dominant;' +
            'isColorFilterDisabled = false;' +
            // A source Blob can differ from the indexed template after header
            // stripping, quantisation, or transforms — keep this null so no
            // native moderator-only restore endpoint can submit stale pixels
            // at the current shim position (matches the original prototype's
            // reasoning verbatim).
            'ghostImageFileObject = null;' +
            'delete window["' + GPP_NATIVE_MIRROR_GLOBAL + '"];'
        );
        if (template.position) {
            const dataUrl = gppEncodeRgbaToDataUrl(rgba, template.width, template.height);
            gppWriteNativeGhostLocalStorage({ gridX: template.position.gridX, gridY: template.position.gridY }, dataUrl);
        }

        gppMirroredTemplateId = template.id;
        gppMirroredIndices = template.indices;
        gppMirroredPalette = template.palette;
    }

    // Cheap path: the focused template's core (pixels/palette) hasn't
    // changed since the last full mirror, so only refresh what a mask toggle
    // or a position move could have changed — no RGBA/ImageData rebuild.
    function gppWriteLightNativeMirror(template) {
        const core = gppCreateCore();
        const paletteData = gppBuildPaletteMirrorData(core, template);

        const target = gppNativeBridgeTarget();
        target[GPP_NATIVE_MIRROR_GLOBAL] = {
            topLeft: template.position ? { gridX: template.position.gridX, gridY: template.position.gridY } : null,
            paletteColors: paletteData.paletteColors,
            activeRgba: paletteData.activeRgba,
        };

        gppWriteNativeGhostMirror(
            'var _p = window["' + GPP_NATIVE_MIRROR_GLOBAL + '"];' +
            'if (!_p) return;' +
            'var _paletteColors = [];' +
            'for (var _i = 0; _i < _p.paletteColors.length; _i++) {' +
            '  var _pc = _p.paletteColors[_i];' +
            '  _paletteColors.push({ r: _pc.r, g: _pc.g, b: _pc.b, rgba: _pc.rgba, hex: _pc.hex, count: _pc.count, totalCount: _pc.totalCount });' +
            '}' +
            'var _activeSet = new Set();' +
            'for (var _j = 0; _j < _p.activeRgba.length; _j++) _activeSet.add(_p.activeRgba[_j]);' +
            'ghostImageTopLeft = _p.topLeft ? { gridX: _p.topLeft.gridX, gridY: _p.topLeft.gridY } : null;' +
            'ghostPaletteColors = _paletteColors;' +
            'ghostActivePaletteColors = _activeSet;' +
            'isColorFilterDisabled = false;' +
            'delete window["' + GPP_NATIVE_MIRROR_GLOBAL + '"];'
        );
        if (template.position) {
            gppWriteNativeGhostLocalStorage({ gridX: template.position.gridX, gridY: template.position.gridY }, null);
        }
    }

    // ── Public shim lifecycle ─────────────────────────────────────────

    // Refreshes the native mirror for whichever template is currently
    // focused. Safe to call at any time from any Ghost++ module — it is a
    // no-op unless the shim is active. See the file header for the full list
    // of state changes that should trigger a call to this function.
    function gppShimSyncFocusedTemplate() {
        if (!gppShimActive) return;
        const template = gppState.getFocusedTemplate();
        if (!template) {
            gppClearNativeMirror();
            return;
        }
        const sameCore = template.id === gppMirroredTemplateId
            && template.indices === gppMirroredIndices
            && template.palette === gppMirroredPalette;
        if (sameCore) {
            gppWriteLightNativeMirror(template);
        } else {
            gppWriteFullNativeMirror(template);
        }
    }

    // Snapshot + patch: neutralises the native draw path and installs the
    // native-control capture listeners, then mirrors the focused template.
    // Unconditional (not a setting — assumed whenever Ghost++ itself is
    // enabled, by explicit product decision); re-entrant calls just resync
    // instead of double-patching.
    // ── "Y" (toggle ghost image) compatibility ─────────────────────────
    // The separate GeoPixels++ addon has its own "Toggle ghost image"
    // keybind (default key Y, scripts/geopixels++/0.7.0.js's
    // KEY_BINDINGS.toggleGhost) that directly toggles the NATIVE
    // #ghost-canvas element's `hidden` attribute. It has no knowledge of
    // Ghost++'s own, completely separate renderer canvas
    // (gpp-renderer.js's #gpp-renderer-canvas) — so while Ghost++ owns the
    // overlay slot, pressing Y was toggling visibility on a canvas that
    // gppNativeDrawReplacement above has already made permanently empty,
    // with zero visible effect on the overlay the user actually sees.
    // Mirror the same hidden/shown state onto Ghost++'s own renderer
    // canvas instead, via a MutationObserver on #ghost-canvas's own
    // `hidden` attribute, so Y keeps working as a quick "peek at the map"
    // toggle without touching any per-template opacity setting.
    let gppNativeGhostCanvasObserver = null;
    function gppSyncRendererVisibility(nativeHidden) {
        const renderer = document.getElementById(GPP_RENDERER_CANVAS_ID);
        if (renderer) renderer.style.display = nativeHidden ? 'none' : '';
    }
    function gppInstallGhostCanvasVisibilitySync() {
        if (gppNativeGhostCanvasObserver) return; // already installed
        const attach = nativeCanvas => {
            gppSyncRendererVisibility(nativeCanvas.hidden);
            gppNativeGhostCanvasObserver = new MutationObserver(() => gppSyncRendererVisibility(nativeCanvas.hidden));
            gppNativeGhostCanvasObserver.observe(nativeCanvas, { attributes: true, attributeFilter: ['hidden'] });
        };
        const nativeCanvas = document.getElementById('ghost-canvas');
        if (nativeCanvas) {
            attach(nativeCanvas);
            return;
        }
        // #ghost-canvas is normally part of the static base HTML (present
        // from page load, same as #pixel-canvas) so this branch should be
        // rare — same defensive reasoning as gppReplaceNativeOpener's own
        // retry, though: no reason to assume it can never lose the race.
        // document.body (not map.getContainer()) since the map/renderer
        // may not have mounted yet at this point either.
        const observer = new MutationObserver(() => {
            const found = document.getElementById('ghost-canvas');
            if (!found) return;
            observer.disconnect();
            attach(found);
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    function gppShimEnable() {
        if (gppShimActive) {
            gppShimSyncFocusedTemplate();
            return;
        }
        gppShimActive = true;
        gppSetNativeFunction('drawGhostImageOnCanvas', gppNativeDrawReplacement);
        gppSetNativeFunction('regenerateGhostCanvas', gppNativeRegenerateReplacement);
        gppSetNativeFunction('initializeGhostFromStorage', gppNativeInitializeReplacement);
        gppInstallNativeControlCaptures();
        gppInstallGhostCanvasVisibilitySync();
        // One-time cleanup: a native ghost image (loaded via the native tool
        // itself, or via the legacy Ghost Template Manager driving that same
        // native tool) may already be painted on the native ghost-canvas from
        // before Ghost++ took over. gppNativeDrawReplacement is a no-op from
        // here on, so without this it would stay stuck on screen forever.
        gppClearNativeGhostCanvasPaint();
        try {
            const nativeSnapshot = gppReadNativeGhostSnapshot();
            if (nativeSnapshot && nativeSnapshot.hasGhostImage) {
                console.warn('[GeoPixelcons++] Ghost++: a native ghost image was already active; Ghost++ is taking over the overlay slot.');
            }
        } catch (_) { /* diagnostic only */ }
        gppShimSyncFocusedTemplate();
    }

    // ── Ghost++ placement / transform panel ───────────────────────────
    // Implements gppRenderPositionTransform(container, template, onChange)
    // per gpp-init.js's render-function contract: top-left X/Y fields,
    // click-to-place map capture, one-cell nudge buttons, an opacity slider,
    // a lock checkbox, and a group-noise checkbox. Reads/writes only through
    // gppState (gpp-runtime.js) and the page-realm bridge (gpp-bridge.js) —
    // never touches map/turf/grid globals directly. No flip/rotate controls
    // here — removed per explicit product decision (easy to do in any image
    // editor before importing; not worth the UI weight).

    // Templates currently pinned to "Preview" (100% opacity, solid cell
    // fill, regardless of their own persisted opacity/gapRatio) — a
    // transient, session-only view aid, deliberately NOT persisted and
    // deliberately NOT the same mechanism as template.opacity/the visibility
    // eye-icon (see gpp-renderer.js's gppRendererDrawWebGl/Canvas2d, which
    // read this Set directly — same shared-top-level-scope pattern as
    // gppPlacementPreview below).
    let gppForcedVisibleTemplateIds = new Set();

    // A guild template (template.ephemeral — gpp-guild-templates.js's grid,
    // or the guild menu's "Set as Ghost") is always treated as position-
    // locked, regardless of its own (always-false, since guild stubs never
    // persist) `locked` field — per explicit product decision, no user
    // should be able to move a guild template from this UI, even purely
    // client-side for the current session. Used everywhere template.locked
    // alone used to gate a position mutation, both in this panel's own
    // controls AND the global keyboard shortcuts below, so there is no
    // bypass route (a disabled button alone would not stop Arrow-key nudge
    // or the E-to-place shortcut, which read gppState fresh rather than
    // this panel's own render-time `disabledAttr`).
    function gppIsPositionLocked(template) {
        return !!template && (!!template.locked || !!template.ephemeral);
    }

    // Debounces the focused template's IndexedDB state write while the
    // Opacity slider is being dragged (many 'input' events per drag) —
    // moved here from gpp-view-settings.js along with the slider itself.
    let gppOpacityPersistTimer = null;
    function gppScheduleOpacityPersist(template) {
        if (gppOpacityPersistTimer) clearTimeout(gppOpacityPersistTimer);
        gppOpacityPersistTimer = setTimeout(() => {
            gppOpacityPersistTimer = null;
            gppState.persistTemplateState(template).catch(err => {
                console.error('[GeoPixelcons++] Ghost++ placement: failed to persist template opacity.', err);
            });
        }, 300);
    }

    // Active click-to-place capture, if any. Only one can be in flight at a
    // time; a fresh render (focus change, template deleted, panel rebuilt)
    // always tears down a stale capture via gppCancelPlacementCapture().
    let gppPlacementCaptureCleanup = null;

    // { templateId, position } while a placement capture is tracking the
    // pointer, else null. gpp-renderer.js's gppRendererProjectTemplate()
    // reads this directly (shared top-level scope — see build.js's
    // SRC_ORDER banner) and, when it matches the template being drawn,
    // draws at this position INSTEAD of template.position — this is what
    // makes the ghost image visibly follow the cursor before the placing
    // click commits it, matching the original prototype's
    // (scripts/geopixels-ghost-template-overhaul/1.0.0.js) placementPreview.
    let gppPlacementPreview = null;

    function gppCancelPlacementCapture() {
        if (gppPlacementCaptureCleanup) {
            try { gppPlacementCaptureCleanup(); } catch (_) { /* ignore */ }
            gppPlacementCaptureCleanup = null;
        }
    }

    // Inverse of the renderer's projection: screen pixel -> lngLat (via the
    // live map) -> Web Mercator metres (via turf) -> grid cell (subtract the
    // site's grid origin offset, divide by cell size, round to the nearest
    // integer cell).
    function gppMapClientPointToGrid(map, turf, grid, clientX, clientY) {
        const rect = map.getContainer().getBoundingClientRect();
        const lngLat = map.unproject([clientX - rect.left, clientY - rect.top]);
        if (!lngLat) return null;
        const lng = typeof lngLat.lng === 'number' ? lngLat.lng : lngLat[0];
        const lat = typeof lngLat.lat === 'number' ? lngLat.lat : lngLat[1];
        if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
        const mercator = turf.toMercator([lng, lat]);
        if (!mercator) return null;
        return {
            gridX: Math.round((mercator[0] - grid.offsetMetersX) / grid.gridSize),
            gridY: Math.round((mercator[1] - grid.offsetMetersY) / grid.gridSize),
        };
    }

    // Installs a one-shot, capture-phase click listener directly on the
    // map's container. Capture-phase + stopPropagation/stopImmediatePropagation
    // means the click is consumed before it can ever reach the map canvas's
    // own (native painting) click handler — this is a hard safety
    // requirement, not cosmetic: Ghost++ must never cause a stray pixel
    // placement. `onStatus(text, isError)` is optional UI feedback.
    function gppBeginPlacementCapture(template, onPlaced, onStatus) {
        gppCancelPlacementCapture();
        // Defense in depth — both real call sites (the Place button and the
        // E-to-place keyboard shortcut) already check gppIsPositionLocked
        // before ever reaching here, but this guards against any future or
        // overlooked caller too.
        if (gppIsPositionLocked(template)) {
            if (onStatus) onStatus('This template’s position is locked.', true);
            return;
        }
        const map = gppGetMap();
        const turf = gppGetTurf();
        if (!map || !turf) {
            if (onStatus) onStatus('Map is not ready yet.', true);
            return;
        }
        const mapContainer = map.getContainer();
        const grid = gppReadGridConstants();
        const previousCursor = mapContainer.style.cursor;
        mapContainer.style.cursor = 'crosshair';

        function cleanup() {
            mapContainer.removeEventListener('click', handleClick, true);
            mapContainer.removeEventListener('mousemove', handleMouseMove);
            mapContainer.removeEventListener('mouseleave', handleMouseLeave);
            document.removeEventListener('keydown', handleKeyDown, true);
            mapContainer.style.cursor = previousCursor;
            gppPlacementCaptureCleanup = null;
            if (gppPlacementPreview && gppPlacementPreview.templateId === template.id) {
                gppPlacementPreview = null;
                gppNotifyRendererSchedule();
            }
        }

        function handleMouseMove(event) {
            const position = gppMapClientPointToGrid(map, turf, grid, event.clientX, event.clientY);
            if (!position) return;
            gppPlacementPreview = { templateId: template.id, position };
            gppNotifyRendererSchedule();
        }

        function handleMouseLeave() {
            if (!gppPlacementPreview || gppPlacementPreview.templateId !== template.id) return;
            gppPlacementPreview = null;
            gppNotifyRendererSchedule();
        }

        function handleClick(event) {
            event.preventDefault();
            event.stopPropagation();
            if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation();
            const position = gppMapClientPointToGrid(map, turf, grid, event.clientX, event.clientY);
            cleanup();
            if (position) onPlaced(position);
            else if (onStatus) onStatus('Could not resolve that click to a grid cell.', true);
        }

        function handleKeyDown(event) {
            if (event.key !== 'Escape') return;
            cleanup();
            if (onStatus) onStatus('Placement cancelled.', false);
        }

        mapContainer.addEventListener('click', handleClick, true);
        mapContainer.addEventListener('mousemove', handleMouseMove);
        mapContainer.addEventListener('mouseleave', handleMouseLeave);
        document.addEventListener('keydown', handleKeyDown, true);
        gppPlacementCaptureCleanup = cleanup;
        if (onStatus) onStatus('Move the pointer over the map to preview the template, then click to set its top-left cell. Escape cancels.', false);
    }

    // Scoped, once-injected styling for this panel's controls (mirrors
    // gpp-ui-shell.js's gppInjectShellStyle guard-by-id pattern). Classed
    // rather than bare-element selectors so it can never leak into other
    // Ghost++ panels' buttons/inputs.
    // Rewrites content every call (see gpp-init.js's theme-change observer)
    // instead of no-op-ing once created, so a live dark/light toggle isn't
    // frozen at whatever theme was active on first mount.
    function gppInjectPlacementStyle() {
        let style = document.getElementById('gpp-pt-style');
        if (!style) {
            style = document.createElement('style');
            style.id = 'gpp-pt-style';
            document.head.appendChild(style);
        }
        style.textContent = `
            .gpp-pt-btn {
                padding: 3px 9px; border-radius: 4px; cursor: pointer; font-size: 12px;
                border: 1px solid ${t2('#d1d5db', '#45475a')};
                background: ${t2('#f8fafc', '#313244')};
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-pt-btn:hover:not(:disabled) { background: ${t2('#e2e8f0', '#45475a')}; }
            .gpp-pt-btn:disabled { opacity: .45; cursor: not-allowed; }
            /* Place / Unset / Go to / Preview line up as roughly-equal-width
               columns via flex-grow, but (unlike a fixed-column grid, which
               has no wrap mechanism and forces this row to overflow its
               container once shrunk past the buttons' combined min-content
               width) flex-wrap lets them reflow onto a second line as the
               right panel narrows — matching the nudge-arrow row below,
               which already uses this same pattern. */
            .gpp-pt-row3 {
                display: flex; flex-wrap: wrap; gap: 6px;
            }
            .gpp-pt-row3 .gpp-pt-btn { flex: 1 1 64px; box-sizing: border-box; text-align: center; }
            .gpp-pt-btn-active { border-color: ${t2('#2563eb', '#89b4fa')}; color: ${t2('#2563eb', '#89b4fa')}; }
            .gpp-pt-opacity-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
            .gpp-pt-opacity-row label { flex: 0 0 auto; min-width: 50px; font-size: 12px; color: ${t2('#1f2937', '#e2e2f5')}; }
            .gpp-pt-opacity-row input[type="range"] { flex: 1 1 auto; min-width: 0; }
            .gpp-pt-opacity-value {
                flex: 0 0 auto; min-width: 34px; text-align: right; font-size: 11px;
                font-variant-numeric: tabular-nums; color: ${t2('#475569', '#a6adc8')};
            }
            .gpp-pt-reset-btn {
                border: none; background: transparent; cursor: pointer; font-size: 13px;
                padding: 0 2px; flex-shrink: 0; color: ${t2('#64748b', '#a6adc8')};
            }
            .gpp-pt-input {
                width: 100%; box-sizing: border-box; padding: 4px 6px; border-radius: 4px; font-size: 12px;
                border: 1px solid ${t2('#d1d5db', '#45475a')};
                background: ${t2('#ffffff', '#11111b')};
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-pt-input:disabled { opacity: .5; }
            .gpp-pt-field { flex: 1; display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: ${t2('#64748b', '#a6adc8')}; }
            .gpp-pt-lock { display: flex; align-items: center; gap: 4px; font-size: 12px; color: ${t2('#111827', '#f5f5f5')}; margin-left: auto; }
        `;
    }

    function gppNotifyRendererSchedule() {
        if (typeof gppRendererSchedule === 'function') gppRendererSchedule();
    }

    function gppRenderPositionTransform(container, template, onChange) {
        if (!container) return;
        // Any panel rebuild (focus change, delete, ordinary refresh) tears
        // down a stale in-flight placement capture — it can only ever be
        // valid for the render that started it.
        gppCancelPlacementCapture();
        gppInjectPlacementStyle();

        // Preserve the <details> open/closed state across the full-rebuild
        // refreshes gpp-init.js triggers after every onChange() (a palette
        // swatch toggle, an unrelated section's edit, etc.) — without this,
        // any interaction anywhere in the modal would snap this section back
        // open, per gpp-view-settings.js's identical pattern for the same
        // reason. Defaults to open on the very first render.
        const previousDetails = container.querySelector('details.gpp-collapsible');
        const wasOpen = previousDetails ? previousDetails.open : true;

        container.innerHTML = '';

        const details = document.createElement('details');
        details.className = 'gpp-collapsible';
        details.open = wasOpen;
        const summary = document.createElement('summary');
        summary.textContent = 'Template Settings';
        const body = document.createElement('div');
        body.className = 'gpp-body';
        details.appendChild(summary);
        details.appendChild(body);
        container.appendChild(details);

        if (!template) {
            const empty = document.createElement('p');
            empty.style.cssText = `margin: 0; font-size: 12px; color: ${t2('#64748b', '#a6adc8')};`;
            empty.textContent = 'No template selected.';
            body.appendChild(empty);
            return;
        }

        const locked = gppIsPositionLocked(template);
        const disabledAttr = locked ? 'disabled' : '';
        const groupNoise = !!template.groupNoise;

        body.innerHTML = `
            <div style="display:flex; gap:8px; margin-bottom:8px;">
                <label class="gpp-pt-field">Top-left X
                    <input id="gpp-pt-x" class="gpp-pt-input" type="number" step="1" ${disabledAttr}>
                </label>
                <label class="gpp-pt-field">Top-left Y
                    <input id="gpp-pt-y" class="gpp-pt-input" type="number" step="1" ${disabledAttr}>
                </label>
            </div>
            <div class="gpp-pt-row3" style="margin-bottom:6px;">
                <button type="button" id="gpp-pt-place" class="gpp-pt-btn" ${disabledAttr}>Place</button>
                <button type="button" id="gpp-pt-unset" class="gpp-pt-btn" ${(template.position && !locked) ? '' : 'disabled'} title="Unloads the template from the map — click Place again to put it back">Unset</button>
                <button type="button" id="gpp-pt-goto" class="gpp-pt-btn" ${template.position ? '' : 'disabled'}>Go to</button>
                <button type="button" id="gpp-pt-preview" class="gpp-pt-btn" title="Temporarily show this template at 100% opacity with no cell gaps, without changing its actual Opacity setting">Preview</button>
            </div>
            <div style="display:flex; flex-wrap:wrap; gap:6px; align-items:center; margin-bottom:6px;">
                <button type="button" id="gpp-pt-nudge-left" class="gpp-pt-btn" title="Nudge left" ${disabledAttr}>&larr;</button>
                <button type="button" id="gpp-pt-nudge-up" class="gpp-pt-btn" title="Nudge up" ${disabledAttr}>&uarr;</button>
                <button type="button" id="gpp-pt-nudge-down" class="gpp-pt-btn" title="Nudge down" ${disabledAttr}>&darr;</button>
                <button type="button" id="gpp-pt-nudge-right" class="gpp-pt-btn" title="Nudge right" ${disabledAttr}>&rarr;</button>
            </div>
            <div class="gpp-pt-opacity-row">
                <label for="gpp-pt-opacity">Opacity</label>
                <input id="gpp-pt-opacity" type="range" min="0" max="100" step="1" title="Template overlay opacity. 0% is fully invisible.">
                <span id="gpp-pt-opacity-value" class="gpp-pt-opacity-value"></span>
                <button type="button" id="gpp-pt-opacity-reset" class="gpp-pt-reset-btn" title="Reset to default opacity">&#8630;</button>
            </div>
            <div style="display:flex; align-items:center; margin-bottom:6px;">
                <label class="gpp-pt-lock" style="margin-left:0;" ${template.ephemeral ? 'title="Guild templates are always position-locked — you can\'t move them from this UI."' : ''}><input id="gpp-pt-lock" type="checkbox" ${locked ? 'checked' : ''} ${template.ephemeral ? 'disabled' : ''}> Lock Position</label>
            </div>
            <div style="display:flex; align-items:center; margin-bottom:2px;">
                <label class="gpp-pt-lock" style="margin-left:0;" title="Matches the native ghost tool's Group Noise: merges near-identical source colors and loosens progress/error matching for them."><input id="gpp-pt-group-noise" type="checkbox" ${groupNoise ? 'checked' : ''}> Group noise</label>
            </div>
            <div id="gpp-pt-status" style="font-size:11px; min-height:14px; color:${t2('#64748b', '#a6adc8')};"></div>
        `;

        const xInput = body.querySelector('#gpp-pt-x');
        const yInput = body.querySelector('#gpp-pt-y');
        xInput.value = template.position ? String(template.position.gridX) : '';
        yInput.value = template.position ? String(template.position.gridY) : '';
        const statusEl = body.querySelector('#gpp-pt-status');

        function setStatus(text, isError) {
            statusEl.textContent = text || '';
            statusEl.style.color = isError ? t2('#dc2626', '#f38ba8') : t2('#64748b', '#a6adc8');
        }

        async function commitPosition(gridX, gridY) {
            if (gppIsPositionLocked(template)) return;
            if (!Number.isFinite(gridX) || !Number.isFinite(gridY)) return;
            template.position = { gridX: Math.round(gridX), gridY: Math.round(gridY) };
            await gppState.persistTemplateState(template);
            gppNotifyRendererSchedule();
            onChange();
        }

        function readCoordInputs() {
            const xText = xInput.value.trim();
            const yText = yInput.value.trim();
            const x = Number(xText);
            const y = Number(yText);
            if (!xText || !yText || !Number.isInteger(x) || !Number.isInteger(y)) {
                setStatus('Enter both coordinates as whole numbers.', true);
                return null;
            }
            return { gridX: x, gridY: y };
        }

        function commitCoordInputs() {
            const parsed = readCoordInputs();
            if (!parsed) return;
            commitPosition(parsed.gridX, parsed.gridY).catch(err => setStatus(err && err.message ? err.message : String(err), true));
        }
        xInput.addEventListener('change', commitCoordInputs);
        yInput.addEventListener('change', commitCoordInputs);

        body.querySelector('#gpp-pt-place').addEventListener('click', () => {
            if (gppIsPositionLocked(template)) return;
            gppBeginPlacementCapture(
                template,
                position => {
                    commitPosition(position.gridX, position.gridY)
                        .then(() => setStatus(`Placed at ${position.gridX}, ${position.gridY}.`, false))
                        .catch(err => setStatus(err && err.message ? err.message : String(err), true));
                },
                (text, isError) => setStatus(text, isError)
            );
        });

        // Reuses gpp-library.js's instant (jumpTo, not eased) teleport — same
        // action as a library card's "go to" arrow, just reachable without
        // leaving this panel.
        const gotoBtn = body.querySelector('#gpp-pt-goto');
        if (gotoBtn && !gotoBtn.disabled) {
            gotoBtn.addEventListener('click', () => {
                if (typeof gppLibraryFlyToTemplate === 'function') gppLibraryFlyToTemplate(template);
            });
        }

        const unsetBtn = body.querySelector('#gpp-pt-unset');
        if (unsetBtn && !unsetBtn.disabled) {
            unsetBtn.addEventListener('click', () => {
                if (gppIsPositionLocked(template)) return;
                gppCancelPlacementCapture();
                template.position = null;
                gppState.persistTemplateState(template)
                    .then(() => {
                        gppNotifyRendererSchedule();
                        setStatus('Position cleared — click Place to show it again.', false);
                        onChange();
                    })
                    .catch(err => setStatus(err && err.message ? err.message : String(err), true));
            });
        }

        // A transient view aid, deliberately SEPARATE from template.opacity/
        // the visibility eye-icon (see gppForcedVisibleTemplateIds' own
        // comment near the top of this file) — toggling it never touches
        // the persisted opacity setting, and never persists itself.
        const previewBtn = body.querySelector('#gpp-pt-preview');
        const paintPreviewBtn = () => {
            previewBtn.classList.toggle('gpp-pt-btn-active', gppForcedVisibleTemplateIds.has(template.id));
        };
        paintPreviewBtn();
        previewBtn.addEventListener('click', () => {
            if (gppForcedVisibleTemplateIds.has(template.id)) gppForcedVisibleTemplateIds.delete(template.id);
            else gppForcedVisibleTemplateIds.add(template.id);
            paintPreviewBtn();
            gppNotifyRendererSchedule();
        });

        const opacityInput = body.querySelector('#gpp-pt-opacity');
        const opacityValueEl = body.querySelector('#gpp-pt-opacity-value');
        opacityInput.value = String(Math.round(gppClamp(template.opacity, 0, 1) * 100));
        opacityValueEl.textContent = opacityInput.value + '%';
        opacityInput.addEventListener('input', () => {
            opacityValueEl.textContent = opacityInput.value + '%';
            template.opacity = gppClamp(Number(opacityInput.value) / 100, 0, 1);
            gppNotifyRendererSchedule();
            gppScheduleOpacityPersist(template);
        });
        opacityInput.addEventListener('change', () => {
            gppScheduleOpacityPersist(template);
            onChange();
        });
        body.querySelector('#gpp-pt-opacity-reset').addEventListener('click', () => {
            opacityInput.value = '100';
            opacityValueEl.textContent = '100%';
            template.opacity = 1;
            gppNotifyRendererSchedule();
            gppScheduleOpacityPersist(template);
            onChange();
        });

        function nudge(deltaX, deltaY) {
            if (gppIsPositionLocked(template)) return;
            const base = template.position || { gridX: 0, gridY: 0 };
            commitPosition(base.gridX + deltaX, base.gridY + deltaY)
                .catch(err => setStatus(err && err.message ? err.message : String(err), true));
        }
        body.querySelector('#gpp-pt-nudge-left').addEventListener('click', () => nudge(-1, 0));
        body.querySelector('#gpp-pt-nudge-right').addEventListener('click', () => nudge(1, 0));
        // Grid Y is up-positive: "up" increases gridY, "down" decreases it.
        body.querySelector('#gpp-pt-nudge-up').addEventListener('click', () => nudge(0, 1));
        body.querySelector('#gpp-pt-nudge-down').addEventListener('click', () => nudge(0, -1));

        body.querySelector('#gpp-pt-lock').addEventListener('change', event => {
            template.locked = !!event.target.checked;
            if (template.locked) gppCancelPlacementCapture();
            gppState.persistTemplateState(template)
                .then(() => onChange())
                .catch(err => setStatus(err && err.message ? err.message : String(err), true));
        });

        // Bundled with the template like the original Ghost Template Manager's
        // own per-image groupNoise field (ghost-template-manager.js) — not a
        // one-off UI preference. gppShimSyncFocusedTemplate() best-effort
        // mirrors this onto the native #groupNoiseToggle checkbox (see
        // gpp-native-shim.js) for whatever native-tool compatibility that's
        // still worth while Ghost++ owns the overlay.
        body.querySelector('#gpp-pt-group-noise').addEventListener('change', event => {
            template.groupNoise = !!event.target.checked;
            gppState.persistTemplateState(template)
                .then(() => {
                    if (typeof gppShimSyncFocusedTemplate === 'function') gppShimSyncFocusedTemplate();
                    onChange();
                })
                .catch(err => setStatus(err && err.message ? err.message : String(err), true));
        });

    }

    // ── Global keyboard shortcuts ───────────────────────────────────────
    // Ghost++ shipped with none of these (only its on-screen buttons), but
    // an older, now-abandoned prototype of this same feature
    // (scripts/geopixels-ghost-template-overhaul/1.0.0.js — the version
    // several earlier product decisions in this file already cite) had a
    // global keydown handler that let arrow keys nudge the focused template
    // one cell at a time while its own modal was open. That's the one
    // concrete, evidenced shortcut worth restoring; it's reimplemented here
    // against gpp-pt-nudge-*'s own commitPosition-equivalent logic.
    // "Press E to place a template" (the reported regression) has no
    // matching binding in that prototype, the legacy Ghost Template Manager,
    // or the native site's own configurable keybinds (whose 'ghost' action
    // defaults to G and just opens/toggles the ghost tool — already handled
    // today by gpp-native-shim.js's click-capture redirect on
    // loadGhostImageBtn, independent of this file). E is added here as the
    // closest faithful equivalent: it enters the same click-to-place capture
    // gpp-pt-place's own button triggers, rather than silently doing nothing.

    // Mirrors gppRenderPositionTransform's own commitPosition() — duplicated
    // rather than shared because that one is a closure over a specific
    // render call's `template`/`onChange`/`setStatus`, none of which a
    // global, render-independent keydown listener has access to.
    async function gppShortcutCommitPosition(template, gridX, gridY) {
        if (!template || gppIsPositionLocked(template)) return;
        if (!Number.isFinite(gridX) || !Number.isFinite(gridY)) return;
        template.position = { gridX: Math.round(gridX), gridY: Math.round(gridY) };
        await gppState.persistTemplateState(template);
        gppNotifyRendererSchedule();
        gppRequestUiRefresh();
    }

    function gppNudgeFocusedTemplateByKeyboard(deltaX, deltaY) {
        const template = gppState.getFocusedTemplate();
        if (!template || gppIsPositionLocked(template) || !template.position) return;
        gppShortcutCommitPosition(template, template.position.gridX + deltaX, template.position.gridY + deltaY)
            .catch(err => console.error('[GeoPixelcons++] Ghost++ keyboard nudge failed:', err));
    }

    function gppTriggerPlaceShortcut() {
        const template = gppState.getFocusedTemplate();
        if (!template || gppIsPositionLocked(template)) return;
        gppBeginPlacementCapture(
            template,
            position => {
                gppShortcutCommitPosition(template, position.gridX, position.gridY)
                    .catch(err => console.error('[GeoPixelcons++] Ghost++ keyboard place failed:', err));
            },
            null
        );
    }

    function gppHandlePlacementKeydown(event) {
        const modal = document.getElementById(GPP_IDS.modal);
        if (!modal || modal.classList.contains('gpp-hidden')) return;
        if (event.defaultPrevented) return;
        const target = event.target;
        if (target && typeof target.matches === 'function' && target.matches('input, textarea, select, [contenteditable="true"]')) return;
        // Grid Y is up-positive, matching gpp-pt-nudge-up/-down's own
        // convention (see nudge() above).
        const deltas = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
        const delta = deltas[event.key];
        if (delta) {
            event.preventDefault();
            event.stopPropagation();
            gppNudgeFocusedTemplateByKeyboard(delta[0], delta[1]);
            return;
        }
        if (event.key.length === 1 && event.key.toLowerCase() === 'e' && !event.ctrlKey && !event.metaKey && !event.altKey) {
            event.preventDefault();
            event.stopPropagation();
            gppTriggerPlaceShortcut();
        }
    }

    // Installed unconditionally alongside gpp-renderer.js's own auto-mount
    // gate (same `_settings.ghostPlusPlus` check, top-level script-load
    // time) rather than from inside open()/ensureRuntime() — the listener
    // itself is cheap and self-guards on the modal's own visibility on every
    // keystroke, so there's no benefit to a lazier install, and this keeps
    // it working the very first time the modal opens rather than only after
    // some other init path has run.
    if (_settings.ghostPlusPlus) {
        document.addEventListener('keydown', gppHandlePlacementKeydown, true);
    }

    // ── Ghost++ progress/error scanning ───────────────────────────────
    // Compares a positioned template's opaque cells against the live map and
    // renders the segmented progress bar (gppRenderProgressBar, one of
    // gpp-init.js's render-function hooks). No cancel button/flow exists here
    // on purpose — per product decision, a scan runs to completion; it is
    // fast enough (cropped per-tile bitmaps, batched per-band getImageData, see
    // below) that a cancel affordance was rejected outright.
    //
    // ── scanSummary shape (documented contract for other Ghost++ files) ──
    //   template.scanSummary = {
    //     scannedAt: <ISO string>,
    //     total:   <int>,   // every opaque cell in the template, regardless of mask
    //     correct: <int>,   // ...and matched the map exactly
    //     wrong:   <int>,   // ...and the map has a different colour there
    //     missing: <int>,   // ...and the map tile is loaded but empty there
    //     unknown: <int>,   // ...and the map tile has not synced yet
    //     perColour: [ { index, enabled, correct, wrong, missing, unknown, total }, ... ],
    //       // one entry per palette index present in the template (counts[index] > 0),
    //       // `enabled` reflects template.mask AT SCAN TIME (kept for
    //       // consumers like gpp-palette.js's sort/filter), `total` is that
    //       // colour's opaque pixel count (template.counts[index]). correct/
    //       // wrong/missing/unknown are sampled the same regardless of `enabled`.
    //     states: Uint8Array(width*height),
    //       // one core.constants.ERROR_STATE byte per cell, row-major, same
    //       // indexing as template.indices (row 0 = northernmost row, since
    //       // template position.gridY is Y-up and decreases going down rows).
    //       // Only transparent cells stay ERROR_STATE.UNCHECKED (0) — every
    //       // opaque cell is sampled regardless of its colour's mask state.
    //   }
    //   By construction, correct+wrong+missing+unknown === total for a completed
    //   scan (every opaque cell lands in exactly one bucket) — this is a
    //   statement on the WHOLE template, matching the native ghost tool's own
    //   progress bar, and does not change when a colour is toggled on/off in
    //   the palette (mask still gates which colours draw error CROSSES on the
    //   map and which colours get queued for placement elsewhere — just not
    //   this aggregate count). Read scanSummary's top-level fields directly;
    //   there is no separate "live totals" recompute — the stored numbers
    //   never depend on the current mask, so they never need re-deriving.
    //
    // ── Error-visualization integration shape ─────────────────────────
    // gpp-renderer.js (the WebGL2/Canvas2D template overlay) does not exist yet
    // at the time this file was written, so there is no gppRendererSchedule()
    // to hook into (guarded with a typeof check below in case it lands later —
    // this file will additionally nudge it on every redraw request). Until then,
    // and as a permanent Canvas2D fallback either way, this file owns a small
    // dedicated transparent <canvas> layered inside the map container
    // (#gpp-scan-error-layer) that draws an X mark over each on-screen wrong/
    // missing cell, recomputed from template.scanSummary.states every redraw —
    // never from an accumulating array. If/when the WebGL2 renderer lands, it
    // can instead upload `states` as a per-cell error texture and skip this
    // canvas entirely; `states` (not this drawing code) is the authoritative,
    // documented contract. Redraws are requestAnimationFrame-coalesced and
    // triggered by: map 'move'/'zoom'/'resize', a completed scan, clear-errors,
    // and every gppRenderProgressBar call (so switching the focused template
    // updates the on-map markers even without moving the map).

    const GPP_SCAN_BAND_HEIGHT = 128; // rows per getImageData batch, matches the perf-tested GTM prototype

    let gppScanRunning = false;
    // Which template's scan gppScanRunning refers to — a genuinely concurrent
    // scan of a DIFFERENT template is impossible anyway (gppScanRunInternal
    // samples the single shared map canvas at the currently-visible viewport,
    // so gppScanRunning itself correctly stays a global lock, guarding both
    // gppScanTemplate's own busy check and gppScheduleAutoscan). This id
    // exists purely so the Progress section's buttons (rendered below) can
    // tell "a scan is running for THIS template" apart from "a scan is
    // running for some OTHER template the user has since swapped away from"
    // — without it, swapping focus away from a template with a slow
    // in-flight background scan (e.g. gppTriggerLoadTimeScan on a huge
    // template) left every OTHER template's buttons falsely stuck on
    // "Scanning…"/disabled until that unrelated scan finished.
    let gppScanningTemplateId = null;

    function gppScanIsBusyFor(template) {
        return gppScanRunning && !!template && gppScanningTemplateId === template.id;
    }

    // ── Autoscan (gppSettings.autoscanEnabled) ─────────────────────────
    // Automatically re-runs "Scan progress" on the focused template shortly
    // after the user places a pixel, instead of requiring a manual click
    // every time. Wraps the native placePixelAt (a plain function
    // DECLARATION in the page's own code — unlike a `let` binding, this
    // attaches to window normally, same reasoning as gpp-bridge.js's native
    // draw-function patches) non-destructively: always calls through to
    // whatever was there before (the true native original, or another
    // userscript's own wrapper — composes either way)
    // and only ADDS a debounced scan trigger afterward, never altering
    // placement behaviour itself. Installed once, self-guards on the
    // setting so it's safe to call unconditionally on every runtime init.
    let gppAutoscanHookInstalled = false;
    let gppAutoscanDebounceTimer = null;

    function gppScheduleAutoscan() {
        if (!gppSettings.autoscanEnabled) return;
        clearTimeout(gppAutoscanDebounceTimer);
        gppAutoscanDebounceTimer = setTimeout(() => {
            const template = gppState.getFocusedTemplate();
            if (!template || !template.position || gppScanRunning) return;
            // Unlike the manual Scan button's own click handler (below,
            // ~line 890), which calls onChange() both immediately AND again
            // once its promise resolves, this fires from a bare setTimeout —
            // completely decoupled from any refresh. Without the immediate
            // call here, the button could start scanning without ever
            // visibly showing "Scanning…", relying entirely on some
            // unrelated later action to reveal that state; without the
            // .finally() below, it could stay stuck showing "Scanning…"
            // after finishing, for the same reason. Together these make
            // this call site self-sufficient instead of implicitly
            // depending on whatever else happens to trigger a refresh.
            // gppScanTemplate sets gppScanRunning/gppScanningTemplateId
            // SYNCHRONOUSLY before its first await, so the refresh must
            // come AFTER calling it — refreshing first would still see the
            // old, not-yet-busy state and never show Scanning… at all.
            const pending = gppScanTemplate(template);
            if (typeof gppRequestUiRefresh === 'function') gppRequestUiRefresh();
            pending.catch(err => {
                console.error('[GeoPixelcons++] Ghost++ autoscan failed:', err);
            }).finally(() => {
                if (typeof gppRequestUiRefresh === 'function') gppRequestUiRefresh();
            });
        }, 1500);
    }

    function gppEnsureAutoscanHook() {
        if (gppAutoscanHookInstalled) return;
        const target = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        const orig = target.placePixelAt;
        if (typeof orig !== 'function') return; // native function not ready yet — next init call retries
        gppAutoscanHookInstalled = true;
        target.placePixelAt = function gppPlacePixelAtAutoscanWrapper() {
            const result = orig.apply(this, arguments);
            gppScheduleAutoscan();
            // Unconditional (not gated on autoscanEnabled), unlike the scan
            // trigger above: Hide queued crosshairs needs the on-screen
            // markers to refresh promptly after each placement/queue call,
            // not just after a full rescan. gppScanScheduleErrorRedraw() is
            // already requestAnimationFrame-coalesced, so calling it on every
            // pixel during a bulk queue run still only redraws once per frame.
            gppScanScheduleErrorRedraw();
            return result;
        };
    }

    function gppScanYield() {
        return new Promise(resolve => {
            if (typeof window.requestIdleCallback === 'function') {
                window.requestIdleCallback(() => resolve(), { timeout: 50 });
            } else {
                window.setTimeout(resolve, 0);
            }
        });
    }

    // Classifies one already-decoded image band against the template's
    // indices/mask, writing into the shared `states` buffer and accumulating
    // per-colour counts. `reality` is a Uint8ClampedArray (RGBA, top-row-first)
    // sized `width`, or null when the covering tile has not synced (=> UNKNOWN
    // for every sampled cell in the band, no getImageData needed at all).
    function gppScanProcessBand(template, core, paletteLookup, reality, width, bandHeight, localRowStart, localXStart, states, colourAgg, groupOfIndex) {
        const ERROR_STATE = core.constants.ERROR_STATE;
        const empty = core.emptyValue(template.indexType);
        for (let row = 0; row < bandHeight; row++) {
            const templateRow = localRowStart + row;
            let templateOffset = templateRow * template.width + localXStart;
            let realityOffset = row * width * 4;
            for (let column = 0; column < width; column++, templateOffset++, realityOffset += 4) {
                const expectedIndex = template.indices[templateOffset];
                if (expectedIndex === empty) continue; // transparent cell: stays UNCHECKED
                // Sampled regardless of template.mask — like the native ghost
                // tool's own progress bar, this is a statement on the whole
                // template, not just its currently-enabled colours (mask only
                // gates which colours draw error CROSSES/queue for placement,
                // see gpp-scan.js's other mask checks and gpp-palette.js).
                const state = reality
                    ? core.compareRealityPixel(
                        expectedIndex,
                        reality[realityOffset],
                        reality[realityOffset + 1],
                        reality[realityOffset + 2],
                        reality[realityOffset + 3],
                        paletteLookup,
                        true,
                        groupOfIndex
                    )
                    : ERROR_STATE.UNKNOWN;
                states[templateOffset] = state;
                const agg = colourAgg[expectedIndex];
                if (state === ERROR_STATE.CORRECT) agg.correct++;
                else if (state === ERROR_STATE.WRONG) agg.wrong++;
                else if (state === ERROR_STATE.MISSING) agg.missing++;
                else agg.unknown++;
            }
        }
    }

    // Full scan: crops and reads only the tile rectangles overlapping the
    // template's bounding box (never a full-template-sized canvas read, never
    // a per-pixel drawImage+getImageData call — one getImageData per
    // GPP_SCAN_BAND_HEIGHT-row band, then compareRealityPixel per cell in that
    // batch). A tile's colour bitmap is stored south-up (row 0 = the tile's
    // minimum grid Y), so each band is drawn through a vertical-flip transform
    // before reading, aligning it with the template's north-first row order.
    async function gppScanRunInternal(template) {
        const core = gppCreateCore();
        const ERROR_STATE = core.constants.ERROR_STATE;
        const grid = gppReadGridConstants();
        const tileSize = grid.tileSize;
        const bounds = core.computeGridBounds(template.position, template.width, template.height);
        if (!bounds) throw new Error('Template has no position.');

        const paletteLookup = new Map();
        template.palette.forEach((packed, index) => paletteLookup.set(packed, index));

        // Group Noise: lazily built once per (palette, groupNoise-on) and
        // cached on the template — a template's palette never changes after
        // construction (no in-place flip/rotate exists anymore), so this
        // cache needs no separate invalidation. null whenever groupNoise is
        // off, which keeps compareRealityPixel's new branch byte-identical
        // to before it existed (see gpp-core.js).
        if (template.groupNoise && !template.groupNoiseData) {
            template.groupNoiseData = core.groupPaletteColors(template.palette);
        }
        const groupOfIndex = (template.groupNoise && template.groupNoiseData) ? template.groupNoiseData.groupOfIndex : null;

        const states = new Uint8Array(template.width * template.height); // all UNCHECKED
        const colourAgg = new Array(template.palette.length);
        for (let index = 0; index < colourAgg.length; index++) colourAgg[index] = { correct: 0, wrong: 0, missing: 0, unknown: 0 };

        const firstX = Math.floor(bounds.left / tileSize) * tileSize;
        const lastX = Math.floor(bounds.right / tileSize) * tileSize;
        const firstY = Math.floor(bounds.bottom / tileSize) * tileSize;
        const lastY = Math.floor(bounds.top / tileSize) * tileSize;

        const scratch = document.createElement('canvas');
        const scratchCtx = scratch.getContext('2d', { willReadFrequently: true, colorSpace: 'srgb' });

        for (let tileX = firstX; tileX <= lastX; tileX += tileSize) {
            for (let tileY = firstY; tileY <= lastY; tileY += tileSize) {
                const xMin = Math.max(bounds.left, tileX);
                const xMax = Math.min(bounds.right, tileX + tileSize - 1);
                const yMin = Math.max(bounds.bottom, tileY);
                const yMax = Math.min(bounds.top, tileY + tileSize - 1);
                if (xMin > xMax || yMin > yMax) continue; // this tile doesn't overlap the template

                const regionWidth = xMax - xMin + 1;
                const regionHeight = yMax - yMin + 1;
                const localXStart = xMin - bounds.left;

                const sourceBitmap = gppGetTileBitmap(tileX, tileY); // null => tile not synced yet
                let croppedBitmap = null;
                if (sourceBitmap) {
                    try {
                        croppedBitmap = await createImageBitmap(sourceBitmap, xMin - tileX, yMin - tileY, regionWidth, regionHeight);
                    } catch (_) {
                        croppedBitmap = null;
                    }
                }

                try {
                    for (let bandTop = yMax; bandTop >= yMin; bandTop -= GPP_SCAN_BAND_HEIGHT) {
                        const bandBottom = Math.max(yMin, bandTop - GPP_SCAN_BAND_HEIGHT + 1);
                        const bandHeight = bandTop - bandBottom + 1;
                        const localRowStart = bounds.top - bandTop;

                        let reality = null;
                        if (croppedBitmap) {
                            if (scratch.width !== regionWidth || scratch.height !== bandHeight) {
                                scratch.width = regionWidth;
                                scratch.height = bandHeight;
                            }
                            scratchCtx.setTransform(1, 0, 0, 1, 0, 0);
                            scratchCtx.clearRect(0, 0, regionWidth, bandHeight);
                            scratchCtx.save();
                            scratchCtx.translate(0, bandHeight);
                            scratchCtx.scale(1, -1);
                            scratchCtx.drawImage(
                                croppedBitmap,
                                0, bandBottom - yMin, regionWidth, bandHeight,
                                0, 0, regionWidth, bandHeight
                            );
                            scratchCtx.restore();
                            reality = scratchCtx.getImageData(0, 0, regionWidth, bandHeight).data;
                        }

                        gppScanProcessBand(template, core, paletteLookup, reality, regionWidth, bandHeight, localRowStart, localXStart, states, colourAgg, groupOfIndex);
                        await gppScanYield();
                    }
                } finally {
                    if (croppedBitmap) croppedBitmap.close();
                }
            }
        }

        let total = 0, correct = 0, wrong = 0, missing = 0, unknown = 0;
        const perColour = [];
        for (let index = 0; index < template.palette.length; index++) {
            const count = template.counts[index] || 0;
            if (!count) continue;
            const enabled = core.maskHas(template.mask, index);
            const agg = colourAgg[index];
            perColour.push({ index, enabled, correct: agg.correct, wrong: agg.wrong, missing: agg.missing, unknown: agg.unknown, total: count });
            // Always counted toward the aggregate totals — mask no longer
            // gates this (see gppScanProcessBand's comment). `enabled` is
            // still recorded per-colour above for consumers that need it
            // (gpp-palette.js's sort/filter), just not used to exclude a
            // colour from the whole-template progress numbers here.
            total += count;
            correct += agg.correct;
            wrong += agg.wrong;
            missing += agg.missing;
            unknown += agg.unknown;
        }

        template.scanSummary = { scannedAt: new Date().toISOString(), total, correct, wrong, missing, unknown, perColour, states };
        return template.scanSummary;
    }

    // Public, guarded entry point — the only sanctioned way to start a scan.
    // Refuses to run a second scan concurrently (no cancel button exists, so
    // "busy" is a silent no-op rather than something the UI needs to surface
    // beyond disabling the button while gppScanRunning is true).
    async function gppScanTemplate(template) {
        if (!template || !template.position) return { ok: false, reason: 'not-positioned' };
        if (gppScanRunning) return { ok: false, reason: 'busy' };
        gppScanRunning = true;
        gppScanningTemplateId = template.id;
        try {
            await gppScanRunInternal(template);
            gppScanScheduleErrorRedraw();
            return { ok: true };
        } catch (error) {
            console.error('[GeoPixelcons++] Ghost++ scan failed:', error);
            return { ok: false, reason: 'error', error };
        } finally {
            gppScanRunning = false;
            gppScanningTemplateId = null;
        }
    }

    // Flies the map to the wrong/missing cell nearest the current map centre,
    // among currently-enabled colours. Grid<->world conversion mirrors
    // encodePositionHeader/computeGridBounds: gridX/gridY are pure grid
    // indices, offsetMetersX/Y are added only when converting to world meters
    // (and subtracted when converting the other way), kept symmetric here.
    async function gppFlyToNearestError(template) {
        if (!template || !template.position || !template.scanSummary) return { ok: false, reason: 'nothing-to-search' };
        const map = gppGetMap();
        const turf = gppGetTurf();
        if (!map || !turf) return { ok: false, reason: 'map-unavailable' };
        const core = gppCreateCore();
        const ERROR_STATE = core.constants.ERROR_STATE;
        const grid = gppReadGridConstants();
        const states = template.scanSummary.states;

        const center = map.getCenter();
        const mercator = turf.toMercator([center.lng, center.lat]);
        const centerX = (mercator[0] - grid.offsetMetersX) / grid.gridSize;
        const centerY = (mercator[1] - grid.offsetMetersY) / grid.gridSize;

        // Search whichever of wrong/missing is currently toggled visible; if
        // neither is (a scan just finished and the user hasn't opted into a
        // display yet), search both so the action is still useful.
        const searchWrong = template._gppShowWrong || !template._gppShowMissing;
        const searchMissing = template._gppShowMissing || !template._gppShowWrong;

        let nearest = null;
        let nearestDistance = Infinity;
        const chunk = 65536;
        for (let start = 0; start < states.length; start += chunk) {
            const end = Math.min(states.length, start + chunk);
            for (let pixel = start; pixel < end; pixel++) {
                const state = states[pixel];
                if (state === ERROR_STATE.WRONG && !searchWrong) continue;
                if (state === ERROR_STATE.MISSING && !searchMissing) continue;
                if (state !== ERROR_STATE.WRONG && state !== ERROR_STATE.MISSING) continue;
                const paletteIndex = template.indices[pixel];
                if (!core.maskHas(template.mask, paletteIndex)) continue; // respect current filter
                const localX = pixel % template.width;
                const localY = Math.floor(pixel / template.width);
                const gridX = template.position.gridX + localX;
                const gridY = template.position.gridY - localY;
                const dx = gridX - centerX;
                const dy = gridY - centerY;
                const distance = dx * dx + dy * dy;
                if (distance < nearestDistance) {
                    nearestDistance = distance;
                    nearest = { gridX, gridY };
                }
            }
            await gppScanYield();
        }

        if (!nearest) return { ok: false, reason: 'none-found' };
        const target = turf.toWgs84([
            nearest.gridX * grid.gridSize + grid.offsetMetersX,
            nearest.gridY * grid.gridSize + grid.offsetMetersY,
        ]);
        // Instant, not eased — same explicit product decision as the
        // template library's own teleport (gppLibraryFlyToTemplate): jump
        // straight there rather than flying/easing into it.
        map.jumpTo({ center: target, zoom: Math.max(map.getZoom(), 17) });
        // Pulses a highlight ring on the exact cell just teleported to, so
        // it's obvious at a glance which one needs correcting — independent
        // of the ambient error-cross display settings (see
        // gppScanStartNearestErrorGlow), since this is a direct response to
        // the button click, not the passive crosshair overlay.
        gppScanStartNearestErrorGlow(template.id, nearest.gridX, nearest.gridY);
        return { ok: true };
    }

    // Clearing the error DISPLAY is deliberately separate from discarding the
    // scan itself — template.scanSummary (the progress numbers) is untouched
    // here. Only the two session-only, non-persisted display toggles below
    // are reset. A fresh scan (gppScanTemplate) turns both back on.
    function gppClearTemplateErrors(template) {
        if (!template) return;
        template._gppShowWrong = false;
        template._gppShowMissing = false;
        gppScanScheduleErrorRedraw();
    }

    // ── Viewport-bounded error-marker overlay (Canvas2D) ───────────────
    // North-up, zero-pitch projection is assumed (the site disables rotation
    // and pitch), so the viewport is a simple axis-aligned grid rectangle.

    function gppScanComputeViewportGridBounds() {
        const map = gppGetMap();
        const turf = gppGetTurf();
        if (!map || !turf || typeof map.getBounds !== 'function') return null;
        const grid = gppReadGridConstants();
        const mapBounds = map.getBounds();
        const nw = mapBounds.getNorthWest();
        const se = mapBounds.getSouthEast();
        const nwMercator = turf.toMercator([nw.lng, nw.lat]);
        const seMercator = turf.toMercator([se.lng, se.lat]);
        return {
            left: Math.floor((nwMercator[0] - grid.offsetMetersX) / grid.gridSize) - 1,
            right: Math.ceil((seMercator[0] - grid.offsetMetersX) / grid.gridSize) + 1,
            bottom: Math.floor((seMercator[1] - grid.offsetMetersY) / grid.gridSize) - 1,
            top: Math.ceil((nwMercator[1] - grid.offsetMetersY) / grid.gridSize) + 1,
        };
    }

    let gppScanOverlayCanvas = null;
    let gppScanMapWired = false;
    let gppScanRedrawScheduled = false;

    // ── "Nearest error" target glow ─────────────────────────────────────
    // A short pulsing ring drawn over the exact cell gppFlyToNearestError
    // just teleported to — deliberately independent of gppSettings.showErrors
    // and the per-template showWrong/showMissing toggles (gppScanRedrawErrors
    // draws this before any of those gates), since it's a direct response to
    // the button click rather than the passive crosshair overlay.
    let gppNearestErrorGlow = null; // { templateId, gridX, gridY, startTime } | null
    let gppNearestErrorGlowRafId = 0;
    const GPP_NEAREST_ERROR_GLOW_DURATION_MS = 2200;
    const GPP_NEAREST_ERROR_GLOW_PULSE_MS = 650;

    function gppScanStartNearestErrorGlow(templateId, gridX, gridY) {
        gppNearestErrorGlow = { templateId, gridX, gridY, startTime: Date.now() };
        if (gppNearestErrorGlowRafId) return; // animation loop already running, will pick up the new target next frame
        const tick = () => {
            if (!gppNearestErrorGlow) { gppNearestErrorGlowRafId = 0; return; }
            const elapsed = Date.now() - gppNearestErrorGlow.startTime;
            if (elapsed >= GPP_NEAREST_ERROR_GLOW_DURATION_MS) {
                gppNearestErrorGlow = null;
                gppNearestErrorGlowRafId = 0;
                gppScanRedrawErrors(); // one last redraw so the ring actually disappears
                return;
            }
            gppScanRedrawErrors();
            gppNearestErrorGlowRafId = requestAnimationFrame(tick);
        };
        gppNearestErrorGlowRafId = requestAnimationFrame(tick);
    }

    // Parented inside the map's own container (not window/body) so
    // map.project()'s container-relative pixel coordinates line up with the
    // canvas's own pixel space without any extra offset bookkeeping, and so it
    // tracks the container's on-screen position/size for free via CSS.
    function gppScanEnsureOverlay() {
        const map = gppGetMap();
        if (!map || typeof map.getContainer !== 'function') return null;
        const host = map.getContainer();
        if (gppScanOverlayCanvas && gppScanOverlayCanvas.parentElement === host) return gppScanOverlayCanvas;
        if (gppScanOverlayCanvas && gppScanOverlayCanvas.parentElement) gppScanOverlayCanvas.remove();
        const canvas = document.createElement('canvas');
        canvas.id = 'gpp-scan-error-layer';
        canvas.style.cssText = 'position:absolute; left:0; top:0; width:100%; height:100%; pointer-events:none; z-index:5;';
        host.appendChild(canvas);
        gppScanOverlayCanvas = canvas;
        return canvas;
    }

    function gppScanWireMapEvents() {
        if (gppScanMapWired) return;
        const map = gppGetMap();
        if (!map || typeof map.on !== 'function') return;
        map.on('move', gppScanScheduleErrorRedraw);
        map.on('zoom', gppScanScheduleErrorRedraw);
        map.on('resize', gppScanScheduleErrorRedraw);
        window.addEventListener('resize', gppScanScheduleErrorRedraw);
        gppScanMapWired = true;
    }

    // rAF-coalesced: any number of calls within one frame (map 'move' fires
    // continuously while panning) collapse into a single redraw.
    function gppScanScheduleErrorRedraw() {
        gppScanWireMapEvents();
        if (typeof gppRendererSchedule === 'function') {
            try { gppRendererSchedule(); } catch (_) { /* renderer's own concern */ }
        }
        if (gppScanRedrawScheduled) return;
        gppScanRedrawScheduled = true;
        requestAnimationFrame(() => {
            gppScanRedrawScheduled = false;
            gppScanRedrawErrors();
        });
    }

    function gppScanRedrawErrors() {
        const canvas = gppScanEnsureOverlay();
        if (!canvas) return;
        const ctx = canvas.getContext('2d');
        const rect = canvas.parentElement.getBoundingClientRect();
        const dpr = window.devicePixelRatio || 1;
        const wantWidth = Math.max(1, Math.round(rect.width * dpr));
        const wantHeight = Math.max(1, Math.round(rect.height * dpr));
        if (canvas.width !== wantWidth || canvas.height !== wantHeight) {
            canvas.width = wantWidth;
            canvas.height = wantHeight;
        }
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, rect.width, rect.height);

        const template = (typeof gppState !== 'undefined') ? gppState.getFocusedTemplate() : null;
        const map = gppGetMap();
        const turf = gppGetTurf();

        // "Nearest error" target glow — deliberately independent of
        // gppSettings.showErrors/showWrong/showMissing below (a direct
        // response to the button click, not the passive crosshair overlay).
        // Only gated on the glow's own template still being focused and
        // visible, not on the crosshair display settings.
        if (gppNearestErrorGlow && template && template.id === gppNearestErrorGlow.templateId && template.opacity > 0 && map && turf) {
            const grid = gppReadGridConstants();
            const elapsed = Date.now() - gppNearestErrorGlow.startTime;
            try {
                const world = turf.toWgs84([
                    gppNearestErrorGlow.gridX * grid.gridSize + grid.offsetMetersX,
                    gppNearestErrorGlow.gridY * grid.gridSize + grid.offsetMetersY,
                ]);
                const screen = map.project(world);
                if (Number.isFinite(screen.x) && Number.isFinite(screen.y)) {
                    const fadeOut = Math.max(0, 1 - elapsed / GPP_NEAREST_ERROR_GLOW_DURATION_MS);
                    const pulsePhase = (elapsed % GPP_NEAREST_ERROR_GLOW_PULSE_MS) / GPP_NEAREST_ERROR_GLOW_PULSE_MS;
                    const ringRadius = 6 + pulsePhase * 14;
                    ctx.globalAlpha = (1 - pulsePhase) * fadeOut;
                    ctx.strokeStyle = '#fbbf24';
                    ctx.lineWidth = 3;
                    ctx.beginPath();
                    ctx.arc(screen.x, screen.y, ringRadius, 0, Math.PI * 2);
                    ctx.stroke();
                    ctx.globalAlpha = 1;
                }
            } catch (_) { /* map/turf not ready this frame — try again next tick */ }
        }

        if (!gppSettings.showErrors) return; // master switch — see gpp-scan.js's Error Settings section
        const showWrong = !!(template && template._gppShowWrong);
        const showMissing = !!(template && template._gppShowMissing);
        if (!template || template.opacity <= 0 || !template.position || !template.scanSummary) return;
        if (!showWrong && !showMissing) return; // nothing toggled on — Clear leaves scanSummary intact but stops here
        if (!map || !turf) return;

        const core = gppCreateCore();
        const ERROR_STATE = core.constants.ERROR_STATE;
        const empty = core.emptyValue(template.indexType);
        const grid = gppReadGridConstants();
        const bounds = core.computeGridBounds(template.position, template.width, template.height);
        const viewport = gppScanComputeViewportGridBounds();
        if (!bounds || !viewport) return;

        const left = Math.max(bounds.left, viewport.left);
        const right = Math.min(bounds.right, viewport.right);
        const bottom = Math.max(bounds.bottom, viewport.bottom);
        const top = Math.min(bounds.top, viewport.top);
        if (left > right || bottom > top) return; // template isn't on screen at all

        // Extreme-zoom-out guard: bail rather than iterate a huge cell grid.
        if ((right - left + 1) * (top - bottom + 1) > 400000) return;

        // Affine grid->screen transform, computed ONCE from 3 projected
        // reference points (origin + one step along each grid axis) instead
        // of calling turf.toWgs84()+map.project() per cell inside the loop
        // below — those do real trig work (Mercator inverse projection,
        // etc.), and doing it per error cell was the actual "atrocious
        // performance with thousands of errors" bottleneck: thousands of
        // trig-heavy calls, every single animation frame while panning.
        // North-up/zero-pitch is already assumed elsewhere in this file (see
        // gppScanComputeViewportGridBounds), so grid->screen is a plain
        // linear map — this mirrors gpp-renderer.js's own
        // gppRendererProjectTemplate, which uses the identical technique for
        // the per-cell dot overlay.
        const originWorld = turf.toWgs84([left * grid.gridSize + grid.offsetMetersX, top * grid.gridSize + grid.offsetMetersY]);
        const stepXWorld = turf.toWgs84([(left + 1) * grid.gridSize + grid.offsetMetersX, top * grid.gridSize + grid.offsetMetersY]);
        const stepYWorld = turf.toWgs84([left * grid.gridSize + grid.offsetMetersX, (top - 1) * grid.gridSize + grid.offsetMetersY]);
        const originPx = map.project(originWorld);
        const stepXPx = map.project(stepXWorld);
        const stepYPx = map.project(stepYWorld);
        const stepXdx = stepXPx.x - originPx.x;
        const stepXdy = stepXPx.y - originPx.y;
        const stepYdx = stepYPx.x - originPx.x;
        const stepYdy = stepYPx.y - originPx.y;
        const cellPx = Math.abs(stepXdx);
        if (!(cellPx >= 2)) return;

        const settings = gppState.settings || {};
        const shape = settings.errorShape || 'x';
        const opacity = Number.isFinite(settings.errorOpacity) ? settings.errorOpacity : 1;
        if (opacity <= 0) return;
        const sizeScale = Number.isFinite(settings.errorSizeScale) ? settings.errorSizeScale : 1;
        const half = Math.max(1.5, cellPx * 0.32 * sizeScale);

        const states = template.scanSummary.states;

        // One bulk read of the native queue rather than one page-realm call
        // per on-screen cell (see gpp-bridge.js's gppReadQueuedPixelKeys).
        // Only fetched when the setting is on — an empty Set is a no-op skip
        // check below, so this stays free when the feature isn't in use.
        const queuedKeys = gppSettings.hideQueuedCrosses !== false
            ? new Set(gppReadQueuedPixelKeys())
            : null;

        // Collect screen positions first, then draw each shape type as ONE
        // batched path (single beginPath/stroke or fill call) instead of one
        // stroke() per marker — this is the actual performance win when a
        // scan surfaces thousands of markers; per-call canvas overhead, not
        // the cell-iteration itself, was the bottleneck.
        const points = [];
        for (let gridY = top; gridY >= bottom; gridY--) {
            const localY = template.position.gridY - gridY;
            if (localY < 0 || localY >= template.height) continue;
            const dGridY = top - gridY;
            for (let gridX = left; gridX <= right; gridX++) {
                const localX = gridX - template.position.gridX;
                if (localX < 0 || localX >= template.width) continue;
                const pixel = localY * template.width + localX;
                const expectedIndex = template.indices[pixel];
                if (expectedIndex === empty) continue;
                if (!core.maskHas(template.mask, expectedIndex)) continue; // respect current filter
                const state = states[pixel];
                if (state === ERROR_STATE.WRONG && !showWrong) continue;
                if (state === ERROR_STATE.MISSING && !showMissing) continue;
                if (state !== ERROR_STATE.WRONG && state !== ERROR_STATE.MISSING) continue;
                if (queuedKeys && queuedKeys.has(gridX + ',' + gridY)) continue; // already queued — see Hide queued crosshairs
                const dGridX = gridX - left;
                points.push(originPx.x + dGridX * stepXdx + dGridY * stepYdx, originPx.y + dGridX * stepXdy + dGridY * stepYdy);
            }
        }
        if (!points.length) return;

        ctx.globalAlpha = opacity;
        ctx.strokeStyle = settings.errorColor || '#dc2626';
        ctx.fillStyle = settings.errorColor || '#dc2626';
        ctx.lineWidth = Math.max(1, Math.min(2, cellPx / 4));

        if (shape === 'circle') {
            ctx.beginPath();
            for (let i = 0; i < points.length; i += 2) {
                ctx.moveTo(points[i] + half, points[i + 1]);
                ctx.arc(points[i], points[i + 1], half, 0, Math.PI * 2);
            }
            ctx.fill();
        } else if (shape === 'square') {
            ctx.beginPath();
            for (let i = 0; i < points.length; i += 2) {
                ctx.rect(points[i] - half, points[i + 1] - half, half * 2, half * 2);
            }
            ctx.fill();
        } else { // 'x'
            ctx.beginPath();
            for (let i = 0; i < points.length; i += 2) {
                ctx.moveTo(points[i] - half, points[i + 1] - half);
                ctx.lineTo(points[i] + half, points[i + 1] + half);
                ctx.moveTo(points[i] + half, points[i + 1] - half);
                ctx.lineTo(points[i] - half, points[i + 1] + half);
            }
            ctx.stroke();
        }
        ctx.globalAlpha = 1;
    }

    function gppScanFormatRelativeTime(iso) {
        if (!iso) return '';
        const then = new Date(iso).getTime();
        if (!Number.isFinite(then)) return '';
        const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
        if (seconds < 5) return 'just now';
        if (seconds < 60) return `${seconds}s ago`;
        const minutes = Math.round(seconds / 60);
        if (minutes < 60) return `${minutes}m ago`;
        const hours = Math.round(minutes / 60);
        if (hours < 24) return `${hours}h ago`;
        return new Date(iso).toLocaleDateString();
    }

    function gppScanStyleButton(button, primary) {
        button.style.cssText =
            'font:inherit; padding:3px 8px; border-radius:5px; cursor:pointer;' +
            'border:1px solid ' + t2('#cbd5e1', '#45475a') + ';' +
            'background:' + (primary ? t2('#2563eb', '#89b4fa') : t2('#ffffff', '#313244')) + ';' +
            'color:' + (primary ? t2('#ffffff', '#1e1e2e') : t2('#111827', '#f5f5f5')) + ';' +
            (button.disabled ? 'opacity:.5; cursor:default;' : '');
    }

    // Sums scanSummary.perColour[i].wrong/missing only for palette indices
    // CURRENTLY enabled in template.mask (the live mask, not each entry's own
    // possibly-stale `enabled` snapshot from scan time) — matching exactly
    // what gppScanRedrawErrors draws on the map right now.
    function gppScanCountEnabledErrors(template, kind) {
        const summary = template.scanSummary;
        if (!summary || !Array.isArray(summary.perColour)) return 0;
        const core = gppCreateCore();
        let count = 0;
        summary.perColour.forEach(entry => {
            if (core.maskHas(template.mask, entry.index)) count += entry[kind] || 0;
        });
        return count;
    }

    // Restores the native ghost tool's "how many crosses did that just place"
    // alert (see js/ghost22.js's findPlacedErrors()/findMissingErrors(), which
    // call the page's own showAlert()) for Ghost++'s own Show errors/Show
    // missing toggles, filtered to the currently-enabled colours only.
    function gppScanAlertEnabledCount(template, kind) {
        const target = gppNativeBridgeTarget();
        if (typeof target.showAlert !== 'function') return;
        const count = gppScanCountEnabledErrors(template, kind);
        const noun = kind === 'wrong' ? 'error' : 'missing pixel';
        if (count === 0) {
            target.showAlert('Success', (kind === 'wrong' ? 'Perfect match! No errors' : 'No missing pixels') + ' found among your currently enabled colors.');
        } else {
            target.showAlert('Info', 'Found ' + count.toLocaleString() + ' ' + noun + (count === 1 ? '' : 's') + ' among your currently enabled colors.');
        }
    }

    // ── gpp-init.js render-function contract ───────────────────────────
    // container id: 'gpp-progress-section'. `template` may be null.
    function gppRenderProgressBar(container, template, onChange) {
        if (!container) return;
        gppScanScheduleErrorRedraw(); // keep on-map markers in sync with the focused template on every UI refresh

        // Same wasOpen-preservation pattern as every other gpp-init.js
        // section's <details> — read before the wipe, restore after.
        const previousDetails = container.querySelector('details.gpp-collapsible');
        const wasOpen = previousDetails ? previousDetails.open : true;
        container.innerHTML = '';

        const details = document.createElement('details');
        details.className = 'gpp-collapsible';
        details.open = wasOpen;
        const summary = document.createElement('summary');
        summary.textContent = 'Progress';
        details.appendChild(summary);

        const wrap = document.createElement('div');
        wrap.className = 'gpp-body';
        details.appendChild(wrap);

        // Left-justified — the <summary> above already shows "Progress",
        // so no title/spacer is needed to push these to the right.
        const headRow = document.createElement('div');
        headRow.style.cssText = 'display:flex; align-items:center; gap:6px; margin-bottom:6px; flex-wrap:wrap; justify-content:flex-start;';

        const scanBusy = gppScanIsBusyFor(template);

        const scanBtn = document.createElement('button');
        scanBtn.type = 'button';
        scanBtn.textContent = scanBusy ? 'Scanning…' : 'Scan progress';
        scanBtn.disabled = !template || !template.position || scanBusy;
        scanBtn.title = !template ? '' : (!template.position ? 'Place the template on the map first.' : 'Compare this template against the live map.');
        gppScanStyleButton(scanBtn, true);
        headRow.appendChild(scanBtn);

        // Scanning only computes progress numbers. Error/missing MARKERS are
        // a separate, explicit opt-in the user can turn on afterward — two
        // independent toggles (matching the native ghost menu's own wrong-
        // vs-missing distinction), not bundled into the scan action itself.
        const showErrBtn = document.createElement('button');
        showErrBtn.type = 'button';
        const wrongOn = !!(template && template._gppShowWrong);
        showErrBtn.textContent = wrongOn ? 'Hide errors' : 'Show errors';
        showErrBtn.disabled = !template || !template.scanSummary || scanBusy;
        showErrBtn.title = 'Mark cells painted the wrong color.';
        gppScanStyleButton(showErrBtn, wrongOn);
        headRow.appendChild(showErrBtn);

        const showMissBtn = document.createElement('button');
        showMissBtn.type = 'button';
        const missingOn = !!(template && template._gppShowMissing);
        showMissBtn.textContent = missingOn ? 'Hide missing' : 'Show missing';
        showMissBtn.disabled = !template || !template.scanSummary || scanBusy;
        showMissBtn.title = 'Mark cells that are not painted at all yet.';
        gppScanStyleButton(showMissBtn, missingOn);
        headRow.appendChild(showMissBtn);

        const nearestBtn = document.createElement('button');
        nearestBtn.type = 'button';
        nearestBtn.textContent = 'Nearest error';
        nearestBtn.disabled = !template || !template.scanSummary || scanBusy;
        gppScanStyleButton(nearestBtn, false);
        headRow.appendChild(nearestBtn);

        const clearBtn = document.createElement('button');
        clearBtn.type = 'button';
        clearBtn.textContent = 'Clear';
        clearBtn.title = 'Hide error/missing markers. Does not discard the scan — progress above stays.';
        clearBtn.disabled = !template || (!template._gppShowWrong && !template._gppShowMissing) || scanBusy;
        gppScanStyleButton(clearBtn, false);
        headRow.appendChild(clearBtn);

        wrap.appendChild(headRow);

        const autoscanRow = document.createElement('label');
        autoscanRow.style.cssText = 'display:flex; align-items:center; gap:6px; font-size:11px; margin:0 0 6px; cursor:pointer; color:' + t2('#1f2937', '#e2e2f5') + ';';
        const autoscanCheckbox = document.createElement('input');
        autoscanCheckbox.type = 'checkbox';
        autoscanCheckbox.checked = !!gppSettings.autoscanEnabled;
        autoscanCheckbox.addEventListener('change', () => {
            gppSettings.autoscanEnabled = autoscanCheckbox.checked;
            gppState.saveSettings();
        });
        autoscanRow.title = 'Automatically re-scans the focused template shortly after you place pixels';
        autoscanRow.append(autoscanCheckbox, document.createTextNode('Autoscan'));
        wrap.appendChild(autoscanRow);

        const barOuter = document.createElement('div');
        barOuter.style.cssText = 'display:flex; height:10px; border-radius:5px; overflow:hidden; background:' + t2('#e5e7eb', '#313244') + ';';
        wrap.appendChild(barOuter);

        const summaryLine = document.createElement('div');
        summaryLine.style.cssText = 'font-size:11px; margin-top:4px; color:' + t2('#475569', '#a6adc8') + ';';
        wrap.appendChild(summaryLine);

        if (!template) {
            barOuter.style.opacity = '0.4';
            summaryLine.textContent = 'Select or import a template.';
        } else if (!template.position) {
            barOuter.style.opacity = '0.4';
            summaryLine.textContent = 'Place the template on the map, then scan to see progress.';
        } else if (!template.scanSummary) {
            const neutral = document.createElement('div');
            neutral.style.cssText = 'width:100%; background:' + t2('#cbd5e1', '#45475a') + ';';
            neutral.title = 'Not scanned yet';
            barOuter.appendChild(neutral);
            summaryLine.textContent = 'Not scanned yet.';
        } else {
            // A statement on the WHOLE template, like the native ghost tool's
            // own progress bar — not filtered by which colours are currently
            // enabled (see gppScanRunInternal's aggregation). Read straight
            // off scanSummary rather than recomputing against the current
            // mask, since scanSummary itself is no longer mask-dependent.
            const summary = template.scanSummary;
            const total = summary.total;
            const notPlaced = Math.max(0, total - summary.correct - summary.wrong);
            const unscanned = Math.max(0, notPlaced - summary.missing - summary.unknown);
            const pct = value => (total > 0 ? (value / total) * 100 : 0);

            if (total <= 0) {
                const neutral = document.createElement('div');
                neutral.style.cssText = 'width:100%; background:' + t2('#cbd5e1', '#45475a') + ';';
                neutral.title = 'Template has no opaque pixels';
                barOuter.appendChild(neutral);
                summaryLine.textContent = 'Template has no opaque pixels — nothing to show.';
            } else {
                const correctSeg = document.createElement('div');
                correctSeg.style.cssText = `width:${pct(summary.correct)}%; background:${t2('#16a34a', '#a6e3a1')};`;
                correctSeg.title = `Correct: ${summary.correct.toLocaleString()} px`;
                barOuter.appendChild(correctSeg);

                const wrongSeg = document.createElement('div');
                wrongSeg.style.cssText = `width:${pct(summary.wrong)}%; background:${t2('#dc2626', '#f38ba8')};`;
                wrongSeg.title = `Wrong color: ${summary.wrong.toLocaleString()} px`;
                barOuter.appendChild(wrongSeg);

                const notPlacedSeg = document.createElement('div');
                notPlacedSeg.style.cssText = `width:${pct(notPlaced)}%; background:${t2('#94a3b8', '#6c7086')};`;
                notPlacedSeg.title = `Not yet placed: ${notPlaced.toLocaleString()} px`
                    + ` (missing: ${summary.missing.toLocaleString()}, tile not loaded: ${summary.unknown.toLocaleString()}`
                    + (unscanned ? `, not yet scanned: ${unscanned.toLocaleString()}` : '') + ')';
                barOuter.appendChild(notPlacedSeg);

                const donePct = Math.round(pct(summary.correct));
                summaryLine.textContent = `${summary.correct.toLocaleString()} completed of ${total.toLocaleString()} total (${donePct}%)`
                    + (summary.scannedAt ? ` — scanned ${gppScanFormatRelativeTime(summary.scannedAt)}` : '');

                // Always shown whenever there's something to report — no
                // longer gated on the per-template Show errors/Show missing
                // toggles (those now only control the on-map crosshairs,
                // via gppScanRedrawErrors, same as always). Whole-template
                // counts (summary.wrong/summary.missing directly), matching
                // the "X completed of Y total" statement immediately above,
                // not the mask-filtered currently-enabled-colours-only
                // counts the map's own crosshair drawing uses — an
                // always-visible summary staying stable regardless of which
                // colours you happen to have toggled on is less confusing
                // than one that changes complexion with the palette.
                if (summary.wrong > 0 || summary.missing > 0) {
                    const parts = [];
                    if (summary.wrong > 0) parts.push(`${summary.wrong.toLocaleString()} error${summary.wrong === 1 ? '' : 's'}`);
                    if (summary.missing > 0) parts.push(`${summary.missing.toLocaleString()} missing`);
                    const countsLine = document.createElement('div');
                    countsLine.style.cssText = 'font-size:11px; margin-top:2px; color:' + t2('#475569', '#a6adc8') + ';';
                    countsLine.textContent = parts.join(', ')
                        + ((summary.wrong > 0 && summary.missing > 0) ? ` (${(summary.wrong + summary.missing).toLocaleString()} combined)` : '');
                    wrap.appendChild(countsLine);
                }
            }
        }

        container.appendChild(details);

        scanBtn.addEventListener('click', () => {
            if (gppScanRunning || !template || !template.position) return;
            const pending = gppScanTemplate(template); // sets gppScanRunning synchronously before its first await
            onChange();
            pending.then(() => onChange());
        });
        showErrBtn.addEventListener('click', () => {
            if (!template || !template.scanSummary) return;
            template._gppShowWrong = !template._gppShowWrong;
            if (template._gppShowWrong) gppScanAlertEnabledCount(template, 'wrong');
            gppScanScheduleErrorRedraw();
            onChange();
        });
        showMissBtn.addEventListener('click', () => {
            if (!template || !template.scanSummary) return;
            template._gppShowMissing = !template._gppShowMissing;
            if (template._gppShowMissing) gppScanAlertEnabledCount(template, 'missing');
            gppScanScheduleErrorRedraw();
            onChange();
        });
        nearestBtn.addEventListener('click', () => {
            if (!template || !template.scanSummary) return;
            gppFlyToNearestError(template);
        });
        clearBtn.addEventListener('click', () => {
            if (!template) return;
            gppClearTemplateErrors(template);
            onChange();
        });
    }

    // ── gpp-init.js render-function contract ───────────────────────────
    // container id: 'gpp-error-settings-section'. Global (applies to every
    // template's error-marker rendering — see gppScanRedrawErrors above,
    // which reads gppSettings.errorShape/errorColor/errorOpacity/errorSizeScale
    // fresh on every redraw), so `template` is accepted for signature
    // consistency with the other render hooks but unused.
    function gppRenderErrorSettings(container) {
        if (!container) return;
        const previous = container.querySelector('details.gpp-collapsible');
        const wasOpen = previous ? previous.open : false;
        container.innerHTML = '';

        const details = document.createElement('details');
        details.className = 'gpp-collapsible';
        details.open = wasOpen;
        const summary = document.createElement('summary');
        summary.textContent = 'Error Settings';
        details.appendChild(summary);

        const body = document.createElement('div');
        body.className = 'gpp-body';

        // Matches this codebase's existing reset-icon convention (see
        // hide-paint-menu.js / ghost-template-manager.js's own '↺' buttons).
        function addResetButton(rowEl, title, onClick) {
            const btn = document.createElement('button');
            btn.type = 'button';
            btn.innerHTML = '↺';
            btn.title = title;
            btn.style.cssText = 'border:none; background:transparent; cursor:pointer; font-size:13px; padding:0 2px; flex-shrink:0; color:' + t2('#64748b', '#a6adc8') + ';';
            btn.addEventListener('click', onClick);
            rowEl.appendChild(btn);
        }

        function row(labelText) {
            const r = document.createElement('div');
            r.style.cssText = 'display:flex; align-items:center; gap:8px; margin:6px 0;';
            const label = document.createElement('label');
            label.style.cssText = 'flex:0 0 auto; min-width:70px; font-size:12px; color:' + t2('#1f2937', '#e2e2f5') + ';';
            label.textContent = labelText;
            r.appendChild(label);
            body.appendChild(r);
            return r;
        }

        // Master switch — moved here (was in View Settings' template
        // subsection) so it lives alongside the settings it actually gates.
        // gppScanRedrawErrors() reads gppSettings.showErrors directly, so
        // unchecking this hides every error/missing marker on the map
        // regardless of any individual template's own Show errors/Show
        // missing toggle state — the rows below only affect HOW markers
        // look, so they're dimmed and disabled while this is off.
        const showErrorsRow = document.createElement('div');
        showErrorsRow.style.cssText = 'display:flex; align-items:center; gap:8px; margin:6px 0;';
        const showErrorsLabel = document.createElement('label');
        showErrorsLabel.style.cssText = 'display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer; color:' + t2('#1f2937', '#e2e2f5') + ';';
        const showErrorsInput = document.createElement('input');
        showErrorsInput.type = 'checkbox';
        showErrorsInput.checked = !!gppSettings.showErrors;
        const showErrorsText = document.createElement('span');
        showErrorsText.textContent = 'Show error crosses';
        showErrorsLabel.append(showErrorsInput, showErrorsText);
        showErrorsRow.appendChild(showErrorsLabel);

        // To the right of Show error crosses. Defaults on: once a pixel is
        // queued (native queuedPixels — see gpp-bridge.js's
        // gppReadQueuedPixelKeys), its crosshair stops drawing even before it
        // actually paints on the map, so the crosshairs left on screen are
        // exactly the ones still needing to be queued.
        const hideQueuedLabel = document.createElement('label');
        hideQueuedLabel.style.cssText = 'display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer; margin-left:14px; color:' + t2('#1f2937', '#e2e2f5') + ';';
        const hideQueuedInput = document.createElement('input');
        hideQueuedInput.type = 'checkbox';
        hideQueuedInput.checked = gppSettings.hideQueuedCrosses !== false;
        const hideQueuedText = document.createElement('span');
        hideQueuedText.textContent = 'Hide queued crosshairs';
        hideQueuedLabel.title = 'Stop showing a crosshair once you\'ve queued a pixel over it, even before it actually paints — only the crosshairs you still need to queue stay visible.';
        hideQueuedLabel.append(hideQueuedInput, hideQueuedText);
        showErrorsRow.appendChild(hideQueuedLabel);
        hideQueuedInput.addEventListener('change', () => {
            gppSettings.hideQueuedCrosses = hideQueuedInput.checked;
            gppState.saveSettings();
            gppScanScheduleErrorRedraw();
        });

        body.appendChild(showErrorsRow);

        function applyShowErrorsGate() {
            const on = showErrorsInput.checked;
            [shapeSelect, colorInput, opacityInput, sizeInput].forEach(el => { el.disabled = !on; });
            [shapeRow, colorRow, opacityRow, sizeRow].forEach(r => { r.style.opacity = on ? '' : '.45'; });
        }
        showErrorsInput.addEventListener('change', () => {
            gppSettings.showErrors = showErrorsInput.checked;
            gppState.saveSettings();
            applyShowErrorsGate();
            gppScanScheduleErrorRedraw();
        });

        const shapeRow = row('Shape');
        const shapeSelect = document.createElement('select');
        ['x', 'circle', 'square'].forEach(value => {
            const opt = document.createElement('option');
            opt.value = value;
            opt.textContent = value === 'x' ? 'X mark' : (value === 'circle' ? 'Circle' : 'Square');
            if ((gppSettings.errorShape || 'x') === value) opt.selected = true;
            shapeSelect.appendChild(opt);
        });
        shapeSelect.addEventListener('change', () => {
            gppSettings.errorShape = shapeSelect.value;
            gppState.saveSettings();
            gppScanScheduleErrorRedraw();
        });
        shapeRow.appendChild(shapeSelect);

        const colorRow = row('Color');
        const colorInput = document.createElement('input');
        colorInput.type = 'color';
        colorInput.value = gppSettings.errorColor || '#dc2626';
        colorInput.addEventListener('input', () => {
            gppSettings.errorColor = colorInput.value;
            gppState.saveSettings();
            gppScanScheduleErrorRedraw();
        });
        colorRow.appendChild(colorInput);
        addResetButton(colorRow, 'Reset to default color', () => {
            colorInput.value = '#dc2626';
            gppSettings.errorColor = '#dc2626';
            gppState.saveSettings();
            gppScanScheduleErrorRedraw();
        });

        const opacityRow = row('Opacity');
        const opacityInput = document.createElement('input');
        opacityInput.type = 'range';
        opacityInput.min = '0'; opacityInput.max = '100'; opacityInput.step = '1';
        opacityInput.value = String(Math.round((Number.isFinite(gppSettings.errorOpacity) ? gppSettings.errorOpacity : 1) * 100));
        opacityInput.style.flex = '1';
        const opacityValue = document.createElement('span');
        opacityValue.style.cssText = 'font-size:11px; min-width:32px; text-align:right;';
        opacityValue.textContent = opacityInput.value + '%';
        opacityInput.addEventListener('input', () => {
            opacityValue.textContent = opacityInput.value + '%';
            gppSettings.errorOpacity = Number(opacityInput.value) / 100;
            gppState.saveSettings();
            gppScanScheduleErrorRedraw();
        });
        opacityRow.append(opacityInput, opacityValue);
        addResetButton(opacityRow, 'Reset to default opacity', () => {
            opacityInput.value = '100';
            opacityValue.textContent = '100%';
            gppSettings.errorOpacity = 1;
            gppState.saveSettings();
            gppScanScheduleErrorRedraw();
        });

        const sizeRow = row('Size');
        const sizeInput = document.createElement('input');
        sizeInput.type = 'range';
        sizeInput.min = '25'; sizeInput.max = '250'; sizeInput.step = '5';
        sizeInput.value = String(Math.round((Number.isFinite(gppSettings.errorSizeScale) ? gppSettings.errorSizeScale : 1) * 100));
        sizeInput.style.flex = '1';
        const sizeValue = document.createElement('span');
        sizeValue.style.cssText = 'font-size:11px; min-width:32px; text-align:right;';
        sizeValue.textContent = sizeInput.value + '%';
        sizeInput.addEventListener('input', () => {
            sizeValue.textContent = sizeInput.value + '%';
            gppSettings.errorSizeScale = Number(sizeInput.value) / 100;
            gppState.saveSettings();
            gppScanScheduleErrorRedraw();
        });
        sizeRow.append(sizeInput, sizeValue);
        addResetButton(sizeRow, 'Reset to default size', () => {
            sizeInput.value = '100';
            sizeValue.textContent = '100%';
            gppSettings.errorSizeScale = 1;
            gppState.saveSettings();
            gppScanScheduleErrorRedraw();
        });

        applyShowErrorsGate();

        details.appendChild(body);
        container.appendChild(details);
    }

    // ── Ghost++ palette panel ──────────────────────────────────────────
    // Redesigned colour palette for the focused template. Ports the sort (8
    // options) and filter (6 checkboxes incl. progress-gated subset) logic
    // from src/features/ghost-palette-search.js (lines ~433-493, 518-691,
    // 594-639, 732-749) faithfully, adapted from the native page-scope
    // Set/DOM-swatch model to Ghost++'s own template.mask bitset via
    // gppCreateCore()'s maskHas/maskSet/maskToggle/maskOnly. This panel never
    // touches native page state — everything here is local to the focused
    // template record (see gpp-runtime.js for its shape).
    //
    // Entry point wired by gpp-init.js's render-function contract:
    //   gppRenderPalette(container, template, onChange)
    // `container` is 'gpp-palette-section', rebuilt/called on every refresh.
    // A small per-container controller (gppPaletteControllers) is kept so the
    // search text / sort / open filter menu / scroll position survive a
    // refresh that doesn't actually change which template is focused — only
    // the swatch grid itself (and the progress-gated control availability)
    // is resynced on every call; the controls chrome is built once.

    const GPP_PALETTE_STYLE_ID = 'gpp-palette-style';

    const GPP_PALETTE_SORT_OPTIONS = [
        { value: 'default', text: 'Sort: Most used (default)' },
        { value: 'leastUsed', text: 'Sort: Least used' },
        { value: 'mostRemaining', text: 'Sort: Most remaining' },
        { value: 'leastRemaining', text: 'Sort: Least remaining' },
        { value: 'mostPct', text: 'Sort: Most % remaining' },
        { value: 'leastPct', text: 'Sort: Least % remaining' },
        { value: 'byColor', text: 'Sort: Color' },
        { value: 'byColorRev', text: 'Sort: Color reversed' },
    ];
    const GPP_PALETTE_PROGRESS_SORT_VALUES = new Set(['mostRemaining', 'leastRemaining', 'mostPct', 'leastPct']);

    const GPP_PALETTE_FILTER_OPTIONS = [
        // Same underlying behaviour as before (hide anything that isn't a
        // search hit) — renamed to match the clearer, more direct wording
        // requested ("Show search results only"), rather than adding a
        // second, functionally-identical checkbox alongside it.
        { value: 'hideUnmatched', text: 'Show search results only' },
        { value: 'hideCompleted', text: 'Hide completed colors' },
        { value: 'hideInProgress', text: 'Hide in-progress colors' },
        { value: 'hideUnstarted', text: 'Hide unstarted colors' },
        { value: 'ownedOnly', text: 'Owned colors only' },
        { value: 'countRange', text: 'Filter within pixel count…' },
    ];

    // Rewrites content every call (see gpp-init.js's theme-change observer)
    // instead of no-op-ing once created, so a live dark/light toggle isn't
    // frozen at whatever theme was active on first mount.
    function gppInjectPaletteStyle() {
        let style = document.getElementById(GPP_PALETTE_STYLE_ID);
        if (!style) {
            style = document.createElement('style');
            style.id = GPP_PALETTE_STYLE_ID;
            document.head.appendChild(style);
        }
        style.textContent = `
            .gpp-palette-empty { font-size: 12px; color: ${t2('#64748b', '#a6adc8')}; padding: 6px 2px; }
            .gpp-palette-panel { display: flex; flex-direction: column; gap: 7px; margin: 8px 0; }
            .gpp-gnc-group {
                padding: 6px 0; border-bottom: 1px solid ${t2('#e5e7eb', '#313244')};
            }
            .gpp-gnc-group:last-child { border-bottom: none; }
            .gpp-gnc-heading {
                display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600;
                color: ${t2('#111827', '#f5f5f5')}; margin-bottom: 4px;
            }
            .gpp-gnc-heading .gpp-gnc-meta { font-weight: 400; color: ${t2('#64748b', '#a6adc8')}; }
            .gpp-gnc-members { display: flex; flex-direction: column; gap: 2px; padding-left: 4px; }
            .gpp-gnc-member {
                display: flex; align-items: center; gap: 6px; font-size: 11px;
                color: ${t2('#1f2937', '#e2e2f5')};
            }
            .gpp-gnc-member .gpp-gnc-kept { font-weight: 600; color: ${t2('#16a34a', '#a6e3a1')}; }
            .gpp-gnc-swatch {
                display: inline-block; width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0;
                border: 1px solid ${t2('rgba(0,0,0,.28)', 'rgba(255,255,255,.28)')};
            }
            .gpp-gnc-hex { font-family: ui-monospace, Menlo, Consolas, monospace; }
            #gpp-palette-tooltip {
                position: fixed; z-index: 10070; pointer-events: none; display: none;
                padding: 6px 9px; border-radius: 7px; font-size: 12px; line-height: 1.4;
                background: ${t2('#ffffff', '#1e1e2e')};
                border: 1px solid ${t2('#d1d5db', '#45475a')};
                box-shadow: 0 8px 20px ${t2('rgba(15,23,42,.28)', 'rgba(0,0,0,.6)')};
                color: ${t2('#111827', '#f5f5f5')};
            }
            #gpp-palette-tooltip .gpp-palette-tooltip-hex {
                font-family: ui-monospace, Menlo, Consolas, monospace; font-weight: 700;
                display: flex; align-items: center; gap: 6px;
            }
            #gpp-palette-tooltip .gpp-palette-tooltip-swatch {
                display: inline-block; width: 10px; height: 10px; border-radius: 3px;
                border: 1px solid ${t2('rgba(0,0,0,.28)', 'rgba(255,255,255,.28)')};
            }
            #gpp-palette-tooltip .gpp-palette-tooltip-stats {
                margin-top: 2px; color: ${t2('#64748b', '#a6adc8')};
            }
            .gpp-palette-search-input {
                width: 100%; box-sizing: border-box; padding: 5px 8px; border-radius: 6px;
                border: 2px solid ${t2('#d1d5db', '#45475a')};
                background: ${t2('#ffffff', '#11111b')}; color: ${t2('#111827', '#f5f5f5')};
                font-size: 12px;
            }
            .gpp-palette-search-input:focus { outline: none; border-color: ${t2('#3b82f6', '#89b4fa')}; }
            .gpp-palette-search-input::placeholder { color: ${t2('#94a3b8', '#7f849c')}; }
            .gpp-palette-bulk-row { display: flex; gap: 6px; }
            .gpp-palette-bulk-row button,
            .gpp-palette-bulk-row .gpp-palette-filter-dropdown {
                flex: 1 1 0; min-width: 0;
            }
            .gpp-palette-bulk-row button {
                border: 2px solid ${t2('#d1d5db', '#45475a')}; border-radius: 6px;
                background: ${t2('#ffffff', '#11111b')}; color: ${t2('#111827', '#f5f5f5')};
                font-size: 11px; font-weight: 600; cursor: pointer; padding: 4px 6px;
            }
            .gpp-palette-bulk-row button:hover { background: ${t2('#f3f4f6', '#313244')}; }
            /* Buy all colors/Get hex values and Sort/Filters both use this
               exact 2-column CSS Grid (not flex:1 1 0) so their column
               boundaries are pixel-identical regardless of content — a grid
               track's minmax(0, 1fr) has no "shrink to content" ambiguity
               the way a flex item's automatic minimum size can, which is
               what caused these two rows to drift out of alignment before. */
            .gpp-palette-2col-row {
                display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 6px;
            }
            .gpp-palette-2col-row button,
            .gpp-palette-2col-row .gpp-palette-filter-dropdown,
            .gpp-palette-2col-row .gpp-palette-sort-wrap {
                width: 100%; box-sizing: border-box; min-width: 0;
            }
            .gpp-palette-filter-dropdown { position: relative; }
            .gpp-palette-filter-button,
            .gpp-palette-sort-select {
                width: 100%; box-sizing: border-box; min-height: 28px;
                border: 2px solid ${t2('#d1d5db', '#45475a')}; border-radius: 6px;
                background: ${t2('#ffffff', '#11111b')}; color: ${t2('#111827', '#f5f5f5')};
                font-size: 11px; cursor: pointer;
                text-align: center; text-align-last: center;
            }
            .gpp-palette-filter-button {
                display: flex; align-items: center; justify-content: center;
                gap: 6px; padding: 3px 8px;
            }
            .gpp-palette-filter-menu {
                display: none; position: absolute; top: calc(100% + 4px); left: 0; z-index: 20;
                min-width: 204px; padding: 6px; border-radius: 8px;
                border: 1px solid ${t2('#e5e7eb', '#313244')};
                background: ${t2('#ffffff', '#181825')};
                box-shadow: 0 8px 24px rgba(0,0,0,.28);
            }
            .gpp-palette-filter-menu.gpp-open { display: flex; flex-direction: column; gap: 2px; }
            .gpp-palette-filter-option {
                display: flex; align-items: center; gap: 6px; padding: 3px 4px; border-radius: 5px;
                font-size: 12px; cursor: pointer; user-select: none;
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-palette-filter-option:hover { background: ${t2('#f3f4f6', '#313244')}; }
            .gpp-palette-filter-option input { width: 13px; height: 13px; cursor: pointer; }
            .gpp-palette-filter-count {
                display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
                padding: 3px 4px 4px 22px; font-size: 12px; color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-palette-count-input {
                width: 58px; box-sizing: border-box; padding: 2px 5px; font-size: 11px;
                border-radius: 5px; border: 2px solid ${t2('#d1d5db', '#45475a')};
                background: ${t2('#ffffff', '#11111b')}; color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-palette-sort-wrap { flex: 1 1 0; min-width: 0; }
            .gpp-palette-grid {
                display: grid; grid-template-columns: repeat(auto-fill, minmax(26px, 1fr));
                /* Explicit row floor matching the column formula — Firefox
                   (unlike Chrome) can fail to derive a correct implicit row
                   height from aspect-ratio alone on grid items once the
                   container gets narrow enough to reflow the column count,
                   collapsing rows and stacking swatches on top of each
                   other. This gives it a real number to size rows from
                   instead of depending on that computation. */
                grid-auto-rows: minmax(26px, 1fr);
                gap: 3px; max-height: 260px; overflow-y: auto; padding: 2px;
                scrollbar-gutter: stable;
            }
            /* View Settings > Global > "Palette view" — list mode. Space-
               efficient rectangular rows instead of square tiles; same
               sort/filter pipeline (performFilterSort), just a different
               per-entry DOM shape (see gppPaletteApplyListLayout). Overrides
               the grid's own display:grid/grid-template-columns above. */
            .gpp-palette-grid.gpp-palette-list-mode {
                display: flex; flex-direction: column;
            }
            .gpp-swatch.gpp-swatch-list {
                aspect-ratio: auto; min-height: 26px; width: 100%;
                flex-direction: row; align-items: center; justify-content: flex-start;
                gap: 7px; padding: 3px 8px; border-radius: 6px;
                background: ${t2('#ffffff', '#181825')};
            }
            .gpp-swatch.gpp-swatch-list:hover { transform: none; background: ${t2('#f3f4f6', '#232336')}; }
            .gpp-palette-list-chip {
                width: 16px; height: 16px; border-radius: 4px; flex-shrink: 0;
                border: 1px solid ${t2('rgba(0,0,0,.28)', 'rgba(255,255,255,.28)')};
            }
            .gpp-palette-list-hex {
                font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11px; flex-shrink: 0;
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-palette-list-progress-text {
                font-size: 10px; margin-left: auto; flex-shrink: 0;
                color: ${t2('#64748b', '#a6adc8')};
            }
            .gpp-palette-list-bar-outer {
                width: 46px; height: 5px; border-radius: 3px; overflow: hidden; flex-shrink: 0;
                display: block; background: ${t2('#e5e7eb', '#313244')};
            }
            .gpp-palette-list-bar-fill { display: block; height: 100%; }
            .gpp-swatch {
                position: relative; aspect-ratio: 1 / 1; min-height: 15px; border-radius: 4px;
                border: 1px solid ${t2('rgba(0,0,0,.28)', 'rgba(255,255,255,.28)')};
                cursor: pointer; display: flex; align-items: center; justify-content: center;
                padding: 0; transition: transform .08s ease;
            }
            .gpp-swatch:hover { transform: scale(1.15); z-index: 2; }
            /* Grayscale/opacity dimming is gated behind the grid's own
               .gpp-palette-gray-disabled modifier class (toggled in
               performFilterSort from gppSettings.grayDisabledSwatches — a
               View Settings > Global checkbox, on by default) — the
               diagonal slash below is NOT gated by it and always applies
               regardless, per explicit product decision: "gray" is an
               optional extra emphasis on top of the slash, not a
               replacement for it. */
            .gpp-palette-gray-disabled .gpp-swatch.gpp-swatch-off { filter: grayscale(.7) opacity(.4); }
            /* Diagonal slash across the whole tile — the merged progress/
               enabled indicator alone wasn't visually distinct enough from
               "enabled but low progress" at a glance (explicit product
               decision); this is on top of the grayscale/opacity above when
               that's also on, not a replacement for it. */
            .gpp-swatch.gpp-swatch-off::after {
                content: ''; position: absolute; inset: 0; pointer-events: none;
                border-radius: inherit;
                background: linear-gradient(to top right,
                    transparent calc(50% - 1px), rgba(50,50,50,.75) calc(50% - 1px),
                    rgba(50,50,50,.75) calc(50% + 1px), transparent calc(50% + 1px));
            }
            /* Single top-right indicator — merges the old separate on/off dot
               and completion-progress badge into one (per explicit product
               decision). pointer-events:none so it never intercepts the
               swatch's own hover/click/drag handling. The off-state rule
               below layers extra grayscale/opacity on top of the whole-button
               graying above, so the SAME dot communicates both progress and
               enabled/disabled. */
            .gpp-swatch-progress {
                position: absolute; top: 3px; right: 3px; width: 7px; height: 7px;
                border-radius: 50%; pointer-events: none; box-sizing: border-box;
                display: flex; align-items: center; justify-content: center;
                font-size: 6px; line-height: 1; font-weight: 900; color: #16a34a;
                box-shadow: 0 0 0 1px rgba(0,0,0,.55);
            }
            .gpp-swatch-progress-complete {
                top: 0; right: 0; width: 100%; height: 100%;
                background-color: transparent; box-shadow: none;
                /* Two overlapping strokes of the same path: a wider black
                   one drawn first, then the theme green on top narrower —
                   leaves a thin black outline visible on both edges so the
                   checkmark stays legible on bright/light swatch colors
                   (explicit product request, after the earlier white
                   drop-shadow glow was removed). */
                background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4 13l5 5L20 6' fill='none' stroke='black' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M4 13l5 5L20 6' fill='none' stroke='${t2('%2316a34a', '%23a6e3a1')}' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
                background-repeat: no-repeat; background-position: center; background-size: 65% 65%;
            }
            .gpp-swatch-progress-unstarted { background: transparent; border: 1.5px solid #000; }
            .gpp-swatch-progress-inprogress { border: 1px solid rgba(0,0,0,.4); }
            .gpp-palette-gray-disabled .gpp-swatch.gpp-swatch-off .gpp-swatch-progress { filter: grayscale(1) opacity(.6); }
            /* Search-match glow — same pulse timing/box-shadow values as
               ghost-palette-search.js's .color-search-glow / pulse-glow, plus an
               added static white+black double ring so the gold glow itself
               stays visible no matter the swatch's own hue. */
            .gpp-swatch.gpp-palette-glow {
                animation: gpp-palette-pulse-glow 1.5s ease-in-out infinite;
                box-shadow: 0 0 0 2px rgba(255,255,255,.95), 0 0 0 4px rgba(0,0,0,.85), 0 0 8px 2px rgba(255,215,0,.8);
            }
            @keyframes gpp-palette-pulse-glow {
                0%, 100% { box-shadow: 0 0 0 2px rgba(255,255,255,.95), 0 0 0 4px rgba(0,0,0,.85), 0 0 8px 2px rgba(255,215,0,.8); }
                50% { box-shadow: 0 0 0 2px rgba(255,255,255,.95), 0 0 0 4px rgba(0,0,0,.85), 0 0 12px 3px rgba(255,215,0,1); }
            }
        `;
    }

    // gpp-scan.js's scanSummary carries per-colour breakdown as an array
    // (`perColour: [{index, correct, ...}, ...]`, one entry per palette
    // index that has any pixels — see that file's header comment), not an
    // object/array indexable by palette index directly. Building this
    // lookup once per performFilterSort() pass (rather than re-scanning
    // perColour per swatch) keeps sort/filter O(n) instead of O(n²).
    function gppPaletteBuildColourLookup(template) {
        const lookup = new Map();
        if (template.scanSummary) {
            template.scanSummary.perColour.forEach(entry => lookup.set(entry.index, entry));
        }
        return lookup;
    }

    // Per-swatch stats used by both sort and filter. `hasProgress` gates the
    // "remaining"/completion-based sort options and filter checkboxes exactly
    // like ghost-palette-search.js's syncProgressDependentControls() (see
    // that file's hasProgressStats()); here the gate is per-template (a
    // scanSummary either exists for the whole template or it doesn't) rather
    // than per-swatch-title-string. `colourLookup` is gppPaletteBuildColourLookup's
    // Map, keyed by palette index -> perColour entry; omit for a one-off call
    // (falls back to no progress data, matching the no-scan state).
    function gppPaletteStats(template, index, colourLookup) {
        const total = template.counts[index] || 0;
        const summary = template.scanSummary;
        const entry = colourLookup ? colourLookup.get(index) : null;
        const completed = summary && entry ? entry.correct : 0;
        const remaining = summary ? Math.max(0, total - completed) : 0;
        const remainingPercent = total > 0 ? (remaining / total) * 100 : 0;
        return { total, completed, remaining, remainingPercent };
    }

    // Interpolates red (0% complete) -> green (100% complete) for the
    // in-progress swatch badge, per the user's explicit "red-to-green
    // gradient shading" request.
    function gppPaletteProgressColor(fraction) {
        const clamped = Math.max(0, Math.min(1, fraction));
        const from = [220, 38, 38];   // tailwind red-600
        const to = [34, 197, 94];     // tailwind green-500
        const rgb = from.map((c, i) => Math.round(c + (to[i] - c) * clamped));
        return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
    }

    // ── "Sync with selected color" Ghost++ compatibility ─────────────────
    // The legacy Ghost Palette Color Search tool's own ♻️ "Sync Ghost With
    // Selected Color" button (ghost-palette-search.js, under
    // #imageGroupDropdown — deliberately NOT duplicated as a second button
    // inside Ghost++'s own panel, per explicit product decision: same
    // button, same toggle, same location as before Ghost++ existed) works
    // by directly manipulating the NATIVE #ghostColorPalette DOM. That
    // element is only ever (re)built by the native populateColorPaletteUI(),
    // which only runs from the native "load a ghost image" flow
    // (loadGhostImageBtn click / ghostImageInput change — see
    // js/ghost22.js). gpp-native-shim.js deliberately intercepts and blocks
    // both of those while Ghost++ owns the overlay slot
    // (GPP_NATIVE_CONTROL_TARGETS), so #ghostColorPalette never gets
    // populated for whatever template Ghost++ has focused — the legacy
    // toggle would otherwise silently find zero swatches to act on.
    //
    // This is the compatibility seam: ghost-palette-search.js's own
    // applyAutoEnableSelectedColor calls this FIRST and skips its native-DOM
    // path entirely whenever it returns true (Ghost++ has a template
    // focused) — see that file's own updated comment. No enabled/disabled
    // state of its own here; the legacy button's existing _syncGhostEnabled
    // toggle is still the only ON/OFF switch, exactly as before.
    function gppApplySelectedColorToFocusedTemplate(color) {
        if (typeof gppState === 'undefined' || typeof color !== 'string' || !color) return false;
        const template = gppState.getFocusedTemplate();
        if (!template || !template.palette || !template.palette.length) return false;
        // Native paint-color values are 8-digit RGBA hex strings, e.g.
        // "#FF0000FF" (Colors[index] — see js/index148.js's SetColors,
        // whose swatch buttons use the hex string itself as both their id
        // and their onclick's changeColor(color) argument). Ghost++'s own
        // palette hex (core.packedToHex) is 6-digit RGB with no alpha
        // channel — compare on just that leading portion, uppercased, same
        // normalization the legacy tool's own applyAutoEnableSelectedColor
        // already used.
        const hex = (color.length >= 7 ? color.slice(0, 7) : color).toUpperCase();
        const core = gppCreateCore();
        let changed = false;
        for (let index = 0; index < template.palette.length; index++) {
            const isMatch = core.packedToHex(template.palette[index]) === hex;
            if (core.maskHas(template.mask, index) !== isMatch) {
                core.maskSet(template.mask, index, isMatch);
                changed = true;
            }
        }
        if (changed) {
            gppState.persistTemplateState(template).catch(err => {
                console.error('[GeoPixelcons++] Ghost++ palette: failed to persist color-sync mask change.', err);
            });
            if (typeof gppRendererSchedule === 'function') gppRendererSchedule();
            if (typeof gppRequestUiRefresh === 'function') gppRequestUiRefresh();
        }
        return true; // Ghost++ handled this (a focused template exists) regardless of whether the mask actually changed
    }

    // List-mode row content, shared by buildSwatch/buildGroupSwatch — a
    // color chip (the tile's own background color moves here, since the
    // row itself needs a neutral background for the hex/progress text to
    // stay readable), the hex value, "<completed>/<total>" (the "x/y" the
    // list view was asked to show), and a mini progress bar. The per-swatch
    // corner completion badge (gpp-swatch-progress) is grid-mode only —
    // this row's own progress text/bar already conveys the same thing,
    // spelled out instead of iconified.
    function gppPaletteApplyListLayout(button, hex, stats, hasProgress) {
        const chip = document.createElement('span');
        chip.className = 'gpp-palette-list-chip';
        chip.style.backgroundColor = hex;
        chip.setAttribute('aria-hidden', 'true');

        const hexLabel = document.createElement('span');
        hexLabel.className = 'gpp-palette-list-hex';
        hexLabel.textContent = hex;

        const progressText = document.createElement('span');
        progressText.className = 'gpp-palette-list-progress-text';
        progressText.textContent = hasProgress
            ? `${stats.completed.toLocaleString()}/${stats.total.toLocaleString()}`
            : `${stats.total.toLocaleString()} px`;

        const barOuter = document.createElement('span');
        barOuter.className = 'gpp-palette-list-bar-outer';
        if (hasProgress && stats.total > 0) {
            const fraction = Math.max(0, Math.min(1, stats.completed / stats.total));
            const barFill = document.createElement('span');
            barFill.className = 'gpp-palette-list-bar-fill';
            barFill.style.width = Math.round(fraction * 100) + '%';
            barFill.style.background = gppPaletteProgressColor(fraction);
            barOuter.appendChild(barFill);
        }

        button.append(chip, hexLabel, progressText, barOuter);
    }

    // ── Custom floating swatch tooltip (mouse-following) ────────────────
    // Replaces the native `title` attribute and the old shared "Hex:"
    // readout row — hovering a swatch shows hex + completed/remaining right
    // next to the cursor instead, per explicit product decision ("make it
    // feel much more responsive and interactive"). Singleton element, built
    // lazily on first hover and reused for every swatch afterward, same
    // pattern as gpp-library.js's floating hover preview.
    let gppPaletteTooltipEl = null;
    function gppPaletteEnsureTooltipEl() {
        if (gppPaletteTooltipEl && document.body.contains(gppPaletteTooltipEl)) return gppPaletteTooltipEl;
        const el = document.createElement('div');
        el.id = 'gpp-palette-tooltip';
        document.body.appendChild(el);
        gppPaletteTooltipEl = el;
        return el;
    }
    function gppPaletteShowTooltip(event, hex, stats, hasProgress) {
        const el = gppPaletteEnsureTooltipEl();
        const hexLine = document.createElement('div');
        hexLine.className = 'gpp-palette-tooltip-hex';
        const swatchDot = document.createElement('span');
        swatchDot.className = 'gpp-palette-tooltip-swatch';
        swatchDot.style.backgroundColor = hex;
        hexLine.append(swatchDot, document.createTextNode(hex));
        const statsLine = document.createElement('div');
        statsLine.className = 'gpp-palette-tooltip-stats';
        statsLine.textContent = hasProgress
            ? `${stats.completed.toLocaleString()} / ${stats.total.toLocaleString()} placed`
            : `${stats.total.toLocaleString()} px`;
        el.innerHTML = '';
        el.append(hexLine, statsLine);
        el.style.display = 'block';
        gppPaletteMoveTooltip(event);
    }
    function gppPaletteMoveTooltip(event) {
        const el = gppPaletteTooltipEl;
        if (!el || el.style.display === 'none') return;
        const margin = 14;
        let left = event.clientX + margin;
        let top = event.clientY + margin;
        const rect = el.getBoundingClientRect();
        if (left + rect.width > window.innerWidth) left = event.clientX - rect.width - margin;
        if (top + rect.height > window.innerHeight) top = event.clientY - rect.height - margin;
        el.style.left = Math.max(4, left) + 'px';
        el.style.top = Math.max(4, top) + 'px';
    }
    function gppPaletteHideTooltip() {
        if (gppPaletteTooltipEl) gppPaletteTooltipEl.style.display = 'none';
    }

    // container element -> live controller. A WeakMap needs no manual
    // cleanup: when gpp-init.js's renderShell() rebuilds the left panel on
    // every modal open, the old container (and its controller) are simply
    // garbage-collected.
    const gppPaletteControllers = new WeakMap();

    function gppRenderPalette(container, template, onChange) {
        if (!container) return;
        gppInjectPaletteStyle();
        const templateKey = template ? template.id : null;
        let controller = gppPaletteControllers.get(container);
        if (!controller || controller.templateKey !== templateKey) {
            controller = gppCreatePaletteController(container);
            controller.templateKey = templateKey;
            gppPaletteControllers.set(container, controller);
        }
        controller.update(template, onChange);
    }

    function gppCreatePaletteController(container) {
        const core = gppCreateCore();

        // Preserve open/closed state across a template switch (which rebuilds
        // this whole controller from scratch, unlike an ordinary same-
        // template refresh) — same pattern as every other gpp-init.js
        // section's <details>. Defaults to open on the very first render.
        const previousDetails = container.querySelector('details.gpp-collapsible');
        const wasOpen = previousDetails ? previousDetails.open : true;
        // Separate id (not just `details.gpp-collapsible`, which would match
        // the Template Colors details above instead) since this is a SECOND
        // collapsible in the same container — defaults to collapsed, unlike
        // every other section here.
        const previousGncDetails = container.querySelector('#gpp-palette-gnc-details');
        const gncWasOpen = previousGncDetails ? previousGncDetails.open : false;
        container.innerHTML = '';

        const controller = { template: null, onChange: null, templateKey: undefined };

        const details = document.createElement('details');
        details.className = 'gpp-collapsible';
        details.open = wasOpen;
        const summary = document.createElement('summary');
        summary.textContent = 'Template Colors';
        details.appendChild(summary);
        const detailsBody = document.createElement('div');
        detailsBody.className = 'gpp-body';
        details.appendChild(detailsBody);
        container.appendChild(details);

        // Group Noise Changes — only shown when template.groupNoise is on
        // AND it actually merged 2+ colours together (see
        // updateGroupNoiseChanges below, called from performFilterSort right
        // after groupNoiseData is computed/refreshed). Hidden via
        // display:none rather than left out of the DOM entirely so its own
        // open/closed toggle survives being hidden and re-shown.
        const gncDetails = document.createElement('details');
        gncDetails.id = 'gpp-palette-gnc-details';
        gncDetails.className = 'gpp-collapsible';
        gncDetails.open = gncWasOpen;
        gncDetails.style.display = 'none';
        const gncSummary = document.createElement('summary');
        gncSummary.textContent = 'Group Noise Changes';
        gncDetails.appendChild(gncSummary);
        const gncBody = document.createElement('div');
        gncBody.className = 'gpp-body';
        gncDetails.appendChild(gncBody);
        container.appendChild(gncDetails);

        // Left empty (no text) when no template is focused — the drop zone
        // above already makes the next step obvious.
        const emptyEl = document.createElement('div');
        emptyEl.className = 'gpp-palette-empty';
        detailsBody.appendChild(emptyEl);

        const panel = document.createElement('div');
        panel.className = 'gpp-palette-panel';
        panel.style.display = 'none';
        detailsBody.appendChild(panel);

        // ── Search box + "show matches at top" ──────────────────────────
        const searchInput = document.createElement('input');
        searchInput.type = 'text';
        searchInput.className = 'gpp-palette-search-input';
        searchInput.placeholder = 'Search hex (comma/space separated for multiple)';
        panel.appendChild(searchInput);

        // ── Bulk actions: three rows, per explicit product decision — each
        // pair grouped by what it acts on (everything / a personal subset /
        // your in-game palette), with self-explanatory labels instead of
        // the old bare "All"/"None"/"Owned".
        const bulkRowTop = document.createElement('div');
        bulkRowTop.className = 'gpp-palette-bulk-row';
        const allBtn = document.createElement('button');
        allBtn.type = 'button'; allBtn.textContent = 'Enable all'; allBtn.title = 'Enable every color';
        const noneBtn = document.createElement('button');
        noneBtn.type = 'button'; noneBtn.textContent = 'Disable all'; noneBtn.title = 'Disable every color';

        const bulkRowMiddle = document.createElement('div');
        bulkRowMiddle.className = 'gpp-palette-bulk-row';
        const ownedBtn = document.createElement('button');
        ownedBtn.type = 'button'; ownedBtn.textContent = 'Enable owned'; ownedBtn.title = 'Enable only colors you own';
        const enableFilteredBtn = document.createElement('button');
        enableFilteredBtn.type = 'button'; enableFilteredBtn.textContent = 'Enable filtered';
        enableFilteredBtn.title = 'Enable only colors currently matching the search box / filter checkboxes below, and disable the rest';

        const bulkRowBottom = document.createElement('div');
        bulkRowBottom.className = 'gpp-palette-bulk-row';
        const activeBtn = document.createElement('button');
        activeBtn.type = 'button'; activeBtn.textContent = 'Match palette'; activeBtn.title = 'Enable only colors currently active in your game palette';
        // Calls the native setGhostColorsAsActivePalette() directly — it
        // already reads ghostActivePaletteColors, which Ghost++'s
        // compatibility shim (gpp-native-shim.js) keeps mirrored to this
        // template's own enabled colors, so no reimplementation is needed
        // here. A plain function DECLARATION in ghost22.js (unlike a `let`
        // binding), so it attaches to window normally and unsafeWindow can
        // call it directly — no page-realm injection needed.
        const setPaletteBtn = document.createElement('button');
        setPaletteBtn.type = 'button';
        setPaletteBtn.textContent = 'Set palette';
        setPaletteBtn.title = "Set your in-game active palette to exactly this template's enabled colors (same as the native ghost tool's own \"Set Palette\")";
        setPaletteBtn.addEventListener('click', () => {
            const target = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
            if (typeof target.setGhostColorsAsActivePalette === 'function') {
                target.setGhostColorsAsActivePalette();
            }
        });
        bulkRowTop.append(allBtn, noneBtn);
        bulkRowMiddle.append(ownedBtn, enableFilteredBtn);
        bulkRowBottom.append(activeBtn, setPaletteBtn);
        panel.append(bulkRowTop, bulkRowMiddle, bulkRowBottom);

        // No "Sync with selected color" button here — per explicit product
        // decision, that stays the legacy Ghost Palette Color Search tool's
        // own ♻️ button under #imageGroupDropdown (unchanged location), not
        // a duplicate control inside Ghost++'s own panel. See
        // gppApplySelectedColorToFocusedTemplate above for the compatibility
        // seam that makes that existing button work correctly against
        // Ghost++'s focused template.

        // Own row, below the three bulk-action rows above — per explicit
        // product decision, kept visually separate since it triggers a
        // purchase flow rather than just changing which colors are enabled.
        const buyRow = document.createElement('div');
        buyRow.className = 'gpp-palette-bulk-row gpp-palette-2col-row';
        const buyBtn = document.createElement('button');
        buyBtn.type = 'button'; buyBtn.textContent = 'Buy all colors';
        buyBtn.title = "Reveal the profile panel's Bulk Purchase Colors card, pre-filled with every color in this template you don't already own";
        buyRow.appendChild(buyBtn);

        // ── "Get hex values" dropdown ─────────────────────────────────────
        // Reuses the filter dropdown's own popup-menu classes/behaviour
        // (gpp-palette-filter-dropdown/-button/-menu/-option, .gpp-open) —
        // a generic small popup pattern already established in this file,
        // not specific to the filter checkboxes despite the class names.
        // "Filtered" means whatever the search box + filter checkboxes
        // currently leave visible — the exact same `visible` index list
        // performFilterSort() last computed into renderState.
        const hexDropdown = document.createElement('div');
        hexDropdown.className = 'gpp-palette-filter-dropdown';
        const hexButton = document.createElement('button');
        hexButton.type = 'button';
        hexButton.className = 'gpp-palette-filter-button';
        const hexButtonText = document.createElement('span');
        hexButtonText.textContent = 'Get hex values';
        const hexArrow = document.createElement('span');
        hexArrow.textContent = '▾';
        hexArrow.style.cssText = 'font-size:10px;opacity:.7;';
        hexButton.append(hexButtonText, hexArrow);

        const hexMenu = document.createElement('div');
        hexMenu.className = 'gpp-palette-filter-menu';
        const GPP_HEX_VALUE_SCOPES = [
            { value: 'all', text: 'All colors' },
            { value: 'owned', text: 'Owned colors only' },
            { value: 'notOwned', text: 'Not owned colors only' },
            { value: 'enabled', text: 'Enabled colors only' },
            { value: 'enabledOwned', text: 'Enabled + owned colors' },
            { value: 'filtered', text: 'Filtered colors only' },
            { value: 'filteredOwned', text: 'Filtered + owned colors only' },
        ];

        function copyHexValuesForScope(scope) {
            const template = controller.template;
            if (!template) return;
            const ownedHex = new Set(gppReadGamePalette().map(row => String(row.hex).toUpperCase()));
            const filteredSet = new Set(renderState.visible || []);
            const hexes = [];
            for (let index = 0; index < template.palette.length; index++) {
                const hex = core.packedToHex(template.palette[index]);
                const isOwned = ownedHex.has(hex);
                const isEnabled = core.maskHas(template.mask, index);
                const isFiltered = filteredSet.has(index);
                let include;
                switch (scope) {
                    case 'all': include = true; break;
                    case 'owned': include = isOwned; break;
                    case 'notOwned': include = !isOwned; break;
                    case 'enabled': include = isEnabled; break;
                    case 'enabledOwned': include = isEnabled && isOwned; break;
                    case 'filtered': include = isFiltered; break;
                    case 'filteredOwned': include = isFiltered && isOwned; break;
                    default: include = false;
                }
                if (include) hexes.push(hex);
            }
            const text = hexes.join(', ');
            const flashCopied = () => {
                hexButtonText.textContent = hexes.length ? `Copied ${hexes.length}!` : 'Nothing to copy';
                setTimeout(() => { hexButtonText.textContent = 'Get hex values'; }, 1200);
            };
            if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText(text).then(flashCopied).catch(() => alert(text || 'No matching colors.'));
            } else {
                alert(text || 'No matching colors.');
            }
        }

        GPP_HEX_VALUE_SCOPES.forEach(({ value, text }) => {
            const option = document.createElement('div');
            option.className = 'gpp-palette-filter-option';
            option.textContent = text;
            option.addEventListener('click', () => {
                hexMenu.classList.remove('gpp-open');
                copyHexValuesForScope(value);
            });
            hexMenu.appendChild(option);
        });
        hexButton.addEventListener('click', event => {
            event.stopPropagation();
            hexMenu.classList.toggle('gpp-open');
        });
        hexMenu.addEventListener('click', event => event.stopPropagation());
        document.addEventListener('click', () => hexMenu.classList.remove('gpp-open'));
        hexDropdown.append(hexButton, hexMenu);
        buyRow.appendChild(hexDropdown);

        panel.appendChild(buyRow);

        // ── Filters dropdown ──────────────────────────────────────────────
        const controlsRow = document.createElement('div');
        controlsRow.className = 'gpp-palette-controls-row gpp-palette-2col-row';

        const filterDropdown = document.createElement('div');
        filterDropdown.className = 'gpp-palette-filter-dropdown';
        const filterButton = document.createElement('button');
        filterButton.type = 'button';
        filterButton.className = 'gpp-palette-filter-button';
        const filterButtonText = document.createElement('span');
        filterButtonText.textContent = 'Filters';
        const filterArrow = document.createElement('span');
        filterArrow.textContent = '▾';
        filterArrow.style.cssText = 'font-size:10px;opacity:.7;';
        filterButton.append(filterButtonText, filterArrow);

        const filterMenu = document.createElement('div');
        filterMenu.className = 'gpp-palette-filter-menu';

        const filterInputs = [];
        let countMinInput = null;
        let countMaxInput = null;
        GPP_PALETTE_FILTER_OPTIONS.forEach(({ value, text }) => {
            const label = document.createElement('label');
            label.className = 'gpp-palette-filter-option';
            const input = document.createElement('input');
            input.type = 'checkbox';
            input.value = value;
            input.addEventListener('change', () => {
                updateFilterButtonLabel();
                performFilterSort();
            });
            filterInputs.push({ input, label, value });
            const span = document.createElement('span');
            span.textContent = text;
            label.append(input, span);
            filterMenu.appendChild(label);
        });

        const countSubRow = document.createElement('div');
        countSubRow.className = 'gpp-palette-filter-count';
        countSubRow.style.display = 'none';
        countMinInput = document.createElement('input');
        countMinInput.type = 'number'; countMinInput.min = '0'; countMinInput.placeholder = 'min';
        countMinInput.className = 'gpp-palette-count-input';
        countMaxInput = document.createElement('input');
        countMaxInput.type = 'number'; countMaxInput.min = '0'; countMaxInput.placeholder = 'max';
        countMaxInput.className = 'gpp-palette-count-input';
        const countDash = document.createElement('span');
        countDash.textContent = '–'; countDash.style.opacity = '.6';
        [countMinInput, countMaxInput].forEach(input => input.addEventListener('input', () => performFilterSort()));
        countSubRow.append(countMinInput, countDash, countMaxInput);
        filterMenu.appendChild(countSubRow);

        const countRangeEntry = filterInputs.find(entry => entry.value === 'countRange');
        if (countRangeEntry) {
            countRangeEntry.input.addEventListener('change', () => {
                countSubRow.style.display = countRangeEntry.input.checked ? '' : 'none';
                if (!countRangeEntry.input.checked) {
                    countMinInput.value = '';
                    countMaxInput.value = '';
                }
            });
        }

        function updateFilterButtonLabel() {
            const n = filterInputs.filter(entry => entry.input.checked).length;
            filterButtonText.textContent = n ? `Filters (${n})` : 'Filters';
        }

        filterButton.addEventListener('click', event => {
            event.stopPropagation();
            filterMenu.classList.toggle('gpp-open');
        });
        filterMenu.addEventListener('click', event => event.stopPropagation());
        document.addEventListener('click', () => filterMenu.classList.remove('gpp-open'));

        filterDropdown.append(filterButton, filterMenu);

        // ── Sort dropdown ───────────────────────────────────────────────
        const sortWrap = document.createElement('div');
        sortWrap.className = 'gpp-palette-sort-wrap';
        const sortSelect = document.createElement('select');
        sortSelect.className = 'gpp-palette-sort-select';
        sortSelect.title = 'Scroll while hovering to change sort order';
        GPP_PALETTE_SORT_OPTIONS.forEach(({ value, text }) => {
            const opt = document.createElement('option');
            opt.value = value;
            opt.textContent = text;
            sortSelect.appendChild(opt);
        });
        sortSelect.addEventListener('change', () => performFilterSort());
        sortSelect.addEventListener('wheel', event => {
            event.preventDefault();
            const dir = event.deltaY > 0 ? 1 : -1;
            let next = sortSelect.selectedIndex + dir;
            while (next >= 0 && next < sortSelect.options.length && sortSelect.options[next].disabled) next += dir;
            next = Math.min(Math.max(next, 0), sortSelect.options.length - 1);
            if (next !== sortSelect.selectedIndex) {
                sortSelect.selectedIndex = next;
                performFilterSort();
            }
        }, { passive: false });
        sortWrap.appendChild(sortSelect);

        // Sort on the left, Filters on the right — per explicit product decision.
        controlsRow.append(sortWrap, filterDropdown);
        panel.appendChild(controlsRow);

        // ── Grid ─────────────────────────────────────────────────────────
        const grid = document.createElement('div');
        grid.className = 'gpp-palette-grid';
        panel.appendChild(grid);

        // Progressive rendering: an initial batch, then more appended as the
        // user scrolls near the bottom — no hard cap + "narrow your search"
        // message. `renderState` tracks the full sorted/filtered index list
        // for the CURRENT search/filter/sort pass so scrolling can keep
        // appending from it without recomputing the whole pass.
        const GPP_PALETTE_BATCH_SIZE = 200;
        let renderState = { visible: [], visibleGroups: null, matching: null, colourLookup: null, shownCount: 0 };

        function renderNextBatch() {
            const { visible, visibleGroups, matching, colourLookup } = renderState;
            const renderList = visibleGroups || visible;
            const groupNoiseData = visibleGroups ? controller.template.groupNoiseData : null;
            let start = renderState.shownCount;
            for (;;) {
                const end = Math.min(renderList.length, start + GPP_PALETTE_BATCH_SIZE);
                for (let i = start; i < end; i++) {
                    const entry = renderList[i];
                    grid.appendChild(
                        visibleGroups
                            ? buildGroupSwatch(controller.template, entry, groupNoiseData, matching, colourLookup)
                            : buildSwatch(controller.template, entry, matching.has(entry), colourLookup)
                    );
                }
                renderState.shownCount = end;
                // BUG FIX: on a wide left panel, the CSS grid can fit 200+
                // swatches per row without ever overflowing max-height, so
                // grid.scrollHeight never exceeds grid.clientHeight, the
                // 'scroll' listener below never fires, and this function
                // would otherwise never get called again — users get stuck
                // seeing only the first batch forever. If the grid still
                // isn't scrollable and more items remain, keep rendering
                // batches immediately instead of waiting for a scroll that
                // will never come. `end === start` (a batch made no
                // progress) always stops the loop, so this is bounded by
                // renderList.length / GPP_PALETTE_BATCH_SIZE either way.
                if (end === start || renderState.shownCount >= renderList.length || grid.scrollHeight > grid.clientHeight) break;
                start = end;
            }
        }

        grid.addEventListener('scroll', () => {
            const total = (renderState.visibleGroups || renderState.visible).length;
            if (renderState.shownCount >= total) return;
            // Within ~2 batches' worth of scroll from the bottom, load more.
            if (grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 400) renderNextBatch();
        });

        // ── Drag-to-toggle: hold LEFT + drag to enable every swatch passed
        // over, hold RIGHT + drag to disable — mirrors the drag-paint
        // interaction common in palette/selection UIs. Only engages once the
        // pointer actually leaves the swatch it was pressed on (see the
        // mousemove listener below), so a plain click/right-click keeps
        // behaving exactly like before (click handler in buildSwatch / native
        // context menu) — no double-toggle, no menu suppressed on a
        // stationary right-click.
        let dragPaint = null; // { button, originEl, visited: Set<index> } while a press might turn into a drag
        let suppressNextClick = false;
        // Tracks where the GESTURE started, not where the eventual contextmenu
        // event's target ends up — a real drag routinely finishes just past
        // the grid's tight bounding box, and a target-based check misses that.
        // Safety-timer-cleared so a contextmenu event that never fires (for
        // any reason) can't leave unrelated right-clicks elsewhere on the
        // page permanently suppressed.
        let rightMouseDownInGrid = false;
        let rightMouseDownSafetyTimer = null;

        function setSwatchMaskState(swatchEl, hex, nowEnabled) {
            swatchEl.classList.toggle('gpp-swatch-off', !nowEnabled);
            swatchEl.setAttribute('aria-pressed', String(nowEnabled));
            swatchEl.setAttribute('aria-label', `${nowEnabled ? 'Hide' : 'Show'} ${hex}`);
            const currentTemplate = controller.template;
            if (currentTemplate) {
                gppState.persistTemplateState(currentTemplate).catch(err => {
                    console.error('[GeoPixelcons++] Ghost++ palette: failed to persist template state', err);
                });
            }
            if (typeof gppRendererSchedule === 'function') gppRendererSchedule();
            if (typeof controller.onChange === 'function') controller.onChange();
        }

        // Reused by both the drag gesture and buildSwatch's click handler so
        // there's one shared toggle+persist call site — gated per
        // dragPaint.visited so a swatch already painted this gesture is
        // never re-toggled on a repeated pass.
        function applyDragPaintTo(swatchEl) {
            if (!dragPaint || !swatchEl) return;
            const index = Number(swatchEl.dataset.colorIndex);
            if (Number.isNaN(index) || dragPaint.visited.has(index)) return;
            dragPaint.visited.add(index);
            const template = controller.template;
            if (!template) return;
            const targetEnabled = dragPaint.button === 0; // left drag enables, right drag disables
            // Group Noise: a grouped swatch carries every member index it
            // represents (see buildGroupSwatch) — drag-paint the whole group
            // together, same as a click, instead of just its representative.
            const memberIndices = swatchEl.dataset.groupMembers
                ? swatchEl.dataset.groupMembers.split(',').map(Number)
                : [index];
            if (memberIndices.every(i => core.maskHas(template.mask, i) === targetEnabled)) return; // already at target state
            memberIndices.forEach(i => core.maskSet(template.mask, i, targetEnabled));
            setSwatchMaskState(swatchEl, core.packedToHex(template.palette[index]), targetEnabled);
        }

        grid.addEventListener('mousedown', event => {
            if (event.button !== 0 && event.button !== 2) return;
            const swatchEl = event.target.closest && event.target.closest('.gpp-swatch');
            if (!swatchEl || !controller.template) return;
            if (event.button === 2) {
                rightMouseDownInGrid = true;
                clearTimeout(rightMouseDownSafetyTimer);
                rightMouseDownSafetyTimer = setTimeout(() => { rightMouseDownInGrid = false; }, 2000);
            }
            dragPaint = { button: event.button, originEl: swatchEl, visited: new Set() };
        });

        grid.addEventListener('mousemove', event => {
            if (!dragPaint) return;
            const swatchEl = event.target.closest && event.target.closest('.gpp-swatch');
            if (!swatchEl) return;
            if (dragPaint.visited.size === 0 && swatchEl !== dragPaint.originEl) {
                // Pointer has actually left the origin swatch — this is a
                // real drag, not a plain click. Paint the origin too (it was
                // "passed over" as part of the gesture) and stop the click
                // that fires on release from acting on top of it.
                if (dragPaint.button === 0) suppressNextClick = true;
                applyDragPaintTo(dragPaint.originEl);
            }
            applyDragPaintTo(swatchEl);
        });

        // Listen on document, not just grid, so a release outside the grid
        // bounds (dragged past its edge) still ends the gesture.
        document.addEventListener('mouseup', () => { dragPaint = null; });

        // Unconditionally suppress the native context menu for any right-
        // click gesture that started on a swatch — mirrors
        // paint-brush-swap.js's own grid.addEventListener('contextmenu', e
        // => e.preventDefault()): right-click is entirely repurposed here
        // (drag-to-disable), so there's no legitimate native-menu use case
        // to preserve, whether or not the press actually turned into a
        // drag. Scoped to document (not grid) and keyed off where the
        // GESTURE STARTED (rightMouseDownInGrid), not where the contextmenu
        // event's target ends up, since a real drag routinely finishes just
        // past the grid's tight bounding box.
        document.addEventListener('contextmenu', event => {
            if (rightMouseDownInGrid) {
                rightMouseDownInGrid = false;
                clearTimeout(rightMouseDownSafetyTimer);
                event.preventDefault();
            }
        });

        searchInput.addEventListener('input', () => performFilterSort());

        // ── Bulk action wiring ─────────────────────────────────────────
        function persistAndNotify(template) {
            gppState.persistTemplateState(template).catch(err => {
                console.error('[GeoPixelcons++] Ghost++ palette: failed to persist template state', err);
            });
            if (typeof gppRendererSchedule === 'function') gppRendererSchedule();
            performFilterSort();
            if (typeof controller.onChange === 'function') controller.onChange();
        }

        allBtn.addEventListener('click', () => {
            const template = controller.template;
            if (!template) return;
            template.mask = core.makeFullMask(template.palette.length, template.counts);
            persistAndNotify(template);
        });
        noneBtn.addEventListener('click', () => {
            const template = controller.template;
            if (!template) return;
            template.mask = new Uint32Array(Math.ceil(template.palette.length / 32));
            persistAndNotify(template);
        });
        function applyGamePaletteMask(activeOnly) {
            const template = controller.template;
            if (!template) return;
            const rows = gppReadGamePalette();
            const allowedHex = new Set();
            rows.forEach(row => {
                if (!row || (activeOnly && !row.active)) return;
                if (row.hex) allowedHex.add(String(row.hex).toUpperCase());
            });
            const mask = new Uint32Array(Math.ceil(template.palette.length / 32));
            for (let index = 0; index < template.palette.length; index++) {
                if (allowedHex.has(core.packedToHex(template.palette[index]))) core.maskSet(mask, index, true);
            }
            template.mask = mask;
            persistAndNotify(template);
        }
        activeBtn.addEventListener('click', () => applyGamePaletteMask(true));
        ownedBtn.addEventListener('click', () => applyGamePaletteMask(false));
        // "Enable filtered" — same meaning as the "Filtered colors only"
        // scope in the Get hex values dropdown above: whatever the search
        // box + filter checkboxes currently leave visible, per
        // performFilterSort()'s own renderState.visible (the raw, ungrouped
        // index list — even with Group Noise on, this replaces every
        // member's own mask bit individually, matching how All/None/Owned
        // above already operate on raw indices rather than group
        // representatives).
        enableFilteredBtn.addEventListener('click', () => {
            const template = controller.template;
            if (!template) return;
            // renderState.visible alone is NOT enough here: typing a search
            // term without ALSO checking "Show search results only"
            // (hideUnmatched) only sorts/glows matches by default — it does
            // not exclude non-matches from `visible` (see
            // performFilterSort's own isUnmatched handling). A user who
            // just typed a search and clicks a button literally called
            // "Enable filtered" expects it to act on those matches, not
            // require separately finding and checking a hidden checkbox
            // first — so an active search term is treated as its own
            // exclusion criterion here regardless of that checkbox's real
            // state, while everything else `visible` already excludes
            // (hideCompleted/hideInProgress/hideUnstarted/ownedOnly/
            // countRange, all independent of hideUnmatched) is still
            // respected as-is.
            const hasActiveSearch = searchInput.value.trim().length > 0;
            const matchingSet = renderState.matching || new Set();
            const mask = new Uint32Array(Math.ceil(template.palette.length / 32));
            (renderState.visible || []).forEach(index => {
                if (hasActiveSearch && !matchingSet.has(index)) return;
                core.maskSet(mask, index, true);
            });
            template.mask = mask;
            persistAndNotify(template);
        });
        buyBtn.addEventListener('click', () => {
            const template = controller.template;
            if (!template) return;
            if (typeof gppBulkPurchaseOpenProfilePanel !== 'function') {
                alert('Bulk Purchase Colors is disabled in GeoPixelcons++ settings.');
                return;
            }
            const ownedHex = new Set(gppReadGamePalette().map(row => String(row.hex).toUpperCase()));
            const seen = new Set();
            const needed = [];
            for (let index = 0; index < template.palette.length; index++) {
                const hex = core.packedToHex(template.palette[index]);
                if (ownedHex.has(hex) || seen.has(hex)) continue;
                seen.add(hex);
                needed.push(hex);
            }
            if (!needed.length) {
                alert('Every color in this template is already owned.');
                return;
            }
            // Reveals the profile panel's Bulk Purchase Colors card and
            // autopopulates its textarea — mirrors the legacy native-ghost-
            // menu "Bulk Purchase Colors" button's own flow (see
            // bulk-purchase-colors.js's handlePurchaseUnowned), per explicit
            // product decision, rather than opening the Bulk Purchase
            // Preview modal directly.
            gppBulkPurchaseOpenProfilePanel(needed);
        });

        // ── Progress-gated control availability (mirrors
        // ghost-palette-search.js's syncProgressDependentControls) ───────
        function syncProgressGatedControls() {
            const template = controller.template;
            const hasProgress = !!(template && template.scanSummary);
            // The completed/in-progress/unstarted filter checkboxes stay
            // always visible/enabled, even before a scan has run: pre-scan,
            // the correct assumption is 0 pixels placed for every color, so
            // the (possibly inaccurate-until-scanned) filter results are
            // still acceptable and expected — see performFilterSort's
            // isCompleted/isInProgress/isUnstarted, which no longer gate on
            // hasProgress either.
            Array.from(sortSelect.options).forEach(opt => {
                const needsProgress = GPP_PALETTE_PROGRESS_SORT_VALUES.has(opt.value);
                opt.hidden = needsProgress && !hasProgress;
                opt.disabled = needsProgress && !hasProgress;
            });
            if (!hasProgress && GPP_PALETTE_PROGRESS_SORT_VALUES.has(sortSelect.value)) sortSelect.value = 'default';
            updateFilterButtonLabel();
            return hasProgress;
        }

        // ── Group Noise Changes ────────────────────────────────────────────
        // Shown only when template.groupNoise is on AND grouping actually
        // reduced the colour count (groups.length < palette.length means at
        // least one group has 2+ members) — per explicit product decision,
        // this section should be entirely absent otherwise, not just empty.
        function updateGroupNoiseChanges(template) {
            const data = template.groupNoiseData;
            const merged = data ? data.groups.filter(g => g.memberIndices.length > 1) : [];
            if (!template.groupNoise || !merged.length) {
                gncDetails.style.display = 'none';
                return;
            }
            gncDetails.style.display = '';
            gncBody.innerHTML = '';
            // Largest groups (most colours merged together) first — the ones
            // most worth double-checking sit at the top instead of wherever
            // they happened to land in palette order.
            merged.sort((a, b) => b.memberIndices.length - a.memberIndices.length);
            merged.forEach(group => {
                const repHex = core.packedToHex(template.palette[group.representativeIndex]);
                const totalPx = group.memberIndices.reduce((sum, i) => sum + (template.counts[i] || 0), 0);

                const groupEl = document.createElement('div');
                groupEl.className = 'gpp-gnc-group';

                const heading = document.createElement('div');
                heading.className = 'gpp-gnc-heading';
                const headingSwatch = document.createElement('span');
                headingSwatch.className = 'gpp-gnc-swatch';
                headingSwatch.style.backgroundColor = repHex;
                const headingText = document.createElement('span');
                headingText.innerHTML = `<span class="gpp-gnc-hex">${repHex}</span> <span class="gpp-gnc-meta">(${group.memberIndices.length} colors merged, ${totalPx.toLocaleString()} px total)</span>`;
                heading.append(headingSwatch, headingText);
                groupEl.appendChild(heading);

                const membersEl = document.createElement('div');
                membersEl.className = 'gpp-gnc-members';
                group.memberIndices.forEach(index => {
                    const hex = core.packedToHex(template.palette[index]);
                    const px = template.counts[index] || 0;
                    const isRep = index === group.representativeIndex;
                    const memberEl = document.createElement('div');
                    memberEl.className = 'gpp-gnc-member';
                    const swatch = document.createElement('span');
                    swatch.className = 'gpp-gnc-swatch';
                    swatch.style.backgroundColor = hex;
                    const label = document.createElement('span');
                    label.innerHTML = `<span class="gpp-gnc-hex">${hex}</span> — ${px.toLocaleString()} px` + (isRep ? ' <span class="gpp-gnc-kept">(kept)</span>' : '');
                    memberEl.append(swatch, label);
                    membersEl.appendChild(memberEl);
                });
                groupEl.appendChild(membersEl);
                gncBody.appendChild(groupEl);
            });
        }

        // ── Search / filter / sort / render ───────────────────────────────
        function performFilterSort() {
            const template = controller.template;
            if (!template) return;
            syncProgressGatedControls();
            // Read fresh every call (not just at grid-creation time) so
            // toggling the View Settings > Global "Gray unselected color
            // boxes" checkbox updates an already-open palette immediately,
            // not just on the next template switch — see gpp-palette.js's
            // own CSS comment on this class.
            grid.classList.toggle('gpp-palette-gray-disabled', gppSettings.grayDisabledSwatches !== false);
            // Same live-read-every-call pattern as the line above — toggling
            // View Settings > Global > "Palette view" updates an
            // already-open palette immediately, no template switch needed.
            grid.classList.toggle('gpp-palette-list-mode', gppSettings.paletteViewMode === 'list');

            const searchTerms = searchInput.value.trim().split(/[\s,]+/).map(t => t.toUpperCase()).filter(Boolean);
            const checked = new Set(filterInputs.filter(entry => entry.input.checked).map(entry => entry.value));
            const hideUnmatched = checked.has('hideUnmatched');
            const hideCompleted = checked.has('hideCompleted');
            const hideInProgress = checked.has('hideInProgress');
            const hideUnstarted = checked.has('hideUnstarted');
            const ownedOnly = checked.has('ownedOnly');
            const countRangeOn = checked.has('countRange');
            const minCount = (countRangeOn && countMinInput.value !== '') ? parseInt(countMinInput.value, 10) : NaN;
            const maxCount = (countRangeOn && countMaxInput.value !== '') ? parseInt(countMaxInput.value, 10) : NaN;
            const hasMin = !isNaN(minCount);
            const hasMax = !isNaN(maxCount);

            let ownedHexSet = null;
            if (ownedOnly) {
                ownedHexSet = new Set();
                gppReadGamePalette().forEach(row => { if (row && row.hex) ownedHexSet.add(String(row.hex).toUpperCase()); });
            }

            const count = template.palette.length;
            const matching = new Set();
            if (searchTerms.length) {
                for (let index = 0; index < count; index++) {
                    const hex = core.packedToHex(template.palette[index]);
                    if (searchTerms.some(term => hex.includes(term))) matching.add(index);
                }
            }

            const colourLookup = gppPaletteBuildColourLookup(template);
            const visible = [];
            for (let index = 0; index < count; index++) {
                const stats = gppPaletteStats(template, index, colourLookup);
                const isUnmatched = searchTerms.length > 0 && !matching.has(index);
                // Not gated on scanSummary: pre-scan, completed defaults to 0
                // for every color (gppPaletteStats), so these fall out to
                // "everything unstarted" — the correct assumption per
                // product decision, and exactly what makes these filters
                // usable before a scan has ever run.
                const isCompleted = stats.total > 0 && stats.completed >= stats.total;
                const isInProgress = stats.total > 0 && stats.completed > 0 && stats.completed < stats.total;
                const isUnstarted = stats.total > 0 && stats.completed === 0;
                const hex = core.packedToHex(template.palette[index]);
                const isUnowned = ownedOnly && ownedHexSet && !ownedHexSet.has(hex);
                const outOfRange = (hasMin || hasMax) && ((hasMin && stats.total < minCount) || (hasMax && stats.total > maxCount));
                if ((hideUnmatched && isUnmatched) || (hideCompleted && isCompleted) ||
                    (hideInProgress && isInProgress) || (hideUnstarted && isUnstarted) ||
                    isUnowned || outOfRange) continue;
                visible.push(index);
            }

            // Always on when there's an actual search with a hit — no
            // separate opt-in checkbox, per explicit product decision
            // (matches should always float to the top of the grid).
            const showMatchesAtTop = searchTerms.length > 0 && matching.size > 0;

            const sortValue = sortSelect.value;
            const cmp = (a, b) => {
                if (showMatchesAtTop) {
                    const ma = matching.has(a), mb = matching.has(b);
                    if (ma !== mb) return ma ? -1 : 1;
                }
                const sa = gppPaletteStats(template, a, colourLookup);
                const sb = gppPaletteStats(template, b, colourLookup);
                const tie = a - b;
                switch (sortValue) {
                    case 'default': return (sb.total - sa.total) || tie;
                    case 'leastUsed': return (sa.total - sb.total) || tie;
                    case 'mostRemaining': return (sb.remaining - sa.remaining) || tie;
                    case 'leastRemaining': return (sa.remaining - sb.remaining) || tie;
                    case 'mostPct': return (sb.remainingPercent - sa.remainingPercent) || tie;
                    case 'leastPct': return (sa.remainingPercent - sb.remainingPercent) || tie;
                    case 'byColor': {
                        const ha = core.packedToHex(template.palette[a]);
                        const hb = core.packedToHex(template.palette[b]);
                        return (ha < hb ? -1 : ha > hb ? 1 : 0) || tie;
                    }
                    case 'byColorRev': {
                        const ha = core.packedToHex(template.palette[a]);
                        const hb = core.packedToHex(template.palette[b]);
                        return (ha > hb ? -1 : ha < hb ? 1 : 0) || tie;
                    }
                    default: return tie;
                }
            };
            visible.sort(cmp);

            // Group Noise: collapse the already-filtered/sorted raw-index
            // list down to one entry per group (its representative),
            // keeping first-occurrence order so search-matches-at-top and
            // the chosen sort still determine which group appears first.
            // Filtering/sorting itself (above) stays entirely per-raw-index
            // and unchanged — this is purely a post-processing display step,
            // gated on template.groupNoise so a template with it off takes
            // the exact same code path as before this feature existed.
            let visibleGroups = null;
            if (template.groupNoise) {
                if (!template.groupNoiseData) template.groupNoiseData = core.groupPaletteColors(template.palette);
                const seen = new Set();
                visibleGroups = [];
                for (const index of visible) {
                    const rep = template.groupNoiseData.groupOfIndex[index];
                    if (seen.has(rep)) continue;
                    seen.add(rep);
                    visibleGroups.push(rep);
                }
            }
            updateGroupNoiseChanges(template);

            // Progressive rendering (see renderNextBatch/the grid scroll
            // listener above) instead of a hard cap + "narrow your search"
            // message — the first batch renders immediately, the rest loads
            // as the user scrolls down.
            const previousScrollTop = grid.scrollTop;
            grid.innerHTML = '';
            renderState = { visible, visibleGroups, matching, colourLookup, shownCount: 0 };
            renderNextBatch();
            // performFilterSort() runs on every refresh (e.g. a single swatch
            // toggle, via controller.update), which always rebuilds the grid
            // from scratch — collapsing it back down to however many batches
            // renderNextBatch()'s own eager-load loop decided were enough to
            // overflow the container, which can be far short of how many
            // batches the user had actually scrolled through. Restoring
            // scrollTop straight against that much shorter content gets
            // silently clamped by the browser, which reads as a jump toward
            // the top. Keep loading batches until there's enough rendered
            // height to actually contain the previous position (or nothing
            // left to load — e.g. a filter now genuinely excludes what used
            // to be there, in which case clamping is the correct behaviour).
            const renderTotal = (visibleGroups || visible).length;
            while (renderState.shownCount < renderTotal && grid.scrollHeight - grid.clientHeight < previousScrollTop) {
                renderNextBatch();
            }
            grid.scrollTop = previousScrollTop;
        }

        function buildSwatch(template, index, isMatch, colourLookup) {
            const packed = template.palette[index];
            const hex = core.packedToHex(packed);
            const enabled = core.maskHas(template.mask, index);
            const stats = gppPaletteStats(template, index, colourLookup);

            // Colour-only swatch — no hex text on the face; the hex/stats are
            // conveyed entirely via the custom mouse-following tooltip
            // (mouseenter/mousemove) rather than a native title attribute.
            const listMode = gppSettings.paletteViewMode === 'list';

            const button = document.createElement('button');
            button.type = 'button';
            button.className = 'gpp-swatch' + (enabled ? '' : ' gpp-swatch-off') + (isMatch ? ' gpp-palette-glow' : '') + (listMode ? ' gpp-swatch-list' : '');
            button.setAttribute('aria-pressed', String(enabled));
            button.setAttribute('aria-label', `${enabled ? 'Hide' : 'Show'} ${hex}`);
            button.dataset.colorIndex = String(index);

            const hasProgress = !!template.scanSummary;

            if (listMode) {
                gppPaletteApplyListLayout(button, hex, stats, hasProgress);
            } else {
                button.style.backgroundColor = hex;
                // Per-swatch completion badge — only shown once a scan has run
                // for this template (progress is otherwise unknown, not "0%").
                // complete: white circle + green check (explicit user request:
                // "make sure this time complete is just white with a green
                // check mark to make it pop out more"); unstarted: black
                // unfilled ring; in-progress: red-to-green interpolated fill.
                if (hasProgress && stats.total > 0) {
                    const badge = document.createElement('span');
                    if (stats.completed >= stats.total) {
                        badge.className = 'gpp-swatch-progress gpp-swatch-progress-complete';
                    } else if (stats.completed <= 0) {
                        badge.className = 'gpp-swatch-progress gpp-swatch-progress-unstarted';
                    } else {
                        badge.className = 'gpp-swatch-progress gpp-swatch-progress-inprogress';
                        badge.style.background = gppPaletteProgressColor(stats.completed / stats.total);
                    }
                    badge.setAttribute('aria-hidden', 'true');
                    button.appendChild(badge);
                }
            }

            button.addEventListener('mouseenter', event => gppPaletteShowTooltip(event, hex, stats, hasProgress));
            button.addEventListener('mousemove', event => gppPaletteMoveTooltip(event));
            button.addEventListener('mouseleave', gppPaletteHideTooltip);
            button.addEventListener('click', () => {
                // A drag-paint gesture (see the grid-level mousedown/
                // mousemove listeners above) that touched this swatch
                // already applied + persisted its target state — skip so a
                // drag that starts and ends on the same swatch doesn't
                // toggle it right back.
                if (suppressNextClick) { suppressNextClick = false; return; }
                const currentTemplate = controller.template;
                if (!currentTemplate) return;
                const nowEnabled = core.maskToggle(currentTemplate.mask, index);
                setSwatchMaskState(button, hex, nowEnabled);
            });
            return button;
        }

        // Group Noise variant of buildSwatch — one swatch represents every
        // near-duplicate colour core.groupPaletteColors() merged together
        // (see gpp-core.js). Same DOM shape/classes as buildSwatch (styling
        // free), but colour/stats/enabled-state/click all operate across the
        // WHOLE group's member indices rather than a single index.
        function buildGroupSwatch(template, representativeIndex, groupNoiseData, matching, colourLookup) {
            const memberIndices = groupNoiseData.groupByRepresentative.get(representativeIndex).memberIndices;
            const packed = template.palette[representativeIndex];
            const hex = core.packedToHex(packed);
            // Defensive: check every member, not just the representative —
            // normal operation keeps them in lockstep (see the click handler
            // below and applyDragPaintTo), but this stays correct even if
            // something upstream ever left them mismatched.
            const enabled = memberIndices.every(i => core.maskHas(template.mask, i));
            const isMatch = memberIndices.some(i => matching.has(i));

            let total = 0, completed = 0;
            memberIndices.forEach(i => {
                const s = gppPaletteStats(template, i, colourLookup);
                total += s.total;
                completed += s.completed;
            });
            const stats = { total, completed, remaining: Math.max(0, total - completed), remainingPercent: total > 0 ? (Math.max(0, total - completed) / total) * 100 : 0 };

            const listMode = gppSettings.paletteViewMode === 'list';

            const button = document.createElement('button');
            button.type = 'button';
            button.className = 'gpp-swatch' + (enabled ? '' : ' gpp-swatch-off') + (isMatch ? ' gpp-palette-glow' : '') + (listMode ? ' gpp-swatch-list' : '');
            button.setAttribute('aria-pressed', String(enabled));
            button.setAttribute('aria-label', `${enabled ? 'Hide' : 'Show'} ${hex} (${memberIndices.length} grouped colors)`);
            button.dataset.colorIndex = String(representativeIndex);
            button.dataset.groupMembers = memberIndices.join(',');

            const hasProgress = !!template.scanSummary;
            if (listMode) {
                gppPaletteApplyListLayout(button, hex, stats, hasProgress);
            } else {
                button.style.backgroundColor = hex;
                if (hasProgress && stats.total > 0) {
                    const badge = document.createElement('span');
                    if (stats.completed >= stats.total) {
                        badge.className = 'gpp-swatch-progress gpp-swatch-progress-complete';
                    } else if (stats.completed <= 0) {
                        badge.className = 'gpp-swatch-progress gpp-swatch-progress-unstarted';
                    } else {
                        badge.className = 'gpp-swatch-progress gpp-swatch-progress-inprogress';
                        badge.style.background = gppPaletteProgressColor(stats.completed / stats.total);
                    }
                    badge.setAttribute('aria-hidden', 'true');
                    button.appendChild(badge);
                }
            }

            button.addEventListener('mouseenter', event => gppPaletteShowTooltip(event, hex, stats, hasProgress));
            button.addEventListener('mousemove', event => gppPaletteMoveTooltip(event));
            button.addEventListener('mouseleave', gppPaletteHideTooltip);
            button.addEventListener('click', () => {
                if (suppressNextClick) { suppressNextClick = false; return; }
                const currentTemplate = controller.template;
                if (!currentTemplate) return;
                const nowEnabled = !memberIndices.every(i => core.maskHas(currentTemplate.mask, i));
                memberIndices.forEach(i => core.maskSet(currentTemplate.mask, i, nowEnabled));
                setSwatchMaskState(button, hex, nowEnabled);
            });
            return button;
        }

        controller.update = function gppPaletteControllerUpdate(template, onChange) {
            controller.template = template || null;
            controller.onChange = typeof onChange === 'function' ? onChange : null;
            gppPaletteHideTooltip(); // avoid a stuck tooltip across a re-render/deselection
            if (!controller.template) {
                emptyEl.style.display = '';
                panel.style.display = 'none';
                gncDetails.style.display = 'none';
                return;
            }
            emptyEl.style.display = 'none';
            panel.style.display = '';
            performFilterSort();
        };

        return controller;
    }

    // ── Ghost++ template library + Manage Templates modal ────────────
    // Implements the `gppRenderTemplateLibrary(container, onChange)` render
    // hook that gpp-init.js calls into GPP_IDS.rightContent (the shell's
    // collapsible right panel) after every state change. Two pieces:
    //   1. A compact grid of small cached thumbnails (click = focus,
    //      hover = floating larger preview, corner arrow = focus + teleport).
    //   2. A separate "Manage templates" overlay modal (multiselect,
    //      bulk export/delete, multi-file import, visibility + drag reorder).
    // Only this file's `gppRenderTemplateLibrary` name is a contract other
    // files depend on; everything else here is internal (`gppLibrary*` /
    // `GPP_LIB_*`) and safe to change freely.

    const GPP_LIB_STYLE_ID = 'gpp-library-style';
    const GPP_LIB_MANAGE_ID = 'gpp-lib-manage-modal';
    const GPP_LIB_HOVER_ID = 'gpp-lib-hover-preview';
    const GPP_LIB_FULLVIEW_ID = 'gpp-lib-fullview-overlay';
    // Container gpp-init.js's refreshAll() renders gpp-placement.js's
    // Position/Transform section into — lives inside gpp-lib-current now
    // (see gppRenderTemplateLibrary), so its id is shared across files the
    // same way GPP_IDS is; declared here since gpp-library.js is what
    // creates the element.
    const GPP_LIB_CURRENT_PT_ID = 'gpp-lib-current-pt';
    const GPP_LIB_GRID_DETAILS_ID = 'gpp-lib-grid-collapsible';
    const GPP_LIB_THUMB_SIZE = 52;   // px, compact grid CSS layout box (on-screen thumbnail size)
    const GPP_LIB_THUMB_RENDER_SIZE = 256; // px, offscreen canvas resolution the thumb is rasterized at — higher than the on-screen box so it stays crisp (retina, hover-zoom) instead of matching the display size 1:1
    const GPP_LIB_PREVIEW_SIZE = 220; // px, hover preview's max on-screen box — the canvas itself renders at the template's own true resolution (see gppLibraryShowHoverPreview), this only bounds its displayed size

    // Per-template cached thumbnail canvases, keyed by template.id. Entries
    // are invalidated (and their canvases regenerated) only when a
    // template's core payload actually changes — detected cheaply via
    // reference-identity of `indices`/`palette` plus width/height, not by
    // re-hashing pixel data every render. `transformIndexed` in gpp-core.js
    // always returns a *new* indices array, so any flip/rotate naturally
    // busts this cache; ordinary re-renders (colour toggles, drag, etc.)
    // reuse the same canvas nodes untouched.
    const gppLibraryThumbCache = new Map(); // id -> { indicesRef, paletteRef, width, height, small }

    // Singleton floating hover-preview element (position:fixed, follows the
    // cursor). Built lazily on first hover, reused for every card afterward.
    let gppLibraryHoverEl = null;

    // Shared drag-reorder state for the compact .gpp-lib-grid cards — same
    // shape/role as the Manage Templates modal's own per-invocation
    // `dragState` (see gppOpenManageTemplatesModal), just module-level since
    // gppRenderTemplateLibrary has no per-call context object of its own to
    // stash it on.
    let gppGridDragState = { id: null };

    // Manage Templates modal's List vs Grid view choice — module-level so it
    // survives closing/reopening the modal within the same page session (no
    // per-template data involved, so localStorage persistence isn't worth
    // the extra plumbing). Defaults to grid per explicit product decision.
    let gppManageViewMode = 'grid';

    // Rewrites content every call (see gpp-init.js's theme-change observer)
    // instead of no-op-ing once created, so a live dark/light toggle isn't
    // frozen at whatever theme was active on first mount.
    function gppLibraryInjectStyle() {
        let style = document.getElementById(GPP_LIB_STYLE_ID);
        if (!style) {
            style = document.createElement('style');
            style.id = GPP_LIB_STYLE_ID;
            document.head.appendChild(style);
        }
        style.textContent = `
            .gpp-lib-toolbar {
                display: flex; align-items: center; justify-content: space-between; gap: 8px;
                padding-bottom: 8px; margin-bottom: 8px;
                border-bottom: 1px solid ${t2('#d1d5db', '#45475a')};
            }
            .gpp-lib-count { font-size: 11px; color: ${t2('#64748b', '#a6adc8')}; }
            .gpp-lib-current {
                display: flex; flex-direction: column; align-items: center; gap: 6px;
                padding-bottom: 10px; margin-bottom: 10px;
                border-bottom: 1px solid ${t2('#d1d5db', '#45475a')};
            }
            /* .gpp-lib-current's own border-bottom above already divides it
               from the Templates grid — details.gpp-collapsible's generic
               border-top would otherwise stack a second line right beneath
               it with no gap, since this collapsible is its very next
               sibling. */
            #${GPP_LIB_GRID_DETAILS_ID} { border-top: none; }
            .gpp-lib-current canvas {
                display: block; max-width: 100%; max-height: 220px; width: auto; height: auto;
                image-rendering: pixelated; border-radius: 6px;
                background: ${t2('#f1f5f9', '#292a3a')};
                border: 1px solid ${t2('#d1d5db', '#45475a')};
            }
            .gpp-lib-current-canvas-wrap { position: relative; max-width: 100%; }
            .gpp-lib-current-canvas-wrap canvas { cursor: zoom-in; }
            /* .gpp-lib-current's align-items:center is meant for the
               (narrower-than-full-width) canvas thumbnail above this, not
               for the Position/Transform controls gpp-init.js mounts here —
               those should span the full available width. Without this,
               #gpp-lib-current-pt shrink-wraps to fit-content, and since a
               flex item's default min-width:auto floors that at its content's
               min-content size, it stays pinned near its widest row's natural
               width even when the right panel is dragged much narrower —
               pushing that row to overflow past both the container's own
               edges instead of actually reflowing into the space available. */
            #${GPP_LIB_CURRENT_PT_ID} { align-self: stretch; min-width: 0; }
            .gpp-lib-current-unload {
                position: absolute; top: 3px; right: 3px; width: 18px; height: 18px;
                border-radius: 9999px; border: none; display: flex; align-items: center; justify-content: center;
                font-size: 10px; line-height: 1; cursor: pointer; opacity: .65; transition: opacity .1s;
                background: ${t2('rgba(255,255,255,.85)', 'rgba(30,30,46,.85)')};
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-lib-current-unload:hover { opacity: 1; }
            .gpp-lib-current-name {
                font-size: 12px; font-weight: 600; text-align: center; max-width: 100%;
                overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-lib-editable-name {
                cursor: text; border-radius: 4px; padding: 1px 4px; margin: -1px -4px;
            }
            .gpp-lib-editable-name:hover {
                background: ${t2('rgba(37,99,235,.08)', 'rgba(137,180,250,.12)')};
            }
            .gpp-lib-rename-input {
                font: inherit; width: 100%; box-sizing: border-box;
                padding: 1px 3px; border-radius: 4px;
                border: 1px solid ${t2('#2563eb', '#89b4fa')};
                background: ${t2('#ffffff', '#11111b')};
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-lib-btn {
                font: inherit; font-size: 11px; cursor: pointer; border-radius: 6px;
                padding: 4px 9px; border: 1px solid ${t2('#d1d5db', '#45475a')};
                background: ${t2('#ffffff', '#313244')}; color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-lib-btn:hover:not(:disabled) { background: ${t2('#f3f4f6', '#45475a')}; }
            .gpp-lib-btn:disabled { opacity: .45; cursor: not-allowed; }
            .gpp-lib-btn-danger { color: ${t2('#dc2626', '#f38ba8')}; }
            .gpp-lib-empty {
                font-size: 11px; color: ${t2('#64748b', '#a6adc8')}; padding: 10px 2px;
            }
            .gpp-lib-grid {
                display: grid; grid-template-columns: repeat(auto-fill, minmax(${GPP_LIB_THUMB_SIZE}px, 1fr));
                /* Explicit row floor matching the column formula — see
                   gpp-palette.js's identical .gpp-palette-grid fix for why:
                   Firefox can fail to derive implicit row height from
                   .gpp-lib-card's aspect-ratio (nested inside the
                   .gpp-lib-cell flex wrapper) once the panel narrows enough
                   to reflow the column count, collapsing cards toward zero
                   height — which also swallows their click area and hides
                   their canvas thumbnail, since both size off the
                   collapsed card. */
                grid-auto-rows: minmax(${GPP_LIB_THUMB_SIZE}px, 1fr);
                gap: 10px 6px;
            }
            .gpp-lib-cell { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
            .gpp-lib-card {
                position: relative; aspect-ratio: 1 / 1; border-radius: 6px; overflow: hidden;
                cursor: pointer; box-sizing: border-box;
                background: ${t2('#f1f5f9', '#292a3a')};
                border: 1px solid ${t2('#d1d5db', '#45475a')};
                transition: box-shadow .1s, border-color .1s;
            }
            .gpp-lib-card:hover { border-color: ${t2('#2563eb', '#89b4fa')}; }
            .gpp-lib-card-focused {
                border-color: ${t2('#2563eb', '#89b4fa')};
                box-shadow: 0 0 0 2px ${t2('rgba(37,99,235,.35)', 'rgba(137,180,250,.35)')};
            }
            /* Drag-reorder — analogous to .gpp-lib-row-dragging/-dragover
               (Manage Templates modal) but scoped separately since these are
               square grid cards, not list rows. */
            .gpp-lib-card-dragging { opacity: .4; }
            .gpp-lib-card-dragover { border-color: ${t2('#2563eb', '#89b4fa')}; box-shadow: 0 0 0 2px ${t2('rgba(37,99,235,.35)', 'rgba(137,180,250,.35)')}; }
            .gpp-lib-card canvas.gpp-lib-thumb-canvas {
                display: block; width: 100%; height: 100%; image-rendering: pixelated;
            }
            .gpp-lib-arrow {
                position: absolute; top: 3px; right: 3px; width: 17px; height: 17px;
                border-radius: 9999px; border: none; display: flex; align-items: center; justify-content: center;
                font-size: 9px; line-height: 1; cursor: pointer; opacity: .55; transition: opacity .1s;
                background: ${t2('rgba(255,255,255,.85)', 'rgba(30,30,46,.85)')};
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-lib-card:hover .gpp-lib-arrow { opacity: 1; }
            .gpp-lib-vis-btn {
                position: absolute; bottom: 3px; right: 3px; width: 17px; height: 17px;
                border-radius: 9999px; border: none; display: flex; align-items: center; justify-content: center;
                font-size: 9px; line-height: 1; cursor: pointer; opacity: .55; transition: opacity .1s;
                background: ${t2('rgba(255,255,255,.85)', 'rgba(30,30,46,.85)')};
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-lib-card:hover .gpp-lib-vis-btn { opacity: 1; }
            #${GPP_LIB_HOVER_ID} {
                position: fixed; z-index: 10060; pointer-events: none; display: none;
                padding: 6px; border-radius: 8px;
                background: ${t2('#ffffff', '#1e1e2e')};
                border: 1px solid ${t2('#d1d5db', '#45475a')};
                box-shadow: 0 8px 24px ${t2('rgba(15,23,42,.28)', 'rgba(0,0,0,.6)')};
            }
            #${GPP_LIB_HOVER_ID} canvas {
                display: block; max-width: ${GPP_LIB_PREVIEW_SIZE}px; max-height: ${GPP_LIB_PREVIEW_SIZE}px; width: auto; height: auto;
                image-rendering: pixelated; border-radius: 4px;
                background: ${t2('#f1f5f9', '#292a3a')};
            }
            .gpp-lib-hover-caption {
                margin-top: 4px; font-size: 10px; text-align: center;
                max-width: ${GPP_LIB_PREVIEW_SIZE}px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
                color: ${t2('#64748b', '#a6adc8')};
            }
            .gpp-lib-fullview-overlay {
                position: fixed; inset: 0; z-index: 10070; background: rgba(0,0,0,.55);
                display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
                padding: 32px; box-sizing: border-box;
            }
            .gpp-lib-fullview-canvas {
                display: block; max-width: 90vw; max-height: 82vh; width: auto; height: auto;
                image-rendering: pixelated; border-radius: 6px;
                box-shadow: 0 12px 32px rgba(0,0,0,.5);
            }
            .gpp-lib-fullview-caption {
                font-size: 12px; text-align: center; max-width: 90vw;
                overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
                color: ${t2('#64748b', '#a6adc8')};
            }
            .gpp-lib-fullview-close {
                position: absolute; top: 16px; right: 20px; width: 28px; height: 28px;
                border-radius: 9999px; border: none; display: flex; align-items: center; justify-content: center;
                font-size: 14px; cursor: pointer;
                background: ${t2('rgba(255,255,255,.85)', 'rgba(30,30,46,.85)')};
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-lib-fullview-close:hover { background: ${t2('#f3f4f6', '#45475a')}; }
            .gpp-lib-manage-overlay {
                position: fixed; inset: 0; z-index: 10050; background: rgba(0,0,0,.55);
                display: flex; align-items: center; justify-content: center;
            }
            .gpp-lib-manage-panel {
                width: min(92vw, 560px); max-height: 82vh; display: flex; flex-direction: column; gap: 10px;
                border-radius: 10px; padding: 14px; box-sizing: border-box;
                background: ${t2('#ffffff', '#1e1e2e')}; color: ${t2('#111827', '#f5f5f5')};
                border: 1px solid ${t2('#d1d5db', '#45475a')};
                box-shadow: 0 12px 32px rgba(0,0,0,.35);
                font: 13px system-ui, sans-serif;
            }
            .gpp-lib-manage-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
            .gpp-lib-manage-title { font-weight: 600; font-size: 14px; }
            .gpp-lib-manage-close { border: none; background: transparent; color: inherit; cursor: pointer; font-size: 15px; }
            .gpp-lib-manage-toolbar {
                display: flex; align-items: center; gap: 8px; padding: 6px 0;
                border-top: 1px solid ${t2('#e5e7eb', '#313244')}; border-bottom: 1px solid ${t2('#e5e7eb', '#313244')};
            }
            .gpp-lib-manage-toolbar-spacer { flex: 1; }
            .gpp-lib-manage-selectall { display: flex; align-items: center; gap: 6px; font-size: 12px; }
            .gpp-lib-manage-export-progress { display: flex; flex-direction: column; gap: 4px; padding: 2px 0 4px; }
            .gpp-lib-manage-export-progress-label {
                font-size: 11px; color: ${t2('#64748b', '#a6adc8')};
                overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
            }
            .gpp-lib-manage-export-progress-bar {
                height: 6px; border-radius: 9999px; overflow: hidden;
                background: ${t2('#e5e7eb', '#313244')};
            }
            .gpp-lib-manage-export-progress-fill {
                height: 100%; width: 0%; border-radius: 9999px;
                background: ${t2('#2563eb', '#89b4fa')};
                transition: width .12s ease;
            }
            .gpp-lib-manage-list {
                flex: 1; min-height: 120px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;
                scrollbar-gutter: stable;
            }
            .gpp-lib-manage-empty { font-size: 12px; color: ${t2('#64748b', '#a6adc8')}; padding: 12px 4px; text-align: center; }
            .gpp-lib-row {
                display: flex; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 6px;
                border: 1px solid transparent;
            }
            .gpp-lib-row:hover { background: ${t2('#f3f4f6', '#292a3a')}; }
            .gpp-lib-row-dragging { opacity: .4; }
            .gpp-lib-row-dragover { border-color: ${t2('#2563eb', '#89b4fa')}; }
            .gpp-lib-row-handle { cursor: grab; user-select: none; color: ${t2('#94a3b8', '#6c7086')}; font-size: 12px; flex-shrink: 0; }
            .gpp-lib-row-thumb {
                width: 22px; height: 22px; border-radius: 3px; flex-shrink: 0; image-rendering: pixelated;
                background: ${t2('#f1f5f9', '#292a3a')};
            }
            .gpp-lib-row-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
            .gpp-lib-row-name { font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
            .gpp-lib-row-dims { font-size: 10px; color: ${t2('#64748b', '#a6adc8')}; }
            .gpp-lib-row-vis {
                border: none; background: transparent; cursor: pointer; font-size: 13px; flex-shrink: 0;
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-lib-row-goto {
                border: none; background: transparent; cursor: pointer; font-size: 12px; flex-shrink: 0;
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-lib-row-goto:disabled { opacity: .35; cursor: not-allowed; }
            .gpp-lib-manage-header-right { display: flex; align-items: center; gap: 8px; }
            .gpp-lib-manage-view-toggle {
                display: flex; border-radius: 6px; overflow: hidden;
                border: 1px solid ${t2('#d1d5db', '#45475a')};
            }
            .gpp-lib-manage-view-btn {
                font: inherit; font-size: 12px; line-height: 1; cursor: pointer; border: none; padding: 4px 7px;
                background: ${t2('#ffffff', '#313244')}; color: ${t2('#64748b', '#a6adc8')};
            }
            .gpp-lib-manage-view-btn:hover { background: ${t2('#f3f4f6', '#45475a')}; }
            .gpp-lib-manage-view-btn-active {
                background: ${t2('#2563eb', '#89b4fa')}; color: ${t2('#ffffff', '#1e1e2e')};
            }
            /* Fixed-size cards (no 1fr stretch) — scroll, don't reflow, as
               the column count changes; overrides .gpp-lib-manage-list's
               flex column layout when this modifier class is also present. */
            .gpp-lib-manage-list-grid {
                display: grid; grid-template-columns: repeat(auto-fill, 92px); grid-auto-rows: min-content;
                gap: 10px; align-content: start; justify-content: start;
            }
            .gpp-lib-manage-grid-card {
                position: relative; width: 92px; box-sizing: border-box;
                display: flex; flex-direction: column; gap: 3px; cursor: pointer;
            }
            .gpp-lib-manage-grid-card-dragging { opacity: .4; }
            .gpp-lib-manage-grid-thumb-wrap {
                position: relative; width: 92px; height: 92px; border-radius: 6px; overflow: hidden; box-sizing: border-box;
                background: ${t2('#f1f5f9', '#292a3a')};
                border: 1px solid ${t2('#d1d5db', '#45475a')};
                transition: box-shadow .1s, border-color .1s;
            }
            .gpp-lib-manage-grid-card:hover .gpp-lib-manage-grid-thumb-wrap { border-color: ${t2('#2563eb', '#89b4fa')}; }
            .gpp-lib-manage-grid-card-focused .gpp-lib-manage-grid-thumb-wrap {
                border-color: ${t2('#2563eb', '#89b4fa')};
                box-shadow: 0 0 0 2px ${t2('rgba(37,99,235,.35)', 'rgba(137,180,250,.35)')};
            }
            .gpp-lib-manage-grid-card-dragover .gpp-lib-manage-grid-thumb-wrap {
                border-color: ${t2('#2563eb', '#89b4fa')};
                box-shadow: 0 0 0 2px ${t2('rgba(37,99,235,.35)', 'rgba(137,180,250,.35)')};
            }
            .gpp-lib-manage-grid-card-selected .gpp-lib-manage-grid-thumb-wrap {
                border-color: ${t2('#d97706', '#f9a825')};
                box-shadow: 0 0 0 2px ${t2('rgba(217,119,6,.35)', 'rgba(249,168,37,.35)')};
            }
            /* object-fit: contain (not width/height:100% stretch) — the
               canvas now renders at the template's own true, possibly
               non-square resolution (gppLibraryRenderFullCanvas), so it
               must letterbox to fit this square box instead of distorting.
               Slightly darkened by default, full brightness on hover —
               per explicit product feedback, makes the overlaid corner
               buttons (opaque at all times, see below) read clearly
               against any thumbnail without needing their own opacity
               dance, and gives a clear hover affordance for the whole card. */
            .gpp-lib-manage-grid-thumb-wrap canvas {
                display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated;
                filter: brightness(0.72); transition: filter .1s;
            }
            .gpp-lib-manage-grid-card:hover .gpp-lib-manage-grid-thumb-wrap canvas { filter: brightness(1); }
            .gpp-lib-manage-grid-name {
                font-size: 10px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
                color: ${t2('#111827', '#f5f5f5')};
            }
            /* Always fully opaque (not translucent-until-hover) — per
               explicit product feedback, these need to be readable at a
               glance without hovering first; the thumbnail's own darken/
               brighten-on-hover treatment above is what now carries the
               hover affordance instead. */
            .gpp-lib-manage-grid-sel, .gpp-lib-manage-grid-vis, .gpp-lib-manage-grid-goto {
                position: absolute; width: 17px; height: 17px; border-radius: 9999px; border: none;
                display: flex; align-items: center; justify-content: center; font-size: 9px; line-height: 1; cursor: pointer;
                background: ${t2('rgba(255,255,255,.85)', 'rgba(30,30,46,.85)')};
                color: ${t2('#111827', '#f5f5f5')};
            }
            .gpp-lib-manage-grid-sel { top: 3px; left: 3px; }
            .gpp-lib-manage-grid-vis { top: 3px; right: 3px; }
            .gpp-lib-manage-grid-goto { bottom: 3px; left: 3px; }
            .gpp-lib-manage-grid-sel-active { background: ${t2('#d97706', '#f9a825')}; color: ${t2('#ffffff', '#1e1e2e')}; opacity: 1; }
        `;
    }

    // ── Thumbnail generation (nearest-neighbour, bounded by output size) ──
    // Cost is O(size²), independent of the source template's resolution —
    // this is what makes it safe to call on every cache miss without ever
    // touching the full-resolution `indices` array pixel-by-pixel.
    function gppLibraryRenderThumbCanvas(template, size) {
        const canvas = document.createElement('canvas');
        canvas.className = 'gpp-lib-thumb-canvas';
        canvas.width = size;
        canvas.height = size;
        const width = template.width;
        const height = template.height;
        const indices = template.indices;
        const palette = template.palette;
        if (!width || !height || !indices || !palette || !palette.length) return canvas;
        const core = gppCreateCore();
        const empty = core.emptyValue(template.indexType);
        const scale = Math.min(size / width, size / height);
        const drawWidth = Math.max(1, Math.round(width * scale));
        const drawHeight = Math.max(1, Math.round(height * scale));
        const offsetX = Math.floor((size - drawWidth) / 2);
        const offsetY = Math.floor((size - drawHeight) / 2);
        const ctx = canvas.getContext('2d');
        const imageData = ctx.createImageData(drawWidth, drawHeight);
        const data = imageData.data;
        for (let oy = 0; oy < drawHeight; oy++) {
            const sy = Math.min(height - 1, Math.floor(oy / scale));
            const sRowBase = sy * width;
            const dRowBase = oy * drawWidth;
            for (let ox = 0; ox < drawWidth; ox++) {
                const sx = Math.min(width - 1, Math.floor(ox / scale));
                const value = indices[sRowBase + sx];
                if (value === empty) continue; // stays transparent (ImageData is zero-initialized)
                const packed = palette[value];
                const di = (dRowBase + ox) * 4;
                data[di] = (packed >>> 16) & 0xFF;
                data[di + 1] = (packed >>> 8) & 0xFF;
                data[di + 2] = packed & 0xFF;
                data[di + 3] = 255;
            }
        }
        ctx.putImageData(imageData, offsetX, offsetY);
        return canvas;
    }

    // ── Full-resolution render (current-template panel + fullscreen preview) ──
    // Unlike gppLibraryRenderThumbCanvas (which rasterizes into a SIZE-
    // bounded canvas, downsampling the source), this builds the canvas at
    // the template's own width/height — one real source pixel per canvas
    // pixel — so CSS, not this function, is what scales it down to fit. Not
    // cached in gppLibraryThumbCache: it's a fresh, independent canvas node
    // every call, never one the hover preview or grid cards could steal via
    // appendChild (see this file's header comment on that invariant).
    function gppLibraryRenderFullCanvas(template) {
        const canvas = document.createElement('canvas');
        canvas.className = 'gpp-lib-thumb-canvas';
        const width = template.width || 1;
        const height = template.height || 1;
        canvas.width = width;
        canvas.height = height;
        const indices = template.indices;
        const palette = template.palette;
        if (!template.width || !template.height || !indices || !palette || !palette.length) return canvas;
        const core = gppCreateCore();
        const empty = core.emptyValue(template.indexType);
        const ctx = canvas.getContext('2d');
        const imageData = ctx.createImageData(width, height);
        const data = imageData.data;
        for (let i = 0; i < width * height; i++) {
            const value = indices[i];
            if (value === empty) continue; // stays transparent (ImageData is zero-initialized)
            const packed = palette[value];
            const di = i * 4;
            data[di] = (packed >>> 16) & 0xFF;
            data[di + 1] = (packed >>> 8) & 0xFF;
            data[di + 2] = packed & 0xFF;
            data[di + 3] = 255;
        }
        ctx.putImageData(imageData, 0, 0);
        return canvas;
    }

    function gppLibraryEnsureThumb(template) {
        let entry = gppLibraryThumbCache.get(template.id);
        const stale = !entry
            || entry.indicesRef !== template.indices
            || entry.paletteRef !== template.palette
            || entry.width !== template.width
            || entry.height !== template.height;
        if (stale) {
            entry = {
                indicesRef: template.indices,
                paletteRef: template.palette,
                width: template.width,
                height: template.height,
                small: gppLibraryRenderThumbCanvas(template, GPP_LIB_THUMB_RENDER_SIZE),
            };
            gppLibraryThumbCache.set(template.id, entry);
        }
        return entry;
    }

    function gppLibraryPruneThumbCache() {
        if (!gppLibraryThumbCache.size) return;
        const liveIds = new Set(gppState.templates.map(t => t.id));
        for (const id of Array.from(gppLibraryThumbCache.keys())) {
            if (!liveIds.has(id)) gppLibraryThumbCache.delete(id);
        }
    }

    // ── Hover preview (floating, follows cursor) ──────────────────────
    function gppLibraryEnsureHoverEl() {
        if (gppLibraryHoverEl && document.body.contains(gppLibraryHoverEl)) return gppLibraryHoverEl;
        const el = document.createElement('div');
        el.id = GPP_LIB_HOVER_ID;
        document.body.appendChild(el);
        gppLibraryHoverEl = el;
        return el;
    }

    function gppLibraryShowHoverPreview(event, template) {
        const el = gppLibraryEnsureHoverEl();
        // Full/true resolution (not the bounded thumb cache) — a fresh,
        // independent canvas every call, never the cached `.small` node a
        // card/row already owns (see this file's header comment on that
        // invariant); CSS bounds its displayed size via max-width/max-height.
        const canvas = gppLibraryRenderFullCanvas(template);
        el.innerHTML = '';
        el.appendChild(canvas);
        const caption = document.createElement('div');
        caption.className = 'gpp-lib-hover-caption';
        caption.textContent = (template.name || 'Untitled template') + '  ' + template.width + '×' + template.height;
        el.appendChild(caption);
        el.style.display = 'block';
        gppLibraryMoveHoverPreview(event);
    }

    function gppLibraryMoveHoverPreview(event) {
        const el = gppLibraryHoverEl;
        if (!el || el.style.display === 'none') return;
        const margin = 16;
        const rect = el.getBoundingClientRect();
        let left = event.clientX + margin;
        let top = event.clientY + margin;
        if (left + rect.width > window.innerWidth) left = event.clientX - rect.width - margin;
        if (top + rect.height > window.innerHeight) top = event.clientY - rect.height - margin;
        el.style.left = Math.max(4, left) + 'px';
        el.style.top = Math.max(4, top) + 'px';
    }

    function gppLibraryHideHoverPreview() {
        if (gppLibraryHoverEl) gppLibraryHoverEl.style.display = 'none';
    }

    // ── Teleport (corner-arrow "go to" action) ────────────────────────
    // Centres on the template's placed footprint rather than its raw
    // top-left grid coordinate, using the same grid->mercator->wgs84
    // convention as the standalone prototype's flyToNearestError (and
    // core.js's pick-points tooling): mercator = grid * gridSize (+ the
    // site's meter offset), then turf.toWgs84(...) for the map's lng/lat.
    function gppLibraryComputeTemplateCenterGrid(template) {
        if (!template.position || !Number.isFinite(template.position.gridX) || !Number.isFinite(template.position.gridY)) return null;
        const width = template.width || 1;
        const height = template.height || 1;
        return {
            gridX: template.position.gridX + (width - 1) / 2,
            gridY: template.position.gridY - (height - 1) / 2,
        };
    }

    // Instant, not eased — explicit user request: teleporting to a template
    // should jump straight there (jumpTo), not ease into it (flyTo).
    function gppLibraryFlyToTemplate(template) {
        const center = gppLibraryComputeTemplateCenterGrid(template);
        if (!center) return;
        const map = gppGetMap();
        const turf = gppGetTurf();
        if (!map || !turf || typeof map.jumpTo !== 'function') return;
        const grid = gppReadGridConstants();
        const mercX = center.gridX * grid.gridSize + grid.offsetMetersX;
        const mercY = center.gridY * grid.gridSize + grid.offsetMetersY;
        try {
            const target = turf.toWgs84([mercX, mercY]);
            if (!target) return;
            const currentZoom = typeof map.getZoom === 'function' ? map.getZoom() : 12.5;
            map.jumpTo({ center: target, zoom: Math.max(currentZoom, 12.5) });
        } catch (_) {
            // Never let a teleport click throw out of a UI handler — map/turf
            // may not be fully ready yet; simply do nothing in that case.
        }
    }

    // ── Inline rename (current-template panel + Manage Templates rows) ──
    // Renaming never touches pixel content, so it must never call
    // gppLegacyAddImage (that would re-encode/re-hash the blob for no
    // reason) — gppState.persistTemplateState already writes `name`
    // straight into Ghost++'s own per-template state record (GPP_STATE_STORE,
    // keyed by the SAME id whether shared 'legacy_'-prefixed or private
    // 'gpp_'-prefixed — see gpp-runtime.js), and that stored name always
    // wins over the shared/private core record's own name on next load
    // (gppLoadSharedLibraryTemplates / gppLoadTemplateLibrary: `state.name
    // || ...`), so a plain read-modify-write of that one record is
    // sufficient — no separate touch of GP_Ghost_History or
    // GPP_TEMPLATE_STORE needed.
    async function gppLibraryRenameTemplate(template, newName) {
        const trimmed = (newName || '').trim();
        if (!trimmed || trimmed === template.name) return;
        template.name = trimmed;
        await gppState.persistTemplateState(template);
    }

    // Swaps `nameEl`'s text for an inline <input>, committing on Enter/blur
    // and discarding on Escape. `onDone` re-renders whatever surface this
    // name lives in (current-template panel or a Manage Templates row) —
    // called even when the edit is cancelled, since the caller still needs
    // its normal (non-input) name element restored.
    function gppLibraryStartRename(nameEl, template, onDone) {
        if (nameEl.querySelector('input')) return; // already editing
        const original = template.name || 'Untitled template';
        nameEl.textContent = '';
        // nameEl is a flex item; gpp-lib-current's own align-items:center
        // (unlike gpp-lib-row-meta, which stretches by default) would
        // otherwise leave it sized to its old text content instead of the
        // available width, so the input's own width:100% would resolve
        // against a near-arbitrary box instead of actually filling the row.
        nameEl.style.alignSelf = 'stretch';
        const input = document.createElement('input');
        input.type = 'text';
        input.className = 'gpp-lib-rename-input';
        input.value = original;
        input.maxLength = 200;
        nameEl.appendChild(input);
        input.focus();
        input.select();

        let settled = false;
        function finish(commit) {
            if (settled) return;
            settled = true;
            input.removeEventListener('blur', onBlur);
            input.removeEventListener('keydown', onKeydown);
            if (commit) {
                gppLibraryRenameTemplate(template, input.value).then(onDone);
            } else {
                onDone();
            }
        }
        function onBlur() { finish(true); }
        function onKeydown(event) {
            event.stopPropagation(); // don't let Escape/Enter reach anything else (e.g. the modal's own close-on-Escape)
            if (event.key === 'Enter') { event.preventDefault(); finish(true); }
            else if (event.key === 'Escape') { event.preventDefault(); finish(false); }
        }
        input.addEventListener('blur', onBlur);
        input.addEventListener('keydown', onKeydown);
        input.addEventListener('click', event => event.stopPropagation());
        input.addEventListener('mousedown', event => event.stopPropagation());
    }

    // ── Fullscreen preview overlay (gpp-lib-thumb-canvas click) ────────
    // Mirrors gpp-lib-manage-overlay's own backdrop/dismiss conventions
    // (fixed inset:0 scrim, click-through-to-backdrop closes, Escape
    // closes) at a higher z-index so it can float above both that modal
    // and the hover preview. Renders its own independent full-resolution
    // canvas (gppLibraryRenderFullCanvas) rather than reusing any cached
    // thumb — same reasoning as the current-template panel itself.
    function gppLibraryOpenFullPreview(template) {
        if (document.getElementById(GPP_LIB_FULLVIEW_ID)) return; // already open
        const overlay = document.createElement('div');
        overlay.id = GPP_LIB_FULLVIEW_ID;
        overlay.className = 'gpp-lib-fullview-overlay';

        const canvas = gppLibraryRenderFullCanvas(template);
        canvas.className = 'gpp-lib-fullview-canvas';
        overlay.appendChild(canvas);

        const caption = document.createElement('div');
        caption.className = 'gpp-lib-fullview-caption';
        caption.textContent = (template.name || 'Untitled template') + '  ' + template.width + '×' + template.height;
        overlay.appendChild(caption);

        const closeBtn = document.createElement('button');
        closeBtn.type = 'button';
        closeBtn.className = 'gpp-lib-fullview-close';
        closeBtn.textContent = '✕';
        closeBtn.setAttribute('aria-label', 'Close preview');
        overlay.appendChild(closeBtn);

        function close() {
            document.removeEventListener('keydown', onKeydown);
            overlay.remove();
        }
        function onKeydown(event) {
            if (event.key === 'Escape') close();
        }
        document.addEventListener('keydown', onKeydown);
        closeBtn.addEventListener('click', close);
        overlay.addEventListener('click', event => { if (event.target === overlay) close(); });

        document.body.appendChild(overlay);
    }

    // ── Compact thumbnail grid (GPP_IDS.rightContent) ─────────────────
    // Returns a `.gpp-lib-cell` wrapper (the actual grid item) rather than
    // the square thumbnail box itself, kept as a thin wrapper around the
    // square aspect-ratio:1/1 card (needed for the canvas/arrow/vis-button
    // overlay). No name label here — names don't matter in this compact
    // view (per explicit product decision, favouring density) and the
    // card's own `title` attribute still surfaces the name on hover; renaming
    // stays reachable via gpp-lib-current-name/gpp-lib-row-name instead.
    function gppLibraryBuildCard(template, focusedId, onChange) {
        const cell = document.createElement('div');
        cell.className = 'gpp-lib-cell';

        const card = document.createElement('div');
        card.className = 'gpp-lib-card' + (template.id === focusedId ? ' gpp-lib-card-focused' : '');
        card.title = template.name || 'Untitled template';

        const entry = gppLibraryEnsureThumb(template);
        card.appendChild(entry.small);

        // No arrow at all when unplaced — its absence IS the visual signal
        // that this template still needs to be placed (or was Unset), per
        // explicit product decision, rather than a disabled/tinted button.
        const hasPosition = !!gppLibraryComputeTemplateCenterGrid(template);
        if (hasPosition) {
            const arrowBtn = document.createElement('button');
            arrowBtn.type = 'button';
            arrowBtn.className = 'gpp-lib-arrow';
            arrowBtn.textContent = '➤';
            arrowBtn.title = 'Go to this template’s location';
            arrowBtn.addEventListener('click', event => {
                event.stopPropagation();
                (async () => {
                    await gppState.focusTemplate(template.id);
                    gppLibraryFlyToTemplate(template);
                    onChange();
                })();
            });
            card.appendChild(arrowBtn);
        }

        // Quick opacity 0%/100% toggle right on the card — the same
        // mechanism as the Opacity slider and the Manage Templates row's own
        // eye button (see gppLibraryToggleVisibility), just reachable
        // without opening either.
        const visBtn = document.createElement('button');
        visBtn.type = 'button';
        visBtn.className = 'gpp-lib-vis-btn';
        const paintCardVis = () => {
            const visible = template.opacity > 0;
            visBtn.textContent = visible ? '👁️' : '🚫';
            visBtn.title = visible ? 'Visible — click to hide' : 'Hidden — click to show';
        };
        paintCardVis();
        visBtn.addEventListener('click', event => {
            event.stopPropagation();
            gppLibraryToggleVisibility(template).then(() => {
                paintCardVis();
                onChange();
            });
        });
        card.appendChild(visBtn);

        card.addEventListener('click', () => {
            if (template.id === gppState.focusedTemplateId) return;
            (async () => {
                await gppState.focusTemplate(template.id);
                onChange();
            })();
        });
        card.addEventListener('mouseenter', event => gppLibraryShowHoverPreview(event, template));
        card.addEventListener('mousemove', gppLibraryMoveHoverPreview);
        card.addEventListener('mouseleave', gppLibraryHideHoverPreview);

        // Drag-reorder — same mechanism as the Manage Templates modal's rows
        // (gppLibraryReorderTemplates), just retargeted onto the compact grid
        // card and gppGridDragState instead of a modal-local dragState.
        card.draggable = true;
        card.addEventListener('dragstart', event => {
            gppGridDragState.id = template.id;
            event.dataTransfer.effectAllowed = 'move';
            try { event.dataTransfer.setData('text/plain', template.id); } catch (_) { /* some browsers require this call to succeed for drag to start */ }
            gppLibraryHideHoverPreview(); // don't let the floating hover-preview linger over the drag
            card.classList.add('gpp-lib-card-dragging');
        });
        card.addEventListener('dragend', () => {
            gppGridDragState.id = null;
            card.classList.remove('gpp-lib-card-dragging');
            card.classList.remove('gpp-lib-card-dragover');
        });
        card.addEventListener('dragover', event => {
            if (!gppGridDragState.id || gppGridDragState.id === template.id) return;
            event.preventDefault();
            event.dataTransfer.dropEffect = 'move';
            card.classList.add('gpp-lib-card-dragover');
        });
        card.addEventListener('dragleave', () => card.classList.remove('gpp-lib-card-dragover'));
        card.addEventListener('drop', event => {
            event.preventDefault();
            card.classList.remove('gpp-lib-card-dragover');
            const sourceId = gppGridDragState.id;
            gppGridDragState.id = null;
            if (!sourceId || sourceId === template.id) return;
            (async () => {
                await gppLibraryReorderTemplates(sourceId, template.id);
                onChange();
            })();
        });

        cell.appendChild(card);

        return cell;
    }

    function gppRenderTemplateLibrary(container, onChange) {
        if (!container) return;
        gppLibraryInjectStyle();
        gppLibraryHideHoverPreview();
        gppLibraryPruneThumbCache();

        // gpp-placement.js's Position/Transform section now lives INSIDE
        // this panel (see below), so its own <details> open/closed toggle
        // would otherwise snap shut/open on every unrelated refresh this
        // function's own full innerHTML wipe triggers — same failure mode
        // gpp-placement.js's own wasOpen check already guards against for
        // its narrower re-renders, just one level up. Read it before the
        // wipe, restore it by seeding a matching stub afterward (see below).
        const previousPt = container.querySelector('#' + GPP_LIB_CURRENT_PT_ID + ' details.gpp-collapsible');
        const ptWasOpen = previousPt ? previousPt.open : true;

        // Same wasOpen-preservation as above, for the grid's own new
        // show/hide collapsible (see below) — scoped by id, not just
        // `details.gpp-collapsible`, since the pt stub above is also one.
        const previousGridDetails = container.querySelector('#' + GPP_LIB_GRID_DETAILS_ID);
        const gridWasOpen = previousGridDetails ? previousGridDetails.open : true;

        container.innerHTML = '';

        const templates = gppState.templates;

        const toolbar = document.createElement('div');
        toolbar.className = 'gpp-lib-toolbar';
        const countLabel = document.createElement('span');
        countLabel.className = 'gpp-lib-count';
        // "0 templates" while the very first decode pass is still in flight
        // would read as "your library was wiped" rather than "still
        // loading" — gppState.runtimeReady (gpp-runtime.js) distinguishes
        // the two. Never shown once runtimeReady flips true, even for a
        // genuinely-empty library (matches the grid's own silent-empty
        // convention below).
        countLabel.textContent = !templates.length && !gppState.runtimeReady
            ? 'Loading…'
            : (templates.length === 1 ? '1 template' : (templates.length + ' templates'));
        const manageBtn = document.createElement('button');
        manageBtn.type = 'button';
        manageBtn.className = 'gpp-lib-btn';
        manageBtn.textContent = '🗂️ Manage';
        manageBtn.title = 'Manage templates';
        manageBtn.addEventListener('click', () => gppOpenManageTemplatesModal(onChange));
        toolbar.appendChild(countLabel);
        toolbar.appendChild(manageBtn);
        container.appendChild(toolbar);

        // Dedicated "current template" section — a larger view of whichever
        // template is focused, above the thumbnail grid, plus (below the
        // name) the Position/Transform controls gpp-init.js renders into
        // gpp-lib-current-pt right after this function returns. Renders its
        // OWN independent canvas via gppLibraryRenderFullCanvas rather than
        // reusing gppLibraryEnsureThumb's cached `.small` entry — that same
        // cached node is also what a card's canvas or the hover preview
        // relies on (appendChild relocates a DOM node, it doesn't clone it),
        // which would otherwise silently steal this section's canvas away
        // the moment any card is hovered. Full resolution (not the
        // bounded-size thumb) so the preview shows one real source
        // pixel per canvas pixel before CSS scales it down to fit.
        const focusedId = gppState.focusedTemplateId;
        let focusedTemplate = templates.find(t => t.id === focusedId);
        if (!focusedTemplate) {
            // A guild template loaded via the guild menu's "Set as Ghost"
            // (gpp-native-shim.js) never appears in gppState.templates — it
            // lives in the deliberately separate, ephemeral
            // gppState.guildTemplates array (see gpp-runtime.js's own header
            // comment) — but once focused it still deserves this same
            // preview + Position/Transform panel, same as gppGetFocusedTemplate's
            // own fallback already treats it as "the" focused template
            // everywhere else in Ghost++.
            focusedTemplate = gppState.guildTemplates.find(t => t.id === focusedId);
        }

        if (!templates.length && !focusedTemplate) {
            if (!gppState.runtimeReady) {
                // Still decoding the shared/private template library in the
                // background (gpp-init.js's open() now renders the shell
                // and this panel BEFORE that finishes, for a faster-feeling
                // first open) — say so instead of silently looking empty,
                // which reads as "my templates are gone" for anyone with a
                // large library, per explicit product feedback.
                const loading = document.createElement('div');
                loading.className = 'gpp-lib-empty';
                loading.textContent = 'Loading templates...';
                container.appendChild(loading);
            }
            // Otherwise (a genuinely empty library) left empty — the drop
            // zone in the left panel already makes the next step obvious.
            return;
        }

        const current = document.createElement('div');
        current.className = 'gpp-lib-current';

        if (focusedTemplate) {
            const canvasWrap = document.createElement('div');
            canvasWrap.className = 'gpp-lib-current-canvas-wrap';
            const fullCanvas = gppLibraryRenderFullCanvas(focusedTemplate);
            fullCanvas.title = 'Click for a full-screen preview';
            fullCanvas.addEventListener('click', () => gppLibraryOpenFullPreview(focusedTemplate));
            canvasWrap.appendChild(fullCanvas);

            const unloadBtn = document.createElement('button');
            unloadBtn.type = 'button';
            unloadBtn.className = 'gpp-lib-current-unload';
            unloadBtn.textContent = '✕';
            unloadBtn.title = focusedTemplate.ephemeral ? 'Unload template' : 'Unload template (keeps it in your library)';
            unloadBtn.setAttribute('aria-label', 'Unload template');
            unloadBtn.addEventListener('click', event => {
                event.stopPropagation();
                (async () => {
                    await gppState.focusTemplate(null);
                    onChange();
                })();
            });
            canvasWrap.appendChild(unloadBtn);
            current.appendChild(canvasWrap);

            const nameEl = document.createElement('div');
            nameEl.className = 'gpp-lib-current-name gpp-lib-editable-name';
            nameEl.textContent = focusedTemplate.name || 'Untitled template';
            nameEl.title = 'Click to rename';
            nameEl.addEventListener('click', event => {
                event.stopPropagation();
                gppLibraryStartRename(nameEl, focusedTemplate, onChange);
            });
            current.appendChild(nameEl);
        }

        // Always present (even with no focused template) so
        // gppRenderPositionTransform — called by gpp-init.js right after
        // this function returns — has a container to render its own
        // "No template selected" state into; a matching gpp-collapsible
        // stub, pre-set to the previously-read open/closed state, is what
        // lets that call's own wasOpen check (reading THIS container right
        // before it wipes it) restore instead of default to open.
        const ptContainer = document.createElement('div');
        ptContainer.id = GPP_LIB_CURRENT_PT_ID;
        const ptStub = document.createElement('details');
        ptStub.className = 'gpp-collapsible';
        ptStub.open = ptWasOpen;
        ptContainer.appendChild(ptStub);
        current.appendChild(ptContainer);

        container.appendChild(current);

        // The "Templates" grid itself is still specifically the personal
        // library (gppState.templates) — a focused GUILD template with an
        // otherwise-empty personal library gets the "current template"
        // section above, but no empty "Templates" collapsible below it.
        if (templates.length) {
            const gridDetails = document.createElement('details');
            gridDetails.className = 'gpp-collapsible';
            gridDetails.id = GPP_LIB_GRID_DETAILS_ID;
            gridDetails.open = gridWasOpen;
            const gridSummary = document.createElement('summary');
            gridSummary.textContent = 'Templates';
            const gridBody = document.createElement('div');
            gridBody.className = 'gpp-body';
            gridDetails.appendChild(gridSummary);
            gridDetails.appendChild(gridBody);

            const grid = document.createElement('div');
            grid.className = 'gpp-lib-grid';
            templates.forEach(template => grid.appendChild(gppLibraryBuildCard(template, focusedId, onChange)));
            gridBody.appendChild(grid);

            container.appendChild(gridDetails);
        }
    }

    // ── Manage Templates modal ─────────────────────────────────────────
    // `gppState.templates` is a live-array getter (see gpp-runtime.js); an
    // ordinary drag-reorder mutates it in place with splice + a full
    // order-index renumber, mirroring gppState.normalizeTemplateOrder()'s
    // contract. Delete (which reassigns the underlying array) is handled
    // entirely through gppState.deleteTemplate(), never here.
    async function gppLibraryReorderTemplates(sourceId, targetId) {
        const list = gppState.templates;
        const fromIndex = list.findIndex(t => t.id === sourceId);
        const toIndex = list.findIndex(t => t.id === targetId);
        if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
        const [moved] = list.splice(fromIndex, 1);
        list.splice(toIndex, 0, moved);
        gppState.normalizeTemplateOrder();
        await Promise.all(list.map(t => gppState.persistTemplateState(t)));
    }

    function gppLibraryBuildManageRow(template, ctx) {
        const row = document.createElement('div');
        row.className = 'gpp-lib-row';
        row.draggable = true;

        row.addEventListener('dragstart', event => {
            ctx.dragState.id = template.id;
            event.dataTransfer.effectAllowed = 'move';
            try { event.dataTransfer.setData('text/plain', template.id); } catch (_) { /* some browsers require this call to succeed for drag to start */ }
            row.classList.add('gpp-lib-row-dragging');
        });
        row.addEventListener('dragend', () => {
            ctx.dragState.id = null;
            row.classList.remove('gpp-lib-row-dragging');
            row.classList.remove('gpp-lib-row-dragover');
        });
        row.addEventListener('dragover', event => {
            if (!ctx.dragState.id || ctx.dragState.id === template.id) return;
            event.preventDefault();
            event.dataTransfer.dropEffect = 'move';
            row.classList.add('gpp-lib-row-dragover');
        });
        row.addEventListener('dragleave', () => row.classList.remove('gpp-lib-row-dragover'));
        row.addEventListener('drop', event => {
            event.preventDefault();
            row.classList.remove('gpp-lib-row-dragover');
            const sourceId = ctx.dragState.id;
            ctx.dragState.id = null;
            if (!sourceId || sourceId === template.id) return;
            (async () => {
                await gppLibraryReorderTemplates(sourceId, template.id);
                ctx.renderList();
                ctx.onChange();
            })();
        });

        const handle = document.createElement('span');
        handle.className = 'gpp-lib-row-handle';
        handle.textContent = '⠿';
        handle.title = 'Drag to reorder';
        row.appendChild(handle);

        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.checked = ctx.selectedIds.has(template.id);
        checkbox.addEventListener('change', () => {
            if (checkbox.checked) ctx.selectedIds.add(template.id); else ctx.selectedIds.delete(template.id);
            ctx.syncBulkButtons();
        });
        row.appendChild(checkbox);

        const rowThumb = document.createElement('canvas');
        rowThumb.className = 'gpp-lib-row-thumb';
        rowThumb.width = 22;
        rowThumb.height = 22;
        const rtx = rowThumb.getContext('2d');
        rtx.imageSmoothingEnabled = false;
        const cached = gppLibraryEnsureThumb(template);
        rtx.drawImage(cached.small, 0, 0, 22, 22);
        row.appendChild(rowThumb);

        const meta = document.createElement('div');
        meta.className = 'gpp-lib-row-meta';
        const nameEl = document.createElement('div');
        nameEl.className = 'gpp-lib-row-name gpp-lib-editable-name';
        nameEl.textContent = template.name || 'Untitled template';
        nameEl.title = 'Click to rename';
        nameEl.addEventListener('click', event => {
            event.stopPropagation();
            // Dragging a row while editing its name would fight text
            // selection/typing inside the input (draggable ancestors hijack
            // pointer gestures) — suspend it for the duration of the edit.
            row.draggable = false;
            gppLibraryStartRename(nameEl, template, () => {
                row.draggable = true;
                ctx.renderList();
                ctx.onChange();
            });
        });
        const dimsEl = document.createElement('div');
        dimsEl.className = 'gpp-lib-row-dims';
        dimsEl.textContent = template.width + '×' + template.height + (template.position ? '' : ' · unplaced');
        meta.appendChild(nameEl);
        meta.appendChild(dimsEl);
        row.appendChild(meta);

        // Same instant (jumpTo, not flyTo) teleport as the compact grid's
        // corner arrow and the Position/Transform panel's own "Go to
        // template" button — reachable here without first focusing the
        // template just to teleport to it.
        const hasPosition = !!gppLibraryComputeTemplateCenterGrid(template);
        const gotoBtn = document.createElement('button');
        gotoBtn.type = 'button';
        gotoBtn.className = 'gpp-lib-row-goto';
        gotoBtn.textContent = '➤';
        gotoBtn.title = hasPosition ? 'Go to this template’s location' : 'No saved location yet';
        gotoBtn.disabled = !hasPosition;
        gotoBtn.addEventListener('click', event => {
            event.stopPropagation();
            if (!hasPosition) return;
            gppLibraryFlyToTemplate(template);
        });
        row.appendChild(gotoBtn);

        const visBtn = document.createElement('button');
        visBtn.type = 'button';
        visBtn.className = 'gpp-lib-row-vis';
        const paintVis = () => {
            const visible = template.opacity > 0;
            visBtn.textContent = visible ? '👁️' : '🚫';
            visBtn.title = visible ? 'Visible — click to hide' : 'Hidden — click to show';
        };
        paintVis();
        visBtn.addEventListener('click', event => {
            event.stopPropagation();
            gppLibraryToggleVisibility(template).then(() => {
                paintVis();
                ctx.onChange();
            });
        });
        row.appendChild(visBtn);

        return row;
    }

    // Fixed-size grid-view card for the Manage Templates modal — the design
    // the user explicitly asked for, modeled on ghost-template-manager.js's
    // `.gp-to-card` (click thumbnail = focus/load, corner buttons for
    // multiselect + go-to-location), but with fixed-width cards (no 1fr
    // stretch) so the grid scrolls instead of reflowing, and reusing
    // Ghost++'s own focus/visibility/teleport plumbing rather than GTM's.
    // No per-card delete button: Ghost++'s list-view rows don't have one
    // either (delete is bulk-only via the toolbar's "Delete selected"), and
    // adding one only to the grid view would make the two views diverge in
    // capability rather than just presentation.
    function gppLibraryBuildManageGridCard(template, ctx) {
        const card = document.createElement('div');
        const focused = template.id === gppState.focusedTemplateId;
        const selected = ctx.selectedIds.has(template.id);
        card.className = 'gpp-lib-manage-grid-card'
            + (focused ? ' gpp-lib-manage-grid-card-focused' : '')
            + (selected ? ' gpp-lib-manage-grid-card-selected' : '');
        card.title = template.name || 'Untitled template';
        card.draggable = true;

        card.addEventListener('dragstart', event => {
            ctx.dragState.id = template.id;
            event.dataTransfer.effectAllowed = 'move';
            try { event.dataTransfer.setData('text/plain', template.id); } catch (_) { /* some browsers require this call to succeed for drag to start */ }
            gppLibraryHideHoverPreview();
            card.classList.add('gpp-lib-manage-grid-card-dragging');
        });
        card.addEventListener('dragend', () => {
            ctx.dragState.id = null;
            card.classList.remove('gpp-lib-manage-grid-card-dragging');
            card.classList.remove('gpp-lib-manage-grid-card-dragover');
        });
        card.addEventListener('dragover', event => {
            if (!ctx.dragState.id || ctx.dragState.id === template.id) return;
            event.preventDefault();
            event.dataTransfer.dropEffect = 'move';
            card.classList.add('gpp-lib-manage-grid-card-dragover');
        });
        card.addEventListener('dragleave', () => card.classList.remove('gpp-lib-manage-grid-card-dragover'));
        card.addEventListener('drop', event => {
            event.preventDefault();
            card.classList.remove('gpp-lib-manage-grid-card-dragover');
            const sourceId = ctx.dragState.id;
            ctx.dragState.id = null;
            if (!sourceId || sourceId === template.id) return;
            (async () => {
                await gppLibraryReorderTemplates(sourceId, template.id);
                ctx.renderList();
                ctx.onChange();
            })();
        });

        const thumbWrap = document.createElement('div');
        thumbWrap.className = 'gpp-lib-manage-grid-thumb-wrap';

        // Full resolution (gppLibraryRenderFullCanvas — the same technique
        // the current-template panel/hover-preview/fullscreen-preview use),
        // not gppLibraryEnsureThumb's bounded/downsampled cache — these
        // cards render at 92x92, large enough on screen to warrant real
        // source pixels instead of a blurrier shared-cache downsample, per
        // explicit product feedback. Always a fresh, independent canvas
        // node (never the cached `.small` this modal's OWN list-view row
        // thumbnails, or the compact grid behind this modal, could have
        // their own claim on via appendChild) — see gppLibraryShowHoverPreview's
        // identical reasoning. CSS (object-fit: contain) letterboxes it to
        // fit the square box without distorting non-square templates.
        thumbWrap.appendChild(gppLibraryRenderFullCanvas(template));

        const selBtn = document.createElement('button');
        selBtn.type = 'button';
        selBtn.className = 'gpp-lib-manage-grid-sel' + (selected ? ' gpp-lib-manage-grid-sel-active' : '');
        selBtn.textContent = selected ? '☑' : '☐';
        selBtn.title = 'Select for bulk actions';
        selBtn.addEventListener('click', event => {
            event.stopPropagation();
            const nowSelected = !ctx.selectedIds.has(template.id);
            if (nowSelected) ctx.selectedIds.add(template.id); else ctx.selectedIds.delete(template.id);
            selBtn.textContent = nowSelected ? '☑' : '☐';
            selBtn.classList.toggle('gpp-lib-manage-grid-sel-active', nowSelected);
            card.classList.toggle('gpp-lib-manage-grid-card-selected', nowSelected);
            ctx.listEl.classList.toggle('gpp-lib-manage-list-grid-selmode', ctx.selectedIds.size > 0);
            ctx.syncBulkButtons();
        });
        thumbWrap.appendChild(selBtn);

        const visBtn = document.createElement('button');
        visBtn.type = 'button';
        visBtn.className = 'gpp-lib-manage-grid-vis';
        const paintVis = () => {
            const visible = template.opacity > 0;
            visBtn.textContent = visible ? '👁️' : '🚫';
            visBtn.title = visible ? 'Visible — click to hide' : 'Hidden — click to show';
        };
        paintVis();
        visBtn.addEventListener('click', event => {
            event.stopPropagation();
            gppLibraryToggleVisibility(template).then(() => {
                paintVis();
                ctx.onChange();
            });
        });
        thumbWrap.appendChild(visBtn);

        // Same visual-logic-as-absence rule as the compact grid's arrow
        // button: no button at all when the template has never been placed
        // (or was Unset), rather than a disabled/tinted one.
        const hasPosition = !!gppLibraryComputeTemplateCenterGrid(template);
        if (hasPosition) {
            const gotoBtn = document.createElement('button');
            gotoBtn.type = 'button';
            gotoBtn.className = 'gpp-lib-manage-grid-goto';
            gotoBtn.textContent = '🎯';
            gotoBtn.title = 'Load & go to this template’s location';
            gotoBtn.addEventListener('click', event => {
                event.stopPropagation();
                (async () => {
                    await gppState.focusTemplate(template.id);
                    gppLibraryFlyToTemplate(template);
                    ctx.renderList();
                    ctx.onChange();
                })();
            });
            thumbWrap.appendChild(gotoBtn);
        }

        thumbWrap.addEventListener('mouseenter', event => gppLibraryShowHoverPreview(event, template));
        thumbWrap.addEventListener('mousemove', gppLibraryMoveHoverPreview);
        thumbWrap.addEventListener('mouseleave', gppLibraryHideHoverPreview);

        card.appendChild(thumbWrap);

        const nameEl = document.createElement('div');
        nameEl.className = 'gpp-lib-manage-grid-name';
        nameEl.textContent = template.name || 'Untitled template';
        card.appendChild(nameEl);

        // Click anywhere on the card (outside the corner buttons, which
        // stopPropagation their own clicks) loads/focuses this template —
        // the exact "clicking on the template thumbnail set it to the
        // currently selected template" behavior requested, ported from
        // GTM's card-body onclick.
        card.addEventListener('click', () => {
            if (template.id === gppState.focusedTemplateId) return;
            (async () => {
                await gppState.focusTemplate(template.id);
                ctx.renderList();
                ctx.onChange();
            })();
        });

        return card;
    }

    // Hiding/showing a template is NOT a separate mechanism from its Opacity
    // slider (gpp-view-settings.js) — it just drives that same value to 0%
    // or 100%, per explicit product decision, so the two controls can never
    // drift out of sync with each other. Shared by the Manage Templates row
    // button, the compact library card button, and the bulk show/hide-
    // selected actions.
    async function gppLibraryToggleVisibility(template) {
        template.opacity = template.opacity > 0 ? 0 : 1;
        await gppState.persistTemplateState(template);
        if (typeof gppRendererSchedule === 'function') gppRendererSchedule();
    }

    async function gppLibrarySetVisibility(template, visible) {
        const target = visible ? 1 : 0;
        if (template.opacity === target) return;
        template.opacity = target;
        await gppState.persistTemplateState(template);
    }

    function gppOpenManageTemplatesModal(onChange) {
        if (document.getElementById(GPP_LIB_MANAGE_ID)) return; // already open
        gppLibraryInjectStyle();

        const selectedIds = new Set();
        const dragState = { id: null };

        const overlay = document.createElement('div');
        overlay.id = GPP_LIB_MANAGE_ID;
        overlay.className = 'gpp-lib-manage-overlay';

        const panel = document.createElement('div');
        panel.className = 'gpp-lib-manage-panel';
        overlay.appendChild(panel);

        const header = document.createElement('div');
        header.className = 'gpp-lib-manage-header';
        const title = document.createElement('span');
        title.className = 'gpp-lib-manage-title';
        title.textContent = 'Manage templates';

        const headerRight = document.createElement('div');
        headerRight.className = 'gpp-lib-manage-header-right';

        const viewToggle = document.createElement('div');
        viewToggle.className = 'gpp-lib-manage-view-toggle';
        const gridViewBtn = document.createElement('button');
        gridViewBtn.type = 'button';
        gridViewBtn.className = 'gpp-lib-manage-view-btn';
        gridViewBtn.textContent = '▦';
        gridViewBtn.title = 'Grid view';
        const listViewBtn = document.createElement('button');
        listViewBtn.type = 'button';
        listViewBtn.className = 'gpp-lib-manage-view-btn';
        listViewBtn.textContent = '☰';
        listViewBtn.title = 'List view';
        viewToggle.appendChild(gridViewBtn);
        viewToggle.appendChild(listViewBtn);

        const closeBtn = document.createElement('button');
        closeBtn.type = 'button';
        closeBtn.className = 'gpp-lib-manage-close';
        closeBtn.textContent = '✕';
        closeBtn.setAttribute('aria-label', 'Close');

        headerRight.appendChild(viewToggle);
        headerRight.appendChild(closeBtn);
        header.appendChild(title);
        header.appendChild(headerRight);
        panel.appendChild(header);

        const toolbar = document.createElement('div');
        toolbar.className = 'gpp-lib-manage-toolbar';
        const selectAllWrap = document.createElement('label');
        selectAllWrap.className = 'gpp-lib-manage-selectall';
        const selectAllCheckbox = document.createElement('input');
        selectAllCheckbox.type = 'checkbox';
        const selectAllText = document.createElement('span');
        selectAllText.textContent = 'All';
        selectAllWrap.appendChild(selectAllCheckbox);
        selectAllWrap.appendChild(selectAllText);
        const spacer = document.createElement('div');
        spacer.className = 'gpp-lib-manage-toolbar-spacer';
        const showBtn = document.createElement('button');
        showBtn.type = 'button';
        showBtn.className = 'gpp-lib-btn';
        showBtn.textContent = 'Show selected';
        const hideBtn = document.createElement('button');
        hideBtn.type = 'button';
        hideBtn.className = 'gpp-lib-btn';
        hideBtn.textContent = 'Hide selected';
        const exportBtn = document.createElement('button');
        exportBtn.type = 'button';
        exportBtn.className = 'gpp-lib-btn';
        exportBtn.textContent = 'Export selected';
        const deleteBtn = document.createElement('button');
        deleteBtn.type = 'button';
        deleteBtn.className = 'gpp-lib-btn gpp-lib-btn-danger';
        deleteBtn.textContent = 'Delete selected';
        toolbar.appendChild(selectAllWrap);
        toolbar.appendChild(spacer);
        toolbar.appendChild(showBtn);
        toolbar.appendChild(hideBtn);
        toolbar.appendChild(exportBtn);
        toolbar.appendChild(deleteBtn);
        panel.appendChild(toolbar);

        // Hidden until an export is actually running — bulk export (unlike
        // show/hide/delete) can take real, visible time (a real PNG
        // re-encode + base64 conversion per template, see
        // gpp-runtime.js's gppExportTemplatesAsJson), and used to leave the
        // modal looking frozen with no feedback while it ran. Per explicit
        // product feedback, wired to that function's own onProgress
        // callback rather than a fake/simulated bar.
        const exportProgressRow = document.createElement('div');
        exportProgressRow.className = 'gpp-lib-manage-export-progress';
        exportProgressRow.style.display = 'none';
        const exportProgressLabel = document.createElement('div');
        exportProgressLabel.className = 'gpp-lib-manage-export-progress-label';
        const exportProgressBarOuter = document.createElement('div');
        exportProgressBarOuter.className = 'gpp-lib-manage-export-progress-bar';
        const exportProgressBarInner = document.createElement('div');
        exportProgressBarInner.className = 'gpp-lib-manage-export-progress-fill';
        exportProgressBarOuter.appendChild(exportProgressBarInner);
        exportProgressRow.appendChild(exportProgressLabel);
        exportProgressRow.appendChild(exportProgressBarOuter);
        panel.appendChild(exportProgressRow);

        function setExportProgress(visible, fraction, label) {
            exportProgressRow.style.display = visible ? '' : 'none';
            if (!visible) return;
            exportProgressBarInner.style.width = Math.round(Math.max(0, Math.min(1, fraction || 0)) * 100) + '%';
            exportProgressLabel.textContent = label || 'Exporting…';
        }

        const listEl = document.createElement('div');
        listEl.className = 'gpp-lib-manage-list';
        panel.appendChild(listEl);

        function syncBulkButtons() {
            const templates = gppState.templates;
            const any = selectedIds.size > 0;
            showBtn.disabled = !any;
            hideBtn.disabled = !any;
            exportBtn.disabled = !any;
            deleteBtn.disabled = !any;
            selectAllCheckbox.checked = templates.length > 0 && selectedIds.size === templates.length;
            selectAllCheckbox.indeterminate = selectedIds.size > 0 && selectedIds.size < templates.length;
        }

        const rowCtx = { selectedIds, dragState, renderList, onChange, syncBulkButtons, listEl };

        function syncViewToggle() {
            gridViewBtn.classList.toggle('gpp-lib-manage-view-btn-active', gppManageViewMode === 'grid');
            listViewBtn.classList.toggle('gpp-lib-manage-view-btn-active', gppManageViewMode === 'list');
        }
        syncViewToggle();
        gridViewBtn.addEventListener('click', () => {
            if (gppManageViewMode === 'grid') return;
            gppManageViewMode = 'grid';
            syncViewToggle();
            renderList();
        });
        listViewBtn.addEventListener('click', () => {
            if (gppManageViewMode === 'list') return;
            gppManageViewMode = 'list';
            syncViewToggle();
            renderList();
        });

        function renderList() {
            // Selections for templates that no longer exist (e.g. deleted
            // from elsewhere while this modal was open) are dropped here.
            const templates = gppState.templates;
            const liveIds = new Set(templates.map(t => t.id));
            selectedIds.forEach(id => { if (!liveIds.has(id)) selectedIds.delete(id); });

            const isGrid = gppManageViewMode === 'grid';
            listEl.classList.toggle('gpp-lib-manage-list-grid', isGrid);
            listEl.classList.toggle('gpp-lib-manage-list-grid-selmode', isGrid && selectedIds.size > 0);
            listEl.innerHTML = '';
            if (!templates.length) {
                const empty = document.createElement('div');
                empty.className = 'gpp-lib-manage-empty';
                empty.textContent = 'No templates yet.';
                listEl.appendChild(empty);
            } else if (isGrid) {
                templates.forEach(template => listEl.appendChild(gppLibraryBuildManageGridCard(template, rowCtx)));
            } else {
                templates.forEach(template => listEl.appendChild(gppLibraryBuildManageRow(template, rowCtx)));
            }
            syncBulkButtons();
        }

        selectAllCheckbox.addEventListener('change', () => {
            selectedIds.clear();
            if (selectAllCheckbox.checked) gppState.templates.forEach(t => selectedIds.add(t.id));
            renderList();
        });

        showBtn.addEventListener('click', () => {
            const chosen = gppState.templates.filter(t => selectedIds.has(t.id));
            if (!chosen.length) return;
            (async () => {
                for (const template of chosen) {
                    try { await gppLibrarySetVisibility(template, true); } catch (err) {
                        console.error('[GeoPixelcons++] Ghost++ bulk show failed:', err);
                    }
                }
                if (typeof gppRendererSchedule === 'function') gppRendererSchedule();
                renderList();
                onChange();
            })();
        });

        hideBtn.addEventListener('click', () => {
            const chosen = gppState.templates.filter(t => selectedIds.has(t.id));
            if (!chosen.length) return;
            (async () => {
                for (const template of chosen) {
                    try { await gppLibrarySetVisibility(template, false); } catch (err) {
                        console.error('[GeoPixelcons++] Ghost++ bulk hide failed:', err);
                    }
                }
                if (typeof gppRendererSchedule === 'function') gppRendererSchedule();
                renderList();
                onChange();
            })();
        });

        exportBtn.addEventListener('click', () => {
            (async () => {
                const chosen = gppState.templates.filter(t => selectedIds.has(t.id));
                if (!chosen.length) return;
                // One combined file, not one download per template — same
                // {version, exportDate, images:[...]} shape the JSON import
                // path already accepts.
                exportBtn.disabled = true;
                setExportProgress(true, 0, 'Exporting 0 / ' + chosen.length + '…');
                try {
                    await gppState.exportTemplatesAsJson(chosen, undefined, (fraction, label) => {
                        setExportProgress(true, fraction, label + ' (' + Math.round(fraction * chosen.length) + ' / ' + chosen.length + ')');
                    });
                } catch (err) {
                    console.error('[GeoPixelcons++] Ghost++ bulk export failed:', err);
                } finally {
                    setExportProgress(false);
                    syncBulkButtons(); // restores exportBtn.disabled from the current selection
                }
            })();
        });

        deleteBtn.addEventListener('click', () => {
            const chosen = gppState.templates.filter(t => selectedIds.has(t.id));
            if (!chosen.length) return;
            const label = chosen.length === 1 ? ('"' + (chosen[0].name || 'this template') + '"') : (chosen.length + ' templates');
            if (!confirm('Delete ' + label + '? This cannot be undone.')) return;
            (async () => {
                for (const template of chosen) {
                    try { await gppState.deleteTemplate(template); } catch (err) {
                        console.error('[GeoPixelcons++] Ghost++ delete failed:', err);
                    }
                }
                selectedIds.clear();
                renderList();
                onChange();
            })();
        });

        function close() {
            document.removeEventListener('keydown', onKeydown);
            overlay.remove();
        }
        function onKeydown(event) {
            if (event.key === 'Escape') close();
        }
        document.addEventListener('keydown', onKeydown);
        closeBtn.addEventListener('click', close);
        overlay.addEventListener('click', event => { if (event.target === overlay) close(); });

        document.body.appendChild(overlay);
        renderList();
    }

    // ── Ghost++ View Settings ──────────────────────────────────────────
    // Implements the gpp-init.js render-function contract's
    // gppRenderViewSettings(container, template, onChange): a collapsible
    // "View Settings" section (using gpp-ui-shell.js's
    // <details class="gpp-collapsible"> pattern), backed by gppSettings —
    // applies to every template, not just the focused one. The per-template
    // Opacity slider used to live here as its own "Template" subsection but
    // has moved to gpp-placement.js's Template Settings panel instead, per
    // explicit product decision (grouped with the other per-template
    // controls). The master "Show error crosses" toggle (gppSettings.showErrors)
    // lives in gpp-scan.js's Error Settings section instead, alongside the
    // shape/color/opacity/size controls it gates — not here.
    //
    // The cell fill/gap-ratio slider intentionally has NO artificial floor:
    // min="0" and clamp(...,0,1), not the standalone prototype's min="20"
    // with clamp(...,0.2,1) — that floor silently prevented a fully-invisible
    // cell and is not repeated here.

    function gppClamp(value, min, max) {
        'use strict';
        if (!Number.isFinite(value)) return min;
        return Math.min(max, Math.max(min, value));
    }

    // Calls the renderer subsystem's repaint hook if it has landed yet;
    // a no-op (beyond the typeof guard) otherwise, per the shared spec.
    function gppScheduleRenderer() {
        'use strict';
        if (typeof gppRendererSchedule === 'function') {
            try { gppRendererSchedule(); } catch (err) {
                console.error('[GeoPixelcons++] Ghost++ view settings: gppRendererSchedule() failed.', err);
            }
        }
    }

    // Rewrites content every call (see gpp-init.js's theme-change observer)
    // instead of no-op-ing once created, so a live dark/light toggle isn't
    // frozen at whatever theme was active on first mount.
    function gppInjectViewSettingsStyle() {
        'use strict';
        let style = document.getElementById('gpp-view-settings-style');
        if (!style) {
            style = document.createElement('style');
            style.id = 'gpp-view-settings-style';
            document.head.appendChild(style);
        }
        style.textContent = `
            .gpp-vs-subhead {
                font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
                color: ${t2('#64748b', '#a6adc8')};
                margin: 10px 0 6px;
            }
            .gpp-vs-subhead:first-child { margin-top: 0; }
            .gpp-vs-row {
                display: flex; align-items: center; gap: 8px; margin: 6px 0;
            }
            .gpp-vs-row.gpp-vs-disabled .gpp-vs-label,
            .gpp-vs-row.gpp-vs-disabled .gpp-vs-value { opacity: .5; }
            .gpp-vs-label {
                flex: 0 0 auto; min-width: 108px; font-size: 12px;
                color: ${t2('#1f2937', '#e2e2f5')};
            }
            .gpp-vs-row input[type="range"] { flex: 1 1 auto; min-width: 0; }
            .gpp-vs-value {
                flex: 0 0 auto; min-width: 34px; text-align: right; font-size: 11px;
                font-variant-numeric: tabular-nums;
                color: ${t2('#475569', '#a6adc8')};
            }
            .gpp-vs-checkbox label {
                display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer;
                color: ${t2('#1f2937', '#e2e2f5')};
            }
            .gpp-vs-empty { font-size: 12px; color: ${t2('#64748b', '#a6adc8')}; }
            .gpp-vs-view-toggle {
                display: flex; border-radius: 6px; overflow: hidden; flex-shrink: 0;
                border: 1px solid ${t2('#d1d5db', '#45475a')};
            }
            .gpp-vs-view-btn {
                font: inherit; font-size: 12px; line-height: 1; cursor: pointer; border: none; padding: 4px 8px;
                background: ${t2('#ffffff', '#313244')}; color: ${t2('#64748b', '#a6adc8')};
            }
            .gpp-vs-view-btn:hover { background: ${t2('#f3f4f6', '#45475a')}; }
            .gpp-vs-view-btn-active {
                background: ${t2('#2563eb', '#89b4fa')}; color: ${t2('#ffffff', '#1e1e2e')};
            }
        `;
    }

    // Matches this codebase's existing reset-icon convention (see
    // hide-paint-menu.js / ghost-template-manager.js's own '↺' buttons).
    function gppAddResetButton(rowEl, title, onClick) {
        'use strict';
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.innerHTML = '↺';
        btn.title = title;
        btn.style.cssText = 'border:none; background:transparent; cursor:pointer; font-size:13px; padding:0 2px; flex-shrink:0; color:' + t2('#64748b', '#a6adc8') + ';';
        btn.addEventListener('click', onClick);
        rowEl.appendChild(btn);
    }

    function gppBuildGlobalViewSettingsSection(onChange) {
        'use strict';
        const wrap = document.createElement('div');

        const heading = document.createElement('div');
        heading.className = 'gpp-vs-subhead';
        heading.textContent = 'Global';
        wrap.appendChild(heading);

        // Cell fill/gap ratio slider — 0-100 mapped to gppSettings.gapRatio 0-1.
        const gapRow = document.createElement('div');
        gapRow.className = 'gpp-vs-row';
        const gapLabel = document.createElement('label');
        gapLabel.className = 'gpp-vs-label';
        gapLabel.htmlFor = 'gpp-vs-gap-ratio';
        gapLabel.textContent = 'Cell fill';
        const gapInput = document.createElement('input');
        gapInput.type = 'range';
        gapInput.id = 'gpp-vs-gap-ratio';
        gapInput.min = '0';
        gapInput.max = '100';
        gapInput.step = '1';
        gapInput.title = 'Ratio between filled cell and gap. 0% is fully invisible, 100% leaves no gap.';
        gapInput.value = String(Math.round(gppClamp(gppSettings.gapRatio, 0, 1) * 100));
        const gapValue = document.createElement('span');
        gapValue.className = 'gpp-vs-value';
        gapValue.textContent = gapInput.value + '%';
        gapInput.addEventListener('input', () => {
            gapValue.textContent = gapInput.value + '%';
            gppSettings.gapRatio = gppClamp(Number(gapInput.value) / 100, 0, 1);
            gppState.saveSettings();
            gppScheduleRenderer();
        });
        gapInput.addEventListener('change', () => { onChange && onChange(); });
        gapRow.append(gapLabel, gapInput, gapValue);
        gppAddResetButton(gapRow, 'Reset to default cell fill', () => {
            gapInput.value = '60';
            gapValue.textContent = '60%';
            gppSettings.gapRatio = 0.6;
            gppState.saveSettings();
            gppScheduleRenderer();
            onChange && onChange();
        });
        wrap.appendChild(gapRow);

        // "Only show current template on map" — on by default (changed from
        // off per explicit product decision: this is the behavior most
        // people want, and it composes with gpp-renderer.js's
        // gppRendererIsTemplateVisible fix (opacity 0 = zero GPU/canvas
        // resources) to guarantee at most one template's resources are ever
        // held at once — a real performance win for a big library, not just
        // a viewing preference). When on, focusing a template hides every
        // other one (gppFocusTemplate -> gppApplyAutoHideUnfocused,
        // gpp-runtime.js); also applied immediately here on check (not just
        // on the next focus change) so the setting feels live rather than
        // inert until you happen to refocus something. Setting key
        // (gppSettings.autoHideUnfocused) and element id
        // (gpp-vs-auto-hide-unfocused) are unchanged — only the label text
        // changed, so existing saved settings still apply correctly.
        const autoHideRow = document.createElement('div');
        autoHideRow.className = 'gpp-vs-row gpp-vs-checkbox';
        const autoHideLabel = document.createElement('label');
        const autoHideInput = document.createElement('input');
        autoHideInput.type = 'checkbox';
        autoHideInput.id = 'gpp-vs-auto-hide-unfocused';
        autoHideInput.checked = !!gppSettings.autoHideUnfocused;
        const autoHideText = document.createElement('span');
        autoHideText.textContent = 'Only show current template on map';
        autoHideLabel.htmlFor = autoHideInput.id;
        autoHideLabel.title = 'Only ever show the template you\'re currently focused on — every other template hides automatically. Also reduces memory/GPU usage when you have many (or very large) templates.';
        autoHideLabel.append(autoHideInput, autoHideText);
        autoHideRow.appendChild(autoHideLabel);
        autoHideInput.addEventListener('change', () => {
            gppSettings.autoHideUnfocused = autoHideInput.checked;
            gppState.saveSettings();
            if (autoHideInput.checked && gppState.focusedTemplateId) {
                gppState.applyAutoHideUnfocused(gppState.focusedTemplateId).then(() => onChange && onChange());
            } else {
                onChange && onChange();
            }
        });
        wrap.appendChild(autoHideRow);

        // "Gray unselected color boxes" — on by default (today's existing
        // behavior). A disabled palette swatch (gpp-palette.js) always gets
        // the diagonal slash regardless of this setting — that part is
        // non-negotiable, per explicit product decision. This checkbox only
        // controls the EXTRA grayscale/opacity dimming layered on top of the
        // slash: on, a disabled swatch is both grayed AND slashed (today's
        // look); off, it's slashed only, with its true color still showing
        // through. Applied live via gpp-palette.js's performFilterSort,
        // which reads this setting fresh on every render — no
        // per-template state to touch here, unlike Auto-hide above.
        const grayRow = document.createElement('div');
        grayRow.className = 'gpp-vs-row gpp-vs-checkbox';
        const grayLabel = document.createElement('label');
        const grayInput = document.createElement('input');
        grayInput.type = 'checkbox';
        grayInput.id = 'gpp-vs-gray-disabled-swatches';
        grayInput.checked = gppSettings.grayDisabledSwatches !== false;
        const grayText = document.createElement('span');
        grayText.textContent = 'Gray unselected color boxes';
        grayLabel.htmlFor = grayInput.id;
        grayLabel.title = 'A disabled color always gets the diagonal slash; this also dims/grays it. Turn off to keep only the slash and see the true color underneath.';
        grayLabel.append(grayInput, grayText);
        grayRow.appendChild(grayLabel);
        grayInput.addEventListener('change', () => {
            gppSettings.grayDisabledSwatches = grayInput.checked;
            gppState.saveSettings();
            onChange && onChange();
        });
        wrap.appendChild(grayRow);

        // "Palette view" — grid (default, the current tile layout) vs list
        // (compact rectangular rows showing the hex value, placed/total as
        // "x/y", and a mini progress bar). Still driven by the exact same
        // sort/filter state as the grid — this only changes how each
        // already-filtered/sorted color is drawn (see gpp-palette.js's
        // performFilterSort, which reads gppSettings.paletteViewMode fresh
        // on every refresh, same live-toggle pattern as Gray unselected
        // color boxes above — no template switch needed to see it apply).
        const viewModeRow = document.createElement('div');
        viewModeRow.className = 'gpp-vs-row';
        const viewModeLabel = document.createElement('span');
        viewModeLabel.className = 'gpp-vs-label';
        viewModeLabel.textContent = 'Palette view';
        const viewModeToggle = document.createElement('div');
        viewModeToggle.className = 'gpp-vs-view-toggle';
        const gridViewBtn = document.createElement('button');
        gridViewBtn.type = 'button';
        gridViewBtn.className = 'gpp-vs-view-btn';
        gridViewBtn.id = 'gpp-vs-palette-view-grid';
        gridViewBtn.textContent = '▦';
        gridViewBtn.title = 'Grid view';
        const listViewBtn = document.createElement('button');
        listViewBtn.type = 'button';
        listViewBtn.className = 'gpp-vs-view-btn';
        listViewBtn.id = 'gpp-vs-palette-view-list';
        listViewBtn.textContent = '☰';
        listViewBtn.title = 'List view';
        function syncPaletteViewButtons() {
            const mode = gppSettings.paletteViewMode === 'list' ? 'list' : 'grid';
            gridViewBtn.classList.toggle('gpp-vs-view-btn-active', mode === 'grid');
            listViewBtn.classList.toggle('gpp-vs-view-btn-active', mode === 'list');
        }
        syncPaletteViewButtons();
        gridViewBtn.addEventListener('click', () => {
            if (gppSettings.paletteViewMode === 'grid') return;
            gppSettings.paletteViewMode = 'grid';
            gppState.saveSettings();
            syncPaletteViewButtons();
            onChange && onChange();
        });
        listViewBtn.addEventListener('click', () => {
            if (gppSettings.paletteViewMode === 'list') return;
            gppSettings.paletteViewMode = 'list';
            gppState.saveSettings();
            syncPaletteViewButtons();
            onChange && onChange();
        });
        viewModeToggle.append(gridViewBtn, listViewBtn);
        viewModeRow.append(viewModeLabel, viewModeToggle);
        wrap.appendChild(viewModeRow);

        // No "Native compatibility" toggle — it's assumed on for as long as
        // Ghost++ itself is enabled (see gpp-native-shim.js), by explicit
        // product decision. There's nothing meaningful to opt out of
        // independently: it keeps native cursor sampling, error-cross
        // rendering, and the location readout pointed at your focused
        // template, and no painting or queue submission is automated.

        // No zoom-out completed-preview toggle here anymore — replaced by the
        // Position/Transform panel's manual "Preview" button (gpp-placement.js),
        // which just drives the same template.opacity field the visibility
        // eye-icon/Opacity slider already use, per explicit product decision.

        return wrap;
    }

    function gppRenderViewSettings(container, template, onChange) {
        'use strict';
        if (!container) return;
        gppInjectViewSettingsStyle();

        // Preserve the <details> open/closed state across the full-rebuild
        // refreshes gpp-init.js triggers after every onChange() — without
        // this, committing any control (a slider's 'change', a checkbox)
        // would snap the whole section shut on every interaction. Defaults
        // to COLLAPSED on the very first render (no prior <details> to read)
        // — per explicit product decision, View Settings and Error Settings
        // start collapsed while Progress/Template Colors/Template Settings/
        // Templates start expanded.
        const previous = container.querySelector('details.gpp-collapsible');
        const wasOpen = previous ? previous.open : false;

        container.innerHTML = '';

        const details = document.createElement('details');
        details.className = 'gpp-collapsible';
        details.open = wasOpen;

        const summary = document.createElement('summary');
        summary.textContent = 'View Settings';
        details.appendChild(summary);

        const body = document.createElement('div');
        body.className = 'gpp-body';
        body.appendChild(gppBuildGlobalViewSettingsSection(onChange));
        details.appendChild(body);

        container.appendChild(details);
    }

    // ── Ghost++ Template Overlay ──────────────────────────────────────
    // Mounts the runtime (gpp-runtime.js) into the shell (gpp-ui-shell.js)
    // and wires ingestion. Panel content is delegated to well-known render
    // functions so palette/library/progress/view-settings modules can be
    // developed independently and plugged in here without editing this file:
    //   gppRenderPositionTransform(container, template)  — collapsible section
    //   gppRenderViewSettings(container, template)        — collapsible section
    //   gppRenderPalette(container, template)              — colour panel
    //   gppRenderProgressBar(container, template)          — completion bar
    //   gppRenderTemplateLibrary(container)                 — right-panel library
    // Each is optional — if undefined, its container is left empty rather
    // than throwing, so this file works standalone while those land.
    // Lets code outside this file's own closures (namely gpp-placement.js's
    // global keyboard-shortcut handler) trigger the exact same side-panel
    // refresh a button click would, without needing its own reference into
    // `open()`'s local `refreshAll` — set once below, right after
    // `refreshAll` is declared (function declarations are hoisted, so the
    // assignment's own position inside _init_ghostPlusPlus() doesn't matter).
    let gppLastRefreshAll = null;
    function gppRequestUiRefresh() {
        if (typeof gppLastRefreshAll === 'function') gppLastRefreshAll();
    }

    if (_settings.ghostPlusPlus) {
        function gppStartGhostPlusPlus() {
        try {
            (function _init_ghostPlusPlus() {
                const modalEl = gppBuildModalShell();
                gppLastRefreshAll = refreshAll;
                const openerRefs = gppReplaceNativeOpener(() => {
                    if (modalEl.classList.contains('gpp-hidden')) open();
                    else close();
                });
                modalEl.addEventListener('click', event => {
                    if (event.target.closest('[data-gpp-action="close"]')) close();
                });

                // Every gppInject*Style() function now rewrites its <style>
                // tag's content on each call (see their own header comments)
                // instead of writing it once — this observer is what actually
                // triggers those re-writes when the site's theme changes live
                // (any toggle, from any source, ultimately flips body.dark,
                // since that's the one signal Tailwind's own dark: variants
                // key off — see CLAUDE.md). Without this, every t2()-derived
                // colour across the whole feature stays frozen at whatever
                // theme was active on first mount.
                let gppThemeRefreshQueued = false;
                function gppRefreshTheme() {
                    if (gppThemeRefreshQueued) return;
                    gppThemeRefreshQueued = true;
                    Promise.resolve().then(() => {
                        gppThemeRefreshQueued = false;
                        [gppInjectShellStyle, gppInjectPaletteStyle, gppLibraryInjectStyle,
                            gppInjectPlacementStyle, gppInjectViewSettingsStyle].forEach(fn => {
                            if (typeof fn === 'function') {
                                try { fn(); } catch (err) { console.error('[GeoPixelcons++] Ghost++ theme refresh failed.', err); }
                            }
                        });
                        // Re-render visible panel content too, since gpp-scan.js's
                        // progress bar (and any other inline-styled elements) only
                        // pick up fresh t2() values when they rebuild, not from a
                        // stylesheet.
                        if (!modalEl.classList.contains('gpp-hidden') && runtimeReady) refreshAll();
                    });
                }
                new MutationObserver(gppRefreshTheme).observe(document.body, { attributes: true, attributeFilter: ['class'] });

                // The MutationObserver above only catches changes to body's
                // own class attribute. core.js's isDarkMode() (which t2()
                // calls through) ALSO reads a separate signal first — the
                // "GeoPixels++" script's own theme choice, stored under
                // localStorage['geo++_settings'] — and that script may not
                // touch body.dark at all when the user switches it. Same-tab
                // localStorage writes never fire the 'storage' event either
                // (that only fires in OTHER tabs), so there is no reliable
                // change signal to listen for here — poll isDarkMode()'s
                // actual return value instead and refresh whenever it flips,
                // regardless of which theme source caused the flip.
                let gppLastKnownDark = isDarkMode();
                setInterval(() => {
                    const nowDark = isDarkMode();
                    if (nowDark !== gppLastKnownDark) {
                        gppLastKnownDark = nowDark;
                        gppRefreshTheme();
                    }
                }, 1000);

                let runtimeReady = null;

                function ensureRuntime() {
                    if (!runtimeReady) runtimeReady = gppInitRuntime().then(state => {
                        // gppShimEnable() is unconditional and re-entrant-safe, so it's
                        // fine to call every time the runtime becomes ready.
                        if (typeof gppShimEnable === 'function') gppShimEnable();
                        // Same self-guarding reasoning — gppEnsureAutoscanHook() only
                        // wraps placePixelAt once and gppScheduleAutoscan() itself
                        // checks gppSettings.autoscanEnabled, so installing the hook
                        // unconditionally here is safe even when autoscan is off.
                        if (typeof gppEnsureAutoscanHook === 'function') gppEnsureAutoscanHook();
                        // Rewires the guild menu's "Set as Ghost" button into Ghost++'s
                        // own ingestion pipeline instead of the native ghost tool —
                        // needs gppState, hence installed here rather than at script load.
                        if (typeof gppEnsureGuildProjectHook === 'function') gppEnsureGuildProjectHook();
                        return state;
                    }).catch(err => {
                        runtimeReady = null;
                        throw err;
                    });
                    return runtimeReady;
                }

                async function open() {
                    modalEl.classList.remove('gpp-hidden');
                    // Always re-center on open, discarding any left/top a
                    // previous drag left behind as an inline style — the
                    // modal DOM node persists across close/reopen (only the
                    // .gpp-hidden class toggles), so without this it would
                    // silently keep reopening wherever it was last dragged to.
                    // getBoundingClientRect() forces the layout needed to read
                    // real dimensions immediately after unhiding it.
                    const modalRect = modalEl.getBoundingClientRect();
                    modalEl.style.left = Math.max(0, (window.innerWidth - modalRect.width) / 2) + 'px';
                    modalEl.style.top = Math.max(0, (window.innerHeight - modalRect.height) / 2) + 'px';
                    modalEl.style.right = 'auto';
                    const body = document.getElementById('gpp-left-body');
                    // Build the shell (drop zone + every section container)
                    // ONCE ever, not on every open — see ensureShellBuilt's
                    // own comment for why this matters far beyond avoiding
                    // wasted DOM work.
                    ensureShellBuilt();
                    // Run one refreshAll() pass IMMEDIATELY, instead of
                    // blocking behind a blank "Loading…" placeholder until
                    // the full template library (gpp-runtime.js's
                    // gppInitRuntime -> gppLoadTemplateLibrary, which decodes
                    // every shared/private template) finishes — that decode
                    // is already running in the background from page load
                    // (gpp-renderer.js's auto-mount also awaits it), so on a
                    // slow/first open the modal used to sit on "Loading…" for
                    // however much of it was still outstanding. Every
                    // gppRender* function already tolerates template=null
                    // (gppState.getFocusedTemplate() safely returns null
                    // while gppTemplates is still empty), so this first pass
                    // just renders each section's normal empty state — real
                    // content replaces it via the second refreshAll() call
                    // below the moment the runtime actually finishes. Skipped
                    // once the runtime has ALREADY finished (every open after
                    // the first) — it would otherwise render the exact same
                    // content the refreshAll() below is about to render again
                    // a moment later, for nothing; with a real-sized library
                    // (tens of templates) that redundant full re-render was a
                    // large, pointless chunk of the "reopening feels slow"
                    // complaint on top of the listener leak above.
                    if (!gppState.runtimeReady) refreshAll();
                    try {
                        await ensureRuntime();
                        refreshAll();
                    } catch (err) {
                        body.innerHTML = '';
                        const errorEl = document.createElement('p');
                        errorEl.style.cssText = 'font-size:12px; color:#ef4444;';
                        errorEl.textContent = 'Ghost++ failed to start: ' + (err && err.message ? err.message : String(err));
                        body.appendChild(errorEl);
                        console.error('[GeoPixelcons++] Ghost++ runtime init failed:', err);
                    }
                }

                function close() {
                    modalEl.classList.add('gpp-hidden');
                }

                // Builds the shell (drop zone + every section container) the
                // FIRST time open() runs, and never again afterward (checked
                // via #gpp-drop-zone's own presence) — previously this wiped
                // and rebuilt #gpp-left-body's entire innerHTML on EVERY
                // open(), which meant #gpp-palette-section (and every other
                // section container) was a brand-new DOM node each time.
                // That defeated gpp-palette.js's own gppPaletteControllers
                // WeakMap (keyed by container — a new container is always a
                // cache miss), forcing gppCreatePaletteController to run
                // fresh on every single open and re-register its 4
                // document-level listeners (two dismiss-on-outside-click,
                // one drag-end, one contextmenu) — accumulating forever with
                // no matching removeEventListener, since the OLD container
                // (and its now-detached listeners' closures) just leaked
                // instead of being cleaned up. With a real personal library
                // (tens of templates, so a non-trivial gppRenderTemplateLibrary/
                // gppCreatePaletteController cost per rebuild too), a handful
                // of reopens was enough for this to be genuinely felt as
                // "opening got slow". Keeping the same container nodes
                // across opens lets that WeakMap cache actually do its job —
                // the controller (and its listeners) are now built once per
                // page load, not once per open.
                function ensureShellBuilt() {
                    if (document.getElementById('gpp-drop-zone')) return;
                    const body = document.getElementById('gpp-left-body');
                    body.innerHTML = `
                        <div id="gpp-drop-zone">
                            <div><strong>Drop, paste, or click to choose template files</strong></div>
                            <div class="gpp-muted" style="font-size:11px;">PNG, JPEG/JFIF, WebP, or .json (export) supported</div>
                            <button type="button" id="gpp-url-upload-btn">or load from a URL</button>
                            <input id="gpp-file-input" type="file" accept="image/png,image/jpeg,image/jfif,image/webp,application/json,.json" multiple style="display:none;">
                        </div>
                        <div id="gpp-ingest-status" class="gpp-muted" style="font-size:11px; min-height:14px;"></div>
                        <div id="gpp-progress-section"></div>
                        <div id="gpp-error-settings-section"></div>
                        <div id="gpp-view-settings-section"></div>
                        <div id="gpp-palette-section"></div>
                    `;
                    wireDropZone();
                }

                function refreshAll() {
                    const template = gppState.getFocusedTemplate();
                    const editingLabel = document.getElementById(GPP_IDS.editingLabel);
                    if (!template) {
                        editingLabel.textContent = '';
                    } else if (template.position) {
                        editingLabel.textContent = template.name + ' — X: ' + template.position.gridX + ', Y: ' + template.position.gridY;
                    } else {
                        editingLabel.textContent = template.name + ' — not placed';
                    }

                    // Single choke point every state-changing action already flows through
                    // (ingest, focus change, mask/colour toggle, position edit, transform all
                    // end in onChange() -> refreshAll()), so this one guarded call is enough to
                    // keep the native-ghost mirror in sync without touching every caller site.
                    if (typeof gppShimSyncFocusedTemplate === 'function') gppShimSyncFocusedTemplate();

                    const progressContainer = document.getElementById('gpp-progress-section');
                    if (typeof gppRenderProgressBar === 'function' && progressContainer) {
                        gppRenderProgressBar(progressContainer, template, refreshAll);
                    }
                    const errorSettingsContainer = document.getElementById('gpp-error-settings-section');
                    if (typeof gppRenderErrorSettings === 'function' && errorSettingsContainer) {
                        gppRenderErrorSettings(errorSettingsContainer, template, refreshAll);
                    }
                    const vsContainer = document.getElementById('gpp-view-settings-section');
                    if (typeof gppRenderViewSettings === 'function' && vsContainer) {
                        gppRenderViewSettings(vsContainer, template, refreshAll);
                    }
                    const paletteContainer = document.getElementById('gpp-palette-section');
                    if (typeof gppRenderPalette === 'function' && paletteContainer) {
                        gppRenderPalette(paletteContainer, template, refreshAll);
                    }
                    const libraryContainer = document.getElementById(GPP_IDS.rightContent);
                    if (typeof gppRenderTemplateLibrary === 'function' && libraryContainer) {
                        gppRenderTemplateLibrary(libraryContainer, refreshAll);
                    }
                    // Position/Transform now renders INSIDE the library panel's
                    // "current template" section (gpp-lib-current) rather than
                    // its own top-level shell section — see gpp-library.js's
                    // gppRenderTemplateLibrary, which is what actually creates
                    // gpp-lib-current-pt, so this call must come after the one
                    // above, not in its old spot right after Error Settings.
                    const ptContainer = document.getElementById(GPP_LIB_CURRENT_PT_ID);
                    if (typeof gppRenderPositionTransform === 'function' && ptContainer) {
                        gppRenderPositionTransform(ptContainer, template, refreshAll);
                    }
                }

                function setIngestStatus(text, isError) {
                    const el = document.getElementById('gpp-ingest-status');
                    if (!el) return;
                    el.textContent = text;
                    el.style.color = isError ? '#ef4444' : '';
                }

                async function ingestFileList(files) {
                    const list = Array.from(files || []);
                    for (const file of list) {
                        try {
                            if (file.type === 'application/json' || /\.json$/i.test(file.name || '')) {
                                const created = await gppState.ingestJsonFile(file, (fraction, label) => {
                                    setIngestStatus((label || 'Importing') + ' — ' + file.name + ' (' + Math.round(fraction * 100) + '%)');
                                });
                                setIngestStatus('Imported ' + created.length + (created.length === 1 ? ' template' : ' templates') + ' from ' + file.name + '.');
                                continue;
                            } else if (gppState.acceptedImageTypes.includes(file.type)) {
                                await gppState.ingestImageFile(file, (fraction, label) => {
                                    setIngestStatus((label || 'Importing') + ' — ' + file.name + ' (' + Math.round(fraction * 100) + '%)');
                                });
                            } else {
                                setIngestStatus('Skipped ' + (file.name || 'file') + ': unsupported type.', true);
                                continue;
                            }
                            setIngestStatus('Imported ' + file.name + '.');
                        } catch (err) {
                            setIngestStatus('Failed to import ' + (file.name || 'file') + ': ' + (err && err.message ? err.message : String(err)), true);
                            console.error('[GeoPixelcons++] Ghost++ ingest failed:', err);
                        }
                    }
                    refreshAll();
                }

                // ---- URL upload (shares the drop zone's own ingest pipeline) ----
                // Mirrors the legacy Ghost Template Manager's own "Load from URL" button
                // (ghost-template-manager.js's handleUrlUpload), reusing the same
                // GM_xmlhttpRequest bypass for CORS/CSP (already granted, plus @connect *,
                // in header.js) -- but funnels the result through THIS feature's own
                // ingestFileList() instead of duplicating its per-type dispatch/status/
                // error handling.
                const GPP_URL_EXT_TO_MIME = {
                    png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
                    jfif: 'image/jfif', webp: 'image/webp', json: 'application/json',
                };

                function gppFetchBlobViaGM(url) {
                    return new Promise((resolve, reject) => {
                        if (typeof GM_xmlhttpRequest !== 'function') {
                            reject(new Error('URL upload is unavailable (GM_xmlhttpRequest is missing).'));
                            return;
                        }
                        GM_xmlhttpRequest({
                            method: 'GET',
                            url: url,
                            responseType: 'blob',
                            onload: response => {
                                if (response.status >= 200 && response.status < 300) {
                                    resolve(response.response);
                                } else {
                                    reject(new Error('HTTP ' + response.status + (response.statusText ? ' ' + response.statusText : '')));
                                }
                            },
                            onerror: () => reject(new Error('Network error while fetching the URL.')),
                            ontimeout: () => reject(new Error('Request timed out.')),
                        });
                    });
                }

                function gppInferTypeFromUrl(url) {
                    try {
                        const match = /\.([a-z0-9]+)$/i.exec(new URL(url).pathname);
                        if (match) return GPP_URL_EXT_TO_MIME[match[1].toLowerCase()] || null;
                    } catch (err) { /* invalid URL -- handled by the caller's own dispatch check */ }
                    return null;
                }

                function gppDeriveFilenameFromUrl(url, mimeType) {
                    let name = '';
                    try {
                        name = decodeURIComponent(new URL(url).pathname.split('/').pop() || '');
                    } catch (err) { /* falls through to the default name below */ }
                    if (!name) name = 'url-upload';
                    const ext = Object.keys(GPP_URL_EXT_TO_MIME).find(key => GPP_URL_EXT_TO_MIME[key] === mimeType);
                    if (ext && !new RegExp('\\.' + ext + '$', 'i').test(name)) name += '.' + ext;
                    return name;
                }

                async function ingestFromUrl(rawUrl) {
                    const url = /^https?:\/\//i.test(rawUrl) ? rawUrl : 'https://' + rawUrl;
                    setIngestStatus('Fetching ' + url + '…');
                    let blob;
                    try {
                        blob = await gppFetchBlobViaGM(url);
                    } catch (err) {
                        setIngestStatus('Failed to fetch URL: ' + (err && err.message ? err.message : String(err)), true);
                        console.error('[GeoPixelcons++] Ghost++ URL ingest fetch failed:', err);
                        return;
                    }
                    // blob.type can carry a trailing parameter (e.g. a real
                    // JSON API responding with 'application/json;
                    // charset=utf-8') that would otherwise defeat the exact
                    // equality/includes() checks below even though the type
                    // itself is perfectly usable.
                    let mimeType = (blob.type || '').split(';')[0].trim().toLowerCase();
                    if (mimeType !== 'application/json' && !gppState.acceptedImageTypes.includes(mimeType)) {
                        mimeType = gppInferTypeFromUrl(url) || mimeType;
                    }
                    if (mimeType !== 'application/json' && !gppState.acceptedImageTypes.includes(mimeType)) {
                        setIngestStatus('Could not tell what that URL points to — make sure it ends in .png/.jpg/.jpeg/.webp/.json or the server sends a matching Content-Type.', true);
                        return;
                    }
                    const file = new File([blob], gppDeriveFilenameFromUrl(url, mimeType), { type: mimeType });
                    await ingestFileList([file]);
                }

                function handleUrlUploadClick(event) {
                    event.stopPropagation(); // the drop zone itself also has a click -> file-picker handler
                    const input = prompt('Enter an image or .json template URL:');
                    if (!input) return;
                    const trimmed = input.trim();
                    if (!trimmed) return;
                    ingestFromUrl(trimmed);
                }

                function wireDropZone() {
                    const dropZone = document.getElementById('gpp-drop-zone');
                    const fileInput = document.getElementById('gpp-file-input');
                    document.getElementById('gpp-url-upload-btn').addEventListener('click', handleUrlUploadClick);
                    dropZone.addEventListener('click', () => fileInput.click());
                    fileInput.addEventListener('change', () => {
                        ingestFileList(fileInput.files);
                        fileInput.value = '';
                    });
                    dropZone.addEventListener('dragover', event => {
                        if (!event.dataTransfer || !Array.from(event.dataTransfer.types || []).includes('Files')) return;
                        event.preventDefault();
                        dropZone.classList.add('gpp-dragging');
                    });
                    dropZone.addEventListener('dragleave', () => dropZone.classList.remove('gpp-dragging'));
                    dropZone.addEventListener('drop', event => {
                        if (!event.dataTransfer || !event.dataTransfer.files.length) return;
                        event.preventDefault();
                        dropZone.classList.remove('gpp-dragging');
                        ingestFileList(event.dataTransfer.files);
                    });
                    modalEl.addEventListener('paste', event => {
                        if (!event.clipboardData) return;
                        const files = Array.from(event.clipboardData.items || [])
                            .filter(item => item.kind === 'file')
                            .map(item => item.getAsFile())
                            .filter(Boolean);
                        if (files.length) ingestFileList(files);
                    });
                }

                _featureStatus.ghostPlusPlus = 'ok';
                dbgPush('Ghost++ Template Overlay initialized successfully — opener mounted' + (openerRefs && openerRefs.native ? ' next to the native ghost button' : ' (native ghost button not found synchronously — see the gpp-opener-native-button log entries above for how that was handled)') + '.', { uiComponent: 'Ghost++ Template Overlay' });
                console.log('[GeoPixelcons++] ✅ Ghost++ Template Overlay loaded');
            })();
        } catch (err) {
            _featureStatus.ghostPlusPlus = 'error';
            dbgPush(`Ghost++ Template Overlay init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Ghost++ Template Overlay' });
            console.error('[GeoPixelcons++] ❌ Ghost++ Template Overlay failed:', err);
        }
        }
        // Logged unconditionally (dbgPush itself no-ops unless Enable
        // Debugging is on) so a user's exported debug log shows exactly
        // which branch Ghost++ took on their machine — the Firefox bug
        // report this was added for ("toggled Ghost++ on, still see the
        // old menu") turned out to be a race in gppReplaceNativeOpener
        // finding the native button, not this gate itself, but knowing
        // whether the page was even done loading yet is the first thing
        // worth checking from an exported log, before digging further.
        dbgPush('Ghost++ init: document.readyState was "' + document.readyState + '" at script-execution time — ' + (document.readyState === 'loading' ? 'waiting for DOMContentLoaded before starting.' : 'starting immediately.'), { uiComponent: 'Ghost++ Template Overlay' });
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', function() {
                dbgPush('Ghost++ init: DOMContentLoaded fired — starting now.', { uiComponent: 'Ghost++ Template Overlay' });
                gppStartGhostPlusPlus();
            });
        } else {
            gppStartGhostPlusPlus();
        }
    }


    // ============================================================
    //  FEATURE: Ghost Palette Color Search [ghostPaletteSearch]
    // ============================================================
    if (_settings.ghostPaletteSearch) {
        try {
            (function _init_ghostPaletteSearch() {

    // Wait for the ghostColorPalette to exist. Polls forever by design (no
    // giveup-and-stop) -- unlike a UI button that can gracefully stay
    // unmounted, this is the anchor the whole search/filter feature is built
    // on, so there's no fallback if it never appears. #ghostColorPalette is
    // never (re)populated while Ghost++ owns the overlay slot (see gpp-
    // native-shim.js's own header comment), so for a Ghost++ user this is a
    // genuinely expected, permanent "still waiting" state, not a bug -- the
    // 15s mark logs that context once instead of leaving zero trace of why
    // this feature's UI never showed up.
    function waitForElement(selector, callback) {
        const startedAt = Date.now();
        let firstCheck = true;
        let warnedSlow = false;
        function check() {
            const element = document.querySelector(selector);
            if (element) {
                dbgPush('Ghost Palette Search: ' + selector + ' found after ' + (Date.now() - startedAt) + 'ms.', { uiComponent: 'Ghost Palette Search' });
                callback(element);
                return;
            }
            if (firstCheck) {
                firstCheck = false;
                dbgPush('Ghost Palette Search: ' + selector + ' not found yet -- watching (polling every 500ms).', { uiComponent: 'Ghost Palette Search' });
            }
            if (!warnedSlow && (Date.now() - startedAt) >= 15000) {
                warnedSlow = true;
                dbgPush('Ghost Palette Search: still waiting for ' + selector + ' after 15s -- if Ghost++ is enabled, this is expected (it blocks the native ghost palette from ever populating while it owns the overlay), so this search UI will stay unmounted until Ghost++ is disabled. Will keep watching in case that changes.', { uiComponent: 'Ghost Palette Search' });
                console.warn('[GeoPixelcons++] Ghost Palette Search: still waiting for ' + selector + ' after 15s.');
            }
            setTimeout(check, 500);
        }
        check();
    }

    // Add CSS for the glow effect
    const style = document.createElement('style');
    style.textContent = `
        .color-search-glow {
            box-shadow: 0 0 8px 2px rgba(255, 215, 0, 0.8) !important;
            animation: pulse-glow 1.5s ease-in-out infinite;
        }

        @keyframes pulse-glow {
            0%, 100% {
                box-shadow: 0 0 8px 2px rgba(255, 215, 0, 0.8) !important;
            }
            50% {
                box-shadow: 0 0 12px 3px rgba(255, 215, 0, 1) !important;
            }
        }

        .color-search-container {
            margin-bottom: 12px;
            padding: 12px;
            background: var(--color-gray-200, #f9fafb);
            border-radius: 8px;
            border: 1px solid var(--color-gray-300, #e5e7eb);
        }

        .color-search-input {
            width: 100%;
            min-height: 34px;
            height: 34px;
            max-height: 110px;
            padding: 8px 12px;
            border: 2px solid var(--color-gray-400, #d1d5db);
            border-radius: 6px;
            font-size: 14px;
            line-height: 18px;
            transition: border-color 0.2s;
            background: var(--color-gray-100, #fff);
            color: var(--color-gray-900, inherit);
            resize: vertical;
            overflow-y: auto;
            box-sizing: border-box;
        }

        .color-search-input:focus {
            outline: none;
            border-color: #3b82f6;
        }

        .color-search-input::placeholder {
            color: var(--color-gray-600, #9ca3af);
        }

        .gpc-search-toggle-row {
            display: flex;
            align-items: center;
            gap: 6px;
            margin-top: 8px;
            font-size: 14px;
            color: var(--color-gray-800, #374151);
        }

        .gpc-search-toggle-row input {
            width: 15px;
            height: 15px;
            cursor: pointer;
        }

        .gpc-search-toggle-row label {
            cursor: pointer;
            user-select: none;
        }

        .gpc-search-controls-row {
            display: flex;
            align-items: center;
            gap: 8px;
            margin-top: 8px;
        }

        .gpc-filter-dropdown {
            position: relative;
            width: 42%;
            min-width: 142px;
            flex: 0 0 42%;
        }

        .gpc-filter-button,
        .gpc-sort-select {
            width: 100%;
            min-height: 30px;
            border: 2px solid var(--color-gray-400, #d1d5db);
            border-radius: 6px;
            background: var(--color-gray-100, #fff);
            color: var(--color-gray-900, inherit);
            font-size: 13px;
            cursor: pointer;
        }

        .gpc-filter-button {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 8px;
            padding: 3px 8px;
            text-align: left;
        }

        .gpc-filter-menu {
            display: none;
            position: absolute;
            top: calc(100% + 4px);
            left: 0;
            z-index: 100000;
            min-width: 210px;
            padding: 6px;
            border: 1px solid var(--color-gray-300, #e5e7eb);
            border-radius: 8px;
            background: var(--color-gray-100, #fff);
            box-shadow: 0 8px 24px rgba(0,0,0,0.16);
        }

        .gpc-filter-menu.gpc-open {
            display: flex;
            flex-direction: column;
            gap: 2px;
        }

        .gpc-filter-option {
            display: flex;
            align-items: center;
            gap: 7px;
            padding: 4px 5px;
            border-radius: 5px;
            font-size: 13px;
            color: var(--color-gray-900, inherit);
            cursor: pointer;
            user-select: none;
        }

        .gpc-filter-option:hover {
            background: var(--color-gray-200, #f3f4f6);
        }

        .gpc-filter-option input {
            width: 14px;
            height: 14px;
            cursor: pointer;
        }

        .gpc-filter-count {
            display: flex;
            align-items: center;
            gap: 6px;
            flex-wrap: wrap;
            padding: 2px 5px 4px 26px;
            font-size: 13px;
            color: var(--color-gray-900, inherit);
        }

        .gpc-count-input {
            width: 64px;
            min-height: 26px;
            border: 2px solid var(--color-gray-400, #d1d5db);
            border-radius: 5px;
            background: var(--color-gray-100, #fff);
            color: var(--color-gray-900, inherit);
            font-size: 12px;
            padding: 2px 6px;
        }

        .gpc-sort-wrap {
            display: flex;
            align-items: center;
            gap: 6px;
            flex: 1 1 auto;
            min-width: 190px;
        }

        .gpc-sort-select {
            flex: 1 1 auto;
            padding: 3px 6px;
        }

        .color-search-container.gpc-controls-stacked .gpc-search-controls-row {
            flex-direction: column;
            align-items: stretch;
        }

        .color-search-container.gpc-controls-stacked .gpc-filter-dropdown {
            width: 100%;
            flex: 0 0 auto;
        }

        .color-search-container.gpc-controls-stacked .gpc-sort-wrap {
            width: 100%;
            min-width: 0;
        }

        @media (max-width: 520px) {
            .gpc-search-controls-row { flex-direction: column; align-items: stretch; }
            .gpc-filter-dropdown { flex: 1 1 100%; width: 100%; }
            .gpc-sort-wrap { min-width: 100%; }
        }
    `;
    document.head.appendChild(style);

    (function installOwnedColorsBridge() {
        const script = document.createElement('script');
        script.textContent = `(function(){
if(window.__gpcOwnedGhostColorsBridge)return;
window.__gpcOwnedGhostColorsBridge=true;
Object.defineProperty(window,'__gpcOwnedGhostColors',{configurable:true,get:function(){
    try{
        if(typeof Colors==='undefined'||!Array.isArray(Colors))return[];
        return Colors.slice(0,Math.max(0,Colors.length-1));
    }catch(e){return[];}
}});
})();`;
        document.head.appendChild(script);
        script.remove();
    })();

    function normalizeGhostHex(value) {
        const match = String(value || '').trim().match(/^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})\b/);
        if (!match) return null;
        let hex = match[1];
        if (hex.length === 3) hex = hex.split('').map(ch => ch + ch).join('');
        if (hex.length === 8) hex = hex.slice(0, 6);
        return '#' + hex.toUpperCase();
    }

    function rgbaStringToHex(value) {
        const nums = String(value || '').match(/[\d.]+/g);
        if (!nums || nums.length < 3) return null;
        return '#' + nums.slice(0, 3).map(n => {
            const channel = Math.max(0, Math.min(255, parseInt(n, 10) || 0));
            return channel.toString(16).toUpperCase().padStart(2, '0');
        }).join('');
    }

    function getGhostButtonHex(btn) {
        const firstTitleLine = (btn.getAttribute('title') || '').split(/[\r\n]+/)[0];
        return normalizeGhostHex(firstTitleLine) || rgbaStringToHex(btn.dataset.colorRgba || btn.style.backgroundColor);
    }

    function getOwnedGhostHexSet() {
        const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        const colors = Array.isArray(pageWindow.__gpcOwnedGhostColors) ? pageWindow.__gpcOwnedGhostColors : [];
        const set = new Set();
        colors.forEach(color => {
            const hex = normalizeGhostHex(color);
            if (hex) set.add(hex);
        });
        return set;
    }

    // Main functionality
    waitForElement('#ghostColorPalette', (paletteDiv) => {
        // Create search container
        const searchContainer = document.createElement('div');
        searchContainer.className = 'color-search-container';

        // Create compact textarea search input
        const textarea = document.createElement('textarea');
        textarea.rows = 1;
        textarea.className = 'color-search-input';
        textarea.placeholder = 'Search color(s) (comma, space, or newline separated)';
        const resizeSearchBox = () => {
            textarea.style.height = '34px';
            textarea.style.height = Math.min(Math.max(34, textarea.scrollHeight), 110) + 'px';
        };

        // Keep the old variable name for the logic below.
        const searchInput = textarea;

        // ── "Show search results first" checkbox ──────────────────────
        const showResultsRow = document.createElement('div');
        showResultsRow.className = 'gpc-search-toggle-row';

        const showResultsCheckbox = document.createElement('input');
        showResultsCheckbox.type = 'checkbox';
        showResultsCheckbox.id = 'gpc-showResultsFirst';

        const showResultsLabel = document.createElement('label');
        showResultsLabel.htmlFor = 'gpc-showResultsFirst';
        showResultsLabel.textContent = 'Show search results first';

        showResultsRow.appendChild(showResultsCheckbox);
        showResultsRow.appendChild(showResultsLabel);

        // ── Compact filters dropdown with checkboxes ──────────────────
        const controlsRow = document.createElement('div');
        controlsRow.className = 'gpc-search-controls-row';

        const filtersDropdown = document.createElement('div');
        filtersDropdown.className = 'gpc-filter-dropdown';

        const filtersButton = document.createElement('button');
        filtersButton.type = 'button';
        filtersButton.className = 'gpc-filter-button';

        const filtersButtonText = document.createElement('span');
        filtersButtonText.textContent = 'Filters';

        const filtersArrow = document.createElement('span');
        filtersArrow.textContent = '▾';
        filtersArrow.style.cssText = 'font-size:10px;opacity:0.7;';

        filtersButton.appendChild(filtersButtonText);
        filtersButton.appendChild(filtersArrow);

        const filtersMenu = document.createElement('div');
        filtersMenu.className = 'gpc-filter-menu';

        const selectedFilters = new Set();
        const filterInputs = [];
        let countMinInput = null, countMaxInput = null;
        const progressFilterValues = new Set(['hideCompleted', 'hideInProgress', 'hideUnstarted']);
        const progressSortValues = new Set(['mostRemaining', 'leastRemaining', 'mostPct', 'leastPct']);

        [
            { value: 'hideUnmatched',  text: 'Hide unmatched colors' },
            { value: 'hideCompleted',  text: 'Hide completed colors' },
            { value: 'hideInProgress', text: 'Hide in-progress colors' },
            { value: 'hideUnstarted',  text: 'Hide unstarted colors' },
            { value: 'ownedOnly',      text: 'Owned colors only' },
            { value: 'countRange',     text: 'Filter within pixel count…' },
        ].forEach(({ value, text }) => {
            const label = document.createElement('label');
            label.className = 'gpc-filter-option';
            label.dataset.filterValue = value;
            if (progressFilterValues.has(value)) label.dataset.requiresProgress = 'true';

            const input = document.createElement('input');
            input.type = 'checkbox';
            input.value = value;
            input.addEventListener('change', () => {
                if (input.checked) selectedFilters.add(value);
                else selectedFilters.delete(value);
                updateFilterButtonText();
                performSearch();
            });
            filterInputs.push(input);

            const span = document.createElement('span');
            span.textContent = text;

            label.appendChild(input);
            label.appendChild(span);
            filtersMenu.appendChild(label);
        });

        // Pixel-count range sub-row — revealed by the 'countRange' checkbox above.
        const countSubRow = document.createElement('div');
        countSubRow.className = 'gpc-filter-count';
        countSubRow.style.display = 'none';
        countMinInput = document.createElement('input');
        countMinInput.type = 'number'; countMinInput.min = '0'; countMinInput.placeholder = 'min';
        countMinInput.className = 'gpc-count-input';
        countMaxInput = document.createElement('input');
        countMaxInput.type = 'number'; countMaxInput.min = '0'; countMaxInput.placeholder = 'max';
        countMaxInput.className = 'gpc-count-input';
        const countDash = document.createElement('span');
        countDash.textContent = '–'; countDash.style.opacity = '0.6';
        [countMinInput, countMaxInput].forEach(inp => inp.addEventListener('input', () => performSearch()));
        countSubRow.appendChild(countMinInput);
        countSubRow.appendChild(countDash);
        countSubRow.appendChild(countMaxInput);
        filtersMenu.appendChild(countSubRow);

        // Show/hide the sub-row when the 'countRange' checkbox changes
        const _countRangeCb = filterInputs.find(i => i.value === 'countRange');
        if (_countRangeCb) {
            _countRangeCb.addEventListener('change', () => {
                countSubRow.style.display = _countRangeCb.checked ? '' : 'none';
                if (!_countRangeCb.checked) {
                    if (countMinInput) countMinInput.value = '';
                    if (countMaxInput) countMaxInput.value = '';
                }
            });
        }

        function updateFilterButtonText() {
            const n = selectedFilters.size;
            filtersButtonText.textContent = n ? `Filters (${n})` : 'Filters';
        }

        filtersButton.addEventListener('click', e => {
            e.stopPropagation();
            filtersMenu.classList.toggle('gpc-open');
        });
        filtersMenu.addEventListener('click', e => e.stopPropagation());
        document.addEventListener('click', () => filtersMenu.classList.remove('gpc-open'));

        filtersDropdown.appendChild(filtersButton);
        filtersDropdown.appendChild(filtersMenu);

        // ── Sort by dropdown ──────────────────────────────────────────
        const sortWrap = document.createElement('div');
        sortWrap.className = 'gpc-sort-wrap';

        const sortSelect = document.createElement('select');
        sortSelect.className = 'gpc-sort-select';
        sortSelect.title = 'Scroll while hovering to change sort order';

        [
            { value: 'default',        text: 'Sort by: Most used (default)' },
            { value: 'leastUsed',      text: 'Sort by: Least used' },
            { value: 'mostRemaining',  text: 'Sort by: Most remaining' },
            { value: 'leastRemaining', text: 'Sort by: Least remaining' },
            { value: 'mostPct',        text: 'Sort by: Most % remaining' },
            { value: 'leastPct',       text: 'Sort by: Least % remaining' },
            { value: 'byColor',        text: 'Sort by: Color' },
            { value: 'byColorRev',     text: 'Sort by: Color reversed' },
        ].forEach(({ value, text }) => {
            const opt = document.createElement('option');
            opt.value = value;
            opt.textContent = text;
            if (progressSortValues.has(value)) opt.dataset.requiresProgress = 'true';
            sortSelect.appendChild(opt);
        });

        sortWrap.appendChild(sortSelect);
        controlsRow.appendChild(filtersDropdown);
        controlsRow.appendChild(sortWrap);

        function styleFilteredActionButton(btn) {
            Object.assign(btn.style, {
                padding: '3px 10px',
                background: 'var(--color-blue-500, #3b82f6)', color: 'var(--color-white, #fff)', border: 'none',
                borderRadius: '5px', cursor: 'pointer', fontSize: '12px',
                fontWeight: '700', whiteSpace: 'nowrap',
            });
            btn.onmouseenter = () => btn.style.opacity = '0.82';
            btn.onmouseleave = () => btn.style.opacity = '1';
        }

        // ── Ghost palette enable action buttons ────────────────────────
        const enableFilteredBtn = document.createElement('button');
        enableFilteredBtn.textContent = 'Enable filtered';
        enableFilteredBtn.title = 'Enable colors currently shown in the ghost palette and disable hidden colors';
        styleFilteredActionButton(enableFilteredBtn);
        enableFilteredBtn.onclick = () => applyEnabledFilteredColors();

        const enableOwnedFilteredBtn = document.createElement('button');
        enableOwnedFilteredBtn.textContent = 'Enable owned and filtered';
        enableOwnedFilteredBtn.title = 'Enable only owned ghost colors currently shown by the active filters';
        styleFilteredActionButton(enableOwnedFilteredBtn);
        enableOwnedFilteredBtn.onclick = () => applyEnabledOwnedFilteredColors();

        // Row for ghost enable actions — sits above the search box, near the other enable/disable buttons
        const filteredRow = document.createElement('div');
        filteredRow.className = 'gpc-filtered-row';
        filteredRow.style.cssText = 'display:flex;justify-content:flex-start;gap:6px;flex-wrap:wrap;padding:0 0 6px 0;';
        filteredRow.appendChild(enableFilteredBtn);
        filteredRow.appendChild(enableOwnedFilteredBtn);

        // Assemble search container
        searchContainer.appendChild(searchInput);
        searchContainer.appendChild(showResultsRow);
        searchContainer.appendChild(controlsRow);

        // Insert before the palette: filteredRow first, then searchContainer, then paletteDiv
        paletteDiv.parentNode.insertBefore(searchContainer, paletteDiv);
        paletteDiv.parentNode.insertBefore(filteredRow, searchContainer);

        // Stack Filters and Sort by when the left modal panel gets narrow, regardless of viewport width.
        const syncControlsStack = () => {
            const width = searchContainer.getBoundingClientRect().width;
            if (width > 0) searchContainer.classList.toggle('gpc-controls-stacked', width < 420);
        };
        if (typeof ResizeObserver !== 'undefined') {
            new ResizeObserver(syncControlsStack).observe(searchContainer);
        } else {
            window.addEventListener('resize', syncControlsStack);
        }
        setTimeout(syncControlsStack, 0);

        // ── Helper: parse pixel stats from a swatch's title ──────────
        // Title format (set by geopixels++ updateGhostPaletteProgress):
        //   #HEX\nX / Y pixels\nZ%
        function parseStats(btn) {
            const title = btn.getAttribute('title') || '';
            const lines = title.split(/\n/).map(l => l.trim()).filter(l => l.length > 0);
            const hex = lines[0] || '';
            let completed = 0, total = 0, percent = 0;
            const pixelMatch = (lines[1] || '').match(/^([\d,]+)\s*\/\s*([\d,]+)/);
            if (pixelMatch) {
                completed = parseInt(pixelMatch[1].replace(/,/g, ''), 10);
                total     = parseInt(pixelMatch[2].replace(/,/g, ''), 10);
            } else {
                const countMatch = (lines[1] || '').match(/([\d,]+)\s*pixel/i);
                if (countMatch) total = parseInt(countMatch[1].replace(/,/g, ''), 10);
            }
            const pctMatch = (lines[2] || '').match(/([\d.]+)%/);
            if (pctMatch) percent = parseFloat(pctMatch[1]);
            const remaining = Math.max(0, total - completed);
            const remainingPercent = total > 0 ? (remaining / total) * 100 : 0;
            return { hex, completed, total, remaining, percent, remainingPercent, hasProgress: !!pixelMatch };
        }

        function hasProgressStats() {
            return Array.from(paletteDiv.querySelectorAll('[title^="#"]')).some(btn => parseStats(btn).hasProgress);
        }

        function syncProgressDependentControls() {
            const hasProgress = hasProgressStats();
            filterInputs.forEach(input => {
                const requiresProgress = progressFilterValues.has(input.value);
                const label = input.closest('.gpc-filter-option');
                if (!label || !requiresProgress) return;
                label.style.display = hasProgress ? '' : 'none';
                input.disabled = !hasProgress;
                if (!hasProgress && input.checked) {
                    input.checked = false;
                    selectedFilters.delete(input.value);
                }
            });
            Array.from(sortSelect.options).forEach(opt => {
                const requiresProgress = progressSortValues.has(opt.value);
                opt.hidden = requiresProgress && !hasProgress;
                opt.disabled = requiresProgress && !hasProgress;
            });
            if (!hasProgress && progressSortValues.has(sortSelect.value)) sortSelect.value = 'default';
            updateFilterButtonText();
            return hasProgress;
        }

        // Capture the geopixels-native order as a tie-breaker for stable sorts.
        let originalOrder = null;
        let originalIndex = new Map();
        let _palObs = null;

        function captureOriginalOrder() {
            originalOrder = Array.from(paletteDiv.querySelectorAll('[title^="#"]'));
            originalIndex = new Map(originalOrder.map((btn, idx) => [btn, idx]));
        }

        // ── Sort + reorder DOM ────────────────────────────────────────
        function applySort(matchingButtons) {
            const sortValue   = sortSelect.value;
            const showFirst   = showResultsCheckbox.checked;
            const hasSearch   = matchingButtons.size > 0;

            // Base list: use captured original order when available
            const base = originalOrder && originalOrder.length > 0 && originalOrder.every(b => paletteDiv.contains(b))
                ? originalOrder
                : Array.from(paletteDiv.querySelectorAll('[title^="#"]'));

            const tie = (a, b) => (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0);
            const cmp = (a, b) => {
                const sa = parseStats(a), sb = parseStats(b);
                switch (sortValue) {
                    case 'default':        return (sb.total - sa.total) || tie(a, b);
                    case 'leastUsed':      return (sa.total - sb.total) || tie(a, b);
                    case 'mostRemaining':  return (sb.remaining - sa.remaining) || tie(a, b);
                    case 'leastRemaining': return (sa.remaining - sb.remaining) || tie(a, b);
                    case 'mostPct':        return (sb.remainingPercent - sa.remainingPercent) || tie(a, b);
                    case 'leastPct':       return (sa.remainingPercent - sb.remainingPercent) || tie(a, b);
                    case 'byColor':        return (sa.hex < sb.hex ? -1 : sa.hex > sb.hex ? 1 : 0) || tie(a, b);
                    case 'byColorRev':     return (sa.hex > sb.hex ? -1 : sa.hex < sb.hex ? 1 : 0) || tie(a, b);
                    default:               return tie(a, b);
                }
            };
            let sorted = [...base].sort(cmp);

            if (showFirst && hasSearch) {
                const matched   = sorted.filter(b => matchingButtons.has(b));
                const unmatched = sorted.filter(b => !matchingButtons.has(b));
                sorted = [...matched, ...unmatched];
            }

            // Disconnect observer while reordering to avoid re-entrant calls
            if (_palObs) _palObs.disconnect();
            const frag = document.createDocumentFragment();
            sorted.forEach(b => frag.appendChild(b));
            paletteDiv.appendChild(frag);
            if (_palObs) _palObs.observe(paletteDiv, { childList: true, subtree: true });
        }

        // ── Search, filter, and sort ──────────────────────────────────
        function performSearch() {
            syncProgressDependentControls();
            const searchValue    = searchInput.value.trim();
            const hideUnmatched     = selectedFilters.has('hideUnmatched');
            const hideCompleted     = selectedFilters.has('hideCompleted');
            const hideInProgress    = selectedFilters.has('hideInProgress');
            const hideUnstarted     = selectedFilters.has('hideUnstarted');
            const hideUnowned       = selectedFilters.has('ownedOnly');
            const countRangeEnabled = selectedFilters.has('countRange');
            const minCount = (countRangeEnabled && countMinInput && countMinInput.value !== '') ? parseInt(countMinInput.value, 10) : NaN;
            const maxCount = (countRangeEnabled && countMaxInput && countMaxInput.value !== '') ? parseInt(countMaxInput.value, 10) : NaN;
            const hasMinCount = !isNaN(minCount);
            const hasMaxCount = !isNaN(maxCount);
            const ownedSet = hideUnowned ? getOwnedGhostHexSet() : null;

            const allBtns = Array.from(paletteDiv.querySelectorAll('[title^="#"]'));

            // Clear previous glow + hidden state
            allBtns.forEach(btn => btn.classList.remove('color-search-glow', 'hidden'));

            // Build search terms
            const searchTerms = searchValue.split(/[\s,]+/)
                .map(t => t.trim().toUpperCase())
                .filter(t => t.length > 0);

            // Apply glow to matches
            const matchingButtons = new Set();
            if (searchTerms.length > 0) {
                allBtns.forEach(btn => {
                    const titleUp = (btn.getAttribute('title') || '').toUpperCase();
                    if (searchTerms.some(t => titleUp.includes(t))) {
                        btn.classList.add('color-search-glow');
                        matchingButtons.add(btn);
                    }
                });
            }

            // Apply hide filters
            allBtns.forEach(btn => {
                const s = parseStats(btn);
                const hasData      = s.total > 0;
                const isUnmatched  = searchTerms.length > 0 && !matchingButtons.has(btn);
                const isCompleted  = hasData && s.completed >= s.total;
                const isInProgress = hasData && s.completed > 0 && s.completed < s.total;
                const isUnstarted  = hasData && s.completed === 0;
                const isUnowned    = hideUnowned && ownedSet && !ownedSet.has(getGhostButtonHex(btn));
                const outOfRange   = (hasMinCount || hasMaxCount) && ((hasMinCount && s.total < minCount) || (hasMaxCount && s.total > maxCount));

                if ((hideUnmatched  && isUnmatched)  ||
                    (hideCompleted  && isCompleted)  ||
                    (hideInProgress && isInProgress) ||
                    (hideUnstarted  && isUnstarted)  ||
                    isUnowned                        ||
                    outOfRange) {
                    btn.classList.add('hidden');
                }
            });

            // Apply sort / show-results-first reordering
            applySort(matchingButtons);
        }

        function commitGhostPaletteChanges(toEnable, toDisable) {
            if (!toEnable.length && !toDisable.length) return;

            const script = document.createElement('script');
            script.textContent = `(function(en,dis){` +
                `en.forEach(r=>ghostActivePaletteColors.add(r));` +
                `dis.forEach(r=>ghostActivePaletteColors.delete(r));` +
                `if(typeof updateColorPaletteUI==='function')updateColorPaletteUI();` +
                `if(typeof regenerateGhostCanvas==='function')regenerateGhostCanvas();` +
                `})(${JSON.stringify(toEnable)},${JSON.stringify(toDisable)});`;
            document.head.appendChild(script);
            script.remove();
        }

        function disableShowAllGhostFilter() {
            const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
            const showAllToggle = document.getElementById('disableColorFilterToggle');
            if (showAllToggle && showAllToggle.checked) {
                showAllToggle.checked = false;
                if (typeof pageWindow.handleColorFilterToggle === 'function') pageWindow.handleColorFilterToggle();
            }
        }

        // ── Enable filtered: enable visible colors, disable hidden ones ──
        function applyEnabledFilteredColors() {
            performSearch();
            const colorButtons = paletteDiv.querySelectorAll('button[data-color-rgba]');
            if (!colorButtons.length) {
                (typeof showAlert === 'function' ? showAlert : alert)('No ghost palette colors found', 'Make sure a ghost image is loaded.');
                return;
            }

            disableShowAllGhostFilter();

            const toEnable = [], toDisable = [];
            let visibleCount = 0;
            colorButtons.forEach(btn => {
                const rgba = btn.dataset.colorRgba;
                if (!rgba) return;
                const shouldBeEnabled = !btn.classList.contains('hidden');
                if (shouldBeEnabled) visibleCount++;
                const isEnabled = btn.classList.contains('border-blue-500');
                if (shouldBeEnabled && !isEnabled) toEnable.push(rgba);
                else if (!shouldBeEnabled && isEnabled) toDisable.push(rgba);
            });

            if (visibleCount === 0) {
                (typeof showAlert === 'function' ? showAlert : alert)('Enable Filtered', 'No visible ghost palette colors are currently shown.');
                return;
            }

            if (!toEnable.length && !toDisable.length) return;

            commitGhostPaletteChanges(toEnable, toDisable);
        }

        // ── Enable owned and filtered: enable visible owned colors only ──
        function applyEnabledOwnedFilteredColors() {
            performSearch();
            const colorButtons = paletteDiv.querySelectorAll('button[data-color-rgba]');
            if (!colorButtons.length) {
                (typeof showAlert === 'function' ? showAlert : alert)('No ghost palette colors found', 'Make sure a ghost image is loaded.');
                return;
            }

            const ownedSet = getOwnedGhostHexSet();
            if (!ownedSet.size) {
                (typeof showAlert === 'function' ? showAlert : alert)('Enable Owned and Filtered', 'No owned colors were detected yet. Refresh your palette or reload GeoPixels, then try again.');
                return;
            }

            disableShowAllGhostFilter();

            const toEnable = [], toDisable = [];
            let visibleCount = 0;
            colorButtons.forEach(btn => {
                const rgba = btn.dataset.colorRgba;
                if (!rgba) return;
                const isVisible = !btn.classList.contains('hidden');
                if (isVisible) visibleCount++;
                const shouldBeEnabled = isVisible && ownedSet.has(getGhostButtonHex(btn));
                const isEnabled = btn.classList.contains('border-blue-500');
                if (shouldBeEnabled && !isEnabled) toEnable.push(rgba);
                else if (!shouldBeEnabled && isEnabled) toDisable.push(rgba);
            });

            if (visibleCount === 0) {
                (typeof showAlert === 'function' ? showAlert : alert)('Enable Owned and Filtered', 'No visible ghost palette colors are currently shown.');
                return;
            }

            commitGhostPaletteChanges(toEnable, toDisable);
        }

        // Add event listeners
        searchInput.addEventListener('input', () => { resizeSearchBox(); performSearch(); });
        sortSelect.addEventListener('change', performSearch);
        sortSelect.addEventListener('wheel', e => {
            e.preventDefault();
            const dir = e.deltaY > 0 ? 1 : -1;
            let next = sortSelect.selectedIndex + dir;
            while (next >= 0 && next < sortSelect.options.length && sortSelect.options[next].disabled) next += dir;
            next = Math.min(Math.max(next, 0), sortSelect.options.length - 1);
            if (next !== sortSelect.selectedIndex) {
                sortSelect.selectedIndex = next;
                performSearch();
            }
        }, { passive: false });
        showResultsCheckbox.addEventListener('change', performSearch);

        // Track the number of color buttons to detect palette resets (new image loaded)
        let previousButtonCount = 0;

        // Watch for dynamically added/removed buttons
        _palObs = new MutationObserver(() => {
            const currentButtonCount = paletteDiv.querySelectorAll('[title^="#"]').length;

            // Significant count change → new image loaded; reset all controls and recapture order
            if (previousButtonCount > 0 && Math.abs(currentButtonCount - previousButtonCount) > 5) {
                searchInput.value = '';
                filterInputs.forEach(input => { input.checked = false; });
                selectedFilters.clear();
                if (countSubRow) countSubRow.style.display = 'none';
                if (countMinInput) countMinInput.value = '';
                if (countMaxInput) countMaxInput.value = '';
                updateFilterButtonText();
                resizeSearchBox();
                sortSelect.value = 'default';
                showResultsCheckbox.checked = false;
                captureOriginalOrder();
            }

            previousButtonCount = currentButtonCount;
            performSearch();
        });

        _palObs.observe(paletteDiv, { childList: true, subtree: true });

        // Initialize original order and button count on first load
        captureOriginalOrder();
        previousButtonCount = paletteDiv.querySelectorAll('[title^="#"]').length;
        syncProgressDependentControls();
        resizeSearchBox();
    });
            })();
            _featureStatus.ghostPaletteSearch = 'ok';
            console.log('[GeoPixelcons++] ✅ Ghost Palette Color Search loaded');
        } catch (err) {
            _featureStatus.ghostPaletteSearch = 'error';
            dbgPush(`Ghost Palette Color Search init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Ghost Palette Color Search' });
            console.error('[GeoPixelcons++] ❌ Ghost Palette Color Search failed:', err);
        }
    }

    // ============================================================
    //  FEATURE: Sync Ghost With Selected Color [showSyncGhostBtn]
    // ============================================================
    // Split out of ghost-palette-search.js into its own feature -- it never
    // depended on anything in that file (the search/filter/sort UI), it was
    // only ever bundled there historically. Standing alone means there is no
    // "display option nested inside an unrelated legacy feature" dependency
    // to keep in sync -- a real Discord report ("button doesn't render" while
    // the display option was checked but its former parent feature was off)
    // came directly from that coupling.
    if (_settings.showSyncGhostBtn) {
        try {
            (function _init_syncGhostColor() {

    let _syncGhostEnabled = false;

    // Patch changeColor once — fires a custom event because pixelColor is a `let`
    // variable in page scope and is not reachable via window.pixelColor.
    (function patchChangeColor() {
        const script = document.createElement('script');
        script.textContent = `(function(){
if(window.__gpc_colorPatchApplied)return;
window.__gpc_colorPatchApplied=true;
var _orig=window.changeColor;
if(typeof _orig!=='function')return;
window.changeColor=function(color){
    _orig.call(this,color);
    document.dispatchEvent(new CustomEvent('gpc:pixelColorChanged',{detail:color}));
};
})();`;
        document.head.appendChild(script);
        script.remove();
    })();

    function applyAutoEnableSelectedColor(targetHex) {
        // Ghost++ compatibility: while it owns the overlay slot, the native
        // #ghostColorPalette DOM below is never (re)populated for whatever
        // template it has focused (see gpp-native-shim.js's own header
        // comment on why — it blocks the native "load a ghost image" flow
        // that's the only thing that ever rebuilds that DOM), so this
        // toggle would otherwise silently find zero swatches to act on.
        // gppApplySelectedColorToFocusedTemplate (gpp-palette.js) drives
        // Ghost++'s own template.mask instead and returns true whenever it
        // handled this (Ghost++ has a template focused) — skip the
        // native-DOM path entirely in that case. Falls through unchanged
        // below whenever Ghost++ isn't enabled, or has nothing focused.
        if (typeof gppApplySelectedColorToFocusedTemplate === 'function' && gppApplySelectedColorToFocusedTemplate(targetHex)) {
            return;
        }
        const paletteDiv = document.getElementById('ghostColorPalette');
        if (!paletteDiv) return;
        const colorButtons = paletteDiv.querySelectorAll('button[data-color-rgba]');
        if (!colorButtons.length) return;
        const normalizedTarget = targetHex.toUpperCase();
        let targetFound = false;
        const toEnable = [], toDisable = [];
        colorButtons.forEach(btn => {
            const rgba = btn.dataset.colorRgba;
            if (!rgba) return;
            let hex = '';
            const firstLine = (btn.getAttribute('title') || '').split(/[\r\n]+/)[0].trim().toUpperCase();
            if (/^#[0-9A-F]{6}$/.test(firstLine)) {
                hex = firstLine;
            } else {
                const m = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
                if (m) hex = '#' + [m[1], m[2], m[3]]
                    .map(n => parseInt(n).toString(16).toUpperCase().padStart(2, '0')).join('');
            }
            const isEnabled = btn.classList.contains('border-blue-500');
            const shouldBeEnabled = hex === normalizedTarget;
            if (shouldBeEnabled) targetFound = true;
            if (shouldBeEnabled && !isEnabled) toEnable.push(rgba);
            else if (!shouldBeEnabled && isEnabled) toDisable.push(rgba);
        });
        if (!targetFound) return;
        if (!toEnable.length && !toDisable.length) return;
        const script = document.createElement('script');
        script.textContent = `(function(en,dis){` +
            `en.forEach(r=>ghostActivePaletteColors.add(r));` +
            `dis.forEach(r=>ghostActivePaletteColors.delete(r));` +
            `if(typeof updateColorPaletteUI==='function')updateColorPaletteUI();` +
            `if(typeof regenerateGhostCanvas==='function')regenerateGhostCanvas();` +
            `})(${JSON.stringify(toEnable)},${JSON.stringify(toDisable)});`;
        document.head.appendChild(script);
        script.remove();
    }

    document.addEventListener('gpc:pixelColorChanged', (e) => {
        if (!_syncGhostEnabled) return;
        applyAutoEnableSelectedColor(e.detail);
    });

    // ── Inject toggle button into #imageGroupDropdown ─────────────────
    // Mirrors gpp-ui-shell.js's gppReplaceNativeOpener retry pattern
    // (synchronous check -> MutationObserver watch -> bounded giveup) --
    // synchronous check, logged either way; if not found, watch the nearest
    // stable ancestor via MutationObserver; 15s giveup, logged to both
    // dbgPush and console.error.
    (function injectSyncGhostBtn() {
        const syncBtnStyle = document.createElement('style');
        syncBtnStyle.textContent =
            '#gpc-sync-ghost-btn[data-active="true"]{background:#dcfce7!important;}' +
            '#gpc-sync-ghost-btn[data-active="true"]:hover{background:#bbf7d0!important;}';
        document.head.appendChild(syncBtnStyle);

        function createButton(dropdown) {
            if (document.getElementById('gpc-sync-ghost-btn')) return;
            const btn = document.createElement('button');
            btn.id = 'gpc-sync-ghost-btn';
            btn.className = 'w-10 h-10 bg-white shadow rounded-full flex items-center justify-center hover:bg-gray-100 cursor-pointer';
            btn.title = 'Sync Ghost With Selected Color';
            btn.textContent = '♻️';
            btn.addEventListener('click', () => {
                _syncGhostEnabled = !_syncGhostEnabled;
                btn.dataset.active = _syncGhostEnabled ? 'true' : 'false';
            });
            dropdown.appendChild(btn);
            dbgPush('Sync Ghost With Selected Color: button mounted into #imageGroupDropdown.', { uiComponent: 'Sync Ghost With Selected Color' });
        }

        const watchStartedAt = Date.now();
        const immediate = document.getElementById('imageGroupDropdown');
        if (immediate) {
            dbgPush('Sync Ghost With Selected Color: #imageGroupDropdown found on the first synchronous check -- mounting the button now.', { uiComponent: 'Sync Ghost With Selected Color' });
            createButton(immediate);
            return;
        }
        dbgPush('Sync Ghost With Selected Color: #imageGroupDropdown NOT found on the first synchronous check -- watching for it to appear.', { uiComponent: 'Sync Ghost With Selected Color' });
        const watchRoot = document.getElementById('controls-left') || document.body;
        const observer = new MutationObserver(() => {
            const found = document.getElementById('imageGroupDropdown');
            if (!found) return;
            observer.disconnect();
            clearTimeout(giveUpTimer);
            dbgPush('Sync Ghost With Selected Color: #imageGroupDropdown appeared ' + (Date.now() - watchStartedAt) + 'ms after watching started -- mounting the button now.', { uiComponent: 'Sync Ghost With Selected Color' });
            createButton(found);
        });
        observer.observe(watchRoot, { childList: true, subtree: true });
        // 15s matches the same give-up duration gppReplaceNativeOpener (and
        // ext-map-movement-lock.js / ext-log-out-button.js) already use for
        // their own late-mounting-element watchers.
        const giveUpTimer = setTimeout(() => {
            observer.disconnect();
            dbgPush('Sync Ghost With Selected Color: gave up after 15s -- #imageGroupDropdown was never found, so the button could not be mounted at all.', { uiComponent: 'Sync Ghost With Selected Color' });
            console.error('[GeoPixelcons++] Sync Ghost With Selected Color: never found #imageGroupDropdown to mount the button into.');
        }, 15000);
    })();

    _featureStatus.showSyncGhostBtn = 'ok';
    console.log('[GeoPixelcons++] ✅ Sync Ghost With Selected Color loaded');
            })();
        } catch (err) {
            _featureStatus.showSyncGhostBtn = 'error';
            dbgPush(`Sync Ghost With Selected Color init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Sync Ghost With Selected Color' });
            console.error('[GeoPixelcons++] ❌ Sync Ghost With Selected Color failed:', err);
        }
    }


    // ============================================================
    //  FEATURE: Paint Menu Controls [hidePaintMenu]
    // ============================================================
    if (_settings.hidePaintMenu) {
        try {
            (function _init_hidePaintMenu() {

    const runPaintMenuInit = (bottomControls, energyDisplay) => {
        // --- 1. CONFIGURATION & STATE ---
        let isCollapsed = false;
        let isTop = false; // whether panel is docked to top
        let dragOffsetX = 0; // px offset from center (persisted)
        const DRAG_STORAGE_KEY = 'gpc-paint-drag-offset';
        const TOP_STORAGE_KEY = 'gpc-paint-is-top';
        try { dragOffsetX = parseFloat(localStorage.getItem(DRAG_STORAGE_KEY)) || 0; } catch {}
        try { isTop = localStorage.getItem(TOP_STORAGE_KEY) === 'true'; } catch {}

        // --- 2. CONTAINER STYLING ---
        // Remove conflicting Tailwind classes
        bottomControls.classList.remove('-translate-x-1/2');
        bottomControls.classList.remove('left-1/2');

        // Keep the original width behavior but add positioning control
        bottomControls.style.position = 'fixed';
        bottomControls.style.bottom = '1rem';
        bottomControls.style.transition = 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
        // Remove any width override to preserve original responsive behavior
        bottomControls.style.width = '';
        bottomControls.style.maxWidth = '';

        // Start centered (preserve original behavior)
        bottomControls.style.left = '50%';
        bottomControls.style.transform = 'translateX(-50%)';

        // --- 3. CREATE UI ELEMENTS ---

        // A. Top bar container (holds drag handle, collapse button, reset button)
        const topBar = document.createElement('div');
        topBar.style.cssText = `
            position: absolute;
            top: -24px;
            left: 0;
            right: 0;
            height: 24px;
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 20;
            pointer-events: none;
        `;

        // B. Collapse Button (first, on the left)
        const toggleBtn = document.createElement('button');
        toggleBtn.innerHTML = '▼';
        toggleBtn.id = 'gpc-hide-paint-toggle';
        toggleBtn.style.cssText = `
            pointer-events: auto;
            width: 28px;
            height: 24px;
            border-bottom: none;
            border-radius: 8px 8px 0 0;
            cursor: pointer;
            font-size: 12px;
            display: flex;
            align-items: center;
            justify-content: center;
        `;
        toggleBtn.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-300';

        // C. Drag handle bar (to the right of collapse)
        const dragBar = document.createElement('div');
        dragBar.id = 'gpc-paint-drag-bar';
        dragBar.style.cssText = `
            pointer-events: auto;
            cursor: grab;
            height: 24px;
            width: 28px;
            border-radius: 8px 8px 0 0;
            display: flex;
            align-items: center;
            justify-content: center;
            user-select: none;
            border-bottom: none;
            margin-left: 2px;
        `;
        dragBar.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-400 dark:text-gray-500';
        dragBar.innerHTML = '<span style="font-size:10px;pointer-events:none;">⋮⋮</span>';

        // D. Reset position button
        const resetBtn = document.createElement('button');
        resetBtn.id = 'gpc-paint-reset-pos';
        resetBtn.title = 'Reset position to center';
        resetBtn.innerHTML = '↺';
        resetBtn.style.cssText = `
            pointer-events: auto;
            width: 28px;
            height: 24px;
            border-bottom: none;
            border-radius: 8px 8px 0 0;
            cursor: pointer;
            font-size: 13px;
            display: flex;
            align-items: center;
            justify-content: center;
            margin-left: 2px;
        `;
        resetBtn.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-300';

        // E. Flip top/bottom button
        const flipBtn = document.createElement('button');
        flipBtn.id = 'gpc-paint-flip-pos';
        flipBtn.title = 'Move to top / bottom';
        flipBtn.innerHTML = isTop ? '⬇' : '⬆';
        flipBtn.style.cssText = `
            pointer-events: auto;
            width: 28px;
            height: 24px;
            border-bottom: none;
            border-radius: 8px 8px 0 0;
            cursor: pointer;
            font-size: 12px;
            display: flex;
            align-items: center;
            justify-content: center;
            margin-left: 2px;
        `;
        flipBtn.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-300';

        // F. Close (switch to inspect mode) button — next to energy display
        const closeBtn = document.createElement('button');
        closeBtn.id = 'gpc-paint-close';
        closeBtn.title = 'Switch to Inspect Mode';
        closeBtn.innerHTML = '✕';
        closeBtn.style.cssText = `
            width: 24px;
            height: 24px;
            border-radius: 6px;
            cursor: pointer;
            font-size: 13px;
            font-weight: 700;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            margin-left: 6px;
            flex-shrink: 0;
            vertical-align: middle;
        `;
        closeBtn.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600';
        closeBtn.addEventListener('click', () => {
            const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
            if (typeof _pw.togglePrimaryMode === 'function') _pw.togglePrimaryMode();
        });
        energyDisplay.parentElement.style.display = 'flex';
        energyDisplay.parentElement.style.alignItems = 'center';
        energyDisplay.insertAdjacentElement('afterend', closeBtn);

        topBar.appendChild(toggleBtn);
        topBar.appendChild(dragBar);
        topBar.appendChild(resetBtn);
        topBar.appendChild(flipBtn);
        bottomControls.appendChild(topBar);

        // --- G. Compact paint overflow: move close + brushes into topBar ---
        if (_settings.compactPaintOverflow) {
            // Create compact close button for the topBar
            const compactCloseBtn = document.createElement('button');
            compactCloseBtn.id = 'gpc-compact-close';
            compactCloseBtn.title = 'Switch to Inspect Mode';
            compactCloseBtn.innerHTML = '✕';
            compactCloseBtn.style.cssText = `
                pointer-events: auto;
                width: 28px;
                height: 24px;
                border-bottom: none;
                border-radius: 8px 8px 0 0;
                cursor: pointer;
                font-size: 13px;
                font-weight: 700;
                display: flex;
                align-items: center;
                justify-content: center;
                margin-left: 2px;
            `;
            compactCloseBtn.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600';
            compactCloseBtn.addEventListener('click', () => {
                const _pw2 = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
                if (typeof _pw2.togglePrimaryMode === 'function') _pw2.togglePrimaryMode();
            });

            // Create compact brush button for the topBar
            const compactBrushBtn = document.createElement('button');
            compactBrushBtn.id = 'gpc-compact-brush';
            compactBrushBtn.title = 'Toggle saved brushes';
            compactBrushBtn.innerHTML = '🖌️';
            compactBrushBtn.style.cssText = `
                pointer-events: auto;
                width: 28px;
                height: 24px;
                border-bottom: none;
                border-radius: 8px 8px 0 0;
                cursor: pointer;
                font-size: 13px;
                display: none;
                align-items: center;
                justify-content: center;
                margin-left: 2px;
            `;
            compactBrushBtn.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600';
            compactBrushBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                const realToggle = document.getElementById('brush-swap-toggle');
                if (realToggle) realToggle.click();
                // Reposition dropdown near this compact button using a rAF so layout is settled
                requestAnimationFrame(() => {
                    const dropdown = document.getElementById('brush-swap-dropdown');
                    if (!dropdown) return;
                    // Move dropdown to body so transforms on ancestors don't affect fixed positioning
                    if (dropdown.parentElement !== document.body) {
                        document.body.appendChild(dropdown);
                    }
                    const btnRect = compactBrushBtn.getBoundingClientRect();
                    const paintIsTop = localStorage.getItem('gpc-paint-is-top') === 'true';
                    dropdown.style.position = 'fixed';
                    dropdown.style.right = 'auto';
                    dropdown.style.margin = '0';
                    // Align dropdown left edge with button left edge
                    dropdown.style.left = btnRect.left + 'px';
                    if (paintIsTop) {
                        dropdown.style.top = (btnRect.bottom + 4) + 'px';
                        dropdown.style.bottom = 'auto';
                    } else {
                        dropdown.style.top = 'auto';
                        dropdown.style.bottom = (window.innerHeight - btnRect.top + 4) + 'px';
                    }
                });
            });

            // Forward scroll-to-swap from compact button to the real toggle
            compactBrushBtn.addEventListener('wheel', (e) => {
                const realToggle = document.getElementById('brush-swap-toggle');
                if (realToggle) {
                    realToggle.dispatchEvent(new WheelEvent('wheel', {
                        deltaY: e.deltaY,
                        clientX: e.clientX,
                        clientY: e.clientY,
                        bubbles: false
                    }));
                }
                e.preventDefault();
            }, { passive: false });

            topBar.appendChild(compactBrushBtn);
            topBar.appendChild(compactCloseBtn);

            // Position X button absolutely to the right so it doesn't shift the centered group
            compactCloseBtn.style.position = 'absolute';
            compactCloseBtn.style.right = '20px';
            compactCloseBtn.style.marginLeft = '0';

            // Hide the inline close button immediately
            closeBtn.style.display = 'none';

            // Hide brush-swap-toggle when it appears, and show compact brush btn
            function hideBrushToggle() {
                const brushToggle = document.getElementById('brush-swap-toggle');
                if (brushToggle) {
                    // Hide the button visually but keep the wrapper layout intact
                    // so the dropdown can still position itself correctly
                    brushToggle.style.visibility = 'hidden';
                    brushToggle.style.width = '0';
                    brushToggle.style.padding = '0';
                    brushToggle.style.margin = '0';
                    brushToggle.style.border = 'none';
                    brushToggle.style.overflow = 'hidden';
                    compactBrushBtn.style.display = 'flex';
                    return true;
                }
                return false;
            }

            // Try immediately, and also watch for it being added later by paintBrushSwap
            if (!hideBrushToggle()) {
                const compactObserver = new MutationObserver(() => {
                    if (hideBrushToggle()) compactObserver.disconnect();
                });
                compactObserver.observe(bottomControls, { childList: true, subtree: true });
                // Safety cleanup
                setTimeout(() => compactObserver.disconnect(), 30000);
            }
        }

        // --- "Paint Here" button injected into hoverInfo ---
        function injectPaintHereButton() {
            if (document.getElementById('gpc-paint-here-btn')) return;
            const hoverInfo = document.getElementById('hoverInfo');
            if (!hoverInfo) return;

            const paintBtn = document.createElement('button');
            paintBtn.id = 'gpc-paint-here-btn';
            paintBtn.className = 'w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors flex items-center justify-center gap-2 cursor-pointer';
            paintBtn.style.marginTop = '8px';
            paintBtn.innerHTML = '🎨 Paint Here';
            paintBtn.addEventListener('click', () => {
                const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
                if (typeof _pw.togglePrimaryMode === 'function') _pw.togglePrimaryMode();
            });

            // Insert after the Share Location button's parent div
            const shareBtn = document.getElementById('shareLocationBtn');
            const shareContainer = shareBtn?.parentElement;
            if (shareContainer) {
                shareContainer.insertAdjacentElement('afterend', paintBtn);
            } else {
                hoverInfo.appendChild(paintBtn);
            }
        }

        // Observe for hoverInfo appearing
        const hoverObserver = new MutationObserver(() => injectPaintHereButton());
        hoverObserver.observe(document.body, { childList: true, subtree: true });
        injectPaintHereButton();

        // Identify the two main content divs for reordering
        // The first child div is the controls row (buttons, energy, etc.)
        // The second is .control-container-colors (color swatches)
        const innerWrapper = bottomControls.querySelector(':scope > div');
        const controlsRow = innerWrapper
            ? innerWrapper.querySelector(':scope > .w-full.flex')
            : null;
        const colorsDiv = innerWrapper
            ? innerWrapper.querySelector(':scope > .control-container-colors')
            : null;
        const swapParent = controlsRow?.parentElement;

        // --- 4. LOGIC ENGINE ---

        const updateState = () => {
            const COLLAPSE_OFFSET = 48;

            // Vertical docking
            if (isTop) {
                bottomControls.style.bottom = 'auto';
                bottomControls.style.top = '1rem';

                // Reorder: colors first, controls second (buttons closer to map edge)
                if (swapParent && colorsDiv && controlsRow) {
                    swapParent.insertBefore(colorsDiv, controlsRow);
                }

                // Button bar goes BELOW the panel when docked top
                topBar.style.top = 'auto';
                topBar.style.bottom = '-24px';
                [toggleBtn, dragBar, resetBtn, flipBtn].forEach(el => {
                    el.style.borderRadius = '0 0 8px 8px';
                    el.style.borderBottom = '';
                    el.style.borderTop = 'none';
                });
                // Also style compact overflow buttons if they exist
                const compactClose1 = document.getElementById('gpc-compact-close');
                const compactBrush1 = document.getElementById('gpc-compact-brush');
                if (compactClose1) { compactClose1.style.borderRadius = '0 0 8px 8px'; compactClose1.style.borderBottom = ''; compactClose1.style.borderTop = 'none'; }
                if (compactBrush1) { compactBrush1.style.borderRadius = '0 0 8px 8px'; compactBrush1.style.borderBottom = ''; compactBrush1.style.borderTop = 'none'; }
            } else {
                bottomControls.style.top = 'auto';
                bottomControls.style.bottom = '1rem';

                // Restore original order: controls first, colors second
                if (swapParent && colorsDiv && controlsRow) {
                    swapParent.insertBefore(controlsRow, colorsDiv);
                }

                // Button bar goes ABOVE the panel when docked bottom
                topBar.style.bottom = 'auto';
                topBar.style.top = '-24px';
                [toggleBtn, dragBar, resetBtn, flipBtn].forEach(el => {
                    el.style.borderRadius = '8px 8px 0 0';
                    el.style.borderBottom = 'none';
                    el.style.borderTop = '';
                });
                // Also style compact overflow buttons if they exist
                const compactClose2 = document.getElementById('gpc-compact-close');
                const compactBrush2 = document.getElementById('gpc-compact-brush');
                if (compactClose2) { compactClose2.style.borderRadius = '8px 8px 0 0'; compactClose2.style.borderBottom = 'none'; compactClose2.style.borderTop = ''; }
                if (compactBrush2) { compactBrush2.style.borderRadius = '8px 8px 0 0'; compactBrush2.style.borderBottom = 'none'; compactBrush2.style.borderTop = ''; }
            }

            const yTransform = isCollapsed
                ? (isTop ? `translateY(calc(-100% + ${COLLAPSE_OFFSET}px))` : `translateY(calc(100% - ${COLLAPSE_OFFSET}px))`)
                : 'translateY(0)';

            bottomControls.style.left = '50%';
            bottomControls.style.right = 'auto';
            bottomControls.style.transform = `translateX(calc(-50% + ${dragOffsetX}px)) ${yTransform}`;
            toggleBtn.innerHTML = isCollapsed
                ? (isTop ? '▼' : '▲')
                : (isTop ? '▲' : '▼');
            flipBtn.innerHTML = isTop ? '⬇' : '⬆';
        };

        // --- 5. DRAG LOGIC ---

        let isDragging = false;
        let dragStartX = 0;
        let dragStartOffset = 0;

        function onDragStart(e) {
            isDragging = true;
            dragStartX = (e.touches ? e.touches[0].clientX : e.clientX);
            dragStartOffset = dragOffsetX;
            dragBar.style.cursor = 'grabbing';
            bottomControls.style.transition = 'none'; // disable animation while dragging
            e.preventDefault();
        }
        function onDragMove(e) {
            if (!isDragging) return;
            const clientX = (e.touches ? e.touches[0].clientX : e.clientX);
            dragOffsetX = dragStartOffset + (clientX - dragStartX);
            // Clamp so the panel stays at least partially on screen
            const halfW = bottomControls.offsetWidth / 2;
            const maxOff = window.innerWidth / 2 - 60;
            dragOffsetX = Math.max(-maxOff, Math.min(maxOff, dragOffsetX));
            updateState();
        }
        function onDragEnd() {
            if (!isDragging) return;
            isDragging = false;
            dragBar.style.cursor = 'grab';
            bottomControls.style.transition = 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
            localStorage.setItem(DRAG_STORAGE_KEY, String(dragOffsetX));
        }

        dragBar.addEventListener('mousedown', onDragStart);
        document.addEventListener('mousemove', onDragMove);
        document.addEventListener('mouseup', onDragEnd);
        dragBar.addEventListener('touchstart', onDragStart, { passive: false });
        document.addEventListener('touchmove', onDragMove, { passive: false });
        document.addEventListener('touchend', onDragEnd);

        // --- 6. EVENT LISTENERS ---

        toggleBtn.addEventListener('click', () => {
            isCollapsed = !isCollapsed;
            updateState();
        });

        resetBtn.addEventListener('click', () => {
            dragOffsetX = 0;
            localStorage.removeItem(DRAG_STORAGE_KEY);
            updateState();
        });

        flipBtn.addEventListener('click', () => {
            isTop = !isTop;
            isCollapsed = false; // expand when flipping
            localStorage.setItem(TOP_STORAGE_KEY, String(isTop));
            updateState();
        });

        // Initialize
        updateState();
        console.log('Bottom controls enhanced: properly centered with left/right positioning.');
    };

    // Mirrors ghost-palette-search.js's injectSyncGhostBtn retry pattern
    // (synchronous check -> MutationObserver watch -> bounded giveup) instead
    // of the old bare setTimeout(init, 500) infinite poll, which had no
    // giveup and logged nothing but a generic 'Elements not found, retrying...'
    // at each attempt. Both #bottomControls and #currentEnergyDisplay must be
    // present before the paint-menu UI can be built, so they're watched together.
    const init = () => {
        const watchStartedAt = Date.now();
        const getAnchors = () => {
            const bottomControls = document.getElementById('bottomControls');
            const energyDisplay = document.getElementById('currentEnergyDisplay');
            return (bottomControls && energyDisplay) ? { bottomControls, energyDisplay } : null;
        };

        const immediate = getAnchors();
        if (immediate) {
            dbgPush('Paint Menu Controls: #bottomControls and #currentEnergyDisplay found on the first synchronous check -- mounting now.', { uiComponent: 'Paint Menu Controls' });
            runPaintMenuInit(immediate.bottomControls, immediate.energyDisplay);
            return;
        }
        dbgPush('Paint Menu Controls: #bottomControls and #currentEnergyDisplay NOT found on the first synchronous check -- watching for them to appear.', { uiComponent: 'Paint Menu Controls' });
        const observer = new MutationObserver(() => {
            const found = getAnchors();
            if (!found) return;
            observer.disconnect();
            clearTimeout(giveUpTimer);
            dbgPush('Paint Menu Controls: #bottomControls and #currentEnergyDisplay appeared ' + (Date.now() - watchStartedAt) + 'ms after watching started -- mounting now.', { uiComponent: 'Paint Menu Controls' });
            runPaintMenuInit(found.bottomControls, found.energyDisplay);
        });
        observer.observe(document.body, { childList: true, subtree: true });
        const giveUpTimer = setTimeout(() => {
            observer.disconnect();
            dbgPush('Paint Menu Controls: gave up after 15s -- #bottomControls and #currentEnergyDisplay were never found together, could not be mounted at all.', { uiComponent: 'Paint Menu Controls' });
            console.error('[GeoPixelcons++] Paint Menu Controls: never found #bottomControls and #currentEnergyDisplay.');
        }, 15000);
    };

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
            })();
            _featureStatus.hidePaintMenu = 'ok';
            console.log('[GeoPixelcons++] ✅ Paint Menu Controls loaded');
        } catch (err) {
            _featureStatus.hidePaintMenu = 'error';
            dbgPush(`Paint Menu Controls init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Paint Menu Controls' });
            console.error('[GeoPixelcons++] ❌ Paint Menu Controls failed:', err);
        }
    }

    // ============================================================
    //  FEATURE: Ghost Template Manager [ghostTemplateManager]
    // ============================================================
    if (_settings.ghostTemplateManager) {
        try {
            (function _init_ghostTemplateManager() {

    // ========== CONFIGURATION ==========
    const DEBUG_MODE = false;
    const DB_NAME = 'GP_Ghost_History';
    const DB_VERSION = 3;
    const STORE_NAME = 'images';

    // Marker Colors for Encoding
    const MARKER_R = 71;
    const MARKER_G = 80;
    const MARKER_B = 88;
    const POSITION_OFFSET = 2147483648;

    let isInternalUpdate = false;
    let previewActive = false;
    let previewOverlay = null;

    // ========== UTILITIES ==========
    function gpLog(msg, data = null) {
        if (!DEBUG_MODE) return;
        console.log(`%c[GP Manager] ${msg}`, "color: #00ffff; background: #000; padding: 2px 4px;", data || '');
    }

    // Debug: Log environment info on load
    gpLog("Script loaded. Environment check:", {
        hasWindow: typeof window !== 'undefined',
        hasUnsafeWindow: typeof unsafeWindow !== 'undefined',
        windowMap: typeof window !== 'undefined' ? typeof window.map : 'N/A',
        windowTurf: typeof window !== 'undefined' ? typeof window.turf : 'N/A',
        unsafeWindowMap: typeof unsafeWindow !== 'undefined' ? typeof unsafeWindow.map : 'N/A',
        unsafeWindowTurf: typeof unsafeWindow !== 'undefined' ? typeof unsafeWindow.turf : 'N/A'
    });

    /**
     * Safely get a page variable, avoiding DOM element conflicts.
     * In some browsers, accessing unsafeWindow.map returns the <div id="map"> element
     * instead of the JavaScript map variable.
     */
    function getPageVariable(varName) {
        // Try window first (works in Chrome/Vivaldi)
        if (typeof window !== 'undefined' && window[varName] !== undefined) {
            const val = window[varName];
            // Make sure it's not a DOM element when we expect an object with methods
            if (varName === 'map' && val instanceof HTMLElement) {
                gpLog(`window.${varName} is a DOM element, trying unsafeWindow`);
            } else {
                gpLog(`Found ${varName} in window`);
                return val;
            }
        }

        // Try unsafeWindow (needed in Firefox/Brave with @grant permissions)
        if (typeof unsafeWindow !== 'undefined' && unsafeWindow[varName] !== undefined) {
            const val = unsafeWindow[varName];
            // Check if it's a DOM element when we expect the map object
            if (varName === 'map' && val instanceof HTMLElement) {
                gpLog(`unsafeWindow.${varName} is a DOM element, looking for alternatives`);
                
                // Try to get the map from common Mapbox/Leaflet global patterns
                // The map might be stored in a different variable or we need wrappedJSObject (Firefox)
                if (typeof unsafeWindow.wrappedJSObject !== 'undefined' && unsafeWindow.wrappedJSObject[varName]) {
                    const wrappedVal = unsafeWindow.wrappedJSObject[varName];
                    if (!(wrappedVal instanceof HTMLElement)) {
                        gpLog(`Found ${varName} in wrappedJSObject`);
                        return wrappedVal;
                    }
                }
                
                // For Brave/Chrome with sandboxing, try accessing via page script injection
                gpLog(`Attempting page context injection for ${varName}`);
                return getPageVariableViaInjection(varName);
            } else {
                gpLog(`Found ${varName} in unsafeWindow`);
                return val;
            }
        }

        // Try wrappedJSObject directly (Firefox)
        if (typeof unsafeWindow !== 'undefined' && 
            typeof unsafeWindow.wrappedJSObject !== 'undefined' && 
            unsafeWindow.wrappedJSObject[varName] !== undefined) {
            gpLog(`Found ${varName} in wrappedJSObject`);
            return unsafeWindow.wrappedJSObject[varName];
        }

        gpLog(`Could not find ${varName} in any scope`);
        return null;
    }

    /**
     * Get a page variable by creating a bridge in the page context.
     * This is needed in Brave when @grant permissions create a sandbox.
     */
    function getPageVariableViaInjection(varName) {
        try {
            // Create a unique ID for this retrieval
            const bridgeId = `__gp_bridge_${varName}_${Date.now()}`;
            
            // Inject a script that copies the variable to a data attribute
            const script = document.createElement('script');
            script.textContent = `
                (function() {
                    if (typeof ${varName} !== 'undefined' && ${varName}) {
                        // Store a marker that the variable exists
                        document.documentElement.setAttribute('${bridgeId}', 'exists');
                        // For map object, we can't directly transfer it, so we'll access it differently
                        if ('${varName}' === 'map' && typeof ${varName}.project === 'function') {
                            document.documentElement.setAttribute('${bridgeId}_hasProject', 'true');
                        }
                    }
                })();
            `;
            document.documentElement.appendChild(script);
            script.remove();
            
            // Check if the variable exists
            const exists = document.documentElement.getAttribute(bridgeId);
            document.documentElement.removeAttribute(bridgeId);
            document.documentElement.removeAttribute(`${bridgeId}_hasProject`);
            
            if (exists === 'exists') {
                gpLog(`${varName} exists in page context, creating proxy`);
                
                // For the map object specifically, we need to create a proxy that executes in page context
                if (varName === 'map') {
                    return createMapProxy();
                } else if (varName === 'turf') {
                    return createTurfProxy();
                }
            }
            
            gpLog(`${varName} not found via injection`);
            return null;
        } catch (e) {
            gpLog(`Error in page context injection for ${varName}:`, e.message);
            return null;
        }
    }

    /**
     * Create a proxy object for the map that executes methods in page context
     */
    function createMapProxy() {
        return {
            project: function(lngLat) {
                // Execute in page context and return result
                const script = document.createElement('script');
                const resultId = `__gp_map_result_${Date.now()}`;
                script.textContent = `
                    (function() {
                        try {
                            const result = map.project([${lngLat[0]}, ${lngLat[1]}]);
                            document.documentElement.setAttribute('${resultId}', JSON.stringify({x: result.x, y: result.y}));
                        } catch(e) {
                            document.documentElement.setAttribute('${resultId}_error', e.message);
                        }
                    })();
                `;
                document.documentElement.appendChild(script);
                script.remove();
                
                const resultStr = document.documentElement.getAttribute(resultId);
                const errorStr = document.documentElement.getAttribute(`${resultId}_error`);
                document.documentElement.removeAttribute(resultId);
                document.documentElement.removeAttribute(`${resultId}_error`);
                
                if (errorStr) {
                    throw new Error(errorStr);
                }
                
                return JSON.parse(resultStr);
            },
            on: function(event, handler) {
                gpLog(`Map event listener for ${event} registered (proxy mode)`);
                // Store the handler for later use
                if (!this._handlers) this._handlers = {};
                if (!this._handlers[event]) this._handlers[event] = [];
                this._handlers[event].push(handler);
                
                // Set up event forwarding via page script
                const listenerId = `__gp_map_listener_${event}_${Date.now()}`;
                const script = document.createElement('script');
                script.textContent = `
                    (function() {
                        if (typeof map !== 'undefined' && map.on) {
                            map.on('${event}', function() {
                                document.documentElement.setAttribute('${listenerId}', Date.now());
                            });
                        }
                    })();
                `;
                document.documentElement.appendChild(script);
                script.remove();
                
                // Set up mutation observer to detect attribute changes
                const observer = new MutationObserver(() => {
                    const val = document.documentElement.getAttribute(listenerId);
                    if (val) {
                        document.documentElement.removeAttribute(listenerId);
                        handler();
                    }
                });
                observer.observe(document.documentElement, { attributes: true });
            },
            off: function(event, handler) {
                gpLog(`Map event listener for ${event} removed (proxy mode)`);
                // In proxy mode, we can't easily remove specific handlers
                // This is a limitation of the bridge approach
            },
            getContainer: function() {
                return document.getElementById('map');
            }
        };
    }

    /**
     * Create a proxy object for turf that executes methods in page context
     */
    function createTurfProxy() {
        return {
            toWgs84: function(mercCoords) {
                const script = document.createElement('script');
                const resultId = `__gp_turf_result_${Date.now()}`;
                script.textContent = `
                    (function() {
                        try {
                            const result = turf.toWgs84([${mercCoords[0]}, ${mercCoords[1]}]);
                            document.documentElement.setAttribute('${resultId}', JSON.stringify(result));
                        } catch(e) {
                            document.documentElement.setAttribute('${resultId}_error', e.message);
                        }
                    })();
                `;
                document.documentElement.appendChild(script);
                script.remove();
                
                const resultStr = document.documentElement.getAttribute(resultId);
                const errorStr = document.documentElement.getAttribute(`${resultId}_error`);
                document.documentElement.removeAttribute(resultId);
                document.documentElement.removeAttribute(`${resultId}_error`);
                
                if (errorStr) {
                    throw new Error(errorStr);
                }
                
                return JSON.parse(resultStr);
            }
        };
    }

    function notifyUser(title, message) {
        // Use safe helper to get showAlert function
        const showAlert = getPageVariable('showAlert');
        
        if (typeof showAlert === 'function') {
            showAlert(title, message);
        } else {
            console.log(`[${title}] ${message}`);
            // Fallback alert if site's showAlert is not available
            alert(`${title}: ${message}`);
        }
    }

    // Reads the live state of the site's native Group Noise checkbox.
    function getGroupNoiseState() {
        const toggle = document.getElementById('groupNoiseToggle');
        return toggle ? !!toggle.checked : false;
    }

    function goToTemplateLocation() {
        const savedCoordsStr = localStorage.getItem('ghostImageCoords');
        if (!savedCoordsStr) {
            notifyUser("No Template", "No ghost image template is currently set.");
            return;
        }
        
        try {
            const coords = JSON.parse(savedCoordsStr);
            if (typeof coords.gridX !== 'number' || typeof coords.gridY !== 'number') {
                notifyUser("Error", "Invalid coordinates in template.");
                return;
            }
            
            // Get goToGridLocation using safe helper
            const goToGridLocation = getPageVariable('goToGridLocation');
            
            if (typeof goToGridLocation === 'function') {
                gpLog(`Teleporting to template location: ${coords.gridX}, ${coords.gridY}`);
                goToGridLocation(coords.gridX, coords.gridY);
            } else {
                notifyUser("Error", "Navigation function not available.");
                gpLog("ERROR: goToGridLocation function not found in window or unsafeWindow");
            }
        } catch (e) {
            dbgPush(`goToTemplateLocation failed to parse coordinates: ${e && e.message ? e.message : String(e)}`, { error: e, uiComponent: 'Ghost Template Manager - Go To Location button' });
            console.error("Failed to parse coordinates:", e);
            notifyUser("Error", "Failed to read template coordinates.");
        }
    }

    // Computes a SHA-256 fingerprint of the file content
    async function computeFileHash(blob) {
        const buffer = await blob.arrayBuffer();
        const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
        const hashArray = Array.from(new Uint8Array(hashBuffer));
        return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
    }

    // Computes a templateId from the clean image content (without position encoding)
    // This allows us to identify the same template even if it's been moved to different positions
    async function computeTemplateId(blob) {
        try {
            const img = await loadImageToCanvas(blob);
            const decoded = decodeRobustPosition(img);
            
            if (decoded && decoded.cleanCanvas) {
                // If position was encoded, use the clean canvas for ID
                const cleanBlob = await new Promise(r => decoded.cleanCanvas.toBlob(r, 'image/png'));
                return await computeFileHash(cleanBlob);
            } else {
                // No position encoding found, use original hash
                return await computeFileHash(blob);
            }
        } catch (e) {
            // On error, fall back to regular hash
            return await computeFileHash(blob);
        }
    }

    // ========== STYLES ==========
    const style = document.createElement('style');
    style.textContent = `
        .gp-to-modal-overlay {
            position: fixed; inset: 0; background: rgba(0, 0, 0, 0.75);
            display: flex; align-items: center; justify-content: center; z-index: 10000;
        }
        .gp-to-modal-panel {
            background: var(--color-gray-100, white); color: var(--color-gray-900, inherit); border-radius: 1rem; padding: 1.5rem;
            width: 95%; max-width: 600px; max-height: 80vh;
            display: flex; flex-direction: column; gap: 1rem;
            box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
        }
        .gp-to-header { display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid var(--color-gray-300, #eee); padding-bottom: 10px; }
        .gp-to-header-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
        .gp-to-title { font-size: 1.25rem; font-weight: bold; color: var(--color-gray-900, #1f2937); }

        .gp-to-grid {
            display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
            gap: 10px; overflow-y: auto; padding: 4px;
        }
        .gp-to-card {
            border: 1px solid var(--color-gray-300, #e5e7eb); border-radius: 8px; overflow: hidden;
            position: relative; transition: transform 0.1s, box-shadow 0.1s;
            cursor: pointer; background: var(--color-gray-200, #f9fafb);
        }
        .gp-to-card:hover { transform: translateY(-2px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); border-color: #3b82f6; }
        .gp-to-card img { width: 100%; height: 100px; object-fit: cover; display: block; }
        .gp-to-card-footer {
            padding: 4px; font-size: 10px; text-align: center;
            background: var(--color-gray-100, #fff); color: var(--color-gray-500, #6b7280); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }
        .gp-to-delete-btn {
            position: absolute; top: 2px; right: 2px;
            background: rgba(239, 68, 68, 0.9); color: white;
            border: none; border-radius: 4px; width: 20px; height: 20px;
            display: flex; align-items: center; justify-content: center;
            font-size: 12px; cursor: pointer; z-index: 2;
            opacity: 0; transition: opacity 0.15s;
        }
        .gp-to-card:hover .gp-to-delete-btn { opacity: 1; }
        .gp-to-delete-btn:hover { background: #dc2626; }
        .gp-to-sel-btn {
            position: absolute; top: 2px; left: 2px;
            background: rgba(255,255,255,0.9); color: #374151;
            border: none; border-radius: 4px; width: 20px; height: 20px;
            display: flex; align-items: center; justify-content: center;
            font-size: 13px; cursor: pointer; z-index: 2; line-height: 1;
            opacity: 0; transition: opacity 0.15s;
        }
        .gp-to-card:hover .gp-to-sel-btn { opacity: 1; }
        .gp-to-sel-btn:hover { background: #dbeafe; }
        .gp-to-sel-btn.selected { background: #3b82f6; color: white; opacity: 1; }
        .gp-to-grid.gp-sel-active .gp-to-sel-btn { opacity: 1; }
        .gp-to-goto-btn {
            position: absolute; bottom: 22px; left: 2px;
            background: rgba(251, 146, 60, 0.92); color: white;
            border: none; border-radius: 4px; width: 20px; height: 20px;
            display: flex; align-items: center; justify-content: center;
            font-size: 11px; cursor: pointer; z-index: 2; line-height: 1;
            opacity: 0; transition: opacity 0.15s;
        }
        .gp-to-card:hover .gp-to-goto-btn { opacity: 1; }
        .gp-to-goto-btn:hover { background: rgba(234, 88, 12, 0.95); }
        .gp-to-card.gp-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #93c5fd; }

        .gp-to-btn {
            padding: 0.5rem 1rem; border-radius: 0.5rem; font-weight: 600; cursor: pointer; border: none;
            display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
            transition: all 0.2s;
        }
        .gp-to-modal-panel .gp-to-btn { font-size: 0.75rem; }
        .gp-to-btn-blue { background-color: #3b82f6; color: white; }
        .gp-to-btn-blue:hover { background-color: #2563eb; }
        .gp-to-btn-green { background-color: #10b981; color: white; }
        .gp-to-btn-green:hover { background-color: #059669; }
        .gp-to-btn-purple { background-color: #8b5cf6; color: white; }
        .gp-to-btn-purple:hover { background-color: #7c3aed; }
        .gp-to-btn-red { background-color: #ef4444; color: white; }
        .gp-to-btn-gray { background-color: var(--color-gray-300, #e5e7eb); color: var(--color-gray-800, #374151); }
        .gp-to-btn-orange { background-color: #f97316; color: white; }
        .gp-to-btn-orange:hover { background-color: #ea580c; }
        .gp-to-btn-cyan { background-color: #06b6d4; color: white; border: 2px solid transparent; }
        .gp-to-btn-cyan:hover { background-color: #0891b2; }
        .gp-to-btn-cyan.active { 
            background-color: #0e7490; 
            border: 2px solid #fbbf24;
            box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.3);
        }

        .gp-to-preview-overlay {
            position: fixed;
            pointer-events: none;
            z-index: 9999;
            opacity: 0.7;
            transition: opacity 0.2s;
        }
    `;
    document.head.appendChild(style);

    // ========== INDEXED DB (CACHE) ==========

    const dbPromise = new Promise((resolve, reject) => {
        const request = indexedDB.open(DB_NAME, DB_VERSION);

        request.onupgradeneeded = (e) => {
            const db = e.target.result;
            const txn = e.target.transaction;

            let store;
            if (!db.objectStoreNames.contains(STORE_NAME)) {
                store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
            } else {
                store = txn.objectStore(STORE_NAME);
            }

            if (!store.indexNames.contains('hash')) {
                store.createIndex('hash', 'hash', { unique: false });
            }
            if (!store.indexNames.contains('templateId')) {
                store.createIndex('templateId', 'templateId', { unique: false });
            }
        };

        request.onsuccess = (e) => resolve(e.target.result);
        request.onerror = (e) => {
            const err = new Error(`IndexedDB failed to open '${DB_NAME}' v${DB_VERSION}: ${e.target.error}`);
            dbgPush(`dbPromise rejected — IndexedDB unavailable: ${err.message}`, { error: err, uiComponent: 'Ghost Template Manager - IndexedDB init' });
            reject(err);
        };
    });

    const HistoryManager = {
        async add(blob, filename, groupNoise) {
            const db = await dbPromise;
            const hash = await computeFileHash(blob);
            const templateId = await computeTemplateId(blob);

            return new Promise((resolve, reject) => {
                const tx = db.transaction(STORE_NAME, 'readwrite');
                const store = tx.objectStore(STORE_NAME);
                const templateIndex = store.index('templateId');

                const req = templateIndex.get(templateId);

                req.onsuccess = () => {
                    const existing = req.result;

                    const item = {
                        blob: blob,
                        name: filename || `Image_${Date.now()}`,
                        date: Date.now(),
                        hash: hash,
                        templateId: templateId,
                        groupNoise: !!groupNoise
                    };

                    if (existing) {
                        // Update the existing record IN PLACE (same numeric id) rather
                        // than delete-then-recreate under a fresh autoincrement id.
                        // GeoPixelcons++'s Ghost++ Template Overlay's optional shared-
                        // library mode keys its own per-template state (position/mask/
                        // opacity/etc, stored separately from this history) by this exact
                        // numeric id — recreating it under a new id on every "reload the
                        // same template" click would silently orphan that state. getAll()
                        // below sorts by `date` rather than relying on id/insertion order
                        // for recency, since the id no longer changes on an update.
                        gpLog("Duplicate template detected (same image, possibly different position). Updating entry in place.");
                        item.id = existing.id;
                        store.put(item);
                    } else {
                        store.add(item);
                    }
                };

                tx.oncomplete = () => resolve();
                tx.onerror = () => reject(tx.error);
            });
        },
        async getAll() {
            const db = await dbPromise;
            return new Promise((resolve, reject) => {
                const tx = db.transaction(STORE_NAME, 'readonly');
                const store = tx.objectStore(STORE_NAME);
                const req = store.getAll();
                // Sort by `date` (most recent first) rather than reversing insertion/
                // id order — add() now updates an existing record's `date` in place on
                // a duplicate match instead of always minting a new (higher) id, so id
                // order no longer tracks recency.
                req.onsuccess = () => resolve(req.result.sort((a, b) => b.date - a.date));
                tx.onerror = () => {
                    const err = new Error(`HistoryManager.getAll transaction failed: ${tx.error}`);
                    dbgPush(err.message, { error: err, uiComponent: 'Ghost Template Manager - HistoryManager.getAll' });
                    reject(err);
                };
            });
        },
        async delete(id) {
            const db = await dbPromise;
            return new Promise((resolve) => {
                const tx = db.transaction(STORE_NAME, 'readwrite');
                tx.objectStore(STORE_NAME).delete(id);
                tx.oncomplete = () => resolve();
            });
        },
        async clear() {
            const db = await dbPromise;
            return new Promise((resolve) => {
                const tx = db.transaction(STORE_NAME, 'readwrite');
                tx.objectStore(STORE_NAME).clear();
                tx.oncomplete = () => resolve();
            });
        }
    };

    // ========== IMPORT/EXPORT FUNCTIONS ==========

    // Helper function to convert blob to base64
    function blobToBase64(blob) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onloadend = () => resolve(reader.result.split(',')[1]);
            reader.onerror = reject;
            reader.readAsDataURL(blob);
        });
    }

    async function exportToZip() {
        gpLog("exportToZip: Starting export...");
        const images = await HistoryManager.getAll();
        gpLog(`exportToZip: Retrieved ${images.length} images`);
        
        if (images.length === 0) {
            notifyUser("Info", "No images to export.");
            return;
        }

        // JSZip doesn't work in Tampermonkey sandbox - use JSON bundle instead
        gpLog("exportToZip: Using JSON bundle export (JSZip incompatible with this environment)");
        
        try {
            const exportData = {
                version: "3.5",
                exportDate: new Date().toISOString(),
                images: []
            };

            for (let i = 0; i < images.length; i++) {
                const imgData = images[i];
                gpLog(`Encoding image ${i+1}/${images.length}: ${imgData.name}`);

                const base64 = await blobToBase64(imgData.blob);

                exportData.images.push({
                    id: imgData.id,
                    name: imgData.name,
                    date: imgData.date,
                    hash: imgData.hash,
                    templateId: imgData.templateId,
                    imageData: base64,
                    mimeType: imgData.blob.type || 'image/png',
                    groupNoise: !!imgData.groupNoise
                });
            }

            gpLog(`exportToZip: Creating download...`);
            
            const jsonStr = JSON.stringify(exportData);
            const blob = new Blob([jsonStr], { type: 'application/json' });
            
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `GeoPixels_History_${Date.now()}.json`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);

            gpLog("exportToZip: Export complete");
            notifyUser("Success", `Exported ${images.length} images to JSON bundle.`);
        } catch (error) {
            dbgPush(`exportToZip failed: ${error && error.message ? error.message : String(error)}`, { error: error, uiComponent: 'Image History - Export All' });
            console.error("exportToZip failed:", error);
            gpLog(`exportToZip: ERROR - ${error.message}`);
            notifyUser("Error", "Failed to export: " + error.message);
        }
    }

    async function exportSelectedToJson(selectedIds) {
        if (selectedIds.size === 0) {
            notifyUser("Info", "No images selected.");
            return;
        }
        const images = await HistoryManager.getAll();
        const filtered = images.filter(img => selectedIds.has(img.id));
        if (filtered.length === 0) {
            notifyUser("Info", "No matching images found.");
            return;
        }
        try {
            const exportData = {
                version: "3.5",
                exportDate: new Date().toISOString(),
                images: []
            };
            for (let i = 0; i < filtered.length; i++) {
                const imgData = filtered[i];
                const base64 = await blobToBase64(imgData.blob);
                exportData.images.push({
                    id: imgData.id,
                    name: imgData.name,
                    date: imgData.date,
                    hash: imgData.hash,
                    templateId: imgData.templateId,
                    imageData: base64,
                    mimeType: imgData.blob.type || 'image/png',
                    groupNoise: !!imgData.groupNoise
                });
            }
            const jsonStr = JSON.stringify(exportData);
            const blob = new Blob([jsonStr], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `GeoPixels_Selected_${Date.now()}.json`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
            notifyUser("Success", `Exported ${filtered.length} image${filtered.length !== 1 ? 's' : ''}.`);
        } catch (error) {
            dbgPush(`exportSelectedToJson failed: ${error && error.message ? error.message : String(error)}`, { error: error, uiComponent: 'Image History - Export Selected' });
            console.error("exportSelectedToJson failed:", error);
            notifyUser("Error", "Failed to export: " + error.message);
        }
    }

    async function importFromZip(file) {
        try {
            gpLog(`importFromZip: Starting import of ${file.name}`);
            
            // Check if it's a JSON file (new format)
            if (file.name.endsWith('.json')) {
                gpLog("importFromZip: Detected JSON bundle format");
                const text = await file.text();
                const data = JSON.parse(text);
                
                if (!data.images || !Array.isArray(data.images)) {
                    notifyUser("Error", "Invalid JSON: 'images' array not found.");
                    return;
                }
                
                let imported = 0;
                for (const imgEntry of [...data.images].reverse()) {
                    // Convert base64 back to blob
                    const byteCharacters = atob(imgEntry.imageData);
                    const byteNumbers = new Array(byteCharacters.length);
                    for (let i = 0; i < byteCharacters.length; i++) {
                        byteNumbers[i] = byteCharacters.charCodeAt(i);
                    }
                    const byteArray = new Uint8Array(byteNumbers);
                    const blob = new Blob([byteArray], { type: imgEntry.mimeType || 'image/png' });
                    
                    // Check for duplicate
                    const existingImages = await HistoryManager.getAll();
                    const isDuplicate = existingImages.some(img => img.hash === imgEntry.hash);
                    
                    if (!isDuplicate) {
                        // Older exports (pre-3.5) have no groupNoise field — default to
                        // disabled on import for backwards compatibility.
                        await HistoryManager.add(blob, imgEntry.name, !!imgEntry.groupNoise);
                        imported++;
                        gpLog(`Imported: ${imgEntry.name}`);
                    } else {
                        gpLog(`Skipped duplicate: ${imgEntry.name}`);
                    }
                }
                
                notifyUser("Success", `Imported ${imported} images from JSON bundle.`);
                return;
            }
            
            // Try ZIP format (legacy) - may not work
            gpLog("importFromZip: Attempting ZIP format (may fail)");
            const zip = await JSZip.loadAsync(file);
            const metadataFile = zip.file('metadata.json');

            if (!metadataFile) {
                notifyUser("Error", "Invalid ZIP: metadata.json not found.");
                return;
            }

            const metadataText = await metadataFile.async('text');
            const metadata = JSON.parse(metadataText);

            let imported = 0;
            for (const item of metadata) {
                const imageFile = zip.file(item.filename);
                if (imageFile) {
                    const blob = await imageFile.async('blob');
                    await HistoryManager.add(blob, item.name);
                    imported++;
                }
            }

            notifyUser("Success", `Imported ${imported} images from ZIP.`);
            return true;
        } catch (e) {
            console.error(e);
            notifyUser("Error", "Failed to import file.");
            return false;
        }
    }

    // ========== ALGORITHM (ENCODE/DECODE) ==========

    function encodeRobustPosition(originalCanvas, gridX, gridY) {
        const width = originalCanvas.width;
        const height = originalCanvas.height;
        const newCanvas = document.createElement('canvas');
        newCanvas.width = width;
        newCanvas.height = height + 1;
        const ctx = newCanvas.getContext('2d', { willReadFrequently: true });
        ctx.drawImage(originalCanvas, 0, 1);
        const headerImage = ctx.getImageData(0, 0, width, 1);
        const data = headerImage.data;
        const valX = (gridX + POSITION_OFFSET) >>> 0;
        const valY = (gridY + POSITION_OFFSET) >>> 0;
        const packetSize = 5;
        const maxPackets = Math.floor(width / packetSize);
        for (let i = 0; i < maxPackets; i++) {
            const base = (i * packetSize) * 4;
            data[base] = MARKER_R; data[base + 1] = MARKER_G; data[base + 2] = MARKER_B; data[base + 3] = 255;
            data[base + 4] = (valX >>> 24) & 0xFF; data[base + 5] = (valX >>> 16) & 0xFF; data[base + 6] = 0; data[base + 7] = 255;
            data[base + 8] = (valX >>> 8) & 0xFF; data[base + 9] = valX & 0xFF; data[base + 10] = 0; data[base + 11] = 255;
            data[base + 12] = (valY >>> 24) & 0xFF; data[base + 13] = (valY >>> 16) & 0xFF; data[base + 14] = 0; data[base + 15] = 255;
            data[base + 16] = (valY >>> 8) & 0xFF; data[base + 17] = valY & 0xFF; data[base + 18] = 0; data[base + 19] = 255;
        }
        ctx.putImageData(headerImage, 0, 0);
        return newCanvas;
    }

    function decodeRobustPosition(img) {
        const canvas = document.createElement('canvas');
        canvas.width = img.width;
        canvas.height = img.height;
        const ctx = canvas.getContext('2d', { willReadFrequently: true });
        ctx.drawImage(img, 0, 0);
        const headerData = ctx.getImageData(0, 0, img.width, 1).data;
        const votesX = new Map();
        const votesY = new Map();
        let validPackets = 0;
        const packetSize = 5;
        const maxPackets = Math.floor(img.width / packetSize);
        for (let i = 0; i < maxPackets; i++) {
            const base = (i * packetSize) * 4;
            if (headerData[base] === MARKER_R && headerData[base + 1] === MARKER_G && headerData[base + 2] === MARKER_B && headerData[base + 3] === 255) {
                const xVal = ((headerData[base + 4] << 24) | (headerData[base + 5] << 16) | (headerData[base + 8] << 8) | headerData[base + 9]) >>> 0;
                const yVal = ((headerData[base + 12] << 24) | (headerData[base + 13] << 16) | (headerData[base + 16] << 8) | headerData[base + 17]) >>> 0;
                votesX.set(xVal, (votesX.get(xVal) || 0) + 1);
                votesY.set(yVal, (votesY.get(yVal) || 0) + 1);
                validPackets++;
            }
        }
        if (validPackets === 0) return null;
        const getWinner = (map) => [...map.entries()].reduce((a, b) => b[1] > a[1] ? b : a)[0];
        const gridX = getWinner(votesX) - POSITION_OFFSET;
        const gridY = getWinner(votesY) - POSITION_OFFSET;
        const cleanCanvas = document.createElement('canvas');
        cleanCanvas.width = img.width;
        cleanCanvas.height = img.height - 1;
        const cleanCtx = cleanCanvas.getContext('2d');
        cleanCtx.drawImage(canvas, 0, 1, img.width, img.height - 1, 0, 0, img.width, img.height - 1);
        return { gridX, gridY, cleanCanvas };
    }

    // ========== PREVIEW FUNCTIONALITY ==========

    let previewImageCache = null;
    let previewRenderHandler = null;

    function drawPreviewImageOnCanvas() {
        gpLog("drawPreviewImageOnCanvas called");
        
        if (!previewOverlay) {
            gpLog("No preview overlay, returning");
            return;
        }
        
        if (!previewActive) {
            gpLog("Preview not active, returning");
            return;
        }

        const savedImageData = localStorage.getItem('ghostImageData');
        const savedCoordsStr = localStorage.getItem('ghostImageCoords');
        
        if (!savedCoordsStr || !savedImageData) {
            gpLog("Missing ghost image data or coords in localStorage");
            return;
        }

        const coords = JSON.parse(savedCoordsStr);
        gpLog("Ghost coords", coords);
        
        // Use cached image to avoid reloading
        if (!previewImageCache || previewImageCache.src !== savedImageData) {
            previewImageCache = new Image();
            previewImageCache.src = savedImageData;
            gpLog("Loading new preview image");
        }
        
        const img = previewImageCache;
        if (!img.complete) {
            gpLog("Image not loaded yet, waiting...");
            img.onload = () => {
                gpLog("Image loaded, redrawing");
                drawPreviewImageOnCanvas();
            };
            return;
        }
        
        gpLog("Image loaded, dimensions:", { width: img.width, height: img.height });

        // Get required game variables
        const pixelCanvas = document.getElementById('pixel-canvas');
        if (!pixelCanvas) {
            gpLog("ERROR: pixel-canvas not found");
            return;
        }

        // Match canvas size to pixel canvas
        if (previewOverlay.width !== pixelCanvas.width || previewOverlay.height !== pixelCanvas.height) {
            previewOverlay.width = pixelCanvas.width;
            previewOverlay.height = pixelCanvas.height;
            gpLog("Resized preview canvas to", { width: pixelCanvas.width, height: pixelCanvas.height });
        }

        const ctx = previewOverlay.getContext('2d');
        const { width, height } = previewOverlay;
        ctx.clearRect(0, 0, width, height);
        gpLog("Cleared canvas");

        // Get map and turf using safe helper to avoid DOM element conflicts
        const map = getPageVariable('map');
        const turf = getPageVariable('turf');
        
        // gridSize is often 25 (standard grid size for geopixels)
        // Try to get from page, fallback to defaults
        let gridSize = getPageVariable('gridSize') || 25;
        let halfSize = getPageVariable('halfSize') || (gridSize / 2);
        let offsetMetersX = getPageVariable('offsetMetersX') || 0;
        let offsetMetersY = getPageVariable('offsetMetersY') || 0;
        
        gpLog("Grid values:", { gridSize, halfSize, offsetMetersX, offsetMetersY });

        if (!map || !turf) {
            gpLog("ERROR: Missing required variables", { 
                hasMap: !!map, 
                hasTurf: !!turf, 
                gridSize: gridSize 
            });
            return;
        }

        if (typeof map.project !== 'function') {
            gpLog("ERROR: map.project is not a function", { mapType: typeof map });
            return;
        }

        // Calculate corners using the SAME method as the game's drawGhostImageOnCanvas
        // Top-left pixel center
        const tl_pixel_center_x = coords.gridX * gridSize;
        const tl_pixel_center_y = coords.gridY * gridSize;

        // Top-left mercator edge
        const tl_merc_edge = [
            tl_pixel_center_x - halfSize + offsetMetersX,
            tl_pixel_center_y + halfSize + offsetMetersY
        ];

        // Bottom-right grid coordinates
        const br_pixel_gridX = coords.gridX + img.width - 1;
        const br_pixel_gridY = coords.gridY - img.height + 1;

        const br_pixel_center_x = br_pixel_gridX * gridSize;
        const br_pixel_center_y = br_pixel_gridY * gridSize;

        // Bottom-right mercator edge
        const br_merc_edge = [
            br_pixel_center_x + halfSize + offsetMetersX,
            br_pixel_center_y - halfSize + offsetMetersY
        ];
        
        gpLog("Mercator coords (ghost method)", { tl_merc_edge, br_merc_edge });

        // Convert to WGS84 and then project to screen
        const topLeftScreen = map.project(turf.toWgs84(tl_merc_edge));
        const bottomRightScreen = map.project(turf.toWgs84(br_merc_edge));
        
        gpLog("Screen coords", { topLeftScreen, bottomRightScreen });

        const drawX = topLeftScreen.x;
        const drawY = topLeftScreen.y;
        const screenWidth = bottomRightScreen.x - drawX;
        const screenHeight = bottomRightScreen.y - drawY;
        
        gpLog("Draw position and dimensions", { drawX, drawY, screenWidth, screenHeight });

        // Check if visible
        if (drawX + screenWidth < 0 || 
            drawX > width ||
            drawY + screenHeight < 0 || 
            drawY > height) {
            gpLog("Image not in viewport, skipping draw");
            return;
        }

        // Draw fully opaque
        ctx.imageSmoothingEnabled = false;
        ctx.drawImage(img, drawX, drawY, screenWidth, screenHeight);
        
        gpLog("Drew preview image successfully");
    }

    function togglePreview(button) {
        gpLog("togglePreview called, current state:", previewActive);
        gpLog("Button click - environment check:", {
            windowExists: typeof window !== 'undefined',
            unsafeWindowExists: typeof unsafeWindow !== 'undefined',
            windowKeys: typeof window !== 'undefined' ? Object.keys(window).filter(k => k.includes('map') || k.includes('turf')).slice(0, 10) : [],
            unsafeWindowKeys: typeof unsafeWindow !== 'undefined' ? Object.keys(unsafeWindow).filter(k => k.includes('map') || k.includes('turf')).slice(0, 10) : []
        });
        
        if (previewActive) {
            // Deactivate preview
            gpLog("Deactivating preview");
            
            if (previewOverlay && previewOverlay.parentNode) {
                previewOverlay.parentNode.removeChild(previewOverlay);
                gpLog("Removed preview overlay from DOM");
            }
            
            // Unhook from map events
            if (previewRenderHandler) {
                const map = getPageVariable('map');
                if (map && typeof map.off === 'function') {
                    try {
                        map.off('move', previewRenderHandler);
                        map.off('zoom', previewRenderHandler);
                        map.off('rotate', previewRenderHandler);
                        gpLog("Removed map event listeners");
                    } catch (e) {
                        gpLog("Error removing map listeners", e);
                    }
                }
            }
            
            previewOverlay = null;
            previewImageCache = null;
            previewRenderHandler = null;
            previewActive = false;
            button.innerHTML = '👁️ Preview';
            button.classList.remove('active');
            gpLog("Preview deactivated");
        } else {
            // Activate preview
            gpLog("Activating preview");
            
            const savedImageData = localStorage.getItem('ghostImageData');
            const savedCoordsStr = localStorage.getItem('ghostImageCoords');
            
            if (!savedImageData || !savedCoordsStr) {
                gpLog("ERROR: No ghost image data in localStorage");
                notifyUser("Error", "No ghost image on map to preview.");
                return;
            }

            gpLog("Found ghost data in localStorage");

            // Find the pixel canvas to match its size
            const pixelCanvas = document.getElementById('pixel-canvas');
            if (!pixelCanvas) {
                gpLog("ERROR: pixel-canvas not found");
                notifyUser("Error", "Pixel canvas not found. Make sure you're on the map view.");
                return;
            }

            gpLog("Found pixel canvas", { width: pixelCanvas.width, height: pixelCanvas.height });

            // Verify map exists
            const map = getPageVariable('map');
            if (!map) {
                gpLog("ERROR: map not found in any scope");
                notifyUser("Error", "Map not initialized yet. Please wait a moment and try again.");
                return;
            }
            
            gpLog("Map object found", { 
                mapType: typeof map, 
                hasProject: typeof map.project,
                isHTMLElement: map instanceof HTMLElement,
                constructor: map.constructor ? map.constructor.name : 'unknown'
            });

            if (typeof map.project !== 'function') {
                gpLog("ERROR: map.project is not a function", { 
                    mapType: typeof map,
                    projectType: typeof map.project,
                    mapKeys: Object.keys(map).slice(0, 20),
                    mapConstructor: map.constructor ? map.constructor.name : 'unknown'
                });
                notifyUser("Error", "Map projection not available. Page may not be fully loaded.");
                return;
            }
            
            gpLog("map.project verified as function");

            // Verify turf exists
            const turf = getPageVariable('turf');
            if (!turf) {
                gpLog("ERROR: turf not found in any scope");
                notifyUser("Error", "Turf.js library not loaded. Page may not be fully loaded.");
                return;
            }
            
            gpLog("Turf object found", { turfType: typeof turf, hasToWgs84: typeof turf.toWgs84 });

            if (typeof turf.toWgs84 !== 'function') {
                gpLog("ERROR: turf.toWgs84 is not a function", { 
                    turfType: typeof turf,
                    toWgs84Type: typeof turf.toWgs84,
                    turfKeys: Object.keys(turf).slice(0, 20)
                });
                notifyUser("Error", "Map projection not available. Page may not be fully loaded.");
                return;
            }
            
            gpLog("turf.toWgs84 verified as function");

            gpLog("Map and turf are ready with required functions");

            // Create preview canvas
            previewOverlay = document.createElement('canvas');
            previewOverlay.id = 'gp-preview-canvas';
            previewOverlay.className = 'pixel-perfect';
            previewOverlay.width = pixelCanvas.width;
            previewOverlay.height = pixelCanvas.height;
            previewOverlay.style.cssText = 'display: block; image-rendering: pixelated; position: absolute; top: 0; left: 0; pointer-events: none; z-index: 5;';

            gpLog("Created preview canvas element");

            // Insert into DOM - find the map container
            const mapContainer = map.getContainer ? map.getContainer() : document.getElementById('map');
            if (mapContainer) {
                mapContainer.appendChild(previewOverlay);
                gpLog("Appended preview canvas to map container");
            } else {
                document.body.appendChild(previewOverlay);
                gpLog("Appended preview canvas to body (fallback)");
            }
            
            previewActive = true;
            button.innerHTML = '👁️ Hide Preview';
            button.classList.add('active');

            // Create render handler
            previewRenderHandler = () => {
                gpLog("Map event triggered, redrawing preview");
                drawPreviewImageOnCanvas();
            };

            // Hook into map events (same as geopixels++)
            try {
                map.on('move', previewRenderHandler);
                map.on('zoom', previewRenderHandler);
                map.on('rotate', previewRenderHandler);
                gpLog("Attached to map events");
            } catch (e) {
                gpLog("ERROR attaching map listeners", e);
            }

            // Render once immediately
            gpLog("Drawing initial preview");
            drawPreviewImageOnCanvas();
            
            gpLog("Preview activated successfully");
        }
    }

    /**
     * Replicates the logic of the 'Save Pos' button to cache the currently placed ghost image.
     * This function is available globally but is no longer called automatically.
     */
    async function cacheCurrentGhostPosition() {
        const savedCoordsStr = localStorage.getItem('ghostImageCoords');
        const savedImageData = localStorage.getItem('ghostImageData');
        if (!savedCoordsStr || !savedImageData) {
            gpLog("Auto-Cache: No ghost image on map or coordinates found.");
            return;
        }
        gpLog("Auto-Cache: Starting cache process.");

        const coords = JSON.parse(savedCoordsStr);
        const img = new Image();
        img.src = savedImageData;
        await new Promise(r => img.onload = r);

        const tempCanvas = document.createElement('canvas');
        tempCanvas.width = img.width; tempCanvas.height = img.height;
        tempCanvas.getContext('2d').drawImage(img, 0, 0);

        const encodedCanvas = encodeRobustPosition(tempCanvas, coords.gridX, coords.gridY);
        encodedCanvas.toBlob(async (blob) => {
            if(!blob) return;

            // Save to History (Cache)
            try {
                await HistoryManager.add(blob, `Backup_${coords.gridX}_${coords.gridY}`, getGroupNoiseState());
                gpLog("Auto-Cache: Cached image with position data.");
                notifyUser("Auto-Cache", `Ghost image position ${coords.gridX}, ${coords.gridY} auto-cached.`);
            } catch (e) {
                dbgPush(`cacheCurrentGhostPosition HistoryManager.add failed: ${e && e.message ? e.message : String(e)}`, { error: e, uiComponent: 'Ghost Template Manager - Auto-Cache' });
                console.error("Auto-Cache failed", e);
                notifyUser("Auto-Cache Error", "Failed to auto-cache the image position.");
            }
        }, 'image/png');
    }
    // Expose for direct use if needed, but primarily used internally now
    window.cacheCurrentGhostPosition = cacheCurrentGhostPosition;


    // ========== GAME INTEGRATION ==========

    function applyCoordinatesToGame(coords) {
        gpLog("Applying coordinates...", coords);

        // Write coords to localStorage immediately so goToTemplateLocation and
        // any other readers have them right away — before the FileReader finishes.
        localStorage.setItem('ghostImageCoords', JSON.stringify(coords));

        // Poll until BOTH conditions are true:
        //   1. The place button is re-enabled (game's FileReader finished processing)
        //   2. ghostImageData is in localStorage (FileReader wrote it)
        // Only then call initializeGhostFromStorage — which reads the closure-scoped
        // `let ghostImageTopLeft` variable inside ghost22.js and is the only way to
        // reliably update it. Direct window property assignment does not reach it.
        let attempts = 0;
        const interval = setInterval(() => {
            const placeBtn = document.getElementById('initiatePlaceGhostBtn');
            const hasImageData = !!localStorage.getItem('ghostImageData');

            if (placeBtn && !placeBtn.disabled && hasImageData) {
                clearInterval(interval);

                const initializeGhostFromStorage = getPageVariable('initializeGhostFromStorage');
                if (typeof initializeGhostFromStorage === 'function') {
                    gpLog("Calling initializeGhostFromStorage (ghostImageData confirmed present)");
                    initializeGhostFromStorage();
                    notifyUser("Auto-Place", `Position detected: ${coords.gridX}, ${coords.gridY}`);
                } else {
                    const msg = `initializeGhostFromStorage not found in page scope — template loaded but coordinates not applied (${coords.gridX}, ${coords.gridY})`;
                    dbgPush(msg, { uiComponent: 'Ghost Template Manager - applyCoordinatesToGame' });
                    gpLog("ERROR: " + msg);
                    notifyUser("Warning", `Position set to ${coords.gridX}, ${coords.gridY} but auto-place failed. Click 'Place on Map' manually.`);
                }
            }

            if (++attempts > 100) {
                clearInterval(interval);
                const msg = `applyCoordinatesToGame timed out after 10s waiting for place button + ghostImageData (coords: ${coords.gridX}, ${coords.gridY}).`;
                dbgPush(msg, { uiComponent: 'Ghost Template Manager - applyCoordinatesToGame' });
                gpLog("Timeout: " + msg);
                notifyUser("Warning", `Position set to ${coords.gridX}, ${coords.gridY} but image may still be loading. Try clicking 'Place on Map' manually.`);
            }
        }, 100);
    }

    async function loadImageToCanvas(blob) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.onload = () => resolve(img);
            img.onerror = reject;
            img.src = URL.createObjectURL(blob);
        });
    }

    // ========== PROCESSING LOGIC ==========

    async function processAndLoadImage(file, saveToHistory = true, uiComponent = null, restoreGroupNoise = undefined) {
        gpLog("Processing image...");
        const placeBtn = document.getElementById('initiatePlaceGhostBtn');
        if (placeBtn) { placeBtn.innerText = "Analyzing..."; placeBtn.disabled = true; }

        try {
            const img = await loadImageToCanvas(file);
            const decoded = decodeRobustPosition(img);

            let finalFile = file;
            let coords = null;

            if (decoded) {
                gpLog("Found encoded position.", { gridX: decoded.gridX, gridY: decoded.gridY });
                coords = { gridX: decoded.gridX, gridY: decoded.gridY };
                const cleanBlob = await new Promise((resolve, reject) => {
                    const toBlobTimeout = setTimeout(() => {
                        const err = new Error('cleanCanvas.toBlob timed out after 10s — canvas may be invalid or too large');
                        dbgPush(err.message, { error: err, uiComponent: uiComponent || 'Ghost Template Manager - processAndLoadImage' });
                        reject(err);
                    }, 10000);
                    decoded.cleanCanvas.toBlob(blob => {
                        clearTimeout(toBlobTimeout);
                        resolve(blob);
                    }, 'image/png');
                });
                finalFile = new File([cleanBlob], file.name || "ghost.png", { type: "image/png" });
            } else {
                gpLog("No encoded position found in image");
                // Ensure finalFile is a File (not a raw Blob from IndexedDB) —
                // DataTransferItemList.add() requires a File, not a Blob.
                if (!(finalFile instanceof File)) {
                    finalFile = new File([finalFile], 'ghost.png', { type: finalFile.type || 'image/png' });
                }
            }

            if (saveToHistory) {
                await HistoryManager.add(file, file.name, getGroupNoiseState());
            }

            // Restore the Group Noise checkbox *before* dispatching the native change
            // event — the game's own handler reads it asynchronously (FileReader ->
            // Image.onload -> extractAndMapColors) after this call returns, so setting
            // it now guarantees the correct value is in place whenever that fires.
            // If "Disable Group Noise" is on, disable-group-noise.js has already replaced
            // `checked` with a getter/setter that ignores writes, so this is naturally a
            // no-op and that setting always wins — no extra branching needed here.
            if (restoreGroupNoise !== undefined) {
                const groupNoiseToggle = document.getElementById('groupNoiseToggle');
                if (groupNoiseToggle) groupNoiseToggle.checked = !!restoreGroupNoise;
            }

            const input = document.getElementById('ghostImageInput');
            const dt = new DataTransfer();
            dt.items.add(finalFile);
            input.files = dt.files;

            isInternalUpdate = true;
            input.dispatchEvent(new Event('change', { bubbles: true }));
            isInternalUpdate = false;

            // Wait for the game to process the image first
            await new Promise(resolve => setTimeout(resolve, 100));

            if (coords) {
                gpLog("Applying coordinates to game", coords);
                applyCoordinatesToGame(coords);
            } else {
                // No encoded position in this image — preserve any existing placement.
                // The user may have already manually placed a previous image; loading a
                // new image without coords should not silently erase that position.
                // Coords are only cleared by the explicit Clear button (clearGhostImage).
                gpLog("No encoded position found — existing coords preserved");
            }

        } catch (e) {
            dbgPush(`processAndLoadImage failed: ${e && e.message ? e.message : String(e)}`, { error: e, uiComponent: uiComponent || 'Ghost Template Manager' });
            console.error(e);
            notifyUser("Error", "Failed to process image.");
        } finally {
            if (placeBtn) placeBtn.innerText = "Place on Map";
        }
    }

    // ========== INTERCEPTOR ==========

    function setupNativeInterceptor() {
        const input = document.getElementById('ghostImageInput');
        if (!input) return;

        // 3. Add .zip to the file input's accepted types
        input.setAttribute('accept', 'image/png, image/jpeg, image/webp, image/gif, application/zip, .zip');

        input.addEventListener('change', async (e) => {
            if (isInternalUpdate) return;
            const file = e.target.files[0];
            if (!file) return;
            e.stopImmediatePropagation();
            e.preventDefault();

            // Check if it's a ZIP file
            if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
                gpLog("Detected ZIP file upload");
                const success = await importFromZip(file);
                if (success) {
                    // Clear the input so same file can be uploaded again
                    input.value = '';
                }
                return;
            }

            // Otherwise process as image
            processAndLoadImage(file, false, 'Ghost Image Input - file picker');
        }, true);
    }

    // ========== UI HANDLERS ==========

    async function handleUrlUpload() {
        const url = prompt("Enter Image or ZIP URL:");
        if (!url) return;
        
        try {
            // Use GM_xmlhttpRequest to bypass CSP restrictions
            const blob = await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    responseType: 'blob',
                    onload: (response) => {
                        if (response.status >= 200 && response.status < 300) {
                            resolve(response.response);
                        } else {
                            reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
                        }
                    },
                    onerror: (error) => {
                        reject(new Error('Failed to fetch URL'));
                    },
                    ontimeout: () => {
                        reject(new Error('Request timed out'));
                    }
                });
            });

            // Check if it's a ZIP file
            if (blob.type === 'application/zip' || blob.type === 'application/x-zip-compressed' || url.toLowerCase().endsWith('.zip')) {
                gpLog("Detected ZIP file from URL");
                await importFromZip(blob);
                notifyUser("Success", "Imported cache from URL!");
                return;
            }

            // Otherwise treat as image
            if (!blob.type.startsWith('image/')) throw new Error("Invalid image");
            processAndLoadImage(new File([blob], "url_upload.png", { type: blob.type }), false, 'Ghost Template Manager - URL Upload');
        } catch (e) {
            dbgPush(`handleUrlUpload failed: ${e && e.message ? e.message : String(e)}`, { error: e, uiComponent: 'Ghost Template Manager - URL Upload' });
            console.error(e);
            notifyUser("Error", "Could not load file from URL: " + e.message);
        }
    }

    async function downloadWithPos() {
        const savedImageData = localStorage.getItem('ghostImageData');
        if (!savedImageData) {
            notifyUser("Error", "No ghost image loaded.");
            return;
        }
        
        const savedCoordsStr = localStorage.getItem('ghostImageCoords');
        const img = new Image();
        img.src = savedImageData;
        await new Promise(r => img.onload = r);

        const tempCanvas = document.createElement('canvas');
        tempCanvas.width = img.width; tempCanvas.height = img.height;
        tempCanvas.getContext('2d').drawImage(img, 0, 0);

        const groupNoise = getGroupNoiseState();

        if (savedCoordsStr) {
            // If coordinates exist, encode them and save
            const coords = JSON.parse(savedCoordsStr);
            const encodedCanvas = encodeRobustPosition(tempCanvas, coords.gridX, coords.gridY);
            encodedCanvas.toBlob(async (blob) => {
                if(!blob) return;

                // Save to History (Cache)
                try {
                    await HistoryManager.add(blob, `Backup_${coords.gridX}_${coords.gridY}`, groupNoise);
                    gpLog("Cached image with position data");
                    notifyUser("Success", "Template saved to history!");
                } catch (e) {
                    dbgPush(`downloadWithPos (with coords) HistoryManager.add failed: ${e && e.message ? e.message : String(e)}`, { error: e, uiComponent: 'Ghost Template Manager - Save Pos button' });
                    console.error("Cache failed", e);
                    notifyUser("Error", "Failed to save template");
                }
            }, 'image/png');
        } else {
            // No coordinates: just save the image as-is
            tempCanvas.toBlob(async (blob) => {
                if(!blob) return;

                try {
                    await HistoryManager.add(blob, `Image_${Date.now()}`, groupNoise);
                    gpLog("Cached image without position data");
                    notifyUser("Success", "Template saved to history!");
                } catch (e) {
                    dbgPush(`downloadWithPos (no coords) HistoryManager.add failed: ${e && e.message ? e.message : String(e)}`, { error: e, uiComponent: 'Ghost Template Manager - Save Pos button' });
                    console.error("Cache failed", e);
                    notifyUser("Error", "Failed to save template");
                }
            }, 'image/png');
        }
    }

    async function openHistoryModal() {
        const existing = document.getElementById('gp-history-modal');
        if (existing) existing.remove();

        const images = await HistoryManager.getAll();
        const modal = document.createElement('div');
        modal.id = 'gp-history-modal';
        modal.className = 'gp-to-modal-overlay';
        modal.innerHTML = `
            <div class="gp-to-modal-panel">
                <div class="gp-to-header">
                    <div class="gp-to-header-row">
                        <span class="gp-to-title">Image History (${images.length})</span>
                        <button id="gp-close-hist" class="gp-to-btn gp-to-btn-gray">Close</button>
                    </div>
                    <div class="gp-to-header-row">
                        <button id="gp-select-all" class="gp-to-btn gp-to-btn-blue">☑ Select All</button>
                        <button id="gp-select-none" class="gp-to-btn gp-to-btn-gray">☐ Select None</button>
                        <button id="gp-export-selected" class="gp-to-btn gp-to-btn-blue" disabled style="opacity:0.5">📤 Export Selected</button>
                        <button id="gp-import-zip" class="gp-to-btn gp-to-btn-green">📁 Import JSON</button>
                    </div>
                </div>
                <div class="gp-to-grid" id="gp-history-grid">
                    ${images.length === 0 ? '<p class="p-4 text-gray-500 col-span-full text-center">No images found.</p>' : ''}
                </div>
                <div style="border-top: 1px solid var(--color-gray-300, #eee); padding-top: 10px; display: flex; justify-content: flex-end;">
                    <button id="gp-clear-all" class="gp-to-btn gp-to-btn-red">🗑️ Clear All</button>
                </div>
            </div>
        `;
        document.body.appendChild(modal);

        const grid = modal.querySelector('#gp-history-grid');
        const selectedIds = new Set();

        function updateExportSelBtn() {
            const btn = modal.querySelector('#gp-export-selected');
            if (!btn) return;
            if (selectedIds.size > 0) {
                btn.textContent = `📤 Export Selected (${selectedIds.size})`;
                btn.disabled = false;
                btn.style.opacity = '1';
                grid.classList.add('gp-sel-active');
            } else {
                btn.textContent = '📤 Export Selected';
                btn.disabled = true;
                btn.style.opacity = '0.5';
                grid.classList.remove('gp-sel-active');
            }
        }

        images.forEach(imgData => {
            const card = document.createElement('div');
            card.className = 'gp-to-card';
            card.dataset.imgId = imgData.id;
            card.innerHTML = `
                <button class="gp-to-delete-btn" title="Delete">✖</button>
                <button class="gp-to-sel-btn" title="Select for export">☐</button>
                <button class="gp-to-goto-btn" title="Load &amp; Go To">🎯</button>
                <img src="${URL.createObjectURL(imgData.blob)}" />
                <div class="gp-to-card-footer">${new Date(imgData.date).toLocaleTimeString()} - ${imgData.name.substring(0,12)}</div>
            `;
            card.onclick = async (e) => {
                if (e.target.closest('.gp-to-delete-btn')) return;
                if (e.target.closest('.gp-to-sel-btn')) return;
                if (e.target.closest('.gp-to-goto-btn')) return;
                await processAndLoadImage(imgData.blob, false, 'Image History - card click', imgData.groupNoise);
                await HistoryManager.add(imgData.blob, imgData.name, imgData.groupNoise);
                if (_settings.modernizeGhostPaletteBtns) loadRecentImages();
                modal.remove();
            };
            card.querySelector('.gp-to-delete-btn').onclick = async () => {
                await HistoryManager.delete(imgData.id);
                selectedIds.delete(imgData.id);
                updateExportSelBtn();
                card.remove();
            };
            card.querySelector('.gp-to-sel-btn').onclick = (e) => {
                e.stopPropagation();
                const selBtn = card.querySelector('.gp-to-sel-btn');
                if (selectedIds.has(imgData.id)) {
                    selectedIds.delete(imgData.id);
                    selBtn.textContent = '☐';
                    selBtn.classList.remove('selected');
                    card.classList.remove('gp-selected');
                } else {
                    selectedIds.add(imgData.id);
                    selBtn.textContent = '☑';
                    selBtn.classList.add('selected');
                    card.classList.add('gp-selected');
                }
                updateExportSelBtn();
            };
            card.querySelector('.gp-to-goto-btn').onclick = async (e) => {
                e.stopPropagation();
                // Decode coords from the image NOW before anything async happens,
                // so we have them in hand regardless of applyCoordinatesToGame's
                // setInterval timing.
                let coords = null;
                try {
                    const img = await loadImageToCanvas(imgData.blob);
                    const decoded = decodeRobustPosition(img);
                    if (decoded) coords = { gridX: decoded.gridX, gridY: decoded.gridY };
                } catch (_) {
                    dbgPush('Image History Load & Go To: failed to decode position from blob', { error: _, uiComponent: 'Image History - Load & Go To' });
                }
                modal.remove();
                await processAndLoadImage(imgData.blob, false, 'Image History - Load & Go To', imgData.groupNoise);
                await HistoryManager.add(imgData.blob, imgData.name, imgData.groupNoise);
                if (_settings.modernizeGhostPaletteBtns) loadRecentImages();
                if (coords) {
                    const goToGridLocation = getPageVariable('goToGridLocation');
                    if (typeof goToGridLocation === 'function') {
                        goToGridLocation(coords.gridX, coords.gridY);
                    }
                } else {
                    notifyUser("Load & Go", "No encoded coordinates found in this template.");
                }
            };
            grid.appendChild(card);
        });

        modal.querySelector('#gp-select-all').onclick = () => {
            images.forEach(imgData => {
                if (!selectedIds.has(imgData.id)) {
                    selectedIds.add(imgData.id);
                    const card = grid.querySelector(`[data-img-id="${imgData.id}"]`);
                    if (card) {
                        card.querySelector('.gp-to-sel-btn').textContent = '☑';
                        card.querySelector('.gp-to-sel-btn').classList.add('selected');
                        card.classList.add('gp-selected');
                    }
                }
            });
            updateExportSelBtn();
        };

        modal.querySelector('#gp-select-none').onclick = () => {
            selectedIds.clear();
            grid.querySelectorAll('.gp-to-card').forEach(card => {
                card.querySelector('.gp-to-sel-btn').textContent = '☐';
                card.querySelector('.gp-to-sel-btn').classList.remove('selected');
                card.classList.remove('gp-selected');
            });
            updateExportSelBtn();
        };

        modal.querySelector('#gp-export-selected').onclick = async () => {
            await exportSelectedToJson(new Set(selectedIds));
        };

        modal.querySelector('#gp-import-zip').onclick = () => {
            const input = document.createElement('input');
            input.type = 'file';
            input.accept = '.json, .zip, application/json, application/zip'; // Accept JSON (new) and ZIP (legacy)
            input.onchange = async (e) => {
                const file = e.target.files[0];
                if (file) {
                    await importFromZip(file);
                    modal.remove();
                    openHistoryModal(); // Refresh the modal
                }
            };
            input.click();
        };

        modal.querySelector('#gp-clear-all').onclick = async () => {
            if(confirm("Clear all cached images?")) {
                await HistoryManager.clear();
                modal.remove();
            }
        };
        modal.querySelector('#gp-close-hist').onclick = () => modal.remove();
    }

    // ========== INJECTION ==========

    /**
     * Watches the document for the coordinate-setting success message
     * and triggers the auto-cache function.
     * This addresses issue #2.
     */
    function setupAlertBodyObserver() {
        const targetNode = document.getElementById('alertBody');
        if (!targetNode) {
             gpLog("Could not find alertBody for position observer.");
             return;
        }

        const observer = new MutationObserver((mutationsList, observer) => {
            for(const mutation of mutationsList) {
                if (mutation.type === 'childList' || mutation.type === 'characterData') {
                    const textContent = targetNode.textContent;
                    if (textContent && textContent.includes("Ghost image position set")) {
                        gpLog("Detected 'Ghost image position set'. Triggering auto-cache.");
                        cacheCurrentGhostPosition();
                        // Disconnect after first success to avoid spamming the cache,
                        // as a new observer will be created when the modal is opened next.
                        observer.disconnect();
                        break;
                    }
                }
            }
        });

        // Start observing the target node for configured mutations
        const config = { childList: true, subtree: true, characterData: true };
        observer.observe(targetNode, config);
    }

    const GPC_GHOST_POS_KEY = 'gpc-ghost-modal-pos';
    let _ghostModalPosSaveTimer = null;

    function rememberGhostModalEnabled() {
        if (typeof gpcMobileCompatibilityActive === 'function' && gpcMobileCompatibilityActive()) return false;
        return !!(_settings.ghostTemplateManager && _settings.rememberGhostModalPos);
    }

    function getSavedGhostModalRect() {
        try {
            const saved = JSON.parse(localStorage.getItem(GPC_GHOST_POS_KEY) || 'null');
            return saved && typeof saved === 'object' ? saved : null;
        } catch (_) {
            return null;
        }
    }

    function restoreGhostModalPlacement(modal) {
        if (!modal || !rememberGhostModalEnabled()) return false;
        const saved = getSavedGhostModalRect();
        if (!saved) return false;

        modal.dataset.gpcRememberRestoring = '1';
        if (saved.top)    modal.style.top = saved.top;
        if (saved.left)   modal.style.left = saved.left;
        if (saved.width)  modal.style.width = saved.width;
        if (saved.height) modal.style.height = saved.height;
        if (saved.top || saved.left) modal.style.transform = 'none';
        setTimeout(() => { delete modal.dataset.gpcRememberRestoring; }, 120);
        return true;
    }

    function saveGhostModalPlacement(modal) {
        if (!modal || !rememberGhostModalEnabled() || modal.dataset.gpcRememberRestoring) return;
        const rect = modal.getBoundingClientRect();
        if (!rect.width || !rect.height) return;
        // When the preview is collapsed the live width is the narrow collapsed width;
        // persist the expanded equivalent so reload doesn't pin a collapsed width.
        const collapsed = modal.dataset.gpcPreviewCollapsed === '1';
        const widthVal = (collapsed && modal.dataset.gpcExpandedWidth)
            ? modal.dataset.gpcExpandedWidth
            : (modal.style.width || `${Math.round(rect.width)}px`);
        localStorage.setItem(GPC_GHOST_POS_KEY, JSON.stringify({
            top:    modal.style.top    || `${Math.round(rect.top)}px`,
            left:   modal.style.left   || `${Math.round(rect.left)}px`,
            width:  widthVal,
            height: modal.style.height || `${Math.round(rect.height)}px`,
        }));
    }

    function scheduleGhostModalPlacementSave(modal) {
        clearTimeout(_ghostModalPosSaveTimer);
        _ghostModalPosSaveTimer = setTimeout(() => saveGhostModalPlacement(modal), 350);
    }

    function patchGhostModalToggleForRemembering() {
        const w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        if (w.__gpcGhostModalRememberPatchQueued) return;
        w.__gpcGhostModalRememberPatchQueued = true;

        const watchStartedAt = Date.now();
        function tryPatch() {
            if (typeof w.toggleGhostModal !== 'function') {
                if (Date.now() - watchStartedAt >= 15000) {
                    dbgPush('Ghost Template Manager: gave up after 15s -- w.toggleGhostModal was never found, so the ghost modal position could not be patched for remembering.', { uiComponent: 'Ghost Template Manager' });
                    console.error('[GeoPixelcons++] Ghost Template Manager: never found w.toggleGhostModal to patch for remembering ghost modal position.');
                    return;
                }
                setTimeout(tryPatch, 500);
                return;
            }
            if (w.toggleGhostModal.__gpcRememberPatched) return;
            dbgPush('Ghost Template Manager: w.toggleGhostModal found ' + (Date.now() - watchStartedAt) + 'ms after watching started -- patching now.', { uiComponent: 'Ghost Template Manager' });
            const originalToggleGhostModal = w.toggleGhostModal;
            w.toggleGhostModal = function (...args) {
                const result = originalToggleGhostModal.apply(this, args);
                if (args[0] !== false && rememberGhostModalEnabled()) {
                    const modal = document.getElementById('ghostImageModal');
                    setupGhostModalPlacementMemory(modal);
                    [0, 60, 180].forEach(delay => setTimeout(() => {
                        restoreGhostModalPlacement(modal);
                    }, delay));
                }
                return result;
            };
            w.toggleGhostModal.__gpcRememberPatched = true;
        }
        tryPatch();
    }

    function installGhostPlacementAutoHide() {
        if (document.documentElement.dataset.gpcGhostPlacementAutoHide) return;
        document.documentElement.dataset.gpcGhostPlacementAutoHide = '1';

        const script = document.createElement('script');
        script.textContent = `(function(){
if(window.__gpcToolModeEventPatch)return;
window.__gpcToolModeEventPatch=true;
var watchStartedAt=Date.now();
function patch(){
    if(typeof setToolMode!=='function'){
        if(Date.now()-watchStartedAt>=15000){
            if(window.__gpcDebugBridge&&window.__gpcDebugBridge.isEnabled()){
                window.__gpcDebugBridge.push('Ghost Template Manager: gave up after 15s -- setToolMode was never found, so the toolModeChanged event patch could not be applied.',{uiComponent:'Ghost Template Manager'});
            }
            console.error('[GeoPixelcons++] Ghost Template Manager: never found setToolMode to patch for the toolModeChanged event.');
            return;
        }
        setTimeout(patch,500);
        return;
    }
    if(setToolMode.__gpcToolModeEventPatched)return;
    if(window.__gpcDebugBridge&&window.__gpcDebugBridge.isEnabled()){
        window.__gpcDebugBridge.push('Ghost Template Manager: setToolMode found '+(Date.now()-watchStartedAt)+'ms after watching started -- patching now.',{uiComponent:'Ghost Template Manager'});
    }
    var original=setToolMode;
    window.setToolMode=function(tool){
        var result=original.apply(this,arguments);
        document.dispatchEvent(new CustomEvent('gpc:toolModeChanged',{detail:{tool:tool}}));
        return result;
    };
    window.setToolMode.__gpcToolModeEventPatched=true;
}
patch();
})();`;
        document.head.appendChild(script);
        script.remove();

        let hiddenForPlacement = null;
        let restoreTimer = null;
        const restore = () => {
            if (!hiddenForPlacement) return;
            clearTimeout(restoreTimer);
            hiddenForPlacement.classList.remove('hidden');
            delete hiddenForPlacement.dataset.gpcHiddenForPlacement;
            hiddenForPlacement = null;
        };

        document.addEventListener('click', e => {
            const btn = e.target && e.target.closest ? e.target.closest('#initiatePlaceGhostBtn') : null;
            if (!btn || !e.isTrusted || btn.disabled) return;
            setTimeout(() => {
                const modal = document.getElementById('ghostImageModal');
                if (!modal) return;
                hiddenForPlacement = modal;
                modal.dataset.gpcHiddenForPlacement = '1';
                modal.classList.add('hidden');
                clearTimeout(restoreTimer);
                restoreTimer = setTimeout(restore, 60000);
            }, 0);
        }, true);

        document.addEventListener('gpc:toolModeChanged', e => {
            if (!hiddenForPlacement) return;
            if (!e.detail || e.detail.tool !== 'ghostPlacement') restore();
        });
    }

    function setupGhostModalPlacementMemory(modal) {
        if (!modal || !rememberGhostModalEnabled()) return;
        patchGhostModalToggleForRemembering();
        if (!modal.dataset.gpcRememberObserver) {
            modal.dataset.gpcRememberObserver = '1';
            new MutationObserver(() => scheduleGhostModalPlacementSave(modal))
                .observe(modal, { attributes: true, attributeFilter: ['style'] });
        }
        [0, 80, 240].forEach(delay => setTimeout(() => restoreGhostModalPlacement(modal), delay));
    }

    function injectControls() {
        const modal = document.getElementById('ghostImageModal');
        if (!modal) return;
        const container = modal.querySelector('.flex.flex-wrap.items-center.justify-center.gap-3');
        if (!container || container.dataset.gpInjected) return;
        container.dataset.gpInjected = "true";
        patchGhostModalToggleForRemembering();
        setupGhostModalPlacementMemory(modal);
        installGhostPlacementAutoHide();

        // 1. Remove the 'hidden' class from the hexDisplay span
        const hexDisplay = document.getElementById('hexDisplay');
        if (hexDisplay) {
            hexDisplay.classList.remove('hidden');
            gpLog("Removed 'hidden' class from hexDisplay.");
        }

        setupNativeInterceptor();

        const btnUrl = document.createElement('button');
        btnUrl.innerHTML = '🔗 URL'; btnUrl.className = 'gp-to-btn gp-to-btn-blue shadow';
        btnUrl.title = 'Load from URL (Image or ZIP)';
        btnUrl.onclick = handleUrlUpload;

        const btnLocal = document.createElement('button');
        btnLocal.innerHTML = '📂 File'; btnLocal.className = 'gp-to-btn gp-to-btn-green shadow';
        btnLocal.title = 'Upload Image or ZIP';
        // Note: The click handler for this just triggers the native input, which we intercept.
        btnLocal.onclick = () => document.getElementById('ghostImageInput').click();

        const btnHist = document.createElement('button');
        btnHist.innerHTML = '📜 History'; btnHist.className = 'gp-to-btn gp-to-btn-purple shadow';
        btnHist.onclick = openHistoryModal;

        const btnDL = document.createElement('button');
        btnDL.innerHTML = '💾 Save'; btnDL.className = 'gp-to-btn gp-to-btn-gray shadow';
        btnDL.onclick = downloadWithPos;

        const btnPreview = document.createElement('button');
        btnPreview.innerHTML = '👁️ Preview'; 
        btnPreview.className = 'gp-to-btn gp-to-btn-cyan shadow';
        btnPreview.title = 'Toggle image preview overlay';
        btnPreview.onclick = () => togglePreview(btnPreview);

        const btnGoTo = document.createElement('button');
        btnGoTo.innerHTML = '🎯 Go To'; 
        btnGoTo.className = 'gp-to-btn gp-to-btn-orange shadow';
        btnGoTo.title = 'Teleport to template location';
        btnGoTo.onclick = goToTemplateLocation;

        container.prepend(btnGoTo);
        container.prepend(btnPreview);
        container.prepend(btnDL);
        container.prepend(btnHist);
        container.prepend(btnLocal);
        container.prepend(btnUrl);

        // Auto-caching disabled - user must manually press Save Pos button
        // setupAlertBodyObserver();
    }

    const observer = new MutationObserver(() => injectControls());
    observer.observe(document.body, { childList: true, subtree: true });

    document.querySelector('label[for="ghostImageInput"]')?.classList.add('hidden');

    // ── Ghost Menu UI Overhaul ──────────────────────────────────────
    if (_settings.modernizeGhostPaletteBtns) {
        const MODERN_BTN_ORDER = [
            'Toggle All',
            'Set Palette',
            'Match My Palette',
            'Bulk Purchase Colors',
            'Enable Only Owned Ghost Colors',
            'Get Ghost Colors',
            'Get Enabled Ghost Colors',
            'Set Enabled Ghost Colors',
            'Enable filtered',
            'Enable owned and filtered',
        ];

        const BTN_COLORS = {
            'Toggle All':                      { bg: '#bfdbfe', hover: '#93c5fd', fg: '#1e3a8a' },
            'Set Palette':                     { bg: '#bbf7d0', hover: '#86efac', fg: '#14532d' },
            'Match My Palette':                { bg: '#bbf7d0', hover: '#86efac', fg: '#14532d' },
            'Bulk Purchase Colors':            { bg: '#e9d5ff', hover: '#d8b4fe', fg: '#581c87' },
            'Enable Only Owned Ghost Colors':  { bg: '#fbcfe8', hover: '#f9a8d4', fg: '#831843' },
            'Get Ghost Colors':                { bg: '#fbcfe8', hover: '#f9a8d4', fg: '#831843' },
            'Get Enabled Ghost Colors':        { bg: '#fbcfe8', hover: '#f9a8d4', fg: '#831843' },
            'Set Enabled Ghost Colors':        { bg: '#fbcfe8', hover: '#f9a8d4', fg: '#831843' },
            'Enable filtered':                 { bg: '#fed7aa', hover: '#fdba74', fg: '#7c2d12' },
        };
        BTN_COLORS['Enable owned and filtered'] = BTN_COLORS['Enable filtered'];

        function styleModernBtn(btn, label) {
            if (btn.dataset.gpcStyled) return;
            btn.dataset.gpcStyled = '1';
            btn.className = '';
            const c = BTN_COLORS[label] || { bg: '#e2e8f0', hover: '#cbd5e1', fg: '#334155' };
            Object.assign(btn.style, {
                fontSize: '12px', fontWeight: '600',
                padding: '6px 8px', borderRadius: '6px',
                border: 'none', cursor: 'pointer',
                background: c.bg, color: c.fg,
                transition: 'background 0.15s',
                whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
                lineHeight: '1.3',
                boxShadow: 'none', width: '100%',
                textAlign: 'center',
            });
            if (!btn.title) btn.title = label;
            btn.addEventListener('mouseenter', () => { btn.style.background = c.hover; });
            btn.addEventListener('mouseleave', () => { btn.style.background = c.bg; });
        }

        // ── Inject CSS overrides for gp-to-btn colors (one-time) ─────
        if (!document.getElementById('gpc-modern-gp-btn-style')) {
            const s = document.createElement('style');
            s.id = 'gpc-modern-gp-btn-style';
            s.textContent = `
                [data-gp-injected] button, #gpc-modern-gp-grid button {
                    font-size:12px!important; font-weight:600!important;
                    padding:6px 8px!important; border-radius:6px!important;
                    box-shadow:none!important; width:100%!important;
                    white-space:nowrap!important; overflow:hidden!important;
                    text-overflow:ellipsis!important; line-height:1.3!important;
                    text-align:center!important;
                }
                .gp-to-btn-blue   { background-color:#bfdbfe!important; color:#1e3a8a!important; }
                .gp-to-btn-blue:hover { background-color:#93c5fd!important; }
                .gp-to-btn-green  { background-color:#bbf7d0!important; color:#14532d!important; }
                .gp-to-btn-green:hover { background-color:#86efac!important; }
                .gp-to-btn-purple { background-color:#e9d5ff!important; color:#581c87!important; }
                .gp-to-btn-purple:hover { background-color:#d8b4fe!important; }
                .gp-to-btn-gray   { background-color:#f1f5f9!important; color:#475569!important; }
                .gp-to-btn-gray:hover { background-color:#e2e8f0!important; }
                .gp-to-btn-orange { background-color:#fed7aa!important; color:#7c2d12!important; }
                .gp-to-btn-orange:hover { background-color:#fdba74!important; }
                .gp-to-btn-cyan   { background-color:#a5f3fc!important; color:#164e63!important; border:none!important; }
                .gp-to-btn-cyan:hover { background-color:#67e8f9!important; }
                .gp-to-btn-cyan.active { background-color:#0e7490!important; color:#ecfeff!important; border:2px solid #fbbf24!important; box-shadow:0 0 0 3px rgba(251,191,36,.3)!important; }
                .gp-to-btn-red    { background-color:#fecaca!important; color:#991b1b!important; }
                .gp-to-btn-red:hover { background-color:#fca5a5!important; }
                #initiatePlaceGhostBtn { background-color:#bbf7d0!important; color:#14532d!important; }
                #initiatePlaceGhostBtn:hover:not(:disabled) { background-color:#86efac!important; }
                #clearGhostImageBtn    { background-color:#fecaca!important; color:#991b1b!important; }
                #clearGhostImageBtn:hover:not(:disabled) { background-color:#fca5a5!important; }
                #_z_dock_btn { background-color:#c7d2fe!important; color:#312e81!important; }
                #_z_dock_btn:hover { background-color:#a5b4fc!important; }
            `;
            document.head.appendChild(s);
        }

        function applyModernPaletteButtons() {
            const container = document.getElementById('ghostColorPaletteContainer');
            if (!container) return;

            // Remove the ✚₊ collapse toggle and unhide all siblings each time it appears
            container.querySelectorAll('button[data-expanded]').forEach(toggleBtn => {
                let sib = toggleBtn.nextElementSibling;
                while (sib) {
                    if (sib.tagName === 'BUTTON') sib.style.removeProperty('display');
                    sib = sib.nextElementSibling;
                }
                toggleBtn.remove();
            });

            // Collect all action buttons — exclude swatches and search input area,
            // but include the Enable filtered button from .gpc-filtered-row
            const allBtns = Array.from(container.querySelectorAll('button')).filter(btn => {
                if (btn.closest('#ghostColorPalette')) return false;
                if (btn.closest('.color-search-container')) return false;
                if (btn.id === 'gpc-modern-btn-grid') return false;
                return true;
            });

            // Skip if nothing has changed since last run
            const sig = allBtns.map(b => b.textContent.trim()).sort().join('|');
            if (container.dataset.gpcSig === sig) return;
            container.dataset.gpcSig = sig;

            // Build label → button map
            const btnMap = new Map();
            allBtns.forEach(btn => btnMap.set(btn.textContent.trim(), btn));

            // Find or create the 2-column grid
            let grid = document.getElementById('gpc-modern-btn-grid');
            if (!grid) {
                grid = document.createElement('div');
                grid.id = 'gpc-modern-btn-grid';
                grid.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:0 0 8px 0;';
                const h3 = container.querySelector('h3');
                if (h3) h3.insertAdjacentElement('afterend', grid);
                else container.prepend(grid);
            }

            // Move buttons into the grid in the specified order
            MODERN_BTN_ORDER.forEach(label => {
                const btn = btnMap.get(label);
                if (!btn) return;
                styleModernBtn(btn, label);
                grid.appendChild(btn);
            });

            // Hide the now-empty .gpc-filtered-row (its button moved to the grid)
            const filteredRow = container.querySelector('.gpc-filtered-row');
            if (filteredRow && !filteredRow.querySelector('button')) {
                filteredRow.style.display = 'none';
            }

            // Remove now-empty wrapper divs left by the site's layout.
            // Protect any div with an id (ghostColorPalette, etc.) and the search/filter UI.
            Array.from(container.querySelectorAll(':scope > div')).forEach(div => {
                if (div.id) return; // never remove a named element
                if (div.closest('.color-search-container') || div.closest('.gpc-filtered-row')) return;
                if (div.querySelector('button, input, label, select')) return;
                div.remove();
            });
        }

        // ── 4-column grid for the gp-injected action buttons ─────────
        function applyModernGpButtons() {
            const injectedDiv = document.querySelector('[data-gp-injected="true"]');
            if (!injectedDiv) return;

            const allBtns = Array.from(injectedDiv.querySelectorAll('button'));
            if (!allBtns.length) return;

            const sig = allBtns.map(b => (b.id || '') + b.textContent.trim()).sort().join('|');
            if (injectedDiv.dataset.gpcGpSig === sig) return;
            injectedDiv.dataset.gpcGpSig = sig;

            let grid = document.getElementById('gpc-modern-gp-grid');
            if (!grid) {
                grid = document.createElement('div');
                grid.id = 'gpc-modern-gp-grid';
                grid.style.cssText = 'display:grid;grid-template-columns:repeat(4,1fr);gap:6px;padding:0 0 8px 0;';
                injectedDiv.parentNode.insertBefore(grid, injectedDiv);
            }

            allBtns.forEach(btn => grid.appendChild(btn));
            injectedDiv.style.display = 'none';
        }

        const paletteContainerObs = new MutationObserver(() => {
            applyModernPaletteButtons();
            applyModernGpButtons();
            applyModernGhostModalLayout();
        });
        paletteContainerObs.observe(document.body, { childList: true, subtree: true });
        applyModernPaletteButtons();
        applyModernGpButtons();
        applyModernGhostModalLayout();
    }

    // ── Full Ghost Modal Layout Overhaul ─────────────────────────────────
    // (runs independently of modernizeGhostPaletteBtns / Ghost Menu UI Overhaul; called above when that setting is on)
    let _recentBlobUrls = [];
    let _recentLimit = 10;

    function applyModernGhostModalLayout() {
        const modal = document.getElementById('ghostImageModal');
        if (!modal || modal.dataset.gpcModernModal) return;
        // Wait until injectControls has injected the buttons
        if (!modal.querySelector('[data-gp-injected]')) return;
        modal.dataset.gpcModernModal = '1';

        const dark = () => document.body.classList.contains('dark');
        const mobileCompat = typeof gpcMobileCompatibilityActive === 'function' && gpcMobileCompatibilityActive();

        // ── CSS for children only — no width/height rules on the modal itself here.
        //   The modal's own dimensions are set via inline styles below (after stripping
        //   the conflicting Tailwind classes). This way the resize handler can simply
        //   write modal.style.width / modal.style.height with no !important fights.
        if (!document.getElementById('gpc-modal-layout-style')) {
            const s = document.createElement('style');
            s.id = 'gpc-modal-layout-style';
            s.textContent = `
                #gpc-modal-left {
                    flex: 1 1 0;
                    min-width: 0;
                    display: flex;
                    flex-direction: column;
                    overflow: hidden;
                    transition: width 0.22s ease, flex-basis 0.22s ease, opacity 0.18s ease;
                }
                #gpc-modal-left-scroll {
                    flex: 1 1 0;
                    min-height: 0;
                    overflow-y: auto;
                    padding: 10px 14px;
                }
                #gpc-modal-right {
                    width: 280px;
                    flex-shrink: 0;
                    display: flex;
                    flex-direction: column;
                    overflow: hidden;
                    transition: width 0.22s ease, opacity 0.18s ease;
                }
                #ghostImageModal.gpc-preview-animating {
                    transition: width 0.22s ease;
                    overflow: hidden;
                }
                #ghostImageModal.gpc-preview-collapsed {
                    width: var(--gpc-collapsed-width) !important;
                }
                #ghostImageModal.gpc-preview-expanded {
                    width: var(--gpc-expanded-width) !important;
                }
                #gpc-modal-right.gpc-collapsed {
                    width: 34px !important;
                    opacity: 0.95;
                }
                #gpc-modal-right.gpc-collapsed #gpc-modal-right-content {
                    opacity: 0;
                    pointer-events: none;
                }
                #gpc-modal-right.gpc-collapsed .gpc-collapse-label { display: none !important; }
                #gpc-modal-right-content {
                    flex: 1 1 0;
                    min-height: 0;
                    display: flex;
                    flex-direction: column;
                    overflow: hidden;
                    opacity: 1;
                    transition: opacity 0.12s ease;
                }
                #gpc-recent-grid {
                    display: grid;
                    grid-template-columns: repeat(5, minmax(0, 1fr));
                    grid-auto-rows: var(--gpc-recent-cell-size, 48px);
                    gap: 3px;
                    padding: 4px 8px 8px;
                    overflow-y: auto;
                    overflow-x: hidden;
                    flex: 1 1 0;
                    min-height: 0;
                    align-content: start;
                    justify-items: stretch;
                }
                /* Remove the palette's hardcoded max-height so it expands with content */
                #ghostColorPalette {
                    max-height: none !important;
                    overflow-y: visible !important;
                }
                /* Action buttons grid inside the right panel — force 2-col minimum */
                #gpc-modal-right #gpc-modern-gp-grid {
                    display: grid !important;
                    grid-template-columns: repeat(2, 1fr) !important;
                    gap: 4px !important;
                    padding: 4px 0 !important;
                }
                /* Edge drag strips */
                .gpc-edge-drag {
                    position: absolute;
                    z-index: 102;
                    pointer-events: auto;
                    cursor: grab;
                }
                .gpc-edge-drag:active { cursor: grabbing; }
                .gpc-edge-drag.top    { top: 0;    left: 12px; right: 12px; height: 12px; }
                .gpc-edge-drag.bottom { bottom: 0; left: 12px; right: 30px; height: 12px; }
                .gpc-edge-drag.left   { left: 0;   top: 12px;  bottom: 12px; width: 12px; }
                /* .right is a child of #gpc-modal-right, not modal — see JS below */
                .gpc-edge-drag.right  { right: 0;  top: 0; bottom: 30px; width: 12px; z-index: 50; }
                /* Panel splitter — left edge of #gpc-modal-right, drag to resize the split */
                .gpc-panel-splitter {
                    position: absolute;
                    left: -4px; top: 0; bottom: 0;
                    width: 8px;
                    cursor: ew-resize;
                    z-index: 106;
                    background: transparent;
                    transition: background 0.15s;
                }
                .gpc-panel-splitter:hover,
                .gpc-panel-splitter.gpc-ps-dragging {
                    background: rgba(99,102,241,0.25);
                }
                #gpc-modal-right.gpc-collapsed .gpc-panel-splitter { display: none; }
                /* Horizontal preview-height resize strip */
                .gpc-preview-hresize {
                    flex-shrink: 0;
                    height: 8px;
                    cursor: ns-resize;
                    position: relative;
                    z-index: 10;
                    background: transparent;
                    transition: background 0.15s;
                }
                .gpc-preview-hresize::after {
                    content: '';
                    position: absolute;
                    left: 12px; right: 12px;
                    top: 50%; transform: translateY(-50%);
                    height: 1px;
                    background: var(--color-gray-200, #e5e7eb);
                    transition: background 0.15s;
                }
                .gpc-preview-hresize:hover,
                .gpc-preview-hresize.gpc-ps-dragging {
                    background: rgba(99,102,241,0.15);
                }
                .gpc-preview-hresize:hover::after,
                .gpc-preview-hresize.gpc-ps-dragging::after {
                    background: rgba(99,102,241,0.6);
                }
                /* SE resize grip — bottom-right corner only, like guild modal */
                .gpc-resize-se {
                    position: absolute;
                    bottom: 0; right: 0;
                    width: 20px; height: 20px;
                    cursor: nwse-resize;
                    z-index: 101;
                    border-radius: 0 0 1rem 0;
                    background: linear-gradient(135deg, transparent 50%, #94a3b8 50%);
                    opacity: 0.5;
                    pointer-events: auto;
                    transition: opacity 0.15s;
                }
                .gpc-resize-se:hover { opacity: 1; }
                #ghostImageModal.gpc-mobile-compat {
                    width: calc(100vw - 12px) !important;
                    height: calc(100dvh - 12px) !important;
                    min-width: 0 !important;
                    min-height: 0 !important;
                    max-width: none !important;
                    max-height: none !important;
                    left: 6px !important;
                    top: 6px !important;
                    transform: none !important;
                }
                #ghostImageModal.gpc-mobile-compat #gpc-modal-left {
                    flex: 1 1 auto;
                    width: calc(100% - 34px);
                    min-width: 0;
                }
                #ghostImageModal.gpc-mobile-compat #gpc-modal-right {
                    width: 34px !important;
                    flex: 0 0 34px;
                }
                #ghostImageModal.gpc-mobile-compat.gpc-mobile-preview-open #gpc-modal-left {
                    display: none !important;
                }
                #ghostImageModal.gpc-mobile-compat.gpc-mobile-preview-open #gpc-modal-right {
                    width: 100% !important;
                    flex: 1 1 auto;
                }
                #ghostImageModal.gpc-mobile-compat.gpc-mobile-preview-open #gpc-modal-right-content {
                    opacity: 1;
                    pointer-events: auto;
                }
                #ghostImageModal.gpc-mobile-compat .gpc-edge-drag,
                #ghostImageModal.gpc-mobile-compat .gpc-panel-splitter,
                #ghostImageModal.gpc-mobile-compat .gpc-preview-hresize,
                #ghostImageModal.gpc-mobile-compat .gpc-resize-se {
                    display: none !important;
                }
                #ghostImageModal.gpc-mobile-compat #gpc-modern-gp-grid {
                    grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
                }
            `;
            document.head.appendChild(s);
        }

        // Strip the Tailwind classes that fight with our layout so plain inline styles win.
        ['w-[90%]', 'max-w-lg', 'p-6', 'max-h-[90vh]', 'cursor-move'].forEach(c => modal.classList.remove(c));
        // Set initial modal dimensions as plain inline styles (no !important needed now).
        Object.assign(modal.style, {
            width:        'min(92vw, 800px)',
            height:       '75vh',
            maxWidth:     'none',
            maxHeight:    'none',
            minWidth:     '480px',
            minHeight:    '320px',
            flexDirection:'row',
            gap:          '0',
            padding:      '0',
            alignItems:   'stretch',
        });
        if (mobileCompat) {
            modal.classList.add('gpc-mobile-compat', 'gpc-mobile-controls-open');
            Object.assign(modal.style, {
                width:     'calc(100vw - 12px)',
                height:    'calc(100dvh - 12px)',
                minWidth:  '0',
                minHeight: '0',
                left:      '6px',
                top:       '6px',
                transform: 'none',
            });
        }

        // ── Locate existing DOM nodes ─────────────────────────────
        const closeBtn   = modal.querySelector('button[onclick*="toggleGhostModal"]');
        const h2         = modal.querySelector('h2');
        const scrollArea = modal.querySelector('.flex-grow.overflow-y-auto, .overflow-y-auto');

        // Find the preview container: get the DIRECT parent of #ghostPreviewImage,
        // not just any ancestor (querySelectorAll picks the outermost wrapper first).
        let previewContainer = null;
        if (scrollArea) {
            const _pImg = scrollArea.querySelector('#ghostPreviewImage');
            if (_pImg) {
                previewContainer = _pImg.parentElement;
            } else {
                previewContainer = scrollArea.querySelector('[class*="border-dashed"]');
            }
        }
        if (previewContainer?.parentNode) previewContainer.parentNode.removeChild(previewContainer);

        // Extract the buttons grid.
        // applyModernGpButtons() runs first and moves all buttons from [data-gp-injected]
        // into #gpc-modern-gp-grid, leaving the original div hidden. Grab the grid;
        // fall back to the raw injected div if the grid hasn't been created yet.
        const buttonsRow = scrollArea
            ? (scrollArea.querySelector('#gpc-modern-gp-grid') || scrollArea.querySelector('[data-gp-injected]'))
            : null;
        if (buttonsRow?.parentNode) buttonsRow.parentNode.removeChild(buttonsRow);
        // Also detach the now-empty (hidden) injected wrapper so it doesn't linger in scrollArea.
        const _injectedShell = scrollArea ? scrollArea.querySelector('[data-gp-injected]') : null;
        if (_injectedShell?.parentNode) _injectedShell.parentNode.removeChild(_injectedShell);

        // ── Left panel ───────────────────────────────────────────────
        const leftPanel = document.createElement('div');
        leftPanel.id = 'gpc-modal-left';

        // Header — also the drag handle.
        // IMPORTANT: makeDraggable(modal) in the site source uses `if (e.target !== elmnt) return`
        // which means the site drag ONLY fires when clicking the bare modal surface (impossible
        // once it's fully covered by children). We implement our own drag here instead.
        const leftHeader = document.createElement('div');
        leftHeader.id = 'gpc-modal-left-header';
        leftHeader.className = 'flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700 cursor-move select-none flex-shrink-0 bg-white dark:bg-gray-800';
        if (mobileCompat) leftHeader.classList.remove('cursor-move');

        const leftTitleWrap = document.createElement('div');
        leftTitleWrap.className = 'flex items-center gap-2 min-w-0';

        if (h2) {
            h2.style.cssText = 'margin:0;font-size:1.05rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
            leftTitleWrap.appendChild(h2);
        }
        leftHeader.appendChild(leftTitleWrap);
        // Restyle the close button for the header context.
        // Strip absolute/shadow/bg-white classes (bg-white on white header = invisible);
        // give it a visible, theme-aware appearance instead.
        const effectiveCloseBtn = closeBtn || (() => {
            const b = document.createElement('button');
            b.innerHTML = '&#10005;';
            b.onclick = () => { if (typeof toggleGhostModal === 'function') toggleGhostModal(false); };
            return b;
        })();
        effectiveCloseBtn.className = 'flex-shrink-0 rounded-full cursor-pointer flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100';
        effectiveCloseBtn.style.cssText = 'position:static;border:none;background:transparent;width:2rem;height:2rem;font-size:1.1rem;line-height:1;flex-shrink:0;';
        leftHeader.appendChild(effectiveCloseBtn);
        leftPanel.appendChild(leftHeader);

        // ── Shared drag logic — used by header and all 4 edge strips ────
        function _startDrag(e) {
            if (e.button !== 0 || e.target.closest('button')) return;
            e.preventDefault();
            const r = modal.getBoundingClientRect();
            let st = { mx: e.clientX, my: e.clientY, l: r.left, t: r.top };
            const prevCursor = document.body.style.cursor;
            document.body.style.cursor = 'grabbing';
            const onMove = ev => {
                modal.style.left      = (st.l + ev.clientX - st.mx) + 'px';
                modal.style.top       = (st.t + ev.clientY - st.my) + 'px';
                modal.style.transform = 'none';
            };
            const onUp = () => {
                document.body.style.cursor = prevCursor;
                document.removeEventListener('mousemove', onMove, true);
                document.removeEventListener('mouseup',   onUp,   true);
            };
            document.addEventListener('mousemove', onMove, true);
            document.addEventListener('mouseup',   onUp,   true);
        }
        leftHeader.style.cursor = mobileCompat ? 'default' : 'grab';
        if (!mobileCompat) leftHeader.addEventListener('mousedown', _startDrag);

        if (scrollArea) {
            scrollArea.id = 'gpc-modal-left-scroll';
            // Strip the old Tailwind overflow/grow classes that conflict with the new layout
            scrollArea.className = scrollArea.className
                .replace(/\bflex-grow\b|\boverflow-y-auto\b|\bpr-\S+\b|\b-mr-\S+\b/g, '').trim();
            leftPanel.appendChild(scrollArea);
        }

        // ── Right panel ──────────────────────────────────────────────
        const rightPanel = document.createElement('div');
        rightPanel.id = 'gpc-modal-right';
        // position:relative so the right edge strip can be absolutely positioned inside it
        rightPanel.style.position = 'relative';
        // Use Tailwind dark: variants so colors track the site's active theme
        rightPanel.className = 'border-l border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800';

        // Collapse bar
        const collapseBar = document.createElement('div');
        collapseBar.className = 'flex items-center gap-1 px-2 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 select-none';
        collapseBar.style.minHeight = '34px';

        const collapseBtn = document.createElement('button');
        collapseBtn.className = 'rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 flex-shrink-0 flex items-center justify-center border-none cursor-pointer';
        collapseBtn.style.cssText = 'width:20px;height:20px;font-size:8px;padding:0;line-height:1;';
        collapseBtn.textContent = '◀';
        collapseBtn.title = 'Collapse preview';

        const collapseLabel = document.createElement('span');
        collapseLabel.className = 'gpc-collapse-label text-xs font-bold text-gray-500 dark:text-gray-400';
        collapseLabel.textContent = '🖼 Preview';

        collapseBar.appendChild(collapseBtn);
        collapseBar.appendChild(collapseLabel);
        rightPanel.appendChild(collapseBar);

        const rightContent = document.createElement('div');
        rightContent.id = 'gpc-modal-right-content';

        // Preview image
        if (previewContainer) {
            previewContainer.className = '';
            previewContainer.style.cssText = 'flex-shrink:0;margin:10px;border-radius:8px;overflow:hidden;background:var(--color-gray-100,#f3f4f6);border:1px solid var(--color-gray-200,#e5e7eb);display:flex;align-items:center;justify-content:center;aspect-ratio:4/3;max-height:340px;position:relative;';
            const pImg = previewContainer.querySelector('#ghostPreviewImage');
            if (pImg) pImg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:contain;image-rendering:pixelated;image-rendering:crisp-edges;';
            const pTxt = previewContainer.querySelector('#ghostPreviewText');
            if (pTxt) { pTxt.className = 'text-gray-400 dark:text-gray-500 text-xs text-center'; pTxt.style.cssText = 'position:relative;z-index:1;padding:8px;'; }
            rightContent.appendChild(previewContainer);

            if (!mobileCompat) {
                // Horizontal resize strip — drag to set preview height freely
                const previewHResize = document.createElement('div');
                previewHResize.className = 'gpc-preview-hresize';
                previewHResize.title = 'Drag to resize preview height';
                previewHResize.addEventListener('mousedown', e => {
                    if (e.button !== 0) return;
                    e.preventDefault(); e.stopPropagation();
                    const startY = e.clientY;
                    const startH = previewContainer.getBoundingClientRect().height;
                    // Switch to explicit height so the user can go above the aspect-ratio default
                    previewContainer.style.height = startH + 'px';
                    previewContainer.style.maxHeight = 'none';
                    previewHResize.classList.add('gpc-ps-dragging');
                    document.body.style.cursor = 'ns-resize';
                    const onMove = ev => {
                        const newH = Math.max(60, startH + ev.clientY - startY);
                        previewContainer.style.height = newH + 'px';
                    };
                    const onUp = () => {
                        previewHResize.classList.remove('gpc-ps-dragging');
                        document.body.style.cursor = '';
                        document.removeEventListener('mousemove', onMove, true);
                        document.removeEventListener('mouseup',   onUp,   true);
                    };
                    document.addEventListener('mousemove', onMove, true);
                    document.addEventListener('mouseup',   onUp,   true);
                });
                rightContent.appendChild(previewHResize);
            }
        }

        // Buttons row (URL, File, History, Save Pos, Place on Map, Clear Image …)
        if (buttonsRow) {
            const buttonsWrap = document.createElement('div');
            buttonsWrap.className = 'flex-shrink-0 px-2 py-2';
            // Preserve the grid layout applyModernGpButtons set; just ensure it shows.
            buttonsRow.style.display = '';
            buttonsWrap.appendChild(buttonsRow);
            rightContent.appendChild(buttonsWrap);
        }

        // Recent images section
        const recentWrap = document.createElement('div');
        recentWrap.className = 'border-t border-gray-200 dark:border-gray-700';
        recentWrap.style.cssText = 'flex:1 1 0;min-height:0;display:flex;flex-direction:column;overflow:hidden;';

        const recentHdr = document.createElement('div');
        recentHdr.className = 'flex items-center px-2 gap-1 select-none cursor-pointer flex-shrink-0 text-xs font-bold text-gray-500 dark:text-gray-400';
        recentHdr.style.minHeight = '28px';

        const recentArrow = document.createElement('span');
        recentArrow.textContent = '▾';
        recentArrow.style.cssText = 'transition:transform 0.15s;flex-shrink:0;';

        const recentTitle = document.createElement('span');
        recentTitle.textContent = '🕐 Recent';

        const recentRefreshBtn = document.createElement('button');
        recentRefreshBtn.textContent = '↺';
        recentRefreshBtn.title = 'Refresh';
        recentRefreshBtn.className = 'ml-auto border-none bg-transparent cursor-pointer text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300';
        recentRefreshBtn.style.cssText = 'font-size:13px;padding:0 2px;line-height:1;';
        recentRefreshBtn.addEventListener('click', e => { e.stopPropagation(); loadRecentImages(); });

        recentHdr.appendChild(recentArrow);
        recentHdr.appendChild(recentTitle);
        recentHdr.appendChild(recentRefreshBtn);

        const recentGrid = document.createElement('div');
        recentGrid.id = 'gpc-recent-grid';

        const syncRecentCellSize = () => {
            const width = recentGrid.clientWidth;
            if (!width) return;
            const styles = getComputedStyle(recentGrid);
            const padX = parseFloat(styles.paddingLeft || '0') + parseFloat(styles.paddingRight || '0');
            const gap = parseFloat(styles.columnGap || styles.gap || '0') || 0;
            const cell = Math.max(24, Math.floor((width - padX - gap * 4) / 5));
            recentGrid.style.setProperty('--gpc-recent-cell-size', `${cell}px`);
        };
        if (typeof ResizeObserver !== 'undefined') {
            new ResizeObserver(syncRecentCellSize).observe(recentGrid);
        } else {
            window.addEventListener('resize', syncRecentCellSize);
        }
        setTimeout(syncRecentCellSize, 0);

        let recentOpen = true;
        recentHdr.addEventListener('click', () => {
            recentOpen = !recentOpen;
            recentGrid.style.display = recentOpen ? 'grid' : 'none';
            recentArrow.style.transform = recentOpen ? '' : 'rotate(-90deg)';
        });

        recentWrap.appendChild(recentHdr);
        recentWrap.appendChild(recentGrid);
        rightContent.appendChild(recentWrap);
        rightPanel.appendChild(rightContent);

        // Collapse toggle
        let rightCollapsed = mobileCompat;
        let _previewRightW = 280;  // remembered expanded width of the preview (right) panel
        if (mobileCompat) {
            rightPanel.classList.add('gpc-collapsed');
            collapseBtn.textContent = '▶';
            collapseBtn.title = 'Show preview';
        }

        collapseBtn.addEventListener('click', e => {
            e.stopPropagation();
            rightCollapsed = !rightCollapsed;
            if (mobileCompat) {
                rightPanel.classList.toggle('gpc-collapsed', rightCollapsed);
                modal.classList.toggle('gpc-mobile-controls-open', rightCollapsed);
                modal.classList.toggle('gpc-mobile-preview-open', !rightCollapsed);
                collapseBtn.textContent = rightCollapsed ? '▶' : '◀';
                collapseBtn.title = rightCollapsed ? 'Show preview' : 'Show controls';
                return;
            }
            // Anchor on the live left/controls width plus a remembered preview width, so
            // collapse/expand only removes or re-adds the preview on the right — the total
            // modal width is never reset to a stale or default value.
            if (rightCollapsed) {
                modal.classList.remove('gpc-preview-expanded');
                const currentW = Math.round(modal.getBoundingClientRect().width);
                const rightW = Math.round(rightPanel.getBoundingClientRect().width);
                if (rightW > 38) _previewRightW = rightW;           // remember preview width for expand
                const leftW = currentW - rightW;                    // controls width — keep fixed
                const collapsedW = Math.max(320, leftW + 34);
                modal.style.setProperty('--gpc-expanded-width', `${currentW}px`);
                modal.style.setProperty('--gpc-collapsed-width', `${collapsedW}px`);
                modal.classList.add('gpc-preview-animating', 'gpc-preview-collapsed');
                rightPanel.classList.add('gpc-collapsed');
                modal.dataset.gpcExpandedWidth = `${currentW}px`;
                modal.dataset.gpcPreviewCollapsed = '1';
            } else {
                modal.classList.remove('gpc-preview-collapsed');
                const currentW = Math.round(modal.getBoundingClientRect().width);
                const leftW = currentW - 34;                        // currently collapsed → left = total − stub
                const expandedW = Math.max(480, leftW + _previewRightW);
                rightPanel.style.width = `${_previewRightW}px`;     // restore preview to its remembered width
                modal.style.setProperty('--gpc-expanded-width', `${expandedW}px`);
                modal.style.removeProperty('--gpc-collapsed-width');
                modal.classList.add('gpc-preview-animating', 'gpc-preview-expanded');
                rightPanel.classList.remove('gpc-collapsed');
                modal.dataset.gpcExpandedWidth = `${expandedW}px`;
                modal.dataset.gpcPreviewCollapsed = '0';
            }
            collapseBtn.textContent = rightCollapsed ? '▶' : '◀';
            collapseBtn.title = rightCollapsed ? 'Expand preview' : 'Collapse preview';
        });

        modal.addEventListener('transitionend', e => {
            if (e.target !== modal || e.propertyName !== 'width') return;
            if (!rightCollapsed) {
                modal.classList.remove('gpc-preview-expanded');
                modal.style.width = modal.style.getPropertyValue('--gpc-expanded-width') || 'min(92vw, 800px)';
            } else {
                modal.style.width = modal.style.getPropertyValue('--gpc-collapsed-width');
            }
            modal.classList.remove('gpc-preview-animating');
        });

        // ── SE resize handle (bottom-right only — guild modal pattern) ──────────
        const resizeHandle = document.createElement('div');
        resizeHandle.className = 'gpc-resize-se';

        let _rs = null;
        if (!mobileCompat) resizeHandle.addEventListener('mousedown', e => {
            e.preventDefault(); e.stopPropagation();
            const r = modal.getBoundingClientRect();
            const rightRect = rightPanel.getBoundingClientRect();
            _rs = {
                mx: e.clientX,
                my: e.clientY,
                w: r.width,
                h: r.height,
                rightRatio: rightCollapsed ? null : rightRect.width / Math.max(1, r.width),
                rightTransition: rightPanel.style.transition,
            };
            // Disable any active transition so resize is instant
            modal.classList.remove('gpc-preview-animating', 'gpc-preview-expanded');
            modal.style.userSelect = 'none';
            rightPanel.style.transition = 'none';
            const onRsMove = ev => {
                if (!_rs) return;
                const minW = rightCollapsed ? 320 : 480;
                const nextW = Math.max(minW, _rs.w + ev.clientX - _rs.mx);
                const nextWidth = nextW + 'px';
                modal.style.width = nextWidth;
                if (!rightCollapsed && _rs.rightRatio) {
                    const minRightW = Math.max(34, Math.floor(nextW * 0.05));
                    const maxRightW = Math.max(minRightW, Math.floor(nextW * 0.95));
                    const nextRightW = Math.min(Math.max(minRightW, Math.round(nextW * _rs.rightRatio)), maxRightW);
                    rightPanel.style.width = nextRightW + 'px';
                    _previewRightW = nextRightW;   // remember new preview width for collapse/expand
                }
                // Keep the active CSS variable in sync so !important rules don't fight the resize
                if (rightCollapsed) {
                    modal.style.setProperty('--gpc-collapsed-width', nextWidth);
                } else {
                    modal.style.setProperty('--gpc-expanded-width', nextWidth);
                    modal.dataset.gpcExpandedWidth = nextWidth;
                }
                modal.style.height = Math.max(320, _rs.h + ev.clientY - _rs.my) + 'px';
            };
            const onRsUp = () => {
                if (_rs) rightPanel.style.transition = _rs.rightTransition || '';
                _rs = null;
                modal.style.userSelect = '';
                document.removeEventListener('mousemove', onRsMove, true);
                document.removeEventListener('mouseup',   onRsUp,   true);
            };
            document.addEventListener('mousemove', onRsMove, true);
            document.addEventListener('mouseup',   onRsUp,   true);
        });

        // ── Assemble ──────────────────────────────────────────────────
        while (modal.firstChild) modal.removeChild(modal.firstChild);
        modal.appendChild(leftPanel);
        modal.appendChild(rightPanel);
        if (!mobileCompat) {
            // Top / bottom / left edge strips are children of modal (absolute vs modal)
            ['top', 'bottom', 'left'].forEach(side => {
                const strip = document.createElement('div');
                strip.className = `gpc-edge-drag ${side}`;
                strip.style.cursor = 'grab';  // inline beats CSS class specificity
                strip.addEventListener('mousedown', _startDrag);
                modal.appendChild(strip);
            });
            // Right edge strip must be a child of rightPanel to beat its stacking context
            const rightStrip = document.createElement('div');
            rightStrip.className = 'gpc-edge-drag right';
            rightStrip.style.cursor = 'grab';
            rightStrip.addEventListener('mousedown', _startDrag);
            rightPanel.appendChild(rightStrip);

            // ── Panel splitter (left edge of rightPanel — drag to resize split ratio) ──
            const panelSplitter = document.createElement('div');
            panelSplitter.className = 'gpc-panel-splitter';
            panelSplitter.title = 'Drag to resize preview panel';
            panelSplitter.addEventListener('mousedown', e => {
                if (e.button !== 0 || rightCollapsed) return;
                e.preventDefault(); e.stopPropagation();
                const startX = e.clientX;
                const startW = rightPanel.getBoundingClientRect().width;
                rightPanel.style.transition = 'none';
                panelSplitter.classList.add('gpc-ps-dragging');
                document.body.style.cursor = 'ew-resize';
                const onMove = ev => {
                    const delta = startX - ev.clientX; // drag left → wider right panel
                    const modalW = modal.getBoundingClientRect().width;
                    const minW = Math.max(34, Math.floor(modalW * 0.05));
                    const maxW = Math.max(minW, Math.floor(modalW * 0.95));
                    const newW = Math.min(Math.max(minW, startW + delta), maxW);
                    rightPanel.style.width = newW + 'px';
                    _previewRightW = newW;   // remember split width for collapse/expand
                };
                const onUp = () => {
                    panelSplitter.classList.remove('gpc-ps-dragging');
                    document.body.style.cursor = '';
                    rightPanel.style.transition = '';
                    document.removeEventListener('mousemove', onMove, true);
                    document.removeEventListener('mouseup',   onUp,   true);
                };
                document.addEventListener('mousemove', onMove, true);
                document.addEventListener('mouseup',   onUp,   true);
            });
            rightPanel.appendChild(panelSplitter);

            modal.appendChild(resizeHandle);
        }

        loadRecentImages();
        setupGhostModalPlacementMemory(modal);
    }

    async function loadRecentImages() {
        const grid = document.getElementById('gpc-recent-grid');
        if (!grid) return;
        _recentBlobUrls.forEach(u => URL.revokeObjectURL(u));
        _recentBlobUrls = [];
        const isDark = document.body.classList.contains('dark');
        const loadingColor = isDark ? '#6b7280' : '#94a3b8';
        grid.innerHTML = `<span style="font-size:10px;color:${loadingColor};padding:4px;grid-column:span 5">Loading…</span>`;
        try {
            const allImages = await HistoryManager.getAll();
            const images = allImages.slice(0, _recentLimit);
            grid.innerHTML = '';
            if (!images.length) {
                grid.innerHTML = `<span style="font-size:10px;color:${loadingColor};padding:4px;grid-column:span 5">No history yet</span>`;
                return;
            }
            images.forEach(imgData => {
                const blobUrl = URL.createObjectURL(imgData.blob);
                _recentBlobUrls.push(blobUrl);
                const thumb = document.createElement('div');
                // Use Tailwind dark: variants — same approach as the rest of the modal
                thumb.className = 'rounded bg-gray-200 dark:bg-gray-700 cursor-pointer';
                thumb.style.cssText = 'width:100%;height:100%;min-width:0;overflow:hidden;border:2px solid transparent;box-sizing:border-box;transition:border-color 0.15s;';
                thumb.title = imgData.name;
                const img = document.createElement('img');
                img.src = blobUrl;
                img.style.cssText = 'width:100%;height:100%;object-fit:cover;image-rendering:pixelated;display:block;';
                thumb.appendChild(img);
                thumb.addEventListener('mouseenter', () => { thumb.style.borderColor = '#3b82f6'; });
                thumb.addEventListener('mouseleave', () => { thumb.style.borderColor = 'transparent'; });
                thumb.addEventListener('click', async () => {
                    await processAndLoadImage(imgData.blob, false, 'Recent thumbnails', imgData.groupNoise);
                    await HistoryManager.add(imgData.blob, imgData.name, imgData.groupNoise);
                    loadRecentImages();
                });
                grid.appendChild(thumb);
            });

            if (allImages.length > 10) {
                const makeRecentPagerTile = (text, onClick) => {
                    const btn = document.createElement('button');
                    btn.type = 'button';
                    btn.textContent = text;
                    btn.className = 'rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer';
                    btn.style.cssText = 'width:100%;height:100%;min-width:0;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;text-decoration:underline;padding:0;box-sizing:border-box;transition:background-color 0.15s,border-color 0.15s;';
                    btn.addEventListener('click', e => { e.stopPropagation(); onClick(); });
                    return btn;
                };

                if (_recentLimit < Math.min(50, allImages.length)) {
                    grid.appendChild(makeRecentPagerTile('more', () => { _recentLimit = 50; loadRecentImages(); }));
                }

                if (_recentLimit > 10) {
                    grid.appendChild(makeRecentPagerTile('less', () => { _recentLimit = 10; loadRecentImages(); }));
                }
            }
        } catch(e) {
            grid.innerHTML = '<span style="font-size:10px;color:#ef4444;padding:4px;grid-column:span 5">Failed to load</span>';
        }
    }

    gpLog("GeoPixels Ultimate Ghost Template Manager v3.5 Loaded (with uint8array ZIP fix)");

            })();
            _featureStatus.ghostTemplateManager = 'ok';
            console.log('[GeoPixelcons++] ✅ Ghost Template Manager loaded');
        } catch (err) {
            _featureStatus.ghostTemplateManager = 'error';
            dbgPush(`Ghost Template Manager init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Ghost Template Manager' });
            console.error('[GeoPixelcons++] ❌ Ghost Template Manager failed:', err);
        }
    }


    // ============================================================
    //  SETTING: Disable Group Noise [disableGroupNoise]
    // ============================================================
    if (_settings.disableGroupNoise) {
        try {
            function enforceNoGroupNoise() {
                const toggle = document.getElementById('groupNoiseToggle');
                if (!toggle) return false;

                // Uncheck it
                if (toggle.checked) {
                    toggle.checked = false;
                    // Fire change event so ghost22.js re-processes if a template is loaded
                    if (typeof handleGroupingToggle === 'function') {
                        handleGroupingToggle();
                    }
                }

                // Gray out the toggle and its label, add "disabled" text
                toggle.disabled = true;
                toggle.style.opacity = '0.4';
                toggle.style.cursor = 'not-allowed';
                const container = toggle.closest('label') || toggle.parentElement;
                if (container) {
                    container.style.opacity = '0.4';
                    container.style.cursor = 'not-allowed';
                    // Add "disabled" badge if not already present
                    if (!container.querySelector('.gpc-noise-disabled-badge')) {
                        const badge = document.createElement('span');
                        badge.className = 'gpc-noise-disabled-badge';
                        badge.style.cssText = 'font-size:10px;font-weight:600;color:#ef4444;margin-left:6px;vertical-align:middle;';
                        badge.textContent = '(disabled by GeoPixelcons++)';
                        container.appendChild(badge);
                    }
                }

                // Intercept any programmatic re-checking via a property override
                Object.defineProperty(toggle, 'checked', {
                    get() { return false; },
                    set() { /* no-op: group noise is permanently disabled */ },
                    configurable: true
                });

                return true;
            }

            // Try immediately, then observe for the ghost modal being opened
            if (!enforceNoGroupNoise()) {
                const noiseObserver = new MutationObserver(() => {
                    if (enforceNoGroupNoise()) noiseObserver.disconnect();
                });
                noiseObserver.observe(document.body, { childList: true, subtree: true });
                setTimeout(() => noiseObserver.disconnect(), 60000);
            }

            _featureStatus.disableGroupNoise = 'ok';
            console.log('[GeoPixelcons++] ✅ Disable Group Noise loaded');
        } catch (err) {
            _featureStatus.disableGroupNoise = 'error';
            dbgPush(`Disable Group Noise init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Disable Group Noise' });
            console.error('[GeoPixelcons++] ❌ Disable Group Noise failed:', err);
        }
    }

    // ============================================================
    //  SETTING: Start in Shift Lock [startShiftLock]
    // ============================================================
    if (_settings.startShiftLock) {
        try {
            const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

            // `shiftDown` is a closure variable inside the site's script — NOT a
            // window property (window.shiftDown is always undefined). We cannot read
            // or write it directly. Instead we must go through toggleShiftDown() to
            // actually flip it. We wrap toggleShiftDown first so we can track the
            // current lock state ourselves, then use that in the FindRandomArt patch.
            let _lockOn = false;

            if (typeof _pw.toggleShiftDown === 'function') {
                const _origToggle = _pw.toggleShiftDown;
                _pw.toggleShiftDown = function () {
                    _lockOn = !_lockOn;
                    dbgPush('toggleShiftDown: _lockOn \u2192 ' + _lockOn);
                    return _origToggle.apply(this, arguments);
                };
            }

            // Enable lock on startup (goes through our wrapper, so _lockOn = true).
            if (typeof _pw.toggleShiftDown === 'function') {
                _pw.toggleShiftDown();
                console.log('[GeoPixelcons++] \u2705 Shift Lock enabled on startup');
                dbgPush('startShiftLock: enabled on startup, _lockOn=' + _lockOn);
            }

            // Patch FindRandomArt: temporarily call toggleShiftDown() to flip the
            // closure shiftDown to false before the call, then restore it after.
            if (typeof _pw.FindRandomArt === 'function') {
                const _origFRA = _pw.FindRandomArt;
                _pw.FindRandomArt = async function () {
                    const _wasLocked = _lockOn;
                    if (_wasLocked && typeof _pw.toggleShiftDown === 'function') {
                        _pw.toggleShiftDown(); // _lockOn=false, closure shiftDown=false
                    }
                    try {
                        return await _origFRA.apply(this, arguments);
                    } finally {
                        if (_wasLocked && typeof _pw.toggleShiftDown === 'function') {
                            _pw.toggleShiftDown(); // _lockOn=true, closure shiftDown=true
                        }
                    }
                };
                dbgPush('startShiftLock: FindRandomArt patched');
            }

            _featureStatus.startShiftLock = 'ok';
        } catch (err) {
            _featureStatus.startShiftLock = 'error';
            dbgPush(`Start in Shift Lock init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Start in Shift Lock' });
            console.error('[GeoPixelcons++] \u274C Start in Shift Lock failed:', err);
        }
    }

    // ============================================================
    //  SETTING: Start in Inspect Mode [startInspectMode]
    // ============================================================
    if (_settings.startInspectMode) {
        try {
            const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

            // Wait for the map to be fully ready (canvas contexts exist)
            // before toggling mode — prevents white-screen on load
            function waitForMapReady(cb, maxWait) {
                maxWait = maxWait || 20000;
                const start = Date.now();
                function check() {
                    if (_pw.map && typeof _pw.map.getCanvas === 'function' &&
                        document.getElementById('pixel-canvas') &&
                        document.getElementById('pixel-canvas').getContext) {
                        cb(); return;
                    }
                    if (Date.now() - start >= maxWait) return;
                    setTimeout(check, 300);
                }
                check();
            }

            // Wait for the togglePrimaryModeBtn to exist and its title to
            // stabilize (stop changing) before reading the current mode
            function waitForEl(id, cb) {
                const el = document.getElementById(id);
                if (el) { cb(el); return; }
                const obs = new MutationObserver(() => {
                    const found = document.getElementById(id);
                    if (found) { obs.disconnect(); cb(found); }
                });
                obs.observe(document.body, { childList: true, subtree: true });
                setTimeout(() => obs.disconnect(), 15000);
            }

            function waitForStableTitle(el, cb) {
                let timer = null;
                let done = false;
                function fire() {
                    if (done) return;
                    done = true;
                    clearTimeout(timer);
                    titleObs.disconnect();
                    cb(el);
                }
                const titleObs = new MutationObserver(() => {
                    clearTimeout(timer);
                    timer = setTimeout(fire, 500);
                });
                titleObs.observe(el, { attributes: true, attributeFilter: ['title'] });
                timer = setTimeout(fire, 500);
                setTimeout(fire, 6000);
            }

            waitForEl('togglePrimaryModeBtn', (btn) => {
                waitForMapReady(() => {
                    waitForStableTitle(btn, () => {
                        // title "Switch to Action Mode" = currently inspect
                        // title "Switch to Inspect Mode" = currently action
                        const currentMode = (btn.title || '').toLowerCase().includes('action') ? 'inspect' : 'action';
                        if (currentMode === 'action') {
                            try {
                                if (typeof _pw.togglePrimaryMode === 'function') _pw.togglePrimaryMode();
                                else btn.click();
                            } catch (_) { try { btn.click(); } catch (__) {} }
                            console.log('[GeoPixelcons++] \u2705 Switched to Inspect Mode on startup');
                        }
                    });
                });
            });

            _featureStatus.startInspectMode = 'ok';
        } catch (err) {
            _featureStatus.startInspectMode = 'error';
            dbgPush(`Start in Inspect Mode init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Start in Inspect Mode' });
            console.error('[GeoPixelcons++] \u274C Start in Inspect Mode failed:', err);
        }
    }

    // ============================================================
    //  SETTING: Smooth Zoom Buttons [smoothZoomButtons]
    // ============================================================
    if (_settings.smoothZoomButtons) {
        try {
            const _pw2 = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
            const SZ_MIN = 0.5;
            const SZ_MAX = 22;
            const SZ_STEP = 0.01;
            const SZ_WHEEL_STEP = 0.1;
            const SZ_HOLD_DELAY = 400;
            const SZ_HOLD_INTERVAL = 50;

            // Inject styles — only non-Tailwind-expressible rules (writing-mode, spinner removal)
            const szStyle = document.createElement('style');
            szStyle.textContent = `
                #gpc-smooth-zoom input[type=range] {
                    writing-mode: vertical-lr;
                    direction: rtl;
                    width: 8px;
                    height: 94px;
                    cursor: pointer;
                    accent-color: #22c55e;
                    margin: 0;
                }
                #gpc-smooth-zoom input[type=number]::-webkit-outer-spin-button,
                #gpc-smooth-zoom input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
                #gpc-smooth-zoom input[type=number] { -moz-appearance: textfield; }
            `;
            document.head.appendChild(szStyle);

            function _sz_buildWidget(zoomInBtn, zoomOutBtn, controlsRight) {
                zoomInBtn.style.display = 'none';
                zoomOutBtn.style.display = 'none';

                const wrapper = document.createElement('div');
                wrapper.id = 'gpc-smooth-zoom';
                wrapper.style.cssText = 'display:flex;flex-direction:column;align-items:flex-end;gap:2px;';

                function makeCircleBtn(label, title) {
                    const btn = document.createElement('button');
                    btn.className = 'gpc-sz-btn w-10 h-10 bg-white dark:bg-gray-700 shadow rounded-full flex items-center justify-center hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer select-none flex-shrink-0 text-gray-700 dark:text-gray-200 border-0';
                    btn.textContent = label;
                    btn.title = title;
                    btn.style.cssText = 'font-size:20px;line-height:1;';
                    return btn;
                }

                const plusBtn = makeCircleBtn('+', 'Zoom In');
                const minusBtn = makeCircleBtn('\u2212', 'Zoom Out');

                const sliderWrap = document.createElement('div');
                sliderWrap.className = 'bg-white dark:bg-gray-700 shadow flex items-center justify-center';
                sliderWrap.style.cssText = 'width:40px;height:120px;border-radius:20px;';
                const slider = document.createElement('input');
                slider.type = 'range';
                slider.min = SZ_MIN;
                slider.max = SZ_MAX;
                slider.step = SZ_STEP;
                sliderWrap.appendChild(slider);

                const valueBox = document.createElement('input');
                valueBox.type = 'number';
                valueBox.min = SZ_MIN;
                valueBox.max = SZ_MAX;
                valueBox.step = 0.01;
                valueBox.className = 'text-center text-xs font-semibold border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded-lg shadow';
                valueBox.style.cssText = 'width:40px;height:28px;padding:0 4px;box-sizing:border-box;';

                wrapper.appendChild(plusBtn);
                wrapper.appendChild(sliderWrap);
                wrapper.appendChild(minusBtn);
                wrapper.appendChild(valueBox);
                controlsRight.insertBefore(wrapper, zoomInBtn);

                // Wait for map instance then wire up events
                function _sz_waitForMap(cb) {
                    if (_pw2.map && typeof _pw2.map.getZoom === 'function') { cb(_pw2.map); return; }
                    const t = setInterval(() => {
                        if (_pw2.map && typeof _pw2.map.getZoom === 'function') { clearInterval(t); cb(_pw2.map); }
                    }, 200);
                    setTimeout(() => clearInterval(t), 20000);
                }

                _sz_waitForMap((map) => {
                    function getZ() { return map.getZoom(); }
                    function setZ(z) {
                        const clamped = Math.max(SZ_MIN, Math.min(SZ_MAX, Math.round(parseFloat(z) * 100) / 100));
                        map.setZoom(clamped);
                    }
                    function syncUI() {
                        const z = getZ();
                        slider.value = z;
                        valueBox.value = z.toFixed(2);
                    }
                    syncUI();
                    map.on('zoom', syncUI);

                    slider.addEventListener('input', () => setZ(parseFloat(slider.value)));

                    valueBox.addEventListener('change', () => {
                        const val = parseFloat(valueBox.value);
                        if (!isNaN(val)) setZ(val);
                        syncUI();
                    });
                    valueBox.addEventListener('keydown', (e) => { if (e.key === 'Enter') valueBox.blur(); });

                    let _szHoldTimer = null, _szHoldInterval = null;
                    function startHold(step) {
                        setZ(getZ() + step);
                        _szHoldTimer = setTimeout(() => {
                            _szHoldInterval = setInterval(() => setZ(getZ() + step), SZ_HOLD_INTERVAL);
                        }, SZ_HOLD_DELAY);
                    }
                    function stopHold() {
                        clearTimeout(_szHoldTimer);
                        clearInterval(_szHoldInterval);
                        _szHoldTimer = null;
                        _szHoldInterval = null;
                    }

                    [[plusBtn, SZ_STEP], [minusBtn, -SZ_STEP]].forEach(([btn, step]) => {
                        btn.addEventListener('mousedown', () => startHold(step));
                        btn.addEventListener('mouseup', stopHold);
                        btn.addEventListener('mouseleave', stopHold);
                        btn.addEventListener('touchstart', (e) => { e.preventDefault(); startHold(step); }, { passive: false });
                        btn.addEventListener('touchend', stopHold);
                    });

                    wrapper.addEventListener('wheel', (e) => {
                        e.preventDefault();
                        setZ(getZ() + (e.deltaY < 0 ? SZ_WHEEL_STEP : -SZ_WHEEL_STEP));
                    }, { passive: false });
                });
            }

            function _sz_waitForEls(cb) {
                const zi = document.getElementById('zoomIn');
                const zo = document.getElementById('zoomOut');
                const cr = document.getElementById('controls-right');
                if (zi && zo && cr) { cb(zi, zo, cr); return; }
                const obs = new MutationObserver(() => {
                    const zi2 = document.getElementById('zoomIn');
                    const zo2 = document.getElementById('zoomOut');
                    const cr2 = document.getElementById('controls-right');
                    if (zi2 && zo2 && cr2) { obs.disconnect(); cb(zi2, zo2, cr2); }
                });
                obs.observe(document.body, { childList: true, subtree: true });
                setTimeout(() => obs.disconnect(), 15000);
            }

            _sz_waitForEls(_sz_buildWidget);

            _featureStatus.smoothZoomButtons = 'ok';
            console.log('[GeoPixelcons++] \u2705 Smooth Zoom Buttons loaded');
        } catch (err) {
            _featureStatus.smoothZoomButtons = 'error';
            dbgPush(`Smooth Zoom Buttons init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Smooth Zoom Buttons' });
            console.error('[GeoPixelcons++] \u274C Smooth Zoom Buttons failed:', err);
        }
    }

    // ============================================================
    //  FEATURE: Guild Overhaul [guildOverhaul]
    // ============================================================
    if (_settings.guildOverhaul) {
        try {
            (function _init_guildOverhaul() {

    // --- Configuration & State ---
    const CONFIG = {
        debugMode: false,
        timeOffset: GM_getValue('debug_time_offset', 0),
        minSnapshotInterval: GM_getValue('min_snapshot_interval', 60 * 60 * 1000),
        maxSnapshots: GM_getValue('max_snapshots', 750)
    };

    const SNAPSHOT_INTERVALS = {
        HOURLY: 60 * 60 * 1000,
        TWELVE_HOURS: 12 * 60 * 60 * 1000,
        TWENTY_FOUR_HOURS: 24 * 60 * 60 * 1000
    };

    const sessionState = {
        visitedCoords: new Set()
    };

    // --- One-time migration: convert old name-keyed snapshots to ID-keyed ---
    // Prior to 1.1.8, members were stored as { "PlayerName#12345": { xp, coords } }.
    // Now they are stored as { "12345": { xp, coords, name: "PlayerName#12345" } }.
    // This runs once at startup and is transparent to the user.
    (function _migrateGuildXPHistory() {
        try {
            const history = GM_getValue('guild_xp_history', []);
            if (!history.length) return;
            let changed = false;
            history.forEach(entry => {
                if (!entry.members) return;
                const oldMembers = entry.members;
                const newMembers = {};
                for (const [key, val] of Object.entries(oldMembers)) {
                    // Already migrated? (pure numeric key)
                    if (/^\d+$/.test(key)) {
                        newMembers[key] = val;
                        continue;
                    }
                    // Legacy format: full name key like "PlayerName#12345"
                    const idMatch = key.match(/#(\d+)$/);
                    if (idMatch) {
                        const id = idMatch[1];
                        if (typeof val === 'number') {
                            newMembers[id] = { xp: val, coords: null, name: key };
                        } else {
                            newMembers[id] = { ...val, name: val.name || key };
                        }
                        changed = true;
                    } else {
                        // No ID found — keep as-is (can't migrate)
                        newMembers[key] = val;
                    }
                }
                entry.members = newMembers;
            });
            if (changed) {
                GM_setValue('guild_xp_history', history);
                console.log('[Guild XP] Migrated old name-keyed snapshots to ID keys');
            }
        } catch (e) {
            console.warn('[Guild XP] Migration failed:', e);
        }
    })();

    // --- Territory Overlay State ---
    const TERRITORY_STORAGE_KEY = 'guildOverhaul_territorySettings';
    let territoryCanvas = null;
    let territoryVisible = false;
    let territoryRects = []; // Array of { gridX, gridY, width, height, label }
    let territoryActivityMap = {}; // Map of rect.index → boolean (has active players)
    let territorySettings = loadTerritorySettings();

    // --- Player Markers Overlay State ---
    const PLAYER_STORAGE_KEY = 'guildOverhaul_playerSettings';
    let playersContainer = null;
    let playersVisible = false;
    let playerMarkerData = []; // Array of { name, gridX, gridY, element, inTerritory }
    let playersShowNames = false;
    let playersColorByTerritory = true;
    let playersShowInTerritory = true;
    let playersShowOutsideTerritory = true;
    let playerSettings = loadPlayerSettings();

    function loadPlayerSettings() {
        try {
            const stored = GM_getValue(PLAYER_STORAGE_KEY, null);
            if (stored) return stored;
        } catch (e) {}
        return {
            markerSize: 28,
            labelFontSize: 11,
            defaultColor: '#ef4444',
            territoryColor: '#3b82f6'
        };
    }

    function savePlayerSettings() {
        GM_setValue(PLAYER_STORAGE_KEY, playerSettings);
    }

    function loadTerritorySettings() {
        try {
            const stored = GM_getValue(TERRITORY_STORAGE_KEY, null);
            if (stored) return stored;
        } catch (e) {}
        return {
            borderColor: '#3b82f6',
            borderThickness: 2,
            showLabels: true,
            labelFontSize: 12,
            showFill: true,
            fillColor: '#3b82f6',
            fillAlpha: 0.15,
            colorByActivity: false,
            activeBorderColor: '#22c55e',
            activeFillColor: '#22c55e',
            abandonedBorderColor: '#6b7280',
            abandonedFillColor: '#6b7280'
        };
    }

    function saveTerritorySettings() {
        GM_setValue(TERRITORY_STORAGE_KEY, territorySettings);
    }

    // --- CSS Styles ---
    // --- CSS Styles (Tailwind-compatible for geopixels++ dark theme) ---
    const style = document.createElement('style');
    style.textContent = `
        .guild-modal-header {
            touch-action: none !important;
            -webkit-user-select: none !important;
            user-select: none !important;
        }
        
        .guild-modal-header span {
            touch-action: none !important;
            -webkit-user-select: none !important;
            user-select: none !important;
            display: block;
            flex: 1;
            padding-right: 10px;
        }
        
        .draggable-panel {
            touch-action: none !important;
        }

        .draggable-panel.gpc-mobile-compat,
        .draggable-panel.gpc-mobile-compat .guild-modal-header,
        .draggable-panel.gpc-mobile-compat .guild-modal-header span {
            touch-action: auto !important;
        }

        /* Use Tailwind CSS variables for dark mode compatibility */
        .guild-message-section {
            border: 1px solid var(--color-gray-200, #e5e7eb);
            border-radius: 0.5rem;
            overflow: hidden;
            background-color: var(--color-white, #fff);
        }

        .guild-message-header {
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: 0.75rem;
            background-color: var(--color-gray-50, #f9fafb);
            cursor: pointer;
            user-select: none;
            color: var(--color-gray-900, #111827);
        }

        .guild-message-header:hover {
            background-color: var(--color-gray-100, #f3f4f6);
        }

        .guild-message-toggle {
            display: inline-block;
            width: 20px;
            height: 20px;
            text-align: center;
            line-height: 20px;
            font-weight: bold;
            color: var(--color-gray-500, #6b7280);
            transition: transform 0.2s ease;
        }

        .guild-message-toggle.collapsed {
            transform: rotate(-90deg);
        }

        .guild-message-content {
            max-height: 500px;
            overflow: hidden;
            transition: max-height 0.3s ease, padding 0.3s ease;
            padding: 0.75rem;
            background-color: var(--color-white, #fff);
            color: var(--color-gray-900, #111827);
        }

        .guild-message-content.collapsed {
            max-height: 0;
            padding: 0;
        }

        @media (max-width: 1024px) {
            #infoTab .grid.grid-cols-1.lg\\:grid-cols-3 {
                grid-template-columns: 1fr !important;
            }
            #infoTab .lg\\:col-span-2 { grid-column: auto !important; }
            #infoTab .lg\\:col-span-1 { grid-column: auto !important; order: 1; }
            #infoTab > .grid { display: flex; flex-direction: column; }
            #guildMembersContainer { order: 1; margin-top: 2rem; }
        }

        #infoTab.message-collapsed > .grid { display: block; }
        #infoTab.message-collapsed #guildMembersContainer { margin-top: 1rem; }

        .guild-find-btn.visited { background-color: var(--color-purple-500, #a855f7) !important; }
        .guild-find-btn.visited:hover { background-color: var(--color-purple-600, #9333ea) !important; }

        .xp-changes-section {
            margin-top: 1.5rem;
            border: 1px solid var(--color-gray-200, #e5e7eb);
            border-radius: 0.5rem;
            overflow: hidden;
            width: 100%;
            background-color: var(--color-white, #fff);
        }

        .xp-changes-header {
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: 0.75rem;
            background-color: var(--color-gray-100, #f1f5f9);
            cursor: pointer;
            user-select: none;
            font-weight: 600;
            color: var(--color-gray-700, #334155);
        }
        .xp-changes-header:hover { background-color: var(--color-gray-200, #e2e8f0); }

        .xp-changes-content { 
            padding: 1rem; 
            background-color: var(--color-white, #fff); 
            color: var(--color-gray-900, #111827);
            display: block; 
        }
        .xp-changes-content.hidden { display: none; }

        .daily-brief-table { 
            width: 100%; 
            border-collapse: collapse; 
            margin-top: 10px;
            color: var(--color-gray-900, #111827);
        }
        .daily-brief-table th, .daily-brief-table td { 
            border: 1px solid var(--color-gray-300, #d1d5db); 
            padding: 8px; 
            text-align: left; 
        }
        .daily-brief-table th { 
            background-color: var(--color-gray-100, #f2f2f2);
            color: var(--color-gray-900, #111827);
        }
        .daily-brief-table td {
            background-color: var(--color-white, #fff);
        }

        .xp-gain { color: var(--color-green-500, #22c55e); }
        .xp-loss { color: var(--color-red-500, #ef4444); }
        .xp-neutral { color: var(--color-gray-500, #94a3b8); }

        .user-cell-content { display: flex; flex-direction: column; gap: 2px; }
        .user-name { font-weight: 500; color: var(--color-gray-900, #111827); }
        .user-coords { font-size: 13px; }

        .member-icon-btn {
            display: inline-flex; align-items: center; justify-content: center;
            width: 24px; height: 24px; border-radius: 4px; cursor: pointer;
            transition: background-color 0.2s; margin-left: 4px; border: none;
            background: transparent; padding: 0;
        }
        .member-icon-btn:hover { background-color: var(--color-gray-100, rgba(0,0,0,0.05)); }
        .discord-icon { color: #5865F2; }
        .map-icon { color: var(--color-sky-500, #0ea5e9); }
        .map-icon.out-of-territory { color: var(--color-red-500, #ef4444); }
        .map-icon.visited { color: var(--color-purple-500, #a855f7); }
        
        .control-button {
            padding: 6px 12px; 
            border: 1px solid var(--color-gray-300, #d1d5db); 
            background: var(--color-white, #fff);
            color: var(--color-gray-900, #111827);
            border-radius: 4px; 
            cursor: pointer; 
            font-size: 12px; 
            transition: background-color 0.2s;
        }
        .control-button:hover { background-color: var(--color-gray-100, #f0f0f0); }
        .control-button.active { 
            background-color: var(--color-blue-500, #3b82f6); 
            color: var(--color-white, #fff); 
            border-color: var(--color-blue-500, #3b82f6); 
        }
        
        .trash-btn {
            background: none; border: none; 
            color: var(--color-red-500, #ef4444); 
            cursor: pointer;
            padding: 2px 4px; font-size: 12px;
        }
        .trash-btn:hover { color: var(--color-red-600, #dc2626); }
        
        .tooltip-popup {
            position: fixed; 
            background: var(--color-gray-800, #333); 
            color: var(--color-gray-100, #fff); 
            padding: 4px 8px;
            border-radius: 4px; font-size: 12px; z-index: 10000; pointer-events: none;
            opacity: 0; transition: opacity 0.2s;
        }
        .tooltip-popup.visible { opacity: 1; }

        #snapshotIntervalSelect {
            background-color: var(--color-white, #fff);
            color: var(--color-gray-900, #111827);
        }
        #snapshotIntervalSelect:hover {
            border-color: var(--color-blue-500, #3b82f6) !important;
            box-shadow: 0 0 4px rgba(59, 130, 246, 0.2) !important;
        }

        #snapshotIntervalSelect:focus {
            border-color: var(--color-blue-500, #3b82f6) !important;
            box-shadow: 0 0 6px rgba(59, 130, 246, 0.3) !important;
            outline: none !important;
        }

        /* --- Player Markers Overlay Styles --- */
        #players-container {
            position: absolute;
            inset: 0;
            pointer-events: none;
            z-index: 5;
            overflow: hidden;
        }

        .player-marker {
            position: absolute;
            pointer-events: auto;
            cursor: pointer;
            transform: translate(-50%, -100%);
            transition: transform 0.15s ease;
            filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35));
        }

        .player-marker:hover {
            transform: translate(-50%, -100%) scale(1.25);
            z-index: 10;
        }

        .player-marker-tooltip {
            position: absolute;
            bottom: 100%;
            left: 50%;
            transform: translateX(-50%);
            margin-bottom: 6px;
            background: var(--color-gray-800, #1f2937);
            color: var(--color-gray-100, #f3f4f6);
            padding: 4px 8px;
            border-radius: 4px;
            font-size: 11px;
            font-weight: 600;
            white-space: nowrap;
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.15s ease;
            box-shadow: 0 2px 6px rgba(0,0,0,0.25);
        }

        .player-marker-tooltip::after {
            content: '';
            position: absolute;
            top: 100%;
            left: 50%;
            transform: translateX(-50%);
            border: 5px solid transparent;
            border-top-color: var(--color-gray-800, #1f2937);
        }

        .player-marker:hover .player-marker-tooltip {
            opacity: 1;
        }

        .player-marker.show-label .player-marker-tooltip {
            opacity: 1;
        }

        .player-marker-options {
            display: flex;
            align-items: center;
            gap: 14px;
            padding: 4px 0;
        }

        .player-marker-options label {
            display: flex;
            align-items: center;
            gap: 5px;
            font-size: 12px;
            font-weight: 500;
            color: var(--color-gray-600, #4b5563);
            cursor: pointer;
            user-select: none;
        }

        .player-marker-options input[type="checkbox"] {
            width: 14px;
            height: 14px;
            cursor: pointer;
            accent-color: var(--color-blue-500, #3b82f6);
        }

        /* --- Territory Overlay Styles --- */
        #territory-canvas {
            position: absolute;
            inset: 0;
            pointer-events: none;
            z-index: 5;
        }

        /* Territory Controls Container */
        #territoryControlsContainer {
            background-color: var(--color-gray-100, #f0f9ff);
            border: 1px solid var(--color-gray-300, #bae6fd);
        }

        .territory-setting-row {
            display: flex;
            align-items: center;
            justify-content: space-between;
            gap: 12px;
        }

        .territory-setting-row label {
            font-size: 13px;
            font-weight: 500;
            color: var(--color-gray-700, #374151);
        }

        .territory-settings-collapsible {
            width: 100%;
            border: 1px solid var(--color-gray-300, #d1d5db);
            border-radius: 8px;
            overflow: hidden;
            background: var(--color-white, #fff);
        }

        .territory-settings-toggle {
            display: flex;
            align-items: center;
            justify-content: space-between;
            padding: 8px 12px;
            background: var(--color-gray-100, #f3f4f6);
            cursor: pointer;
            user-select: none;
            font-size: 13px;
            font-weight: 600;
            color: var(--color-blue-500, #3b82f6);
            border: none;
            width: 100%;
        }

        .territory-settings-toggle:hover {
            background: var(--color-gray-200, #e5e7eb);
        }

        .territory-settings-toggle .toggle-arrow {
            transition: transform 0.2s ease;
            font-size: 11px;
        }

        .territory-settings-toggle .toggle-arrow.collapsed {
            transform: rotate(-90deg);
        }

        .territory-settings-content {
            padding: 12px;
            display: flex;
            flex-direction: column;
            gap: 10px;
            border-top: 1px solid var(--color-gray-200, #e5e7eb);
            background: var(--color-white, #fff);
        }

        .territory-settings-content.collapsed {
            display: none;
        }

        .territory-section-divider {
            border-top: 1px solid var(--color-gray-200, #e5e7eb);
            margin: 2px 0;
            padding-top: 4px;
            font-size: 11px;
            font-weight: 600;
            color: var(--color-gray-500, #6b7280);
            text-transform: uppercase;
            letter-spacing: 0.5px;
        }

        .territory-toggle-btn {
            display: inline-flex;
            align-items: center;
            gap: 6px;
            padding: 8px 16px;
            border-radius: 8px;
            font-size: 13px;
            font-weight: 600;
            cursor: pointer;
            border: none;
            transition: all 0.2s;
        }

        .territory-toggle-btn.active {
            background: var(--color-blue-500, #3b82f6);
            color: var(--color-white, #fff);
            box-shadow: 0 2px 8px rgba(59,130,246,0.3);
        }

        .territory-toggle-btn.inactive {
            background: var(--color-gray-200, #e5e7eb);
            color: var(--color-gray-700, #374151);
        }

        .territory-toggle-btn.inactive:hover {
            background: var(--color-gray-300, #d1d5db);
        }

        /* Territory settings inputs */
        .territory-settings-content input[type="color"] {
            border: 2px solid var(--color-gray-300, #d1d5db);
        }
        .territory-settings-content select {
            background-color: var(--color-white, #fff);
            color: var(--color-gray-900, #111827);
            border: 1px solid var(--color-gray-300, #d1d5db);
        }
        .territory-settings-content input[type="range"] {
            accent-color: var(--color-blue-500, #3b82f6);
        }

        /* Info text in territory controls */
        .territory-info-text {
            font-size: 12px;
            color: var(--color-gray-500, #64748b);
        }

        /* --- Modal Styling for Dark Mode Compatibility --- */
        .gmi-modal-overlay {
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background: rgba(0, 0, 0, 0.5);
            z-index: 9999;
        }

        .gmi-modal {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: var(--color-white, #fff);
            border: 2px solid var(--color-blue-500, #3b82f6);
            border-radius: 8px;
            padding: 20px;
            z-index: 10000;
            box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
            color: var(--color-gray-900, #111827);
        }

        .gmi-modal h3 {
            margin: 0 0 15px 0;
            font-size: 18px;
            font-weight: bold;
            color: var(--color-gray-900, #111827);
        }

        .gmi-modal-btn {
            display: block;
            width: 100%;
            padding: 10px;
            margin: 8px 0;
            border: 1px solid var(--color-gray-300, #d1d5db);
            border-radius: 4px;
            background: var(--color-white, #fff);
            color: var(--color-gray-900, #111827);
            cursor: pointer;
            font-size: 14px;
            transition: background-color 0.2s;
            text-align: left;
        }

        .gmi-modal-btn:hover {
            background-color: var(--color-gray-100, #f3f4f6);
        }

        .gmi-modal-btn.danger {
            color: var(--color-red-500, #ef4444);
        }

        .gmi-modal-btn.danger:hover {
            background-color: rgba(239, 68, 68, 0.1);
        }

        .gmi-modal-btn.warning {
            color: var(--color-yellow-500, #f59e0b);
        }

        .gmi-modal-btn.warning:hover {
            background-color: rgba(245, 158, 11, 0.1);
        }

        .gmi-modal-btn.primary {
            color: var(--color-blue-500, #3b82f6);
        }

        .gmi-modal-btn.primary:hover {
            background-color: rgba(59, 130, 246, 0.1);
        }

        .gmi-modal-section {
            padding: 10px;
            background: var(--color-gray-50, #f9fafb);
            border-radius: 4px;
            border: 1px solid var(--color-gray-200, #e5e7eb);
            margin-bottom: 15px;
        }

        .gmi-modal-select {
            padding: 6px 10px;
            border: 1px solid var(--color-gray-300, #d1d5db);
            border-radius: 4px;
            background: var(--color-white, #fff);
            color: var(--color-gray-900, #111827);
            font-size: 12px;
            cursor: pointer;
        }

        .gmi-modal-label {
            font-weight: 600;
            font-size: 12px;
            color: var(--color-gray-700, #374151);
            user-select: none;
        }

        .gmi-checkbox-option {
            display: flex;
            align-items: center;
            padding: 8px;
            border: 2px solid var(--color-gray-300, #d1d5db);
            border-radius: 4px;
            background: var(--color-white, #fff);
            cursor: pointer;
            font-size: 12px;
            transition: all 0.2s;
        }

        .gmi-checkbox-option:hover {
            border-color: var(--color-gray-400, #9ca3af);
        }

        .gmi-checkbox-option input[type="checkbox"] {
            width: 16px;
            height: 16px;
            margin-right: 8px;
            cursor: pointer;
            accent-color: var(--color-blue-500, #3b82f6);
        }

        .gmi-snapshot-list {
            flex: 1;
            overflow-y: auto;
            border: 1px solid var(--color-gray-200, #e5e7eb);
            border-radius: 4px;
            padding: 10px;
            margin-bottom: 15px;
            background: var(--color-gray-50, #f9fafb);
        }

        .gmi-snapshot-item {
            display: flex;
            align-items: center;
            padding: 8px;
            margin: 4px 0;
            background: var(--color-white, #fff);
            border-radius: 4px;
            border: 1px solid var(--color-gray-200, #e5e7eb);
            transition: background-color 0.2s, border-color 0.2s;
        }

        .gmi-snapshot-item.selected {
            background: rgba(239, 68, 68, 0.1);
            border-color: var(--color-red-300, #fca5a5);
        }

        .gmi-snapshot-item label {
            flex: 1;
            cursor: pointer;
            font-size: 12px;
            color: var(--color-gray-700, #374151);
        }

        .gmi-snapshot-item.selected label {
            color: var(--color-red-700, #b91c1c);
            text-decoration: line-through;
        }

        .gmi-action-btn {
            flex: 1;
            min-width: 120px;
            padding: 12px 16px;
            border: none;
            border-radius: 6px;
            font-weight: 600;
            font-size: 13px;
            cursor: pointer;
            transition: all 0.3s ease;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 6px;
        }

        .gmi-action-btn.danger {
            background: var(--color-red-500, #dc2626);
            color: var(--color-white, #fff);
        }

        .gmi-action-btn.danger:hover {
            background: var(--color-red-600, #b91c1c);
        }

        .gmi-action-btn.primary {
            background: var(--color-blue-500, #3b82f6);
            color: var(--color-white, #fff);
        }

        .gmi-action-btn.primary:hover {
            background: var(--color-blue-600, #2563eb);
        }

        .gmi-action-btn.success {
            background: var(--color-green-500, #10b981);
            color: var(--color-white, #fff);
        }

        .gmi-action-btn.success:hover {
            background: var(--color-green-600, #059669);
        }

        .gmi-action-btn.neutral {
            background: var(--color-gray-100, #f3f4f6);
            color: var(--color-gray-600, #6b7280);
        }

        .gmi-action-btn.neutral:hover {
            background: var(--color-gray-200, #e5e7eb);
        }

        /* Progress popup */
        .gmi-progress-popup {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: var(--color-white, #fff);
            color: var(--color-gray-900, #111827);
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.3);
            z-index: 10000;
            min-width: 300px;
            text-align: center;
        }

        .gmi-progress-popup p {
            color: var(--color-gray-700, #374151);
        }

        .gmi-progress-bar-container {
            width: 100%;
            height: 20px;
            background: var(--color-gray-200, #e5e7eb);
            border-radius: 4px;
            margin-top: 10px;
            overflow: hidden;
        }

        .gmi-progress-bar {
            height: 100%;
            background: var(--color-blue-500, #3b82f6);
            transition: width 0.3s;
        }

        /* CSV Modal */
        .gmi-csv-modal {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: var(--color-white, #fff);
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.2);
            z-index: 10002;
            width: 500px;
            max-width: 90%;
            display: flex;
            flex-direction: column;
            gap: 10px;
        }

        .gmi-csv-modal h3 {
            margin: 0 0 10px 0;
            color: var(--color-gray-800, #1e293b);
            font-size: 1.25rem;
            font-weight: 600;
        }

        .gmi-csv-modal textarea {
            width: 100%;
            height: 300px;
            font-family: monospace;
            font-size: 12px;
            border: 1px solid var(--color-gray-300, #ccc);
            border-radius: 4px;
            resize: vertical;
            background: var(--color-white, #fff);
            color: var(--color-gray-900, #111827);
        }
    `;
    document.head.appendChild(style);

    // --- Helper Functions ---

    function getVirtualNow() {
        return Date.now() + CONFIG.timeOffset;
    }

    function waitForElement(selector, timeout = 10000) {
        return new Promise((resolve, reject) => {
            const startTime = Date.now();
            const checkInterval = setInterval(() => {
                const element = document.querySelector(selector);
                if (element) {
                    clearInterval(checkInterval);
                    resolve(element);
                } else if (Date.now() - startTime > timeout) {
                    clearInterval(checkInterval);
                    reject(new Error(`Element ${selector} not found within ${timeout}ms`));
                }
            }, 100);
        });
    }

    async function fetchUserProfile(targetUserId) {
        try {
            if (!targetUserId) { console.error("Missing targetId"); return null; }
            const response = await fetch('/GetUserProfile', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ "targetId": parseInt(targetUserId) })
            });
            if (!response.ok) throw new Error(`Server returned ${response.status}`);
            return await response.json();
        } catch (err) {
            console.error("Failed to fetch user profile:", err);
            return null;
        }
    }

    function showTooltip(x, y, text) {
        let tooltip = document.getElementById('custom-tooltip');
        if (!tooltip) {
            tooltip = document.createElement('div');
            tooltip.id = 'custom-tooltip';
            tooltip.className = 'tooltip-popup';
            document.body.appendChild(tooltip);
        }
        tooltip.textContent = text;
        tooltip.style.left = x + 10 + 'px';
        tooltip.style.top = y + 'px';
        tooltip.classList.add('visible');
        setTimeout(() => tooltip.classList.remove('visible'), 2000);
    }

    // --- XP Tracking Logic ---

    function parseGuildMembers() {
        const container = document.getElementById('guildMembersContainer');
        if (!container) return null;

        const members = {};
        const memberRows = container.querySelectorAll('div.flex.items-center.justify-between.p-2.rounded-md.bg-white.shadow-sm');

        memberRows.forEach(row => {
            const nameEl = row.querySelector('p.font-semibold');
            const xpEl = row.querySelector('p.text-xs.text-gray-500');

            if (nameEl && xpEl) {
                let fullName = nameEl.textContent.trim();
                const badge = nameEl.querySelector('span');
                if (badge) fullName = fullName.replace(badge.textContent, '').trim();
                
                const xpText = xpEl.textContent;
                const xpMatch = xpText.match(/([\d,.]+)\s*XP$/);
                
                let coords = null;
                const findBtn = row.querySelector('button[onclick^="goToGridLocation"]');
                if (findBtn) {
                    const match = findBtn.getAttribute('onclick').match(/goToGridLocation\((-?\d+),\s*(-?\d+)\)/);
                    if (match) coords = [parseInt(match[1]), parseInt(match[2])];
                }

                if (fullName && xpMatch) {
                    const xp = parseInt(xpMatch[1].replace(/[.,]/g, ''), 10);
                    // Key by numeric user ID extracted from trailing #12345 so name changes
                    // don't cause false LEFT/JOINED events.
                    const idMatch = fullName.match(/#(\d+)$/);
                    const key = idMatch ? idMatch[1] : fullName;
                    members[key] = { xp, coords, name: fullName };
                }
            }
        });
        return members;
    }

    function saveGuildSnapshot(members, forceNew = false) {
        const now = getVirtualNow();
        let history = GM_getValue('guild_xp_history', []);
        const lastEntry = history[history.length - 1];
        const lastBucketStart = lastEntry ? (lastEntry.bucketStartTime || lastEntry.timestamp) : 0;

        const newEntry = { timestamp: now, bucketStartTime: now, members: members };

        if (!forceNew && lastEntry && (now - lastBucketStart < CONFIG.minSnapshotInterval)) {
            newEntry.bucketStartTime = lastBucketStart;
            history[history.length - 1] = newEntry;
            if (CONFIG.debugMode) console.log('[Guild XP] Updated recent snapshot');
        } else {
            history.push(newEntry);
            console.log('[Guild XP] Created new snapshot');
        }

        if (history.length > CONFIG.maxSnapshots) history = history.slice(history.length - CONFIG.maxSnapshots);
        GM_setValue('guild_xp_history', history);
        return history;
    }

    function getXp(val) {
        if (typeof val === 'number') return val;
        if (val && typeof val === 'object' && val.xp !== undefined) return val.xp;
        return 0;
    }
    
    function getCoords(val) {
        if (val && typeof val === 'object' && val.coords) return val.coords;
        return null;
    }

    async function fetchAllGuildMembersData() {
        const currentMembers = parseGuildMembers();
        if (!currentMembers || Object.keys(currentMembers).length === 0) {
            alert('No guild members found. Please wait for members to load.');
            return null;
        }

        const memberNames = Object.keys(currentMembers);
        const allUsersData = [];
        let successCount = 0;
        let failCount = 0;

        const progressDiv = document.createElement('div');
        progressDiv.className = 'gmi-progress-popup';
        progressDiv.innerHTML = `
            <p style="font-weight: bold; margin-bottom: 10px;">Fetching guild member data...</p>
            <p id="progressText" style="font-size: 14px;">0/${memberNames.length}</p>
            <div class="gmi-progress-bar-container">
                <div id="progressBar" class="gmi-progress-bar" style="width: 0%;"></div>
            </div>
        `;
        document.body.appendChild(progressDiv);

        for (let i = 0; i < memberNames.length; i++) {
            const key = memberNames[i];
            // keys are now numeric userId strings (from parseGuildMembers); fall back to
            // legacy #ID extraction for old snapshots that still use full-name keys
            const userId = /^\d+$/.test(key) ? key : (() => {
                const m = key.match(/#(\d+)$/);
                return m ? m[1] : null;
            })();
            if (userId) {
                const data = await fetchUserProfile(userId);
                if (data) { allUsersData.push(data); successCount++; }
                else failCount++;
            } else {
                failCount++;
            }

            const progressPercent = ((i + 1) / memberNames.length) * 100;
            document.getElementById('progressBar').style.width = progressPercent + '%';
            document.getElementById('progressText').textContent = `${i + 1}/${memberNames.length} (${successCount} fetched)`;
        }

        const jsonString = JSON.stringify(allUsersData, null, 2);
        navigator.clipboard.writeText(jsonString).then(() => {
            progressDiv.innerHTML = `
                <p style="font-weight: bold; color: #10b981; margin-bottom: 5px;">✓ Success!</p>
                <p style="font-size: 14px; color: #666;">Fetched: ${successCount} users<br>Failed: ${failCount} users<br><br><strong>JSON copied to clipboard!</strong></p>
            `;
            setTimeout(() => progressDiv.remove(), 3000);
        }).catch((err) => {
            progressDiv.innerHTML = `<p style="font-weight: bold; color: #dc2626;">Error copying to clipboard!</p><p style="font-size: 12px; color: #666;">${err.message}</p>`;
            setTimeout(() => progressDiv.remove(), 3000);
        });

        return allUsersData;
    }

    function calculateXPChanges(oldMembers, newMembers) {
        const changes = [];
        for (const [id, oldVal] of Object.entries(oldMembers)) {
            const oldXp = getXp(oldVal);
            const oldName = oldVal && typeof oldVal === 'object' ? (oldVal.name || id) : id;
            if (newMembers.hasOwnProperty(id)) {
                const newVal = newMembers[id];
                const newXp = getXp(newVal);
                const diff = newXp - oldXp;
                const coords = getCoords(newVal) || getCoords(oldVal);
                const name = (newVal && typeof newVal === 'object' ? newVal.name : null) || oldName;
                changes.push({ type: 'gain', id, name, diff, oldXp, newXp, coords });
            } else {
                const coords = getCoords(oldVal);
                changes.push({ type: 'left', id, name: oldName, oldXp, coords });
            }
        }
        for (const [id, newVal] of Object.entries(newMembers)) {
            if (!oldMembers.hasOwnProperty(id)) {
                const newXp = getXp(newVal);
                const coords = getCoords(newVal);
                const name = newVal && typeof newVal === 'object' ? (newVal.name || id) : id;
                changes.push({ type: 'join', id, name, newXp, coords });
            }
        }
        return changes;
    }

    function getCoordinateColor(coords) {
        if (!coords || coords.length < 2) return { bg: '#f3f4f6', text: '#1f2937' };
        const x = coords[0];
        const y = coords[1];
        const distance = Math.sqrt(x * x + y * y);
        const distanceBand = Math.floor(distance / 25000);
        
        let baseColor;
        if (x >= 0 && y >= 0) {
            const intensity = Math.min(distanceBand * 3, 15);
            baseColor = `hsl(120, 50%, ${97 - intensity}%)`;
        } else if (x < 0 && y >= 0) {
            const intensity = Math.min(distanceBand * 3, 15);
            baseColor = `hsl(0, 50%, ${97 - intensity}%)`;
        } else if (x < 0 && y < 0) {
            const intensity = Math.min(distanceBand * 3, 15);
            baseColor = `hsl(240, 50%, ${97 - intensity}%)`;
        } else {
            const intensity = Math.min(distanceBand * 3, 15);
            baseColor = `hsl(30, 50%, ${97 - intensity}%)`;
        }
        return { bg: baseColor, text: '#1f2937' };
    }

    // --- XP Changes Section ---
    function ensureXPChangesSection() {
        const infoBtn = document.getElementById('infoTabBtn');
        if (!infoBtn) {
            if (document.getElementById('infoTab')) {
                console.log('[Guild XP] Could not find tab buttons, appending to infoTab instead');
                ensureXPChangesSectionLegacy();
            }
            return;
        }

        const tabNav = infoBtn.parentElement;
        if (document.getElementById('xpTrackerTabBtn')) return;

        const existingPanes = document.querySelectorAll('#xpTrackerPane');
        existingPanes.forEach(pane => pane.remove());

        const xpTabBtn = document.createElement('button');
        xpTabBtn.textContent = 'XP Tracker';
        xpTabBtn.id = 'xpTrackerTabBtn';
        xpTabBtn.className = infoBtn.className;
        xpTabBtn.classList.remove('text-blue-600', 'border-blue-500');
        xpTabBtn.classList.add('text-gray-500', 'border-transparent');
        xpTabBtn.style.borderBottom = '2px solid transparent';
        
        const xpTabPane = document.createElement('div');
        xpTabPane.id = 'xpTrackerPane';
        xpTabPane.style.display = 'none';
        xpTabPane.className = 'hidden guild-tab-content'; 
        
        const infoTab = document.getElementById('infoTab');
        const contentContainer = infoTab?.parentElement;

        if (!contentContainer) {
            console.log('[Guild XP] Could not find content container');
            ensureXPChangesSectionLegacy();
            return;
        }

        xpTabBtn.onclick = (e) => {
            e.preventDefault();
            e.stopPropagation();
            const allPanes = contentContainer.querySelectorAll('.guild-tab-content, [id$="Tab"], [id$="Pane"]');
            allPanes.forEach(pane => { pane.style.display = 'none'; pane.classList.add('hidden'); });
            const allBtns = tabNav.querySelectorAll('button');
            allBtns.forEach(btn => {
                btn.classList.remove('text-blue-600', 'border-blue-500');
                btn.classList.add('text-gray-500', 'border-transparent');
                btn.style.borderBottom = '2px solid transparent';
                btn.style.color = ''; 
            });
            xpTabPane.style.display = 'block';
            xpTabPane.classList.remove('hidden');
            xpTabBtn.classList.remove('text-gray-500', 'border-transparent');
            xpTabBtn.classList.add('text-blue-600', 'border-blue-500');
            xpTabBtn.style.borderBottom = '2px solid #3b82f6';
            xpTabBtn.style.color = '#3b82f6';
            renderXPChanges(xpTabPane);
        };

        const existingTabs = tabNav.querySelectorAll('button');
        existingTabs.forEach(btn => {
            if (btn.id === 'xpTrackerTabBtn' || btn.dataset.xpTrackerHooked) return;
            const originalOnClick = btn.onclick;
            btn.onclick = (e) => {
                xpTabPane.style.display = 'none';
                xpTabPane.classList.add('hidden');
                const allPanes = contentContainer.querySelectorAll('.guild-tab-content');
                allPanes.forEach(pane => { if (pane.id !== 'xpTrackerPane') pane.style.display = ''; });
                xpTabBtn.classList.remove('text-blue-600', 'border-blue-500');
                xpTabBtn.classList.add('text-gray-500', 'border-transparent');
                xpTabBtn.style.borderBottom = '2px solid transparent';
                xpTabBtn.style.color = '';
                if (originalOnClick) originalOnClick.call(btn, e);
            };
            btn.dataset.xpTrackerHooked = 'true';
        });

        tabNav.appendChild(xpTabBtn);
        contentContainer.appendChild(xpTabPane);

        const navObserver = new MutationObserver(() => {
            if (!document.getElementById('xpTrackerTabBtn')) tabNav.appendChild(xpTabBtn);
        });
        navObserver.observe(tabNav, { childList: true });
    }

    function ensureXPChangesSectionLegacy() {
        const infoTab = document.getElementById('infoTab');
        if (!infoTab || document.getElementById('xpChangesSection')) return;

        const section = document.createElement('div');
        section.id = 'xpChangesSection';
        section.className = 'xp-changes-section';

        const header = document.createElement('div');
        header.className = 'xp-changes-header';
        header.innerHTML = `<span>XP Changes Tracker</span><span class="toggle-icon">▼</span>`;
        
        const content = document.createElement('div');
        content.className = 'xp-changes-content hidden';
        content.id = 'xpChangesContent';

        header.onclick = () => {
            content.classList.toggle('hidden');
            const icon = header.querySelector('.toggle-icon');
            icon.style.transform = content.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(180deg)';
            if (!content.classList.contains('hidden')) renderXPChanges(content);
        };

        section.appendChild(header);
        section.appendChild(content);
        infoTab.appendChild(section);
    }

    function collapseOtherSections() {
        const messageSection = document.querySelector('.guild-message-section');
        if (messageSection) {
            const content = messageSection.querySelector('.guild-message-content');
            const toggle = messageSection.querySelector('.guild-message-toggle');
            if (content && !content.classList.contains('collapsed')) {
                content.classList.add('collapsed');
                toggle.classList.add('collapsed');
                document.getElementById('infoTab').classList.add('message-collapsed');
            }
        }
    }

    function expandOtherSections() {
        const messageSection = document.querySelector('.guild-message-section');
        if (messageSection) {
            const content = messageSection.querySelector('.guild-message-content');
            const toggle = messageSection.querySelector('.guild-message-toggle');
            if (content && content.classList.contains('collapsed')) {
                content.classList.remove('collapsed');
                toggle.classList.remove('collapsed');
                document.getElementById('infoTab').classList.remove('message-collapsed');
            }
        }
    }

    function exportToCSV(snapshots, currentMembers, fromVal, toVal) {
        // Determine which snapshots to compare based on current selection
        // If called from the button, we might need to pass these values or read them from DOM
        // But since this function was originally designed to dump EVERYTHING, let's adapt it
        // to dump the CURRENT VIEW if specific snapshots are provided, or EVERYTHING if not.
        
        let csvContent = '';

        if (fromVal !== undefined && toVal !== undefined) {
            // Export current view (comparison)
            const getSnapshot = (val) => val === 'current' ? { members: currentMembers } : snapshots[val];
            const fromData = getSnapshot(fromVal);
            const toData = getSnapshot(toVal);
            
            if (!fromData || !toData) return;

            const changes = calculateXPChanges(fromData.members, toData.members);
            
            // Sort (same as view)
            changes.sort((a, b) => {
                if (a.type === 'join') return -1;
                if (b.type === 'join') return 1;
                if (a.type === 'left') return 1;
                if (b.type === 'left') return -1;
                return b.diff - a.diff;
            });

            const csvRows = [
                ["Username", "Change Type", "XP Change", "Old XP", "New XP"],
                ...changes.map(c => {
                    const oldVal = c.oldXp || 0;
                    const newVal = c.newXp || 0;
                    const diff = c.diff !== undefined ? c.diff : (newVal - oldVal);
                    return [`"${c.name || c.id}"`, c.type, diff, oldVal, newVal];
                })
            ];
            csvContent = csvRows.map(e => e.join(",")).join("\n");

        } else {
            // Export Full History (Legacy behavior)
            let csv = 'Snapshot,Timestamp,User,XP\n';
            snapshots.forEach((snap, idx) => {
                const timestamp = new Date(snap.timestamp).toLocaleString();
                for (const [key, data] of Object.entries(snap.members)) {
                    const xp = getXp(data);
                    const displayName = (data && typeof data === 'object' && data.name) ? data.name : key;
                    csv += `${idx + 1},"${timestamp}","${displayName}",${xp}\n`;
                }
            });
            // Add current
            const now = new Date(getVirtualNow()).toLocaleString();
            for (const [key, data] of Object.entries(currentMembers)) {
                const xp = getXp(data);
                const displayName = (data && typeof data === 'object' && data.name) ? data.name : key;
                csv += `Current,"${now}","${displayName}",${xp}\n`;
            }
            csvContent = csv;
        }
        
        // Open CSV Modal
        const csvOverlay = document.createElement('div');
        csvOverlay.className = 'gmi-modal-overlay';
        csvOverlay.style.zIndex = '10001';
        csvOverlay.onclick = () => { csvOverlay.remove(); csvModal.remove(); };

        const csvModal = document.createElement('div');
        csvModal.className = 'gmi-csv-modal';

        const title = document.createElement('h3');
        title.textContent = 'CSV Export';

        const textarea = document.createElement('textarea');
        textarea.value = csvContent;
        textarea.readOnly = true;
        textarea.onclick = () => textarea.select();

        const btnRow = document.createElement('div');
        btnRow.style.display = 'flex';
        btnRow.style.justifyContent = 'flex-end';
        btnRow.style.gap = '10px';

        const copyBtn = document.createElement('button');
        copyBtn.innerHTML = '📋 Copy';
        copyBtn.className = 'control-button';
        copyBtn.onclick = () => {
            textarea.select();
            navigator.clipboard.writeText(csvContent).then(() => {
                const orig = copyBtn.innerHTML;
                copyBtn.innerHTML = '✅ Copied!';
                setTimeout(() => copyBtn.innerHTML = orig, 1000);
            });
        };

        const downloadBtn = document.createElement('button');
        downloadBtn.innerHTML = '💾 Download';
        downloadBtn.className = 'gmi-action-btn success';
        downloadBtn.onclick = () => {
            const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
            const link = document.createElement("a");
            if (link.download !== undefined) {
                const url = URL.createObjectURL(blob);
                link.setAttribute("href", url);
                link.setAttribute("download", `guild_xp_export_${Date.now()}.csv`);
                link.style.visibility = 'hidden';
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);
            }
        };

        const closeBtn = document.createElement('button');
        closeBtn.innerHTML = 'Close';
        closeBtn.className = 'control-button';
        closeBtn.onclick = () => { csvOverlay.remove(); csvModal.remove(); };

        btnRow.appendChild(copyBtn);
        btnRow.appendChild(downloadBtn);
        btnRow.appendChild(closeBtn);

        csvModal.appendChild(title);
        csvModal.appendChild(textarea);
        csvModal.appendChild(btnRow);

        document.body.appendChild(csvOverlay);
        document.body.appendChild(csvModal);
    }

    // --- History Pruning Functions ---

    function deleteAllHistory() {
        if (confirm('Delete ALL snapshots? This cannot be undone.')) {
            GM_setValue('guild_xp_history', []);
            return [];
        }
        return null;
    }

    function keepDailyHistory() {
        let history = GM_getValue('guild_xp_history', []);
        const dailyMap = new Map();

        // Group by day (YYYY-MM-DD)
        history.forEach(entry => {
            const date = new Date(entry.timestamp);
            const dayKey = date.toISOString().split('T')[0]; // YYYY-MM-DD
            
            // Keep the latest snapshot from each day
            if (!dailyMap.has(dayKey) || entry.timestamp > dailyMap.get(dayKey).timestamp) {
                dailyMap.set(dayKey, entry);
            }
        });

        const pruned = Array.from(dailyMap.values()).sort((a, b) => a.timestamp - b.timestamp);
        const removed = history.length - pruned.length;
        
        if (confirm(`This will keep only the latest snapshot from each day.\nSnapshots: ${history.length} → ${pruned.length} (removing ${removed}).\nContinue?`)) {
            GM_setValue('guild_xp_history', pruned);
            return pruned;
        }
        return null;
    }

    function keepWeeklyHistory() {
        let history = GM_getValue('guild_xp_history', []);
        const weeklyMap = new Map();

        // Group by week (ISO week)
        history.forEach(entry => {
            const date = new Date(entry.timestamp);
            const dayOfWeek = date.getUTCDay();
            const diff = date.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
            const weekStart = new Date(date.setUTCDate(diff));
            const weekKey = weekStart.toISOString().split('T')[0]; // Start of week (YYYY-MM-DD)
            
            // Keep the latest snapshot from each week
            if (!weeklyMap.has(weekKey) || entry.timestamp > weeklyMap.get(weekKey).timestamp) {
                weeklyMap.set(weekKey, entry);
            }
        });

        const pruned = Array.from(weeklyMap.values()).sort((a, b) => a.timestamp - b.timestamp);
        const removed = history.length - pruned.length;
        
        if (confirm(`This will keep only the latest snapshot from each week.\nSnapshots: ${history.length} → ${pruned.length} (removing ${removed}).\nContinue?`)) {
            GM_setValue('guild_xp_history', pruned);
            return pruned;
        }
        return null;
    }

    function deleteHistoryOlderThan7Days() {
        let history = GM_getValue('guild_xp_history', []);
        const now = getVirtualNow();
        const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;

        const pruned = history.filter(entry => (now - entry.timestamp) <= sevenDaysMs);
        const removed = history.length - pruned.length;
        
        if (confirm(`This will delete all snapshots older than 7 days.\nSnapshots: ${history.length} → ${pruned.length} (removing ${removed}).\nContinue?`)) {
            GM_setValue('guild_xp_history', pruned);
            return pruned;
        }
        return null;
    }

    function renderCleanHistoryMenu(container, onClose) {
        const overlay = document.createElement('div');
        overlay.className = 'gmi-modal-overlay';

        const modal = document.createElement('div');
        modal.className = 'gmi-modal';
        modal.style.minWidth = '350px';

        const title = document.createElement('h3');
        title.textContent = 'Clean History Options';
        modal.appendChild(title);

        const deleteAllBtn = document.createElement('button');
        deleteAllBtn.innerHTML = 'Select All Snapshots for Deletion';
        deleteAllBtn.className = 'gmi-modal-btn danger';
        deleteAllBtn.onclick = () => {
            const result = deleteAllHistory();
            if (result !== null) {
                onClose(result);
            }
        };
        modal.appendChild(deleteAllBtn);

        const keepDailyBtn = document.createElement('button');
        keepDailyBtn.innerHTML = 'Keep One Snapshot Per Day (Latest)';
        keepDailyBtn.className = 'gmi-modal-btn warning';
        keepDailyBtn.onclick = () => {
            const result = keepDailyHistory();
            if (result !== null) {
                onClose(result);
            }
        };
        modal.appendChild(keepDailyBtn);

        const keepWeeklyBtn = document.createElement('button');
        keepWeeklyBtn.innerHTML = 'Keep One Snapshot Per Week (Latest)';
        keepWeeklyBtn.className = 'gmi-modal-btn primary';
        keepWeeklyBtn.onclick = () => {
            const result = keepWeeklyHistory();
            if (result !== null) {
                onClose(result);
            }
        };
        modal.appendChild(keepWeeklyBtn);

        const delete7DaysBtn = document.createElement('button');
        delete7DaysBtn.innerHTML = 'Select Snapshots Older Than 7 Days for Deletion';
        delete7DaysBtn.className = 'gmi-modal-btn';
        delete7DaysBtn.style.color = 'var(--color-purple-500, #8b5cf6)';
        delete7DaysBtn.onclick = () => {
            const result = deleteHistoryOlderThan7Days();
            if (result !== null) {
                onClose(result);
            }
        };
        modal.appendChild(delete7DaysBtn);

        const cancelBtn = document.createElement('button');
        cancelBtn.innerHTML = 'Cancel';
        cancelBtn.className = 'gmi-modal-btn';
        cancelBtn.style.marginTop = '15px';
        cancelBtn.style.borderTop = '1px solid var(--color-gray-300, #ddd)';
        cancelBtn.style.paddingTop = '15px';
        cancelBtn.onclick = () => {
            overlay.remove();
            modal.remove();
        };
        modal.appendChild(cancelBtn);

        overlay.onclick = () => {
            overlay.remove();
            modal.remove();
        };

        document.body.appendChild(overlay);
        document.body.appendChild(modal);
    }

    // --- Export/Import Functions ---

    function exportSnapshots() {
        let history = GM_getValue('guild_xp_history', []);
        if (history.length === 0) {
            alert('No snapshots to export.');
            return;
        }

        const exportData = {
            version: 1,
            exportDate: new Date().toISOString(),
            snapshotCount: history.length,
            snapshots: history
        };

        const jsonString = JSON.stringify(exportData, null, 2);
        const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
        const link = document.createElement('a');
        const url = URL.createObjectURL(blob);
        link.setAttribute('href', url);
        link.setAttribute('download', `guild_snapshots_${Date.now()}.json`);
        link.style.visibility = 'hidden';
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        
        alert(`Exported ${history.length} snapshots successfully.`);
    }

    function importSnapshots() {
        if (!confirm('WARNING: Importing will ERASE all current snapshots and replace them with the imported data.\n\nAre you sure you want to continue?')) {
            return;
        }

        const input = document.createElement('input');
        input.type = 'file';
        input.accept = '.json';
        input.onchange = (e) => {
            const file = e.target.files[0];
            if (!file) return;

            const reader = new FileReader();
            reader.onload = (event) => {
                try {
                    const importData = JSON.parse(event.target.result);
                    
                    if (!importData.snapshots || !Array.isArray(importData.snapshots)) {
                        alert('Invalid snapshot file format.');
                        return;
                    }

                    if (importData.snapshots.length === 0) {
                        alert('No snapshots found in file.');
                        return;
                    }

                    GM_setValue('guild_xp_history', importData.snapshots);
                    alert(`Successfully imported ${importData.snapshots.length} snapshots.`);
                    
                    // Refresh the UI if open
                    const xpTrackerPane = document.getElementById('xpTrackerPane');
                    if (xpTrackerPane && xpTrackerPane.style.display !== 'none') {
                        renderXPChanges(xpTrackerPane);
                    }
                } catch (error) {
                    alert(`Error importing file: ${error.message}`);
                }
            };
            reader.readAsText(file);
        };
        input.click();
    }

    function renderCleanHistoryModal(onClose) {
        let history = GM_getValue('guild_xp_history', []);
        const selectedIndices = new Set();

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

        const modal = document.createElement('div');
        modal.className = 'gmi-modal';
        modal.style.cssText = `
            width: 90%;
            max-width: 600px;
            max-height: 80vh;
            display: flex;
            flex-direction: column;
        `;

        // Header
        const header = document.createElement('div');
        header.style.cssText = 'margin-bottom: 15px; border-bottom: 2px solid var(--color-gray-200, #e5e7eb); padding-bottom: 10px;';

        const title = document.createElement('h3');
        title.textContent = 'Manage Snapshots';
        header.appendChild(title);

        const info = document.createElement('p');
        info.textContent = `Total snapshots: ${history.length}`;
        info.style.cssText = 'margin: 0; font-size: 12px; color: var(--color-gray-500, #6b7280);';
        header.appendChild(info);

        modal.appendChild(header);

        // Max snapshots control
        const maxSnapshotsDiv = document.createElement('div');
        maxSnapshotsDiv.style.cssText = `
            display: flex;
            align-items: center;
            gap: 10px;
            margin-bottom: 15px;
            padding: 10px;
            background: #f9fafb;
            border-radius: 4px;
            border: 1px solid var(--color-gray-200, #e5e7eb);
        `;

        const maxLabel = document.createElement('label');
        maxLabel.textContent = 'Max Snapshots:';
        maxLabel.className = 'gmi-modal-label';
        maxSnapshotsDiv.appendChild(maxLabel);

        const maxSelect = document.createElement('select');
        maxSelect.className = 'gmi-modal-select';

        const presets = [50, 100, 250, 500, 750, 1000, 2500, 5000, 10000];
        presets.forEach(value => {
            const option = document.createElement('option');
            option.value = value;
            option.textContent = value;
            if (value === CONFIG.maxSnapshots) option.selected = true;
            maxSelect.appendChild(option);
        });

        maxSelect.onchange = (e) => {
            const newMax = parseInt(e.target.value);
            CONFIG.maxSnapshots = newMax;
            GM_setValue('max_snapshots', newMax);
        };

        maxSnapshotsDiv.appendChild(maxSelect);
        modal.appendChild(maxSnapshotsDiv);

        // Snapshot Interval Control
        const intervalDiv = document.createElement('div');
        intervalDiv.className = 'gmi-modal-section';
        intervalDiv.style.cssText = `
            display: grid;
            grid-template-columns: 150px 1fr;
            align-items: center;
            gap: 12px;
        `;

        const intervalLabel = document.createElement('label');
        intervalLabel.textContent = 'Snapshot Interval:';
        intervalLabel.className = 'gmi-modal-label';
        intervalLabel.style.whiteSpace = 'nowrap';
        intervalDiv.appendChild(intervalLabel);

        const intervalSelect = document.createElement('select');
        intervalSelect.id = 'snapshotIntervalSelect';
        intervalSelect.className = 'gmi-modal-select';
        intervalSelect.style.cssText = `
            padding: 8px 12px;
            border: 2px solid #d1d5db;
            border-radius: 6px;
            background: white;
            font-size: 13px;
            cursor: pointer;
            color: #374151;
            transition: all 0.2s ease;
            font-weight: 500;
            max-width: 280px;
        `;
        
        // Add hover and focus styles through a style tag
        intervalSelect.onmouseover = () => {
            intervalSelect.style.borderColor = '#3b82f6';
            intervalSelect.style.boxShadow = '0 0 4px rgba(59, 130, 246, 0.2)';
        };
        intervalSelect.onmouseout = () => {
            if (document.activeElement !== intervalSelect) {
                intervalSelect.style.borderColor = '#d1d5db';
                intervalSelect.style.boxShadow = 'none';
            }
        };
        intervalSelect.onfocus = () => {
            intervalSelect.style.borderColor = '#3b82f6';
            intervalSelect.style.boxShadow = '0 0 6px rgba(59, 130, 246, 0.3)';
        };
        intervalSelect.onblur = () => {
            intervalSelect.style.borderColor = '#d1d5db';
            intervalSelect.style.boxShadow = 'none';
        };

        const hourlyOpt = document.createElement('option');
        hourlyOpt.value = 'hourly';
        hourlyOpt.textContent = 'Hourly (1h)';
        intervalSelect.appendChild(hourlyOpt);

        const twelveHourOpt = document.createElement('option');
        twelveHourOpt.value = '12h';
        twelveHourOpt.textContent = '12 Hours';
        intervalSelect.appendChild(twelveHourOpt);

        const twentyFourHourOpt = document.createElement('option');
        twentyFourHourOpt.value = '24h';
        twentyFourHourOpt.textContent = '24 Hours';
        intervalSelect.appendChild(twentyFourHourOpt);

        const customOpt = document.createElement('option');
        customOpt.value = 'custom';
        customOpt.textContent = `Custom (${formatSnapshotInterval(CONFIG.minSnapshotInterval)})`;
        intervalSelect.appendChild(customOpt);

        // Set current value
        updateSnapshotIntervalDropdown(intervalSelect);

        intervalSelect.onchange = (e) => {
            const selectedValue = e.target.value;
            if (selectedValue === 'hourly') {
                CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.HOURLY;
            } else if (selectedValue === '12h') {
                CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.TWELVE_HOURS;
            } else if (selectedValue === '24h') {
                CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.TWENTY_FOUR_HOURS;
            } else if (selectedValue === 'custom') {
                const userInput = prompt("Enter custom snapshot interval in minutes:", (CONFIG.minSnapshotInterval / (60 * 1000)).toString());
                if (userInput !== null && userInput.trim() !== '') {
                    const minutes = parseFloat(userInput);
                    if (!isNaN(minutes) && minutes > 0) {
                        CONFIG.minSnapshotInterval = minutes * 60 * 1000;
                        const customOption = intervalSelect.querySelector('option[value="custom"]');
                        if (customOption) {
                            customOption.textContent = `Custom (${formatSnapshotInterval(CONFIG.minSnapshotInterval)})`;
                        }
                    } else {
                        alert("Invalid input. Please enter a positive number.");
                        updateSnapshotIntervalDropdown(intervalSelect);
                        return;
                    }
                } else {
                    updateSnapshotIntervalDropdown(intervalSelect);
                    return;
                }
            }

            // Persist the change
            GM_setValue('min_snapshot_interval', CONFIG.minSnapshotInterval);
        };

        intervalDiv.appendChild(intervalSelect);
        modal.appendChild(intervalDiv);

        // Track which preset option is selected (null = none, or the option name)
        let selectedPreset = null;

        // Shortcut options - mutually exclusive checkboxes + Select All toggle
        const shortcutsDiv = document.createElement('div');
        shortcutsDiv.style.cssText = `
            display: grid;
            grid-template-columns: 1fr 1fr;
            gap: 8px;
            margin-bottom: 15px;
        `;

        const checkboxInputStyle = `
            width: 16px;
            height: 16px;
            margin-right: 8px;
            cursor: pointer;
            accent-color: var(--color-blue-500, #3b82f6);
        `;

        // Helper function to update preset selection
        function updatePresetSelection(newPreset) {
            selectedPreset = selectedPreset === newPreset ? null : newPreset;
            
            // Clear the selection if switching presets
            selectedIndices.clear();
            
            if (selectedPreset === 'all') {
                // Select all snapshots
                if (history.length === 0) {
                    alert('No snapshots to select.');
                    selectedPreset = null;
                } else {
                    for (let i = 0; i < history.length; i++) {
                        selectedIndices.add(i);
                    }
                }
            } else if (selectedPreset === 'daily') {
                // Keep daily
                const dailyMap = new Map();
                history.forEach((entry, idx) => {
                    const date = new Date(entry.timestamp);
                    const dayKey = date.toISOString().split('T')[0];
                    if (!dailyMap.has(dayKey)) {
                        dailyMap.set(dayKey, []);
                    }
                    dailyMap.get(dayKey).push(idx);
                });
                dailyMap.forEach(indices => {
                    for (let i = 0; i < indices.length - 1; i++) {
                        selectedIndices.add(indices[i]);
                    }
                });
            } else if (selectedPreset === 'weekly') {
                // Keep weekly
                const weeklyMap = new Map();
                history.forEach((entry, idx) => {
                    const date = new Date(entry.timestamp);
                    const dayOfWeek = date.getUTCDay();
                    const diff = date.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
                    const weekStart = new Date(date.setUTCDate(diff));
                    const weekKey = weekStart.toISOString().split('T')[0];
                    if (!weeklyMap.has(weekKey)) {
                        weeklyMap.set(weekKey, []);
                    }
                    weeklyMap.get(weekKey).push(idx);
                });
                weeklyMap.forEach(indices => {
                    for (let i = 0; i < indices.length - 1; i++) {
                        selectedIndices.add(indices[i]);
                    }
                });
            } else if (selectedPreset === '7days') {
                // 7+ days old
                const now = getVirtualNow();
                const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
                history.forEach((entry, idx) => {
                    if ((now - entry.timestamp) > sevenDaysMs) {
                        selectedIndices.add(idx);
                    }
                });
            }
            
            renderCheckboxList();
            updateCheckboxStates();
        }

        function updateCheckboxStates() {
            allCheckbox.checked = selectedPreset === 'all';
            dailyCheckbox.checked = selectedPreset === 'daily';
            weeklyCheckbox.checked = selectedPreset === 'weekly';
            deleteOldCheckbox.checked = selectedPreset === '7days';
        }

        // All snapshots checkbox
        const allOption = document.createElement('label');
        allOption.className = 'gmi-checkbox-option';
        allOption.style.color = 'var(--color-red-500, #ef4444)';
        const allCheckbox = document.createElement('input');
        allCheckbox.type = 'checkbox';
        allCheckbox.style.cssText = checkboxInputStyle;
        const allLabel = document.createElement('span');
        allLabel.textContent = 'Select All';
        allLabel.style.cssText = 'user-select: none;';
        allOption.appendChild(allCheckbox);
        allOption.appendChild(allLabel);
        allOption.onclick = (e) => {
            if (e.target === allCheckbox) updatePresetSelection('all');
        };
        allOption.onmouseover = (e) => e.currentTarget.style.borderColor = 'var(--color-red-500, #ef4444)';
        allOption.onmouseout = (e) => e.currentTarget.style.borderColor = selectedPreset === 'all' ? 'var(--color-red-500, #ef4444)' : 'var(--color-gray-300, #ddd)';
        shortcutsDiv.appendChild(allOption);

        // Keep daily checkbox
        const dailyOption = document.createElement('label');
        dailyOption.className = 'gmi-checkbox-option';
        dailyOption.style.color = 'var(--color-yellow-500, #f59e0b)';
        const dailyCheckbox = document.createElement('input');
        dailyCheckbox.type = 'checkbox';
        dailyCheckbox.style.cssText = checkboxInputStyle;
        dailyCheckbox.style.accentColor = 'var(--color-yellow-500, #f59e0b)';
        const dailyLabel = document.createElement('span');
        dailyLabel.textContent = 'Keep One Per Day';
        dailyLabel.style.cssText = 'user-select: none;';
        dailyOption.appendChild(dailyCheckbox);
        dailyOption.appendChild(dailyLabel);
        dailyOption.onclick = (e) => {
            if (e.target === dailyCheckbox) updatePresetSelection('daily');
        };
        dailyOption.onmouseover = (e) => e.currentTarget.style.borderColor = 'var(--color-yellow-500, #f59e0b)';
        dailyOption.onmouseout = (e) => e.currentTarget.style.borderColor = selectedPreset === 'daily' ? 'var(--color-yellow-500, #f59e0b)' : 'var(--color-gray-300, #ddd)';
        shortcutsDiv.appendChild(dailyOption);

        // Keep weekly checkbox
        const weeklyOption = document.createElement('label');
        weeklyOption.className = 'gmi-checkbox-option';
        weeklyOption.style.color = 'var(--color-blue-500, #3b82f6)';
        const weeklyCheckbox = document.createElement('input');
        weeklyCheckbox.type = 'checkbox';
        weeklyCheckbox.style.cssText = checkboxInputStyle;
        const weeklyLabel = document.createElement('span');
        weeklyLabel.textContent = 'Keep One Per Week';
        weeklyLabel.style.cssText = 'user-select: none;';
        weeklyOption.appendChild(weeklyCheckbox);
        weeklyOption.appendChild(weeklyLabel);
        weeklyOption.onclick = (e) => {
            if (e.target === weeklyCheckbox) updatePresetSelection('weekly');
        };
        weeklyOption.onmouseover = (e) => e.currentTarget.style.borderColor = 'var(--color-blue-500, #3b82f6)';
        weeklyOption.onmouseout = (e) => e.currentTarget.style.borderColor = selectedPreset === 'weekly' ? 'var(--color-blue-500, #3b82f6)' : 'var(--color-gray-300, #ddd)';
        shortcutsDiv.appendChild(weeklyOption);

        // 7+ days old checkbox
        const deleteOldOption = document.createElement('label');
        deleteOldOption.className = 'gmi-checkbox-option';
        deleteOldOption.style.color = 'var(--color-purple-500, #8b5cf6)';
        const deleteOldCheckbox = document.createElement('input');
        deleteOldCheckbox.type = 'checkbox';
        deleteOldCheckbox.style.cssText = checkboxInputStyle;
        deleteOldCheckbox.style.accentColor = 'var(--color-purple-500, #8b5cf6)';
        const deleteOldLabel = document.createElement('span');
        deleteOldLabel.textContent = 'Delete 7+ Days Old';
        deleteOldLabel.style.cssText = 'user-select: none;';
        deleteOldOption.appendChild(deleteOldCheckbox);
        deleteOldOption.appendChild(deleteOldLabel);
        deleteOldOption.onclick = (e) => {
            if (e.target === deleteOldCheckbox) updatePresetSelection('7days');
        };
        deleteOldOption.onmouseover = (e) => e.currentTarget.style.borderColor = 'var(--color-purple-500, #8b5cf6)';
        deleteOldOption.onmouseout = (e) => e.currentTarget.style.borderColor = selectedPreset === '7days' ? 'var(--color-purple-500, #8b5cf6)' : 'var(--color-gray-300, #ddd)';
        shortcutsDiv.appendChild(deleteOldOption);

        modal.appendChild(shortcutsDiv);

        // Snapshot list container
        const listContainer = document.createElement('div');
        listContainer.className = 'gmi-snapshot-list';
        listContainer.style.cssText = `
            flex: 1;
            overflow-y: auto;
        `;
        modal.appendChild(listContainer);

        function renderCheckboxList() {
            listContainer.innerHTML = '';
            
            if (history.length === 0) {
                listContainer.innerHTML = '<p style="color: #6b7280; text-align: center; padding: 20px;">No snapshots available.</p>';
                return;
            }

            let currentDayKey = null;
            let useAltColor = false;

            history.forEach((entry, idx) => {
                const item = document.createElement('div');
                const isSelected = selectedIndices.has(idx);
                
                // Check if date changed
                const entryDate = new Date(entry.timestamp);
                const entryDayKey = entryDate.toISOString().split('T')[0]; // YYYY-MM-DD
                if (entryDayKey !== currentDayKey) {
                    currentDayKey = entryDayKey;
                    useAltColor = !useAltColor; // Toggle color when day changes
                }
                
                item.className = isSelected ? 'gmi-snapshot-item selected' : 'gmi-snapshot-item';
                if (!isSelected && useAltColor) {
                    item.style.background = 'var(--color-gray-100, #f3f4f6)';
                }

                const checkbox = document.createElement('input');
                checkbox.type = 'checkbox';
                checkbox.checked = selectedIndices.has(idx);
                checkbox.style.cssText = 'margin-right: 10px; cursor: pointer; accent-color: var(--color-blue-500, #3b82f6);';
                checkbox.onchange = (e) => {
                    if (e.target.checked) {
                        selectedIndices.add(idx);
                    } else {
                        selectedIndices.delete(idx);
                    }
                    renderCheckboxList();
                };
                item.appendChild(checkbox);

                const label = document.createElement('label');
                label.style.cssText = `flex: 1; cursor: pointer; font-size: 12px; color: ${isSelected ? 'var(--color-red-700, #991b1b)' : 'var(--color-gray-700, #374151)'}; ${isSelected ? 'text-decoration: line-through;' : ''}`;
                label.onclick = () => {
                    checkbox.checked = !checkbox.checked;
                    if (checkbox.checked) {
                        selectedIndices.add(idx);
                    } else {
                        selectedIndices.delete(idx);
                    }
                    renderCheckboxList();
                };

                const timestamp = new Date(entry.timestamp);
                const memberCount = Object.keys(entry.members).length;
                label.innerHTML = `
                    <span style="font-weight: bold;">${idx + 1})</span>
                    ${timestamp.toLocaleString()} 
                    <span style="color: ${isSelected ? 'var(--color-red-600, #b91c1c)' : 'var(--color-gray-500, #6b7280)'};">(${memberCount} members)</span>
                `;
                item.appendChild(label);

                listContainer.appendChild(item);
            });
        }

        renderCheckboxList();

        // Bottom buttons
        const buttonDiv = document.createElement('div');
        buttonDiv.style.cssText = `
            display: flex;
            gap: 10px;
            flex-wrap: wrap;
            border-top: 1px solid var(--color-gray-200, #e5e7eb);
            padding-top: 15px;
        `;

        const deleteSelectedBtn = document.createElement('button');
        deleteSelectedBtn.innerHTML = '🗑️ Delete Selected';
        deleteSelectedBtn.className = 'gmi-action-btn danger';
        deleteSelectedBtn.onclick = () => {
            if (selectedIndices.size === 0) {
                alert('No snapshots selected.');
                return;
            }
            const newHistory = history.filter((_, idx) => !selectedIndices.has(idx));
            const deleted = history.length - newHistory.length;
            if (confirm(`Delete ${deleted} snapshot(s)?`)) {
                GM_setValue('guild_xp_history', newHistory);
                overlay.remove();
                modal.remove();
                onClose(newHistory);
            }
        };
        buttonDiv.appendChild(deleteSelectedBtn);

        const exportBtn = document.createElement('button');
        exportBtn.innerHTML = '💾 Export Snapshots';
        exportBtn.className = 'gmi-action-btn primary';
        exportBtn.onclick = () => {
            exportSnapshots();
        };
        buttonDiv.appendChild(exportBtn);

        const importBtn = document.createElement('button');
        importBtn.innerHTML = '📥 Import Snapshots';
        importBtn.className = 'gmi-action-btn success';
        importBtn.onclick = () => {
            importSnapshots();
        };
        buttonDiv.appendChild(importBtn);

        const cancelBtn = document.createElement('button');
        cancelBtn.innerHTML = '✕ Close';
        cancelBtn.className = 'gmi-action-btn neutral';
        cancelBtn.onclick = () => {
            overlay.remove();
            modal.remove();
        };
        buttonDiv.appendChild(cancelBtn);

        modal.appendChild(buttonDiv);

        overlay.onclick = () => {
            overlay.remove();
            modal.remove();
        };

        document.body.appendChild(overlay);
        document.body.appendChild(modal);
    }

    function renderXPChanges(container) {
        container.innerHTML = '';
        container.style.display = 'flex';
        container.style.flexDirection = 'column';
        container.style.height = '100%';
        
        const currentMembers = parseGuildMembers();
        let history = GM_getValue('guild_xp_history', []);
        
        if (!currentMembers || Object.keys(currentMembers).length === 0) {
            container.innerHTML = '<p class="text-gray-500">Please wait for members to load...</p>';
            return;
        }

        // --- Controls ---
        const controls = document.createElement('div');
        controls.style.marginBottom = '15px';
        controls.style.display = 'flex';
        controls.style.flexDirection = 'column';
        controls.style.gap = '10px';

        // Snapshot Button + Action Buttons
        const snapRow = document.createElement('div');
        snapRow.style.display = 'flex';
        snapRow.style.justifyContent = 'flex-end';
        snapRow.style.gap = '8px';
        snapRow.style.flexWrap = 'wrap';

        const snapBtn = document.createElement('button');
        snapBtn.innerHTML = '📷 Take a Snapshot';
        snapBtn.className = 'control-button';
        snapBtn.onclick = () => {
            history = saveGuildSnapshot(currentMembers, true);
            renderXPChanges(container);
        };
        snapRow.appendChild(snapBtn);

        const csvBtn = document.createElement('button');
        csvBtn.innerHTML = '📥 Export CSV';
        csvBtn.className = 'control-button';
        csvBtn.onclick = () => {
            // Pass current selection to export function
            exportToCSV(history, currentMembers, fromSelect.value, toSelect.value);
        };
        snapRow.appendChild(csvBtn);

        const exportAllDataBtn = document.createElement('button');
        exportAllDataBtn.innerHTML = '🎨 Export All User Data';
        exportAllDataBtn.className = 'control-button';
        exportAllDataBtn.style.color = '#a855f7';
        exportAllDataBtn.title = 'Fetch and export all guild members\' data (including colors) as JSON';
        exportAllDataBtn.onclick = async () => {
            exportAllDataBtn.disabled = true;
            exportAllDataBtn.style.opacity = '0.5';
            await fetchAllGuildMembersData();
            exportAllDataBtn.disabled = false;
            exportAllDataBtn.style.opacity = '1';
        };
        snapRow.appendChild(exportAllDataBtn);

        const cleanBtn = document.createElement('button');
        cleanBtn.innerHTML = '🧹 Manage History';
        cleanBtn.className = 'control-button';
        cleanBtn.style.color = '#ef4444';
        cleanBtn.onclick = () => {
            renderCleanHistoryModal((newHistory) => {
                history = newHistory;
                renderXPChanges(container);
            });
        };
        snapRow.appendChild(cleanBtn);

        controls.appendChild(snapRow);

        // Selectors
        const getOptions = () => {
            const snaps = history.map((entry, index) => ({
                label: `${index + 1}) ${new Date(entry.timestamp).toLocaleString()}`,
                value: index,
                members: entry.members
            }));
            const curr = {
                label: `Now (${new Date(getVirtualNow()).toLocaleString()})`,
                value: 'current',
                members: currentMembers
            };
            return { snaps, curr, all: [...snaps, curr] };
        };

        let { snaps: snapshots, curr: currentSnapshot, all: allOptions } = getOptions();

        // Filter buttons
        const filterRow = document.createElement('div');
        filterRow.style.display = 'flex';
        filterRow.style.gap = '8px';
        filterRow.style.flexWrap = 'wrap';
        
        let filterMode = 'all'; // 'all', 'active', 'inactive', 'in-territory', 'out-of-territory'

        const clearAllFilterActive = () => {
            allBtn.classList.remove('active');
            activeBtn.classList.remove('active');
            inactiveBtn.classList.remove('active');
            inTerritoryBtn.classList.remove('active');
            outOfTerritoryBtn.classList.remove('active');
        };

        const allBtn = document.createElement('button');
        allBtn.innerHTML = 'Show All';
        allBtn.className = 'control-button active';
        allBtn.onclick = () => {
            filterMode = 'all';
            clearAllFilterActive();
            allBtn.classList.add('active');
            updateTable();
        };
        filterRow.appendChild(allBtn);
        
        const activeBtn = document.createElement('button');
        activeBtn.innerHTML = 'Active';
        activeBtn.className = 'control-button';
        activeBtn.onclick = () => {
            filterMode = 'active';
            clearAllFilterActive();
            activeBtn.classList.add('active');
            updateTable();
        };
        filterRow.appendChild(activeBtn);
        
        const inactiveBtn = document.createElement('button');
        inactiveBtn.innerHTML = 'Inactive';
        inactiveBtn.className = 'control-button';
        inactiveBtn.onclick = () => {
            filterMode = 'inactive';
            clearAllFilterActive();
            inactiveBtn.classList.add('active');
            updateTable();
        };
        filterRow.appendChild(inactiveBtn);

        const inTerritoryBtn = document.createElement('button');
        inTerritoryBtn.innerHTML = '🟦 In Territory';
        inTerritoryBtn.className = 'control-button xp-territory-filter-btn';
        inTerritoryBtn.style.display = playersVisible ? '' : 'none';
        inTerritoryBtn.onclick = () => {
            filterMode = 'in-territory';
            clearAllFilterActive();
            inTerritoryBtn.classList.add('active');
            updateTable();
        };
        filterRow.appendChild(inTerritoryBtn);

        const outOfTerritoryBtn = document.createElement('button');
        outOfTerritoryBtn.innerHTML = '🟥 Out of Territory';
        outOfTerritoryBtn.className = 'control-button xp-territory-filter-btn';
        outOfTerritoryBtn.style.display = playersVisible ? '' : 'none';
        outOfTerritoryBtn.onclick = () => {
            filterMode = 'out-of-territory';
            clearAllFilterActive();
            outOfTerritoryBtn.classList.add('active');
            updateTable();
        };
        filterRow.appendChild(outOfTerritoryBtn);

        controls.appendChild(filterRow);

        const row1 = document.createElement('div');
        row1.style.display = 'flex';
        row1.style.gap = '10px';
        row1.style.alignItems = 'center';
        row1.style.flexWrap = 'wrap';

        const fromSelect = document.createElement('select');
        fromSelect.style.flex = '1';
        fromSelect.style.padding = '4px';
        fromSelect.style.border = '2px solid #3b82f6';
        fromSelect.style.borderRadius = '4px';
        
        const toSelect = document.createElement('select');
        toSelect.style.flex = '1';
        toSelect.style.padding = '4px';
        toSelect.style.border = '2px solid #3b82f6';
        toSelect.style.borderRadius = '4px';

        // Populate
        allOptions.forEach(opt => {
            fromSelect.add(new Option(opt.label, opt.value));
            toSelect.add(new Option(opt.label, opt.value));
        });

        // Defaults
        if (snapshots.length >= 1) {
            fromSelect.value = snapshots[snapshots.length - 1].value;
        } else {
            fromSelect.value = 'current';
        }
        toSelect.value = 'current';

        row1.appendChild(document.createTextNode('From:'));
        row1.appendChild(fromSelect);

        // Delete "From" button
        const deleteFromBtn = document.createElement('button');
        deleteFromBtn.className = 'trash-btn';
        deleteFromBtn.innerHTML = '🗑️';
        deleteFromBtn.title = 'Delete this snapshot';
        deleteFromBtn.onclick = () => {
            const snapIndex = parseInt(fromSelect.value);
            if (snapIndex >= 0 && snapIndex < history.length) {
                if (confirm('Delete this snapshot?')) {
                    history.splice(snapIndex, 1);
                    GM_setValue('guild_xp_history', history);
                    renderXPChanges(container);
                }
            }
        };
        row1.appendChild(deleteFromBtn);

        row1.appendChild(document.createTextNode('To:'));
        row1.appendChild(toSelect);

        // Delete "To" button
        const deleteToBtn = document.createElement('button');
        deleteToBtn.className = 'trash-btn';
        deleteToBtn.innerHTML = '🗑️';
        deleteToBtn.title = 'Delete this snapshot';
        deleteToBtn.onclick = () => {
            const snapIndex = parseInt(toSelect.value);
            if (snapIndex >= 0 && snapIndex < history.length) {
                if (confirm('Delete this snapshot?')) {
                    history.splice(snapIndex, 1);
                    GM_setValue('guild_xp_history', history);
                    renderXPChanges(container);
                }
            }
        };
        row1.appendChild(deleteToBtn);

        controls.appendChild(row1);

        // Results Area
        const resultsDiv = document.createElement('div');
        resultsDiv.style.flex = '1';
        resultsDiv.style.overflowY = 'auto';
        resultsDiv.style.minHeight = '0'; // Crucial for flexbox scrolling
        resultsDiv.style.border = '1px solid #e5e7eb';
        resultsDiv.style.borderRadius = '0.5rem';

        const updateTable = () => {
            resultsDiv.innerHTML = '';
            const fromVal = fromSelect.value;
            const toVal = toSelect.value;
            
            const fromData = fromVal === 'current' ? currentSnapshot : snapshots[fromVal];
            const toData = toVal === 'current' ? currentSnapshot : snapshots[toVal];
            
            if (!fromData || !toData) return;

            let changes = calculateXPChanges(fromData.members, toData.members);
            
            // Apply filter
            if (filterMode === 'active') {
                changes = changes.filter(c => {
                    // Active = Joined OR Positive XP Gain
                    return c.type === 'join' || c.diff > 0;
                });
            } else if (filterMode === 'inactive') {
                changes = changes.filter(c => {
                    // Inactive = Left OR Zero/Negative XP Gain
                    return c.type === 'left' || c.diff <= 0;
                });
            } else if (filterMode === 'in-territory') {
                changes = changes.filter(c => {
                    const markerInfo = playerMarkerData.find(m => m.name === c.name);
                    return markerInfo && markerInfo.inTerritory;
                });
            } else if (filterMode === 'out-of-territory') {
                changes = changes.filter(c => {
                    const markerInfo = playerMarkerData.find(m => m.name === c.name);
                    return markerInfo && !markerInfo.inTerritory;
                });
            }
            
            // Sort
            changes.sort((a, b) => {
                if (a.type === 'join') return -1;
                if (b.type === 'join') return 1;
                if (a.type === 'left') return 1;
                if (b.type === 'left') return -1;
                return b.diff - a.diff;
            });

            const table = document.createElement('table');
            table.className = 'daily-brief-table';
            table.innerHTML = `<thead><tr><th>User</th><th>Change</th><th>Details</th></tr></thead>`;
            const tbody = document.createElement('tbody');

            if (changes.length === 0) {
                tbody.innerHTML = `<tr><td colspan="3" style="text-align:center">No changes.</td></tr>`;
            } else {
                changes.forEach(change => {
                    const tr = document.createElement('tr');
                    
                    // User Cell with Buttons and Coordinates
                    const userTd = document.createElement('td');
                    userTd.style.display = 'flex';
                    userTd.style.alignItems = 'center';
                    userTd.style.gap = '4px';
                    
                    // Create user info (name only)
                    const nameSpan = document.createElement('span');
                    nameSpan.className = 'user-name';
                    nameSpan.textContent = change.name || change.id;
                    
                    userTd.appendChild(nameSpan);

                    // Extract ID — change.id is already the numeric userId string
                    const userId = change.id;
                    if (userId && /^\d+$/.test(userId)) {
                        const discordBtn = document.createElement('button');
                        discordBtn.className = 'member-icon-btn discord-icon';
                        discordBtn.title = 'Check Discord';
                        discordBtn.innerHTML = `
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36" width="16" height="16" fill="currentColor">
                                <path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.11,77.11,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22c1.24-23.25-13.28-47.54-18.9-72.15ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
                            </svg>
                        `;
                        discordBtn.onclick = async (e) => {
                            e.stopPropagation();
                            const data = await fetchUserProfile(userId);
                            if (data && data.discordUser) {
                                navigator.clipboard.writeText(data.discordUser).then(() => {
                                    showTooltip(e.clientX, e.clientY, `Discord ID: ${data.discordUser} copied!`);
                                });
                            } else {
                                showTooltip(e.clientX, e.clientY, 'No Discord ID found.');
                            }
                        };
                        userTd.appendChild(discordBtn);
                    }

                    // Map Button
                    if (change.coords) {
                        const mapBtn = document.createElement('button');
                        mapBtn.className = 'member-icon-btn map-icon';
                        mapBtn.setAttribute('data-player-name', change.name || change.id);
                        // If player markers are active, mark out-of-territory players red
                        if (playersVisible && playerMarkerData.length > 0) {
                            const markerInfo = playerMarkerData.find(m => m.name === (change.name || change.id));
                            if (markerInfo && !markerInfo.inTerritory) {
                                mapBtn.classList.add('out-of-territory');
                            }
                        }
                        mapBtn.title = 'Find on Map';
                        mapBtn.innerHTML = `
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                                <circle cx="12" cy="10" r="3"/>
                                <path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/>
                            </svg>
                        `;
                        const coordKey = `${change.coords[0]},${change.coords[1]}`;
                        if (sessionState.visitedCoords.has(coordKey)) {
                            mapBtn.classList.add('visited');
                        }
                        mapBtn.onclick = () => {
                            // Find the original Find button in the member row and click it
                            const memberName = change.name || change.id;
                            const memberRows = document.querySelectorAll('#guildMembersContainer div.flex.items-center.justify-between');
                            let found = false;
                            for (const row of memberRows) {
                                const nameEl = row.querySelector('p.font-semibold');
                                if (nameEl) {
                                    // Remove badge the same way parseGuildMembers does
                                    let displayName = nameEl.textContent.trim();
                                    const badge = nameEl.querySelector('span');
                                    if (badge) {
                                        displayName = displayName.replace(badge.textContent, '').trim();
                                    }
                                    // Match by exact name to handle both users with and without usernames
                                    if (displayName === memberName) {
                                        const findBtn = row.querySelector('button[onclick^="goToGridLocation"]');
                                        if (findBtn) {
                                            findBtn.click();
                                            found = true;
                                            break;
                                        }
                                    }
                                }
                            }
                            if (!found && window.goToGridLocation) {
                                window.goToGridLocation(change.coords[0], change.coords[1]);
                            }
                            // Mark as visited
                            sessionState.visitedCoords.add(coordKey);
                            mapBtn.classList.add('visited');
                        };
                        userTd.appendChild(mapBtn);
                    }

                    // Display coordinates if available (right-aligned)
                    if (change.coords) {
                        const spacer = document.createElement('div');
                        spacer.style.flex = '1';
                        userTd.appendChild(spacer);
                        
                        const coordsSpan = document.createElement('span');
                        coordsSpan.className = 'user-coords';
                        
                        // Get colors based on quadrant and distance
                        const colors = getCoordinateColor(change.coords);
                        coordsSpan.style.backgroundColor = colors.bg;
                        coordsSpan.style.padding = '2px 6px';
                        coordsSpan.style.borderRadius = '3px';
                        
                        // Create styled parts
                        const openParen = document.createElement('span');
                        openParen.style.color = colors.text;
                        openParen.textContent = '(';
                        
                        const xVal = document.createElement('span');
                        xVal.style.color = colors.text;
                        xVal.style.fontWeight = '500';
                        xVal.textContent = change.coords[0];
                        
                        const comma = document.createElement('span');
                        comma.style.color = colors.text;
                        comma.textContent = ', ';
                        
                        const yVal = document.createElement('span');
                        yVal.style.color = colors.text;
                        yVal.style.fontWeight = '500';
                        yVal.textContent = change.coords[1];
                        
                        const closeParen = document.createElement('span');
                        closeParen.style.color = colors.text;
                        closeParen.textContent = ')';
                        
                        coordsSpan.appendChild(openParen);
                        coordsSpan.appendChild(xVal);
                        coordsSpan.appendChild(comma);
                        coordsSpan.appendChild(yVal);
                        coordsSpan.appendChild(closeParen);
                        
                        userTd.appendChild(coordsSpan);
                    }

                    let changeCell = '';
                    if (change.type === 'gain') {
                        changeCell = change.diff > 0 ? `<td class="xp-gain">+${change.diff.toLocaleString()}</td>` : 
                                     (change.diff < 0 ? `<td class="xp-loss">${change.diff.toLocaleString()}</td>` : `<td class="xp-neutral">0</td>`);
                    } else if (change.type === 'join') {
                        changeCell = `<td class="xp-gain">JOINED</td>`;
                    } else if (change.type === 'left') {
                        changeCell = `<td class="xp-loss">LEFT</td>`;
                    }
                    
                    tr.appendChild(userTd);
                    
                    // Change Cell
                    const changeTd = document.createElement('td');
                    changeTd.innerHTML = changeCell.replace(/^<td.*?>|<\/td>$/g, ''); // Strip outer td tags since we are creating td
                    changeTd.className = changeCell.match(/class="([^"]+)"/)?.[1] || '';
                    tr.appendChild(changeTd);

                    // Details Cell
                    const detailsTd = document.createElement('td');
                    detailsTd.textContent = `${change.oldXp?.toLocaleString() || 0} → ${change.newXp?.toLocaleString() || 0}`;
                    tr.appendChild(detailsTd);

                    tbody.appendChild(tr);
                });
            }
            table.appendChild(tbody);
            resultsDiv.appendChild(table);
        };

        fromSelect.onchange = updateTable;
        toSelect.onchange = updateTable;
        
        updateTable();

        container.appendChild(controls);
        container.appendChild(resultsDiv);
    }

    function formatSnapshotInterval(ms) {
        const seconds = ms / 1000;
        if (seconds < 60) return `${seconds}s`;
        const minutes = seconds / 60;
        if (minutes < 60) return `${minutes.toFixed(1)}m`;
        const hours = minutes / 60;
        if (hours < 24) return `${hours.toFixed(1)}h`;
        const days = hours / 24;
        return `${days.toFixed(1)}d`;
    }

    function getSnapshotIntervalLabel(ms) {
        if (ms === SNAPSHOT_INTERVALS.HOURLY) return 'Hourly (1h)';
        if (ms === SNAPSHOT_INTERVALS.TWELVE_HOURS) return '12 Hours';
        if (ms === SNAPSHOT_INTERVALS.TWENTY_FOUR_HOURS) return '24 Hours';
        return `Custom (${formatSnapshotInterval(ms)})`;
    }

    function updateSnapshotIntervalDropdown(dropdown) {
        // Update dropdown to show current value
        if (CONFIG.minSnapshotInterval === SNAPSHOT_INTERVALS.HOURLY) {
            dropdown.value = 'hourly';
        } else if (CONFIG.minSnapshotInterval === SNAPSHOT_INTERVALS.TWELVE_HOURS) {
            dropdown.value = '12h';
        } else if (CONFIG.minSnapshotInterval === SNAPSHOT_INTERVALS.TWENTY_FOUR_HOURS) {
            dropdown.value = '24h';
        } else {
            dropdown.value = 'custom';
            const customOption = dropdown.querySelector('option[value="custom"]');
            if (customOption) {
                customOption.textContent = `Custom (${formatSnapshotInterval(CONFIG.minSnapshotInterval)})`;
            }
        }
    }

    // =====================================================
    // === TERRITORY MAP OVERLAY (New in 3.0.0) ===
    // =====================================================

    /**
     * Create the territory overlay canvas that sits on top of the map.
     * Similar approach to geopixels++ censor canvas but draws stroke-only rectangles.
     */
    function createTerritoryCanvas() {
        if (territoryCanvas) return;

        territoryCanvas = document.createElement('canvas');
        territoryCanvas.id = 'territory-canvas';
        document.body.appendChild(territoryCanvas);
        console.log('[Guild Territories] Territory canvas created');
    }

    /**
     * Load an image from a data URL and return its natural dimensions.
     */
    function getImageDimensionsFromSrc(src) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
            img.onerror = () => reject(new Error('Failed to load image'));
            img.src = src;
        });
    }

    /**
     * Process all guild projects and build territory rectangles.
     * Each project has imageGridX, imageGridY (top-left) and an image (base64 PNG).
     * Width/height are the pixel dimensions of the PNG (1 pixel = 1 grid unit).
     */
    async function buildTerritoryRects() {
        if (typeof userGuildData === 'undefined' || !userGuildData || !userGuildData.projects) {
            console.warn('[Guild Territories] No guild data or projects available');
            return [];
        }

        const projects = userGuildData.projects;
        if (projects.length === 0) return [];

        const rects = [];

        for (let i = 0; i < projects.length; i++) {
            const project = projects[i];
            try {
                const dims = await getImageDimensionsFromSrc(project.image);
                rects.push({
                    gridX: project.imageGridX,
                    gridY: project.imageGridY,
                    width: dims.width,
                    height: dims.height,
                    index: i + 1 // 1-based logical order matching guild modal display
                });
            } catch (err) {
                console.warn(`[Guild Territories] Failed to get dimensions for project #${i + 1} (id ${project.id}):`, err);
            }
        }

        return rects;
    }

    /**
     * Export guild territories as JSON compatible with the GeoPixels Json "Import JSON" feature.
     * Copies to clipboard in the format: [{ name, x, y, width, height }]
     * where x,y is top-left corner (matching Tauri/Json region format).
     */
    async function exportTerritoriesForJson() {
        const exportBtn = document.getElementById('exportTerritoriesBtn');
        if (exportBtn) {
            exportBtn.disabled = true;
            exportBtn.innerHTML = '⏳ Loading...';
        }

        try {
            // Ensure guild projects are fetched
            if (typeof userGuildData !== 'undefined' && userGuildData && typeof fetchGuildProjects === 'function') {
                await fetchGuildProjects();
            }

            const rects = await buildTerritoryRects();

            if (rects.length === 0) {
                alert('No guild projects found to export.');
                return;
            }

            // Convert to json-compatible format
            const regions = rects.map(rect => ({
                name: `Template #${rect.index}`,
                x: rect.gridX,
                y: rect.gridY,
                width: rect.width,
                height: rect.height
            }));

            const json = JSON.stringify(regions, null, 2);

            try {
                await navigator.clipboard.writeText(json);
                if (exportBtn) {
                    exportBtn.innerHTML = '✅ Copied!';
                    setTimeout(() => { exportBtn.innerHTML = '📋 Export to Clipboard'; }, 2000);
                }
                console.log(`[Guild Territories] Exported ${regions.length} territories to clipboard for Json import`);
            } catch (clipErr) {
                // Fallback: show in prompt for manual copy
                prompt('Copy this JSON and paste into Json\'s "Import JSON":', json);
            }
        } catch (err) {
            console.error('[Guild Territories] Export failed:', err);
            alert('Failed to export territories: ' + err.message);
        } finally {
            if (exportBtn) exportBtn.disabled = false;
            // Restore button text if not in "Copied!" state
            if (exportBtn && !exportBtn.innerHTML.includes('✅')) {
                exportBtn.innerHTML = '📋 Export to Clipboard';
            }
        }
    }

    /**
     * Draw a single territory border rectangle on the canvas.
     * Converts grid coordinates → Mercator → WGS84 → screen pixels.
     * 
     * The coordinate system:
     * - gridX, gridY = top-left of the image in grid space
     * - In GeoPixels, Y axis in grid space is inverted relative to image space
     *   (gridY is top, gridY - height is bottom)
     */
    function drawTerritoryRect(ctx, rect, gSize, color, thickness, fillColor) {
        if (typeof turf === 'undefined' || typeof map === 'undefined') return;

        // Top-left in mercator: gridX is left edge, gridY is top edge
        // The image extends rightward (+X) and downward (-Y in grid terms)
        const topLeftMerc = [
            (rect.gridX - 0.5) * gSize,
            (rect.gridY + 0.5) * gSize
        ];
        const bottomRightMerc = [
            (rect.gridX - 0.5 + rect.width) * gSize,
            (rect.gridY + 0.5 - rect.height) * gSize
        ];

        const topLeftScreen = map.project(turf.toWgs84(topLeftMerc));
        const bottomRightScreen = map.project(turf.toWgs84(bottomRightMerc));

        const screenX = topLeftScreen.x;
        const screenY = topLeftScreen.y;
        const screenW = bottomRightScreen.x - topLeftScreen.x;
        const screenH = bottomRightScreen.y - topLeftScreen.y;

        // Frustum culling - skip if entirely off-screen
        if (
            screenX + screenW < 0 ||
            screenX > ctx.canvas.width ||
            screenY + screenH < 0 ||
            screenY > ctx.canvas.height
        ) return;

        // Optional fill
        if (territorySettings.showFill) {
            ctx.fillStyle = fillColor || territorySettings.fillColor;
            ctx.globalAlpha = territorySettings.fillAlpha;
            ctx.fillRect(screenX, screenY, screenW, screenH);
        }

        // Border stroke
        ctx.strokeStyle = color;
        ctx.lineWidth = thickness;
        ctx.globalAlpha = 1;
        ctx.strokeRect(screenX, screenY, screenW, screenH);

        // Draw project label if enabled (uses logical order #1, #2, etc.)
        if (territorySettings.showLabels && screenW > 40 && screenH > 20) {
            const label = `#${rect.index}`;
            ctx.font = `bold ${territorySettings.labelFontSize}px sans-serif`;
            ctx.fillStyle = color;
            ctx.globalAlpha = 0.85;
            ctx.textAlign = 'left';
            ctx.textBaseline = 'top';
            ctx.fillText(label, screenX + 4, screenY + 4);
        }
    }

    /**
     * Build a map of territory index → boolean indicating whether any guild
     * member is currently positioned inside each territory.
     * Used to distinguish active (in-use) vs abandoned/finished territories.
     */
    function buildTerritoryActivityMap() {
        const activity = {};
        if (territoryRects.length === 0) return activity;

        const members = parseGuildMembers();
        if (!members || Object.keys(members).length === 0) return activity;

        for (const rect of territoryRects) {
            let hasPlayers = false;
            for (const [, data] of Object.entries(members)) {
                const coords = getCoords(data);
                if (coords) {
                    const [gx, gy] = coords;
                    if (
                        gx >= rect.gridX &&
                        gx < rect.gridX + rect.width &&
                        gy <= rect.gridY &&
                        gy > rect.gridY - rect.height
                    ) {
                        hasPlayers = true;
                        break;
                    }
                }
            }
            activity[rect.index] = hasPlayers;
        }
        return activity;
    }

    /**
     * Redraw all territory rectangles on the overlay canvas.
     */
    function drawTerritories() {
        if (!territoryCanvas || !territoryVisible) return;

        const pixelCanvas = document.getElementById('pixel-canvas');
        if (!pixelCanvas) return;

        territoryCanvas.width = pixelCanvas.width;
        territoryCanvas.height = pixelCanvas.height;
        const ctx = territoryCanvas.getContext('2d');
        ctx.clearRect(0, 0, territoryCanvas.width, territoryCanvas.height);

        if (territoryRects.length === 0) return;

        const gSize = (typeof gridSize !== 'undefined') ? gridSize : 25;
        const thickness = territorySettings.borderThickness;

        // Build activity map for two-tone coloring if enabled
        if (territorySettings.colorByActivity) {
            territoryActivityMap = buildTerritoryActivityMap();
        }

        // When both territories and players are visible, compute occupancy
        // to highlight unoccupied territories in red
        const occupancyMap = (playersVisible && playerMarkerData.length > 0)
            ? buildTerritoryActivityMap()
            : null;

        territoryRects.forEach(rect => {
            let color = territorySettings.borderColor;
            let fillColor = territorySettings.fillColor;

            if (territorySettings.colorByActivity) {
                const isActive = territoryActivityMap[rect.index] ?? false;
                color = isActive ? territorySettings.activeBorderColor : territorySettings.abandonedBorderColor;
                fillColor = isActive ? territorySettings.activeFillColor : territorySettings.abandonedFillColor;
            }

            // Override: if players are visible and territory is unoccupied, color red
            if (occupancyMap && !(occupancyMap[rect.index])) {
                color = '#ef4444';
                fillColor = '#ef4444';
            }

            drawTerritoryRect(ctx, rect, gSize, color, thickness, fillColor);
        });
    }

    /**
     * Hook into map events so territories redraw on pan/zoom/resize.
     */
    function hookTerritoryToMap() {
        function waitForMapReady(callback) {
            let tries = 0;
            function check() {
                if (typeof map !== 'undefined' && map && map.on && map.getContainer) callback();
                else if (tries++ < 100) setTimeout(check, 100);
            }
            check();
        }

        waitForMapReady(() => {
            ['move', 'rotate', 'zoom'].forEach(ev => map.on(ev, drawTerritories));
            new ResizeObserver(drawTerritories).observe(map.getContainer());
            map.once('load', drawTerritories);
            console.log('[Guild Territories] Hooked to map events');
        });
    }

    /**
     * Toggle territory overlay on/off. If turning on, process projects first.
     */
    async function toggleTerritories() {
        if (territoryVisible) {
            // Turn off
            territoryVisible = false;
            if (territoryCanvas) {
                const ctx = territoryCanvas.getContext('2d');
                ctx.clearRect(0, 0, territoryCanvas.width, territoryCanvas.height);
            }
            updateTerritoryToggleButton();
            console.log('[Guild Territories] Territories hidden');
            return;
        }

        // Turn on - process projects
        const toggleBtn = document.getElementById('territoryToggleBtn');
        if (toggleBtn) {
            toggleBtn.disabled = true;
            toggleBtn.innerHTML = '⏳ Processing...';
        }

        try {
            // Ensure guild projects are fetched
            if (typeof userGuildData !== 'undefined' && userGuildData && typeof fetchGuildProjects === 'function') {
                await fetchGuildProjects();
            }

            territoryRects = await buildTerritoryRects();

            if (territoryRects.length === 0) {
                if (toggleBtn) {
                    toggleBtn.disabled = false;
                    toggleBtn.innerHTML = '🗺️ Show Territories';
                    toggleBtn.className = 'territory-toggle-btn inactive';
                }
                alert('No guild projects found to display territories for.');
                return;
            }

            createTerritoryCanvas();
            territoryVisible = true;
            drawTerritories();
            updateTerritoryToggleButton();
            console.log(`[Guild Territories] Showing ${territoryRects.length} territories`);

        } catch (err) {
            console.error('[Guild Territories] Error building territories:', err);
            alert('Failed to process territories: ' + err.message);
        }

        if (toggleBtn) toggleBtn.disabled = false;
    }

    /**
     * Update the toggle button appearance based on state.
     */
    function updateTerritoryToggleButton() {
        const toggleBtn = document.getElementById('territoryToggleBtn');
        if (!toggleBtn) return;

        if (territoryVisible) {
            toggleBtn.innerHTML = '🗺️ Hide Territories';
            toggleBtn.className = 'territory-toggle-btn active';
        } else {
            toggleBtn.innerHTML = '🗺️ Show Territories';
            toggleBtn.className = 'territory-toggle-btn inactive';
        }
    }

    /**
     * Build the inline collapsible settings panel HTML.
     * Returns the container element to be appended inside the territory controls.
     */
    function buildTerritorySettingsPanel() {
        const wrapper = document.createElement('div');
        wrapper.className = 'territory-settings-collapsible';
        wrapper.id = 'territorySettingsCollapsible';

        // Toggle header
        const toggle = document.createElement('button');
        toggle.className = 'territory-settings-toggle';
        toggle.innerHTML = '<span>⚙️ Settings</span><span class="toggle-arrow collapsed">▼</span>';

        // Content
        const content = document.createElement('div');
        content.className = 'territory-settings-content collapsed';

        const thicknessOptions = [
            { value: 1, label: 'Thin (1px)' },
            { value: 2, label: 'Normal (2px)' },
            { value: 3, label: 'Medium (3px)' },
            { value: 4, label: 'Thick (4px)' },
            { value: 6, label: 'Heavy (6px)' },
            { value: 8, label: 'Extra Heavy (8px)' }
        ];

        const fillOpacityPct = Math.round(territorySettings.fillAlpha * 100);

        content.innerHTML = `
            <div class="territory-setting-row">
                <label>Border Color</label>
                <input type="color" id="territoryColorInput" value="${territorySettings.borderColor}"
                       class="w-10 h-7 rounded-md cursor-pointer p-0.5">
            </div>
            <div class="territory-setting-row">
                <label>Border Thickness</label>
                <select id="territoryThicknessSelect" class="px-2 py-1 rounded-md text-xs min-w-[120px]">
                    ${thicknessOptions.map(opt =>
                        `<option value="${opt.value}" ${territorySettings.borderThickness == opt.value ? 'selected' : ''}>${opt.label}</option>`
                    ).join('')}
                </select>
            </div>
            <div class="territory-setting-row">
                <label>Show Labels</label>
                <input type="checkbox" id="territoryLabelsCheck" ${territorySettings.showLabels ? 'checked' : ''}
                       class="w-4 h-4 cursor-pointer accent-blue-500">
            </div>
            <div class="territory-setting-row">
                <label>Label Size</label>
                <select id="territoryFontSelect" class="px-2 py-1 rounded-md text-xs min-w-[120px]">
                    ${[10, 12, 14, 16, 18, 20].map(s =>
                        `<option value="${s}" ${territorySettings.labelFontSize == s ? 'selected' : ''}>${s}px</option>`
                    ).join('')}
                </select>
            </div>

            <div class="territory-section-divider">Fill</div>
            <div class="territory-setting-row">
                <label>Enable Fill</label>
                <input type="checkbox" id="territoryFillCheck" ${territorySettings.showFill ? 'checked' : ''}
                       class="w-4 h-4 cursor-pointer accent-blue-500">
            </div>
            <div class="territory-setting-row">
                <label>Fill Color</label>
                <input type="color" id="territoryFillColorInput" value="${territorySettings.fillColor}"
                       class="w-10 h-7 rounded-md cursor-pointer p-0.5">
            </div>
            <div class="territory-setting-row">
                <label>Fill Opacity</label>
                <div class="flex items-center gap-1.5">
                    <input type="range" id="territoryFillAlphaRange" min="0.01" max="1" step="0.01" value="${territorySettings.fillAlpha}"
                           class="w-20 cursor-pointer">
                    <span id="territoryFillAlphaValue" class="text-xs min-w-[30px]" style="color: var(--color-gray-500, #6b7280);">${fillOpacityPct}%</span>
                </div>
            </div>

            <div class="territory-section-divider">Activity Coloring</div>
            <div class="territory-setting-row">
                <label>Color by activity</label>
                <input type="checkbox" id="territoryActivityCheck" ${territorySettings.colorByActivity ? 'checked' : ''}
                       class="w-4 h-4 cursor-pointer accent-blue-500"
                       title="Use different colors for territories with active players vs abandoned/finished">
            </div>
            <div id="activityColorRows" style="display:${territorySettings.colorByActivity ? 'flex' : 'none'};flex-direction:column;gap:10px;">
                <div class="territory-setting-row">
                    <label>Active Border</label>
                    <input type="color" id="territoryActiveBorderInput" value="${territorySettings.activeBorderColor}"
                           class="w-10 h-7 rounded-md cursor-pointer p-0.5">
                </div>
                <div class="territory-setting-row">
                    <label>Active Fill</label>
                    <input type="color" id="territoryActiveFillInput" value="${territorySettings.activeFillColor}"
                           class="w-10 h-7 rounded-md cursor-pointer p-0.5">
                </div>
                <div class="territory-setting-row">
                    <label>Abandoned Border</label>
                    <input type="color" id="territoryAbandonedBorderInput" value="${territorySettings.abandonedBorderColor}"
                           class="w-10 h-7 rounded-md cursor-pointer p-0.5">
                </div>
                <div class="territory-setting-row">
                    <label>Abandoned Fill</label>
                    <input type="color" id="territoryAbandonedFillInput" value="${territorySettings.abandonedFillColor}"
                           class="w-10 h-7 rounded-md cursor-pointer p-0.5">
                </div>
                <p style="font-size:11px;color:var(--color-gray-500,#6b7280);margin:0;">
                    Active = guild members drawing inside. Abandoned = no members inside.
                </p>
            </div>

            <div class="territory-section-divider">Preview</div>
            <div id="territoryPreviewContainer" class="flex items-center justify-center gap-2.5 py-1">
                <div id="territoryPreviewBox" style="width: 70px; height: 44px; border: ${territorySettings.borderThickness}px solid ${territorySettings.colorByActivity ? territorySettings.activeBorderColor : territorySettings.borderColor}; border-radius: 2px; position: relative; display: flex; align-items: flex-start; justify-content: flex-start; padding: 2px; background: var(--color-white, #fff);">
                    <div id="territoryPreviewFill" style="position: absolute; inset: 0; background: ${territorySettings.colorByActivity ? territorySettings.activeFillColor : territorySettings.fillColor}; opacity: ${territorySettings.showFill ? territorySettings.fillAlpha : 0}; border-radius: 1px;"></div>
                    <span style="font-size: ${territorySettings.labelFontSize}px; font-weight: bold; color: ${territorySettings.colorByActivity ? territorySettings.activeBorderColor : territorySettings.borderColor}; position: relative; z-index: 1;">${territorySettings.colorByActivity ? 'Active' : '#1'}</span>
                </div>
                <div id="territoryPreviewBoxAbandoned" style="width: 70px; height: 44px; border: ${territorySettings.borderThickness}px solid ${territorySettings.abandonedBorderColor}; border-radius: 2px; position: relative; display: ${territorySettings.colorByActivity ? 'flex' : 'none'}; align-items: flex-start; justify-content: flex-start; padding: 2px; background: var(--color-white, #fff);">
                    <div id="territoryPreviewFillAbandoned" style="position: absolute; inset: 0; background: ${territorySettings.abandonedFillColor}; opacity: ${territorySettings.showFill ? territorySettings.fillAlpha : 0}; border-radius: 1px;"></div>
                    <span style="font-size: ${territorySettings.labelFontSize}px; font-weight: bold; color: ${territorySettings.abandonedBorderColor}; position: relative; z-index: 1;">Done</span>
                </div>
            </div>
        `;

        // Toggle collapse
        toggle.addEventListener('click', () => {
            content.classList.toggle('collapsed');
            toggle.querySelector('.toggle-arrow').classList.toggle('collapsed');
        });

        wrapper.append(toggle, content);

        // Wire up live preview + auto-save after a brief delay
        const wireEvents = () => {
            const updatePreviewAndSave = () => {
                const color = document.getElementById('territoryColorInput')?.value;
                const thickness = parseInt(document.getElementById('territoryThicknessSelect')?.value);
                const fontSize = parseInt(document.getElementById('territoryFontSelect')?.value);
                const showLabels = document.getElementById('territoryLabelsCheck')?.checked;
                const showFill = document.getElementById('territoryFillCheck')?.checked;
                const fillColor = document.getElementById('territoryFillColorInput')?.value;
                const fillAlpha = parseFloat(document.getElementById('territoryFillAlphaRange')?.value);
                const colorByActivity = document.getElementById('territoryActivityCheck')?.checked;
                const activeBorderColor = document.getElementById('territoryActiveBorderInput')?.value;
                const activeFillColor = document.getElementById('territoryActiveFillInput')?.value;
                const abandonedBorderColor = document.getElementById('territoryAbandonedBorderInput')?.value;
                const abandonedFillColor = document.getElementById('territoryAbandonedFillInput')?.value;

                // Show/hide activity color rows
                const activityRows = document.getElementById('activityColorRows');
                if (activityRows) activityRows.style.display = colorByActivity ? 'flex' : 'none';

                // Update main preview box
                const box = document.getElementById('territoryPreviewBox');
                const fillDiv = document.getElementById('territoryPreviewFill');
                const previewBorderColor = colorByActivity ? activeBorderColor : color;
                const previewFillCol = colorByActivity ? activeFillColor : fillColor;

                if (box) {
                    box.style.borderColor = previewBorderColor;
                    box.style.borderWidth = thickness + 'px';
                    const label = box.querySelector('span');
                    if (label) {
                        label.style.color = previewBorderColor;
                        label.style.fontSize = fontSize + 'px';
                        label.textContent = colorByActivity ? 'Active' : '#1';
                    }
                }
                if (fillDiv) {
                    fillDiv.style.background = previewFillCol;
                    fillDiv.style.opacity = showFill ? fillAlpha : 0;
                }

                // Update abandoned preview box
                const boxAbandoned = document.getElementById('territoryPreviewBoxAbandoned');
                const fillDivAbandoned = document.getElementById('territoryPreviewFillAbandoned');
                if (boxAbandoned) {
                    boxAbandoned.style.display = colorByActivity ? 'flex' : 'none';
                    boxAbandoned.style.borderColor = abandonedBorderColor;
                    boxAbandoned.style.borderWidth = thickness + 'px';
                    const label = boxAbandoned.querySelector('span');
                    if (label) {
                        label.style.color = abandonedBorderColor;
                        label.style.fontSize = fontSize + 'px';
                    }
                }
                if (fillDivAbandoned) {
                    fillDivAbandoned.style.background = abandonedFillColor;
                    fillDivAbandoned.style.opacity = showFill ? fillAlpha : 0;
                }

                const alphaLabel = document.getElementById('territoryFillAlphaValue');
                if (alphaLabel) alphaLabel.textContent = Math.round(fillAlpha * 100) + '%';

                // Save and redraw
                territorySettings.borderColor = color;
                territorySettings.borderThickness = thickness;
                territorySettings.showLabels = showLabels;
                territorySettings.labelFontSize = fontSize;
                territorySettings.showFill = showFill;
                territorySettings.fillColor = fillColor;
                territorySettings.fillAlpha = fillAlpha;
                territorySettings.colorByActivity = colorByActivity;
                territorySettings.activeBorderColor = activeBorderColor;
                territorySettings.activeFillColor = activeFillColor;
                territorySettings.abandonedBorderColor = abandonedBorderColor;
                territorySettings.abandonedFillColor = abandonedFillColor;
                saveTerritorySettings();
                drawTerritories();
            };

            ['territoryColorInput', 'territoryFillColorInput', 'territoryActiveBorderInput', 'territoryActiveFillInput', 'territoryAbandonedBorderInput', 'territoryAbandonedFillInput'].forEach(id => {
                document.getElementById(id)?.addEventListener('input', updatePreviewAndSave);
            });
            ['territoryThicknessSelect', 'territoryFontSelect'].forEach(id => {
                document.getElementById(id)?.addEventListener('change', updatePreviewAndSave);
            });
            ['territoryLabelsCheck', 'territoryFillCheck', 'territoryActivityCheck'].forEach(id => {
                document.getElementById(id)?.addEventListener('change', updatePreviewAndSave);
            });
            document.getElementById('territoryFillAlphaRange')?.addEventListener('input', updatePreviewAndSave);
        };

        // Defer event wiring until after DOM insertion
        setTimeout(wireEvents, 0);

        return wrapper;
    }

    /**
     * Add numbered badges (#1, #2, ...) to each project card in the guild modal.
     * Numbers match the logical display order used by the territory overlay.
     */
    function numberProjectCards() {
        const container = document.getElementById('guildProjectsContainer');
        if (!container) return;

        const cards = container.querySelectorAll(':scope > div');
        cards.forEach((card, i) => {
            // Skip if already numbered
            if (card.querySelector('.project-number-badge')) return;

            // Position the card for the badge
            card.style.position = 'relative';

            const badge = document.createElement('div');
            badge.className = 'project-number-badge';
            badge.textContent = `#${i + 1}`;
            badge.style.cssText = `
                position: absolute;
                top: 6px;
                left: 6px;
                background: var(--color-blue-500, #3b82f6);
                color: var(--color-white, #fff);
                font-size: 11px;
                font-weight: 700;
                padding: 2px 7px;
                border-radius: 6px;
                z-index: 5;
                box-shadow: 0 1px 4px rgba(0,0,0,0.2);
                pointer-events: none;
                line-height: 1.4;
            `;
            card.insertBefore(badge, card.firstChild);
        });
    }

    /**
     * Inject the territory controls into the Projects tab of the guild modal.
     */
    function injectTerritoryControls() {
        const projectsTab = document.getElementById('projectsTab');
        if (!projectsTab) return;

        // Don't inject twice
        if (document.getElementById('territoryControlsContainer')) return;

        const container = document.createElement('div');
        container.id = 'territoryControlsContainer';
        container.className = 'flex flex-col gap-3 mb-3 p-3 rounded-lg border';

        // Top row: toggle button + info (right-aligned)
        const topRow = document.createElement('div');
        topRow.className = 'flex items-center justify-between gap-2 flex-wrap';

        const toggleBtn = document.createElement('button');
        toggleBtn.id = 'territoryToggleBtn';
        toggleBtn.className = territoryVisible ? 'territory-toggle-btn active' : 'territory-toggle-btn inactive';
        toggleBtn.innerHTML = territoryVisible ? '🗺️ Hide Territories' : '🗺️ Show Territories';
        toggleBtn.addEventListener('click', toggleTerritories);

        const playersBtn = document.createElement('button');
        playersBtn.id = 'playersToggleBtn';
        playersBtn.className = playersVisible ? 'territory-toggle-btn active' : 'territory-toggle-btn inactive';
        playersBtn.innerHTML = playersVisible ? '👥 Hide Players' : '👥 Show Players';
        playersBtn.addEventListener('click', togglePlayers);

        const exportBtn = document.createElement('button');
        exportBtn.id = 'exportTerritoriesBtn';
        exportBtn.className = 'territory-toggle-btn inactive';
        exportBtn.innerHTML = '📋 Export to Clipboard';
        exportBtn.title = 'Copy guild territories as JSON for the GeoPixels Json import';
        exportBtn.addEventListener('click', exportTerritoriesForJson);

        const info = document.createElement('span');
        info.className = 'territory-info-text';
        info.textContent = 'Overlay territories or player locations on the map';

        topRow.append(toggleBtn, playersBtn, exportBtn, info);
        container.appendChild(topRow);

        // Player marker options row (checkboxes)
        const optionsRow = document.createElement('div');
        optionsRow.id = 'playersOptionsRow';
        optionsRow.className = 'player-marker-options';
        optionsRow.style.display = playersVisible ? 'flex' : 'none';

        const showNamesLabel = document.createElement('label');
        const showNamesCheck = document.createElement('input');
        showNamesCheck.type = 'checkbox';
        showNamesCheck.id = 'playersShowNamesCheck';
        showNamesCheck.checked = playersShowNames;
        showNamesCheck.addEventListener('change', (e) => {
            playersShowNames = e.target.checked;
            refreshMarkerLabels();
        });
        showNamesLabel.appendChild(showNamesCheck);
        showNamesLabel.appendChild(document.createTextNode('Show all names'));

        const colorTerritoryLabel = document.createElement('label');
        const colorTerritoryCheck = document.createElement('input');
        colorTerritoryCheck.type = 'checkbox';
        colorTerritoryCheck.id = 'playersColorTerritoryCheck';
        colorTerritoryCheck.checked = playersColorByTerritory;
        colorTerritoryCheck.addEventListener('change', (e) => {
            playersColorByTerritory = e.target.checked;
            refreshMarkerColors();
        });
        colorTerritoryLabel.appendChild(colorTerritoryCheck);
        colorTerritoryLabel.appendChild(document.createTextNode('Blue if in territory'));

        const showInTerritoryLabel = document.createElement('label');
        const showInTerritoryCheck = document.createElement('input');
        showInTerritoryCheck.type = 'checkbox';
        showInTerritoryCheck.id = 'playersShowInTerritoryCheck';
        showInTerritoryCheck.checked = playersShowInTerritory;
        showInTerritoryCheck.addEventListener('change', (e) => {
            playersShowInTerritory = e.target.checked;
            updatePlayerPositions();
        });
        showInTerritoryLabel.appendChild(showInTerritoryCheck);
        showInTerritoryLabel.appendChild(document.createTextNode('Show in-territory'));

        const showOutsideTerritoryLabel = document.createElement('label');
        const showOutsideTerritoryCheck = document.createElement('input');
        showOutsideTerritoryCheck.type = 'checkbox';
        showOutsideTerritoryCheck.id = 'playersShowOutsideTerritoryCheck';
        showOutsideTerritoryCheck.checked = playersShowOutsideTerritory;
        showOutsideTerritoryCheck.addEventListener('change', (e) => {
            playersShowOutsideTerritory = e.target.checked;
            updatePlayerPositions();
        });
        showOutsideTerritoryLabel.appendChild(showOutsideTerritoryCheck);
        showOutsideTerritoryLabel.appendChild(document.createTextNode('Show outside territory'));

        optionsRow.append(showNamesLabel, colorTerritoryLabel, showInTerritoryLabel, showOutsideTerritoryLabel);
        container.appendChild(optionsRow);

        // Settings collapsible panels (full width)
        container.appendChild(buildTerritorySettingsPanel());
        container.appendChild(buildPlayerSettingsPanel());

        // Insert at the top of the projects tab, before the first child
        projectsTab.insertBefore(container, projectsTab.firstChild);
    }

    // =====================================================
    // === PLAYER MARKERS OVERLAY (New in 3.1.0) ===
    // =====================================================

    /**
     * Collect guild member positions from the currently rendered member list.
     * Returns array of { name, gridX, gridY } for members with coordinates.
     */
    function buildPlayerMarkerData() {
        const members = parseGuildMembers();
        if (!members) return [];

        const markers = [];
        for (const [key, data] of Object.entries(members)) {
            const coords = getCoords(data);
            if (coords) {
                // key is now a numeric ID string; use data.name (full display name) when available
                const name = (data && data.name) || key;
                markers.push({ name, gridX: coords[0], gridY: coords[1] });
            }
        }
        return markers;
    }

    /**
     * Create the players overlay container (a div for DOM marker elements).
     */
    function createPlayersContainer() {
        if (playersContainer) return;

        playersContainer = document.createElement('div');
        playersContainer.id = 'players-container';
        document.body.appendChild(playersContainer);
        console.log('[Guild Players] Players container created');
    }

    /**
     * Create a single Google-Maps-style pin marker DOM element for a player.
     * The pin tip anchors at the exact grid coordinate.
     */
    /**
     * Check if a grid coordinate falls inside any territory rectangle.
     */
    function isInsideTerritory(gridX, gridY) {
        for (const rect of territoryRects) {
            if (
                gridX >= rect.gridX &&
                gridX < rect.gridX + rect.width &&
                gridY <= rect.gridY &&
                gridY > rect.gridY - rect.height
            ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Get the pin fill color for a marker based on territory status.
     */
    function getMarkerColor(inTerritory) {
        return (playersColorByTerritory && inTerritory) ? playerSettings.territoryColor : playerSettings.defaultColor;
    }

    function createMarkerElement(marker) {
        const wrapper = document.createElement('div');
        wrapper.className = 'player-marker' + (playersShowNames ? ' show-label' : '');
        wrapper.setAttribute('data-player', marker.name);

        const pinColor = getMarkerColor(marker.inTerritory);
        const w = playerSettings.markerSize;
        const h = Math.round(w * 40 / 28); // maintain aspect ratio 28:40

        // Google Maps teardrop SVG pin
        wrapper.innerHTML = `
            <svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 24 36">
                <path class="pin-body" d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" fill="${pinColor}"/>
                <circle cx="12" cy="11" r="4.5" fill="white"/>
            </svg>
            <div class="player-marker-tooltip" style="font-size:${playerSettings.labelFontSize}px">${marker.name.replace(/</g, '&lt;')}</div>
        `;

        // Click to teleport — find the member's actual Find button in the DOM
        // and .click() it (runs in page context), with fallback via script injection
        wrapper.addEventListener('click', (e) => {
            e.stopPropagation();

            let found = false;
            const memberRows = document.querySelectorAll('#guildMembersContainer div.flex.items-center.justify-between');
            for (const row of memberRows) {
                const nameEl = row.querySelector('p.font-semibold');
                if (nameEl) {
                    let displayName = nameEl.textContent.trim();
                    const badge = nameEl.querySelector('span');
                    if (badge) displayName = displayName.replace(badge.textContent, '').trim();
                    if (displayName === marker.name) {
                        const findBtn = row.querySelector('button[onclick^="goToGridLocation"]');
                        if (findBtn) {
                            findBtn.click();
                            found = true;
                            break;
                        }
                    }
                }
            }

            // Fallback: inject a script tag to call goToGridLocation in page context
            if (!found) {
                const s = document.createElement('script');
                s.textContent = `if(typeof goToGridLocation==='function')goToGridLocation(${parseInt(marker.gridX)},${parseInt(marker.gridY)});`;
                document.documentElement.appendChild(s);
                s.remove();
            }

            // Mark as visited
            const coordKey = `${marker.gridX},${marker.gridY}`;
            sessionState.visitedCoords.add(coordKey);
        });

        return wrapper;
    }

    /**
     * Convert a grid coordinate to screen pixel position using the same
     * pipeline as territory overlay: grid → Mercator → WGS84 → screen.
     */
    function gridToScreen(gridX, gridY, gSize) {
        if (typeof turf === 'undefined' || typeof map === 'undefined') return null;
        const mercCoord = [gridX * gSize, gridY * gSize];
        const screenPos = map.project(turf.toWgs84(mercCoord));
        return screenPos; // { x, y }
    }

    /**
     * Reposition all player marker DOM elements to match current map view.
     * Called on every map move/zoom/resize.
     */
    function updatePlayerPositions() {
        if (!playersContainer || !playersVisible) return;

        const gSize = (typeof gridSize !== 'undefined') ? gridSize : 25;
        const viewW = window.innerWidth;
        const viewH = window.innerHeight;
        const margin = 60; // off-screen buffer before hiding

        for (const marker of playerMarkerData) {
            // Hide based on territory visibility checkboxes
            if (marker.inTerritory && !playersShowInTerritory) {
                marker.element.style.display = 'none';
                continue;
            }
            if (!marker.inTerritory && !playersShowOutsideTerritory) {
                marker.element.style.display = 'none';
                continue;
            }

            const pos = gridToScreen(marker.gridX, marker.gridY, gSize);
            if (!pos) continue;

            // Frustum cull with margin
            if (pos.x < -margin || pos.x > viewW + margin || pos.y < -margin || pos.y > viewH + margin) {
                marker.element.style.display = 'none';
            } else {
                marker.element.style.display = '';
                marker.element.style.left = pos.x + 'px';
                marker.element.style.top = pos.y + 'px';
            }
        }
    }

    /**
     * Hook into map events so player markers reposition on pan/zoom/resize.
     */
    function hookPlayersToMap() {
        function waitForMapReady(callback) {
            let tries = 0;
            function check() {
                if (typeof map !== 'undefined' && map && map.on && map.getContainer) callback();
                else if (tries++ < 100) setTimeout(check, 100);
            }
            check();
        }

        waitForMapReady(() => {
            ['move', 'rotate', 'zoom'].forEach(ev => map.on(ev, updatePlayerPositions));
            new ResizeObserver(updatePlayerPositions).observe(map.getContainer());
            map.once('load', updatePlayerPositions);
            console.log('[Guild Players] Hooked to map events');
        });
    }

    /**
     * Toggle player markers overlay on/off.
     */
    /**
     * Update the "Find on Map" buttons in the XP Tracker tab to reflect
     * territory status (red for out-of-territory players) from playerMarkerData.
     */
    function updateXPTrackerMapButtons() {
        const xpPane = document.getElementById('xpTrackerPane');
        if (!xpPane) return;

        const mapBtns = xpPane.querySelectorAll('.map-icon[data-player-name]');
        mapBtns.forEach(btn => {
            const playerName = btn.getAttribute('data-player-name');
            if (!playerName) return;

            // Don't override visited state
            if (btn.classList.contains('visited')) return;

            if (playersVisible && playerMarkerData.length > 0) {
                const markerInfo = playerMarkerData.find(m => m.name === playerName);
                if (markerInfo && !markerInfo.inTerritory) {
                    btn.classList.add('out-of-territory');
                } else {
                    btn.classList.remove('out-of-territory');
                }
            } else {
                btn.classList.remove('out-of-territory');
            }
        });

        // Show/hide territory filter buttons in XP Tracker
        const xpTerritoryBtns = xpPane.querySelectorAll('.xp-territory-filter-btn');
        xpTerritoryBtns.forEach(btn => {
            btn.style.display = playersVisible ? '' : 'none';
        });
    }

    /**
     * Refresh all marker pin colors (e.g. after territory data changes or checkbox toggle).
     */
    function refreshMarkerColors() {
        for (const marker of playerMarkerData) {
            const pinBody = marker.element.querySelector('.pin-body');
            if (pinBody) {
                pinBody.setAttribute('fill', getMarkerColor(marker.inTerritory));
            }
        }
    }

    /**
     * Toggle show-label class on all markers.
     */
    function refreshMarkerLabels() {
        for (const marker of playerMarkerData) {
            marker.element.classList.toggle('show-label', playersShowNames);
        }
    }

    /**
     * Refresh all marker sizes and label font sizes from playerSettings.
     */
    function refreshMarkerSizes() {
        const w = playerSettings.markerSize;
        const h = Math.round(w * 40 / 28);
        for (const marker of playerMarkerData) {
            const svg = marker.element.querySelector('svg');
            if (svg) {
                svg.setAttribute('width', w);
                svg.setAttribute('height', h);
            }
            const tooltip = marker.element.querySelector('.player-marker-tooltip');
            if (tooltip) {
                tooltip.style.fontSize = playerSettings.labelFontSize + 'px';
            }
        }
    }

    /**
     * Build a collapsible settings panel for player marker appearance.
     */
    function buildPlayerSettingsPanel() {
        const wrapper = document.createElement('div');
        wrapper.className = 'territory-settings-collapsible';
        wrapper.id = 'playerSettingsCollapsible';

        const toggle = document.createElement('button');
        toggle.className = 'territory-settings-toggle';
        toggle.innerHTML = '<span>👥 Player Settings</span><span class="toggle-arrow collapsed">▼</span>';

        const content = document.createElement('div');
        content.className = 'territory-settings-content collapsed';

        const sizeOptions = [
            { value: 16, label: 'Tiny (16px)' },
            { value: 20, label: 'Small (20px)' },
            { value: 24, label: 'Medium (24px)' },
            { value: 28, label: 'Default (28px)' },
            { value: 34, label: 'Large (34px)' },
            { value: 42, label: 'Extra Large (42px)' }
        ];

        content.innerHTML = `
            <div class="territory-setting-row">
                <label>Marker Size</label>
                <select id="playerSizeSelect" class="px-2 py-1 rounded-md text-xs min-w-[120px]">
                    ${sizeOptions.map(opt =>
                        `<option value="${opt.value}" ${playerSettings.markerSize == opt.value ? 'selected' : ''}>${opt.label}</option>`
                    ).join('')}
                </select>
            </div>
            <div class="territory-setting-row">
                <label>Label Size</label>
                <select id="playerLabelSizeSelect" class="px-2 py-1 rounded-md text-xs min-w-[120px]">
                    ${[9, 10, 11, 12, 13, 14, 16].map(s =>
                        `<option value="${s}" ${playerSettings.labelFontSize == s ? 'selected' : ''}>${s}px</option>`
                    ).join('')}
                </select>
            </div>
            <div class="territory-setting-row">
                <label>Default Color</label>
                <input type="color" id="playerDefaultColorInput" value="${playerSettings.defaultColor}"
                       class="w-10 h-7 rounded-md cursor-pointer p-0.5">
            </div>
            <div class="territory-setting-row">
                <label>Territory Color</label>
                <input type="color" id="playerTerritoryColorInput" value="${playerSettings.territoryColor}"
                       class="w-10 h-7 rounded-md cursor-pointer p-0.5">
            </div>

            <div class="territory-section-divider">Preview</div>
            <div class="flex items-center justify-center gap-4 py-1">
                <div style="display:flex;flex-direction:column;align-items:center;gap:2px;">
                    <svg id="playerPreviewDefault" xmlns="http://www.w3.org/2000/svg" width="${playerSettings.markerSize}" height="${Math.round(playerSettings.markerSize*40/28)}" viewBox="0 0 24 36">
                        <path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" fill="${playerSettings.defaultColor}"/>
                        <circle cx="12" cy="11" r="4.5" fill="white"/>
                    </svg>
                    <span style="font-size:10px;color:var(--color-gray-500,#6b7280);">Outside</span>
                </div>
                <div style="display:flex;flex-direction:column;align-items:center;gap:2px;">
                    <svg id="playerPreviewTerritory" xmlns="http://www.w3.org/2000/svg" width="${playerSettings.markerSize}" height="${Math.round(playerSettings.markerSize*40/28)}" viewBox="0 0 24 36">
                        <path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" fill="${playerSettings.territoryColor}"/>
                        <circle cx="12" cy="11" r="4.5" fill="white"/>
                    </svg>
                    <span style="font-size:10px;color:var(--color-gray-500,#6b7280);">In Territory</span>
                </div>
            </div>
        `;

        toggle.addEventListener('click', () => {
            content.classList.toggle('collapsed');
            toggle.querySelector('.toggle-arrow').classList.toggle('collapsed');
        });

        wrapper.append(toggle, content);

        const wireEvents = () => {
            const update = () => {
                const size = parseInt(document.getElementById('playerSizeSelect')?.value);
                const labelSize = parseInt(document.getElementById('playerLabelSizeSelect')?.value);
                const defaultColor = document.getElementById('playerDefaultColorInput')?.value;
                const territoryColor = document.getElementById('playerTerritoryColorInput')?.value;

                playerSettings.markerSize = size;
                playerSettings.labelFontSize = labelSize;
                playerSettings.defaultColor = defaultColor;
                playerSettings.territoryColor = territoryColor;

                // Update preview
                const h = Math.round(size * 40 / 28);
                const prevDef = document.getElementById('playerPreviewDefault');
                const prevTer = document.getElementById('playerPreviewTerritory');
                if (prevDef) {
                    prevDef.setAttribute('width', size);
                    prevDef.setAttribute('height', h);
                    prevDef.querySelector('path').setAttribute('fill', defaultColor);
                }
                if (prevTer) {
                    prevTer.setAttribute('width', size);
                    prevTer.setAttribute('height', h);
                    prevTer.querySelector('path').setAttribute('fill', territoryColor);
                }

                savePlayerSettings();
                refreshMarkerSizes();
                refreshMarkerColors();
            };

            ['playerSizeSelect', 'playerLabelSizeSelect'].forEach(id => {
                document.getElementById(id)?.addEventListener('change', update);
            });
            ['playerDefaultColorInput', 'playerTerritoryColorInput'].forEach(id => {
                document.getElementById(id)?.addEventListener('input', update);
            });
        };

        setTimeout(wireEvents, 0);
        return wrapper;
    }

    function togglePlayers() {
        if (playersVisible) {
            // Turn off — remove all marker elements
            playersVisible = false;
            playerMarkerData.forEach(m => m.element.remove());
            playerMarkerData = [];
            updatePlayersToggleButton();
            updatePlayersOptionsVisibility();
            updateXPTrackerMapButtons();
            drawTerritories(); // refresh territory colors (remove red highlights)
            console.log('[Guild Players] Player markers hidden');
            return;
        }

        // Turn on — build markers from current guild members
        const toggleBtn = document.getElementById('playersToggleBtn');
        if (toggleBtn) {
            toggleBtn.disabled = true;
            toggleBtn.innerHTML = '⏳ Loading...';
        }

        const data = buildPlayerMarkerData();

        if (data.length === 0) {
            if (toggleBtn) {
                toggleBtn.disabled = false;
                toggleBtn.innerHTML = '👥 Show Players';
                toggleBtn.className = 'territory-toggle-btn inactive';
            }
            alert('No guild members with coordinates found. Make sure the guild Info tab has loaded.');
            return;
        }

        // If territory data is available, compute in-territory flag for each marker
        // If territoryRects hasn't been built yet, try building it now
        const enrichData = async () => {
            if (territoryRects.length === 0) {
                // Try to build territory rects (non-blocking, best-effort)
                try {
                    if (typeof userGuildData !== 'undefined' && userGuildData && typeof fetchGuildProjects === 'function') {
                        await fetchGuildProjects();
                    }
                    territoryRects = await buildTerritoryRects();
                } catch (e) {
                    console.warn('[Guild Players] Could not build territory rects for coloring:', e);
                }
            }

            // Mark each marker with territory membership
            for (const m of data) {
                m.inTerritory = isInsideTerritory(m.gridX, m.gridY);
            }

            createPlayersContainer();

            // Create DOM marker elements
            playerMarkerData = data.map(m => {
                const el = createMarkerElement(m);
                playersContainer.appendChild(el);
                return { ...m, element: el };
            });

            playersVisible = true;
            updatePlayerPositions();
            updatePlayersToggleButton();
            updatePlayersOptionsVisibility();
            updateXPTrackerMapButtons();
            drawTerritories(); // refresh territory colors (show red for unoccupied)

            const inTerritoryCount = data.filter(m => m.inTerritory).length;
            console.log(`[Guild Players] Showing ${playerMarkerData.length} player markers (${inTerritoryCount} in territory)`);

            if (toggleBtn) toggleBtn.disabled = false;
        };

        enrichData();
    }

    /**
     * Update the player toggle button appearance based on state.
     */
    function updatePlayersToggleButton() {
        const toggleBtn = document.getElementById('playersToggleBtn');
        if (!toggleBtn) return;

        if (playersVisible) {
            toggleBtn.innerHTML = '👥 Hide Players';
            toggleBtn.className = 'territory-toggle-btn active';
        } else {
            toggleBtn.innerHTML = '👥 Show Players';
            toggleBtn.className = 'territory-toggle-btn inactive';
        }
    }

    /**
     * Show/hide the player marker options row based on visibility.
     */
    function updatePlayersOptionsVisibility() {
        const optionsRow = document.getElementById('playersOptionsRow');
        if (optionsRow) {
            optionsRow.style.display = playersVisible ? 'flex' : 'none';
        }
    }

    // =====================================================
    // === MODAL TRANSFORMATION (inherited from v2.0) ===
    // =====================================================

    function setupContentTracking() {
        const infoTab = document.getElementById('infoTab');
        if (!infoTab) return;

        const membersContainer = document.getElementById('guildMembersContainer');
        if (membersContainer) {
            const observer = new MutationObserver(() => {
                ensureXPChangesSection();
                const members = parseGuildMembers();
                if (members && Object.keys(members).length > 0) {
                    saveGuildSnapshot(members);
                }
            });
            observer.observe(membersContainer, { childList: true, subtree: true });
        }

        ensureXPChangesSection();

        // Watch for the projects tab being shown so we can inject territory controls + number badges
        const projectsTab = document.getElementById('projectsTab');
        if (projectsTab) {
            const projectsObserver = new MutationObserver(() => {
                injectTerritoryControls();
                numberProjectCards();
            });
            projectsObserver.observe(projectsTab, { childList: true, subtree: true, attributes: true });
        }

        // Also watch the projects container specifically for re-renders
        const projectsContainer = document.getElementById('guildProjectsContainer');
        if (projectsContainer) {
            const containerObserver = new MutationObserver(() => {
                numberProjectCards();
            });
            containerObserver.observe(projectsContainer, { childList: true, subtree: true });
        }

        // Also hook into the projects tab button click
        const projectsTabBtn = document.getElementById('projectsTabBtn');
        if (projectsTabBtn) {
            const originalOnClick = projectsTabBtn.onclick;
            projectsTabBtn.addEventListener('click', () => {
                // Small delay to ensure tab content is visible
                setTimeout(() => {
                    injectTerritoryControls();
                    numberProjectCards();
                }, 50);
            });
        }
    }

    function setupMessageCollapsible() {
        const msgElement = document.getElementById('guildInfoMessage');
        if (!msgElement) return;

        const parent = msgElement.closest('div');
        if (!parent || parent.classList.contains('guild-message-section')) return;

        const section = document.createElement('div');
        section.className = 'guild-message-section';

        const header = document.createElement('div');
        header.className = 'guild-message-header';
        header.innerHTML = `<span>Guild Message</span><span class="guild-message-toggle">▼</span>`;

        const content = document.createElement('div');
        content.className = 'guild-message-content';

        parent.parentNode.insertBefore(section, parent);
        content.appendChild(parent);
        section.appendChild(header);
        section.appendChild(content);

        header.onclick = () => {
            content.classList.toggle('collapsed');
            header.querySelector('.guild-message-toggle').classList.toggle('collapsed');
            const infoTab = document.getElementById('infoTab');
            if (infoTab) infoTab.classList.toggle('message-collapsed', content.classList.contains('collapsed'));
        };
    }

    /**
     * Adds a slim loading progress bar below the header bar that tracks
     * guild data readiness: members loaded, XP section ready, projects available.
     * Auto-hides with a fade once all milestones are met.
     */
    function setupGuildLoadingBar(panel, headerBar) {
        if (document.getElementById('guild-loading-bar-container')) return;

        const container = document.createElement('div');
        container.id = 'guild-loading-bar-container';
        container.style.cssText = `
            position: absolute; top: 40px; left: 0; right: 0; height: 3px;
            background: rgba(0,0,0,0.1); z-index: 52; overflow: hidden;
            transition: opacity 0.5s ease; cursor: pointer;
        `;

        const bar = document.createElement('div');
        bar.id = 'guild-loading-bar';
        bar.style.cssText = `
            height: 100%; width: 0%; background: linear-gradient(90deg, #60a5fa, #3b82f6);
            transition: width 0.4s ease; border-radius: 0 2px 2px 0;
            pointer-events: none;
        `;
        container.appendChild(bar);

        // Hover tooltip
        const tooltip = document.createElement('div');
        tooltip.id = 'guild-loading-tooltip';
        tooltip.style.cssText = `
            position: fixed; display: none; padding: 6px 10px;
            background: ${isDarkMode() ? '#1e1e2e' : '#1f2937'}; color: #f3f4f6;
            font-size: 11px; line-height: 1.5; border-radius: 6px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.3); pointer-events: none;
            z-index: 100000; white-space: nowrap;
        `;
        document.body.appendChild(tooltip);

        container.addEventListener('mouseenter', () => { tooltip.style.display = 'block'; updateTooltip(); });
        container.addEventListener('mouseleave', () => { tooltip.style.display = 'none'; });
        container.addEventListener('mousemove', (e) => {
            tooltip.style.left = (e.clientX + 12) + 'px';
            tooltip.style.top = (e.clientY + 12) + 'px';
        });

        // Insert right after the header bar
        headerBar.insertAdjacentElement('afterend', container);

        // Milestones: each worth a portion of the bar
        const milestones = {
            modal:    { done: true,  weight: 10, label: 'Modal ready' },
            stats:    { done: false, weight: 20, label: 'Guild stats' },
            members:  { done: false, weight: 40, label: 'Members list' },
            xpTracker:{ done: false, weight: 30, label: 'XP Tracker' },
        };

        function updateTooltip() {
            const lines = Object.values(milestones).map(m =>
                (m.done ? '✅' : '⏳') + ' ' + m.label
            );
            tooltip.innerHTML = lines.join('<br>');
        }

        function updateProgress() {
            let progress = 0;
            let total = 0;
            const pending = [];
            for (const [key, m] of Object.entries(milestones)) {
                total += m.weight;
                if (m.done) progress += m.weight;
                else pending.push(key);
            }
            const pct = Math.round((progress / total) * 100);
            bar.style.width = pct + '%';

            if (pending.length === 0) {
                bar.style.width = '100%';
                updateTooltip();
                setTimeout(() => {
                    container.style.opacity = '0';
                    setTimeout(() => {
                        container.remove();
                        tooltip.remove();
                    }, 600);
                }, 800);
            }
            updateTooltip();
        }

        function markDone(key) {
            if (milestones[key] && !milestones[key].done) {
                milestones[key].done = true;
                updateProgress();
            }
        }

        // Check milestones periodically
        function poll() {
            // Stats: guild XP / pixels text is populated
            const xpEl = document.getElementById('guildInfoExperience');
            if (xpEl && xpEl.textContent.trim().length > 0) markDone('stats');

            // Members: guildMembersContainer has member rows
            const membersEl = document.getElementById('guildMembersContainer');
            if (membersEl && membersEl.querySelectorAll('div.flex.items-center.justify-between').length > 0) {
                markDone('members');
            }

            // XP Tracker: our injected tab button or legacy section exists
            if (document.getElementById('xpTrackerTabBtn') || document.getElementById('xpChangesSection')) markDone('xpTracker');

            // Keep polling until all done
            const allDone = Object.values(milestones).every(m => m.done);
            if (!allDone) setTimeout(poll, 300);
        }

        updateProgress();
        setTimeout(poll, 200);
    }

    async function transformGuildModal() {
        try {
            await waitForElement('#myGuildModal', 10000);

            const modal = document.getElementById('myGuildModal');
            const panel = document.getElementById('myGuildPanel');

            if (!modal || !panel) {
                console.error('[Guild Modal] myGuildModal or myGuildPanel not found');
                return;
            }

            if (panel.classList.contains('draggable-panel')) return;

            const mobileCompat = typeof gpcMobileCompatibilityActive === 'function' && gpcMobileCompatibilityActive();

            modal.style.position = 'fixed';
            modal.style.inset = 'auto';
            modal.style.backgroundColor = 'transparent';
            modal.style.justifyContent = 'flex-start';
            modal.style.alignItems = 'flex-start';
            modal.style.padding = '0';
            modal.style.pointerEvents = 'none';

            panel.style.position = 'fixed';
            panel.style.cursor = 'default';
            panel.style.transform = 'none';
            panel.style.opacity = '1';
            panel.style.scale = '1';
            panel.style.pointerEvents = 'auto';
            panel.classList.add('draggable-panel');
            panel.classList.toggle('gpc-mobile-compat', mobileCompat);

            if (mobileCompat) {
                panel.style.top = '8px';
                panel.style.left = '8px';
                panel.style.width = 'calc(100vw - 16px)';
                panel.style.maxWidth = 'calc(100vw - 16px)';
                panel.style.maxHeight = 'calc(100dvh - 16px)';
                panel.style.overflowY = 'auto';
            } else {
                panel.style.top = '100px';
                panel.style.left = 'calc(50% - 25rem)';
                panel.style.width = '50rem';
                panel.style.maxWidth = '90vw';
                panel.style.maxHeight = '85vh';
                panel.style.overflowY = '';
            }

            const existingHeader = panel.querySelector('.guild-modal-header');
            if (existingHeader) existingHeader.remove();

            const headerBar = document.createElement('div');
            headerBar.className = 'guild-modal-header';
            headerBar.style.cssText = `
                position: absolute; top: 0; left: 0; right: 0; height: 40px;
                background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
                cursor: ${mobileCompat ? 'default' : 'move'}; border-radius: 0.75rem 0.75rem 0 0;
                display: flex; align-items: center; justify-content: space-between;
                padding: 0 16px; color: white; font-weight: 600;
                user-select: none; z-index: 50; pointer-events: auto;
            `;
            
            const titleSpan = document.createElement('span');
            titleSpan.textContent = 'Guild Panel';
            titleSpan.style.cursor = mobileCompat ? 'default' : 'move';
            
            const closeBtn = document.createElement('button');
            closeBtn.textContent = '✕';
            closeBtn.style.cssText = `
                background: none; border: none; color: white; font-size: 24px;
                cursor: pointer; padding: 0; margin: 0;
                display: flex; align-items: center; justify-content: center;
                width: 30px; height: 30px; border-radius: 4px; transition: background-color 0.2s;
            `;
            closeBtn.onmouseover = () => closeBtn.style.backgroundColor = 'rgba(255,255,255,0.2)';
            closeBtn.onmouseout = () => closeBtn.style.backgroundColor = 'transparent';
            closeBtn.onclick = (e) => {
                e.stopPropagation();
                if (typeof window.toggleMyGuildModal === 'function') {
                    window.toggleMyGuildModal();
                } else {
                    const originalClose = document.querySelector('#myGuildModal .close-modal, #myGuildModal [onclick*="toggleMyGuildModal"]');
                    if (originalClose) originalClose.click();
                    else modal.style.display = 'none';
                }
            };
            
            headerBar.appendChild(titleSpan);
            headerBar.appendChild(closeBtn);

            const resizeHandle = document.createElement('div');
            resizeHandle.className = 'guild-modal-resize';
            resizeHandle.style.cssText = `
                position: absolute; bottom: 0; right: 0; width: 20px; height: 20px;
                cursor: nwse-resize;
                background: linear-gradient(135deg, transparent 0%, #3b82f6 100%);
                border-radius: 0 0 0.75rem 0; z-index: 51; pointer-events: auto;
            `;

            panel.style.paddingTop = '50px';
            if (panel.firstChild) panel.insertBefore(headerBar, panel.firstChild);
            else panel.appendChild(headerBar);

            if (!mobileCompat) {
                panel.appendChild(resizeHandle);
                setupDragHandling(panel, titleSpan);
                setupResizeHandling(panel, resizeHandle);
            }
            setupMessageCollapsible();
            setupContentTracking();

            // --- Loading progress bar ---
            setupGuildLoadingBar(panel, headerBar);

            // Inject territory controls and number badges when projects tab is available
            setTimeout(() => {
                injectTerritoryControls();
                numberProjectCards();
            }, 200);

            // Reset panel to center every time the modal is opened (fixes off-screen lock after dragging outside window)
            const _centerPanel = () => {
                if (mobileCompat) {
                    panel.style.top = '8px';
                    panel.style.left = '8px';
                    panel.style.width = 'calc(100vw - 16px)';
                    panel.style.maxWidth = 'calc(100vw - 16px)';
                    panel.style.maxHeight = 'calc(100dvh - 16px)';
                } else {
                    panel.style.top = '100px';
                    panel.style.left = 'calc(50% - 25rem)';
                }
            };
            const _visibilityObserver = new MutationObserver(() => {
                if (!modal.classList.contains('hidden')) _centerPanel();
            });
            _visibilityObserver.observe(modal, { attributes: true, attributeFilter: ['class'] });

            console.log('[Guild Modal] v3.1 - Transformed to draggable floating panel with territories');

        } catch (error) {
            console.error('[Guild Modal] Error transforming modal:', error);
        }
    }

    function setupDragHandling(panel, header) {
        let isDragging = false;
        let startX = 0, startY = 0, offsetX = 0, offsetY = 0;

        const onMouseDown = (e) => {
            if (e.target.closest('.guild-modal-resize') || e.target.closest('button')) return;
            isDragging = true;
            startX = e.clientX;
            startY = e.clientY;
            const rect = panel.getBoundingClientRect();
            offsetX = rect.left;
            offsetY = rect.top;
            panel.style.userSelect = 'none';
            document.addEventListener('mousemove', onMouseMove, true);
            document.addEventListener('mouseup', onMouseUp, true);
            e.preventDefault();
            e.stopPropagation();
        };

        const onMouseMove = (e) => {
            if (!isDragging) return;
            const deltaX = e.clientX - startX;
            const deltaY = e.clientY - startY;
            panel.style.left = (offsetX + deltaX) + 'px';
            panel.style.top = (offsetY + deltaY) + 'px';
        };

        const onMouseUp = () => {
            isDragging = false;
            panel.style.userSelect = 'auto';
            document.removeEventListener('mousemove', onMouseMove, true);
            document.removeEventListener('mouseup', onMouseUp, true);
        };

        header.addEventListener('mousedown', onMouseDown, true);
        
        // Also make the header bar itself draggable
        const headerBar = panel.querySelector('.guild-modal-header');
        if (headerBar && headerBar !== header) {
            headerBar.addEventListener('mousedown', onMouseDown, true);
        }
    }

    function setupResizeHandling(panel, handle) {
        let isResizing = false;
        let startX = 0, startY = 0, startW = 0, startH = 0;

        handle.addEventListener('mousedown', (e) => {
            isResizing = true;
            startX = e.clientX;
            startY = e.clientY;
            const rect = panel.getBoundingClientRect();
            startW = rect.width;
            startH = rect.height;
            panel.style.userSelect = 'none';
            document.addEventListener('mousemove', onMouseMove, true);
            document.addEventListener('mouseup', onMouseUp, true);
            e.preventDefault();
            e.stopPropagation();
        });

        const onMouseMove = (e) => {
            if (!isResizing) return;
            const newW = Math.max(300, startW + (e.clientX - startX));
            const newH = Math.max(200, startH + (e.clientY - startY));
            panel.style.width = newW + 'px';
            panel.style.maxHeight = newH + 'px';
        };

        const onMouseUp = () => {
            isResizing = false;
            panel.style.userSelect = 'auto';
            document.removeEventListener('mousemove', onMouseMove, true);
            document.removeEventListener('mouseup', onMouseUp, true);
        };
    }

    function updateSnapshotIntervalUI() {
        const dropdown = document.getElementById('snapshotIntervalSelect');
        if (dropdown) {
            updateSnapshotIntervalDropdown(dropdown);
        }
    }

    // --- Menu Commands ---
    // Commented out to keep the Tampermonkey menu clean.
    // Uncomment any block below to re-expose it in the menu.
    // All underlying functionality remains intact and accessible via the in-page UI.
    
    /*
    GM_registerMenuCommand("Snapshot Interval: Hourly", () => {
        CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.HOURLY;
        GM_setValue('min_snapshot_interval', CONFIG.minSnapshotInterval);
        updateSnapshotIntervalUI();
        alert(`Snapshot Interval set to: Hourly (1 hour)`);
    });

    GM_registerMenuCommand("Snapshot Interval: 12 Hours", () => {
        CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.TWELVE_HOURS;
        GM_setValue('min_snapshot_interval', CONFIG.minSnapshotInterval);
        updateSnapshotIntervalUI();
        alert(`Snapshot Interval set to: 12 Hours`);
    });

    GM_registerMenuCommand("Snapshot Interval: 24 Hours", () => {
        CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.TWENTY_FOUR_HOURS;
        GM_setValue('min_snapshot_interval', CONFIG.minSnapshotInterval);
        updateSnapshotIntervalUI();
        alert(`Snapshot Interval set to: 24 Hours`);
    });

    GM_registerMenuCommand("Snapshot Interval: Custom", () => {
        const userInput = prompt("Enter custom snapshot interval in minutes:", (CONFIG.minSnapshotInterval / (60 * 1000)).toString());
        if (userInput !== null && userInput.trim() !== '') {
            const minutes = parseFloat(userInput);
            if (!isNaN(minutes) && minutes > 0) {
                CONFIG.minSnapshotInterval = minutes * 60 * 1000;
                GM_setValue('min_snapshot_interval', CONFIG.minSnapshotInterval);
                updateSnapshotIntervalUI();
                alert(`Snapshot Interval set to: ${minutes} minute(s)`);
            } else {
                alert("Invalid input. Please enter a positive number.");
            }
        }
    });

    GM_registerMenuCommand("Toggle Debug Mode", () => {
        CONFIG.debugMode = !CONFIG.debugMode;
        alert(`Debug Mode: ${CONFIG.debugMode ? 'ON' : 'OFF'}`);
    });

    GM_registerMenuCommand("Time Travel: Advance 1 Day", () => {
        CONFIG.timeOffset += 24 * 60 * 60 * 1000;
        GM_setValue('debug_time_offset', CONFIG.timeOffset);
        const virtualDate = new Date(getVirtualNow());
        alert(`Time Travel Active! Virtual Date: ${virtualDate.toDateString()}\nReload the page to apply.`);
    });

    GM_registerMenuCommand("Time Travel: Reset", () => {
        CONFIG.timeOffset = 0;
        GM_setValue('debug_time_offset', 0);
        alert(`Time Travel Reset. Back to reality.`);
    });

    GM_registerMenuCommand("Reset Guild XP History", () => {
        if (confirm("Are you sure you want to clear all stored Guild XP history? This cannot be undone.")) {
            GM_setValue('guild_xp_history', []);
            alert("Guild XP history has been reset.");
        }
    });

    GM_registerMenuCommand("Toggle Territory Overlay", () => {
        toggleTerritories();
    });

    GM_registerMenuCommand("Toggle Player Markers", () => {
        togglePlayers();
    });

    GM_registerMenuCommand("Territory Settings", () => {
        // Open the guild modal projects tab where settings live
        if (typeof window.toggleMyGuildModal === 'function') {
            const modal = document.getElementById('myGuildModal');
            if (modal && modal.classList.contains('hidden')) window.toggleMyGuildModal();
            if (typeof window.switchGuildTab === 'function') window.switchGuildTab('projects');
            setTimeout(() => {
                const collapsible = document.getElementById('territorySettingsCollapsible');
                if (collapsible) {
                    const content = collapsible.querySelector('.territory-settings-content');
                    const arrow = collapsible.querySelector('.toggle-arrow');
                    if (content && content.classList.contains('collapsed')) {
                        content.classList.remove('collapsed');
                        if (arrow) arrow.classList.remove('collapsed');
                    }
                }
            }, 200);
        }
    });
    */

    // --- Initialization ---

    function init() {
        transformGuildModal();
        hookTerritoryToMap();
        hookPlayersToMap();

        const bodyObserver = new MutationObserver((mutations) => {
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        if (node.id === 'myGuildModal' || node.querySelector('#myGuildModal')) {
                            console.log('[Guild Modal] Modal detected, re-initializing...');
                            transformGuildModal();
                        }
                    }
                }
            }
        });

        bodyObserver.observe(document.body, { childList: true, subtree: true });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    console.log('[Guild Modal] v3.4.0 - Loaded with territory map overlay, player markers, territory-aware XP tracker, and activity-aware territory coloring');

            })();
            _featureStatus.guildOverhaul = 'ok';
            console.log('[GeoPixelcons++] ✅ Guild Overhaul loaded');
        } catch (err) {
            _featureStatus.guildOverhaul = 'error';
            dbgPush(`Guild Overhaul init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Guild Overhaul' });
            console.error('[GeoPixelcons++] ❌ Guild Overhaul failed:', err);
        }
    }

    // ============================================================
    //  FEATURE: Paint Brush Swap [paintBrushSwap]
    // ============================================================
    if (_settings.paintBrushSwap) {
        try {
            (function _init_paintBrushSwap() {

    // Page window reference — needed because GPC++ uses @grant which sandboxes `window`
    const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

    // Helper to set page-scope `let` variables (not accessible via window/unsafeWindow)
    // Injects a <script> tag so the assignment runs in the page's own global scope
    function _setPageVar(name, value) {
        try {
            const s = document.createElement('script');
            s.textContent = `${name} = ${JSON.stringify(value)};`;
            (document.head || document.documentElement).appendChild(s);
            s.remove();
        } catch {}
    }
    function _runInPage(code) {
        try {
            const s = document.createElement('script');
            s.textContent = code;
            (document.head || document.documentElement).appendChild(s);
            s.remove();
        } catch {}
    }

    // ============================================
    // DEBUG MODE
    // ============================================
    const DEBUG = false; // Set to true for console logging

    // ============================================
    // STATE MANAGEMENT
    // ============================================
    const STORAGE_KEY = 'brushPresets';
    const RESIZE_STORAGE_KEY = 'brushSwapDropdownSize';
    const MAX_BRUSHES = 100;

    const scriptState = {
        brushes: [],
        nextId: 1,
        dropdownOpen: false,
        isRenaming: null, // Track which brush ID is being renamed
        scrollIndex: -1,  // Track current scroll-swap index (-1 = no selection)
        activeBrushId: null, // Track which brush is currently loaded
        dragState: null   // Track drag-to-reorder state
    };

    // ============================================
    // UTILITY FUNCTIONS
    // ============================================

    function loadBrushes() {
        const saved = localStorage.getItem(STORAGE_KEY);
        if (saved) {
            try {
                scriptState.brushes = JSON.parse(saved);
                scriptState.nextId = Math.max(...scriptState.brushes.map(b => b.id), 0) + 1;
            } catch (e) {
                console.error('Failed to parse brush presets:', e);
                scriptState.brushes = [];
                scriptState.nextId = 1;
            }
        }
    }

    function saveBrushes() {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(scriptState.brushes));
    }

    function addBrush(pattern, brushSize) {
        if (scriptState.brushes.length >= MAX_BRUSHES) {
            // Delete oldest brush (first in array)
            scriptState.brushes.shift();
        }

        const newBrush = {
            id: scriptState.nextId++,
            name: `Brush ${scriptState.nextId}`,
            pattern: pattern,
            brushSize: brushSize
        };

        scriptState.brushes.push(newBrush);
        saveBrushes();
        return newBrush;
    }

    function deleteBrush(id) {
        scriptState.brushes = scriptState.brushes.filter(b => b.id !== id);
        saveBrushes();
        renderDropdown();
    }

    function renameBrush(id, newName) {
        const brush = scriptState.brushes.find(b => b.id === id);
        if (brush) {
            brush.name = newName.trim() || `Brush ${id}`;
            saveBrushes();
            renderDropdown();
        }
    }

    // ============================================
    // BRUSH CAPTURE FROM DOM
    // ============================================

    function captureBrushFromDOM() {
        const brushGrid = document.getElementById('brushGrid');
        if (!brushGrid) {
            console.warn('Brush Swap: brushGrid not found');
            return null;
        }

        const cells = brushGrid.querySelectorAll('div[data-x][data-y]');
        const pattern = [];
        let minX = Infinity, maxX = -Infinity;
        let minY = Infinity, maxY = -Infinity;
        let centerX = -1, centerY = -1;

        // Collect all active cells and find bounds, also locate center marker
        cells.forEach(cell => {
            if (cell.dataset.active === 'true') {
                const x = parseInt(cell.dataset.x);
                const y = parseInt(cell.dataset.y);
                pattern.push({ gridX: x, gridY: y });
                minX = Math.min(minX, x);
                maxX = Math.max(maxX, x);
                minY = Math.min(minY, y);
                maxY = Math.max(maxY, y);
                
                // Find center marker
                if (cell.dataset.isCenter === 'true' || cell.dataset.isCenter === 'true') {
                    centerX = x;
                    centerY = y;
                }
            }
        });

        if (pattern.length === 0) {
            console.warn('Brush Swap: No active cells in brush');
            return null;
        }

        // Calculate brush size from grid bounds
        const brushSize = Math.max(maxX - minX + 1, maxY - minY + 1);
        
        // If center wasn't found (shouldn't happen), use grid center
        if (centerX === -1 || centerY === -1) {
            centerX = Math.floor(brushSize / 2);
            centerY = Math.floor(brushSize / 2);
        }

        // Convert grid coordinates to relative coordinates, centered on the actual center pixel
        const relativePattern = pattern.map(p => ({
            x: p.gridX - centerX,
            y: (p.gridY - centerY) * -1 // Invert Y for consistency
        }));

        if (DEBUG) console.log('Brush Swap: Captured brush from DOM', {
            brushSize,
            centerX,
            centerY,
            pattern: relativePattern,
            cellCount: pattern.length
        });

        return {
            pattern: relativePattern,
            brushSize: brushSize
        };
    }

    function loadBrush(id) {
        const brush = scriptState.brushes.find(b => b.id === id);
        if (!brush) return;

        applyBrushToEditor(brush);
        scriptState.activeBrushId = id;
        toggleDropdown();
    }

    function applyBrushToEditor(brush) {
        // Track which brush is active
        scriptState.activeBrushId = brush.id;
        // Set page globals — BrushSize is `let`-declared so _pw.BrushSize won't reach it
        _setPageVar('BrushSize', brush.brushSize);
        _setPageVar('currentBrushPattern', [...brush.pattern]);
        // Also mirror on _pw for any code that reads from window
        _pw.BrushSize = brush.brushSize;
        _pw.currentBrushPattern = [...brush.pattern];

        if (DEBUG) console.log('Brush Swap: Set globals', {
            BrushSize: _pw.BrushSize,
            currentBrushPattern: _pw.currentBrushPattern
        });

        // Update userConfig
        if (_pw.userConfig) {
            _pw.userConfig = {
                ..._pw.userConfig,
                currentBrushPattern: _pw.currentBrushPattern,
                brushSize: _pw.BrushSize
            };
            localStorage.setItem('userConfig', JSON.stringify(_pw.userConfig));
            if (DEBUG) console.log('Brush Swap: Updated userConfig');
        }

        // Call server save if available
        _pw.saveConfigServer?.();

        // Regenerate the brush grid to reflect the new pattern/size
        _runInPage('generateBrushGrid(currentBrushPattern)');

        if (DEBUG) console.log('Brush Swap: Applied brush to editor', brush);
    }

    // ============================================
    // BRUSH DIMENSION CONTROL
    // ============================================

    function addBrushDimensionDropdown() {
        const brushEditorPanel = document.getElementById('brushEditorPanel');
        if (!brushEditorPanel) return;

        // Check if dropdown already exists
        if (document.getElementById('brush-swap-dimension-select')) return;

        // Find the header area to insert dropdown
        const header = brushEditorPanel.querySelector('h2');
        if (!header) return;

        // Create dropdown container with Tailwind classes
        const dropdownContainer = document.createElement('div');
        dropdownContainer.className = 'flex gap-2 items-center mb-3 px-1.5 dark:text-gray-300';

        // Create label
        const label = document.createElement('label');
        label.textContent = 'Grid Size:';
        label.className = 'text-xs font-semibold text-gray-700 dark:text-gray-300';

        // Create select
        const select = document.createElement('select');
        select.id = 'brush-swap-dimension-select';
        select.className = 'px-2 py-1 text-xs border rounded bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 cursor-pointer';

        const options = [
            { value: 1, label: '1×1' },
            { value: 3, label: '3×3' },
            { value: 5, label: '5×5' },
            { value: 7, label: '7×7' },
            { value: 9, label: '9×9' },
            { value: 11, label: '11×11' },
            { value: 13, label: '13×13' },
            { value: 15, label: '15×15' },
            { value: 17, label: '17×17' },
            { value: 19, label: '19×19' },
            { value: 21, label: '21×21' }
        ];

        // Ensure current BrushSize is in the list
        const curSize = _pw.BrushSize || 5;
        if (!options.some(o => o.value === curSize)) {
            options.push({ value: curSize, label: curSize + '×' + curSize });
            options.sort((a, b) => a.value - b.value);
        }

        options.forEach(opt => {
            const option = document.createElement('option');
            option.value = opt.value;
            option.textContent = opt.label;
            select.appendChild(option);
        });

        // Set current BrushSize as selected
        select.value = _pw.BrushSize || 5;

        // Handle change
        select.addEventListener('change', (e) => {
            const newSize = parseInt(e.target.value);
            _setPageVar('BrushSize', newSize);
            _pw.BrushSize = newSize;
            if (DEBUG) console.log(`Brush Swap: Changed grid size to ${newSize}x${newSize}`);

            // Regenerate grid with new size
            _runInPage('generateBrushGrid(currentBrushPattern)');
        });

        dropdownContainer.appendChild(label);
        dropdownContainer.appendChild(select);

        // Insert after the header
        header.parentNode.insertBefore(dropdownContainer, header.nextSibling);
    }

    // ============================================
    // BRUSH EDITOR ENHANCEMENTS (fill + drag-paint)
    // ============================================

    function _installBrushEditorEnhancements() {
        const panel = document.getElementById('brushEditorPanel');
        if (!panel) return;

        // ── Fill + Reset button row ──────────────────────────
        if (!panel.querySelector('#gpc-brush-fill-btn')) {
            const resetBtn = panel.querySelector('button[onclick="resetBrush()"]');
            if (resetBtn) {
                const container = resetBtn.parentElement;

                // Halve reset button width to make room for fill
                resetBtn.classList.remove('w-full');
                resetBtn.style.width = '50%';

                // Create fill button
                const fillBtn = document.createElement('button');
                fillBtn.id = 'gpc-brush-fill-btn';
                fillBtn.style.width = '50%';
                fillBtn.className = 'py-2 text-sm text-gray-500 hover:text-green-600 hover:bg-gray-50 rounded transition cursor-pointer text-center';
                fillBtn.textContent = 'Fill All';
                fillBtn.title = 'Set all cells to active';
                fillBtn.onclick = () => {
                    const grid = document.getElementById('brushGrid');
                    if (!grid) return;
                    grid.querySelectorAll('[data-x][data-y]').forEach(cell => {
                        cell.dataset.active = 'true';
                        cell.classList.add('!bg-gray-800');
                        cell.classList.remove('bg-white', 'hover:bg-gray-100', 'bg-red-100', 'hover:bg-red-200');
                    });
                };

                container.style.display = 'flex';
                container.style.gap = '8px';
                container.appendChild(fillBtn);
            }
        }

        // ── Drag-to-paint / drag-to-erase on brushGrid ───────
        _installBrushGridDrag();
    }

    function _installBrushGridDrag() {
        const grid = document.getElementById('brushGrid');
        if (!grid || grid._gpcDragInstalled) return;
        grid._gpcDragInstalled = true;

        function setCellActive(cell, active) {
            if (!cell || !cell.dataset || !('x' in cell.dataset)) return;
            const isCenter = cell.dataset.isCenter === 'true';
            cell.dataset.active = active ? 'true' : 'false';
            if (active) {
                cell.classList.add('!bg-gray-800');
                cell.classList.remove('bg-white', 'hover:bg-gray-100', 'bg-red-100', 'hover:bg-red-200');
            } else {
                cell.classList.remove('!bg-gray-800');
                if (isCenter) {
                    cell.classList.add('bg-red-100', 'hover:bg-red-200');
                } else {
                    cell.classList.add('bg-white', 'hover:bg-gray-100');
                }
            }
        }

        // Remove game's onclick handlers (we fully own click/drag behavior)
        function removeOnclikHandlers() {
            grid.querySelectorAll('[data-x][data-y]').forEach(cell => { cell.onclick = null; });
        }
        removeOnclikHandlers();

        // Re-remove onclick whenever generateBrushGrid rebuilds the cells
        const gridObserver = new MutationObserver(removeOnclikHandlers);
        gridObserver.observe(grid, { childList: true });

        let isDragging = false;
        let dragMode = null;    // 'paint' | 'erase'

        grid.addEventListener('mousedown', (e) => {
            const cell = e.target.closest('[data-x][data-y]');
            if (!cell) return;
            e.preventDefault(); // Prevent text selection
            if (e.button === 0) {
                dragMode = 'paint';
                isDragging = true;
                setCellActive(cell, true); // Immediately activate on mousedown
            } else if (e.button === 2) {
                dragMode = 'erase';
                isDragging = true;
                setCellActive(cell, false); // Right-click immediately erases
            }
        });

        grid.addEventListener('mousemove', (e) => {
            if (!isDragging || !dragMode) return;
            const el = document.elementFromPoint(e.clientX, e.clientY);
            const cell = el ? el.closest('[data-x][data-y]') : null;
            if (!cell) return;
            setCellActive(cell, dragMode === 'paint');
        });

        grid.addEventListener('contextmenu', (e) => {
            e.preventDefault(); // Suppress right-click context menu on the grid
        });

        document.addEventListener('mouseup', () => {
            isDragging = false;
            dragMode = null;
        });
    }

    // ============================================

    function createBrushPreview(brush) {
        const grid = document.createElement('div');
        grid.className = 'brush-swap-preview-grid';

        // Create a map of active cells based on pattern
        const activeCells = new Map();
        const centerOffset = Math.floor(brush.brushSize / 2);
        let minX = Infinity, maxX = -Infinity;
        let minY = Infinity, maxY = -Infinity;

        brush.pattern.forEach(offset => {
            // Convert from relative coordinates to grid coordinates
            const gridX = offset.x + centerOffset;
            const gridY = (offset.y * -1) + centerOffset; // Denormalize Y-axis
            activeCells.set(`${gridX},${gridY}`, true);
            minX = Math.min(minX, gridX);
            maxX = Math.max(maxX, gridX);
            minY = Math.min(minY, gridY);
            maxY = Math.max(maxY, gridY);
        });

        // Calculate preview dimensions
        const width = maxX - minX + 1;
        const height = maxY - minY + 1;
        const maxDim = Math.max(width, height);

        // Scale cells to fit compact preview (8px max per cell)
        const cellSize = Math.max(4, Math.floor(32 / maxDim));

        // Calculate center of the pattern bounds (not the grid size)
        const patternCenterX = minX + Math.floor((maxX - minX) / 2);
        const patternCenterY = minY + Math.floor((maxY - minY) / 2);

        // Build preview with full pattern bounds
        for (let y = minY; y <= maxY; y++) {
            for (let x = minX; x <= maxX; x++) {
                const cell = document.createElement('div');
                cell.className = 'brush-swap-preview-cell';
                cell.style.width = cellSize + 'px';
                cell.style.height = cellSize + 'px';

                const isActive = activeCells.has(`${x},${y}`);
                const isCenter = x === patternCenterX && y === patternCenterY;

                if (isActive) {
                    cell.classList.add('active');
                    if (isCenter) {
                        cell.classList.add('center');
                    }
                }

                grid.appendChild(cell);
            }
        }

        // Set grid columns dynamically
        grid.style.gridTemplateColumns = `repeat(${width}, ${cellSize}px)`;

        return grid;
    }

    // ============================================
    // DRAG REORDER
    // ============================================

    function setupDragReorder(handle, itemEl, fromIdx, container) {
        handle.addEventListener('mousedown', (e) => {
            e.preventDefault();
            e.stopPropagation();

            const items = Array.from(container.querySelectorAll('[data-brush-idx]'));
            const rects = items.map(el => el.getBoundingClientRect());
            let currentDropIdx = fromIdx;

            itemEl.classList.add('brush-swap-item-dragging');

            // Remove any existing indicator
            let indicator = container.querySelector('.brush-swap-drop-indicator');

            function onMove(ev) {
                const y = ev.clientY;

                // Find which slot we're hovering
                let dropIdx = items.length; // default to end
                for (let i = 0; i < rects.length; i++) {
                    const mid = rects[i].top + rects[i].height / 2;
                    if (y < mid) {
                        dropIdx = i;
                        break;
                    }
                }
                if (dropIdx === currentDropIdx) return;
                currentDropIdx = dropIdx;

                // Remove old indicator
                if (indicator) indicator.remove();

                // Insert indicator at the drop position
                indicator = document.createElement('div');
                indicator.className = 'brush-swap-drop-indicator';
                if (dropIdx < items.length) {
                    container.insertBefore(indicator, items[dropIdx]);
                } else {
                    container.appendChild(indicator);
                }
            }

            function onUp() {
                document.removeEventListener('mousemove', onMove);
                document.removeEventListener('mouseup', onUp);
                itemEl.classList.remove('brush-swap-item-dragging');
                if (indicator) indicator.remove();

                // Perform the reorder
                if (currentDropIdx !== fromIdx && currentDropIdx !== fromIdx + 1) {
                    const [moved] = scriptState.brushes.splice(fromIdx, 1);
                    const insertAt = currentDropIdx > fromIdx ? currentDropIdx - 1 : currentDropIdx;
                    scriptState.brushes.splice(insertAt, 0, moved);
                    saveBrushes();
                    // Update scrollIndex to follow the moved brush
                    const newIdx = scriptState.brushes.findIndex(b => b.id === moved.id);
                    if (scriptState.scrollIndex === fromIdx) scriptState.scrollIndex = newIdx;
                }
                renderDropdown();
            }

            document.addEventListener('mousemove', onMove);
            document.addEventListener('mouseup', onUp);
        });
    }

    // ============================================
    // UI RENDERING
    // ============================================

    // ============================================
    // BRUSH EXPORT / IMPORT
    // ============================================

    function showExportBrushesModal() {
        const existing = document.getElementById('gpc-brush-export-modal');
        if (existing) { existing.remove(); return; }

        const dark = document.body.classList.contains('dark') ||
                     window.matchMedia('(prefers-color-scheme: dark)').matches;

        const overlay = document.createElement('div');
        overlay.id = 'gpc-brush-export-modal';
        overlay.style.cssText = `
            position: fixed; inset: 0; z-index: 100000;
            background: rgba(0,0,0,0.5); display: flex;
            align-items: center; justify-content: center;
            font-family: system-ui, -apple-system, sans-serif;
        `;
        overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });

        const modal = document.createElement('div');
        modal.style.cssText = `
            background: ${dark ? '#1e1e2e' : '#ffffff'};
            color: ${dark ? '#cdd6f4' : '#1e293b'};
            border-radius: 12px; padding: 0; width: 480px; max-width: 95vw;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3); overflow: hidden;
        `;

        // ── Header ──
        const headerTitle = document.createElement('span');
        headerTitle.style.cssText = 'font-weight:700;font-size:15px;';
        headerTitle.textContent = `\uD83D\uDCE4 Export Brushes`;

        const header = document.createElement('div');
        header.style.cssText = `
            padding: 14px 20px; display: flex; align-items: center;
            justify-content: space-between;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border-bottom: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
        `;
        const closeBtn = document.createElement('button');
        closeBtn.textContent = '\u2715';
        closeBtn.style.cssText = `background:none;border:none;font-size:18px;cursor:pointer;color:${dark ? '#a6adc8' : '#64748b'};padding:4px 8px;border-radius:4px;`;
        closeBtn.onmouseenter = () => closeBtn.style.background = dark ? '#45475a' : '#e2e8f0';
        closeBtn.onmouseleave = () => closeBtn.style.background = 'none';
        closeBtn.onclick = () => overlay.remove();
        header.appendChild(headerTitle);
        header.appendChild(closeBtn);
        modal.appendChild(header);

        const body = document.createElement('div');
        body.style.cssText = 'padding: 16px 20px; display: flex; flex-direction: column; gap: 12px;';

        const hint = document.createElement('div');
        hint.style.cssText = `font-size:13px;color:${dark ? '#a6adc8' : '#64748b'};`;
        hint.textContent = 'Select the brushes to export, then copy or download the JSON.';
        body.appendChild(hint);

        // ── Brush checklist ──
        const listWrap = document.createElement('div');
        listWrap.style.cssText = `
            display: flex; flex-direction: column; gap: 2px;
            max-height: 180px; overflow-y: auto;
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            border-radius: 8px; padding: 6px 8px;
            background: ${dark ? '#181825' : '#f8fafc'};
        `;

        // Select All / None row
        const selRow = document.createElement('div');
        selRow.style.cssText = 'display:flex;gap:10px;margin-bottom:4px;';
        const selAll = document.createElement('button');
        selAll.textContent = 'Select All';
        selAll.style.cssText = `background:none;border:none;cursor:pointer;font-size:11px;font-weight:600;padding:0;color:${dark ? '#89b4fa' : '#3b82f6'};`;
        const selNone = document.createElement('button');
        selNone.textContent = 'Select None';
        selNone.style.cssText = selAll.style.cssText;
        selRow.appendChild(selAll);
        selRow.appendChild(selNone);
        listWrap.appendChild(selRow);

        const checkboxes = [];
        scriptState.brushes.forEach((brush) => {
            const row = document.createElement('label');
            row.style.cssText = `display:flex;align-items:center;gap:8px;padding:3px 2px;cursor:pointer;border-radius:4px;font-size:13px;`;
            row.onmouseenter = () => row.style.background = dark ? '#313244' : '#f1f5f9';
            row.onmouseleave = () => row.style.background = 'none';

            const cb = document.createElement('input');
            cb.type = 'checkbox';
            cb.checked = true;
            cb.style.cssText = 'flex-shrink:0;cursor:pointer;accent-color:#3b82f6;';
            cb.addEventListener('change', updateOutput);

            const nameSpan = document.createElement('span');
            nameSpan.textContent = brush.name;
            nameSpan.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';

            row.appendChild(cb);
            row.appendChild(nameSpan);
            listWrap.appendChild(row);
            checkboxes.push({ cb, brush });
        });

        selAll.onclick  = () => { checkboxes.forEach(x => { x.cb.checked = true;  }); updateOutput(); };
        selNone.onclick = () => { checkboxes.forEach(x => { x.cb.checked = false; }); updateOutput(); };

        body.appendChild(listWrap);

        // ── JSON output ──
        const textarea = document.createElement('textarea');
        textarea.readOnly = true;
        textarea.style.cssText = `
            width: 100%; min-height: 120px; max-height: 200px; box-sizing: border-box;
            padding: 10px; font-family: monospace; font-size: 12px;
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            border-radius: 8px; resize: vertical; outline: none;
            background: ${dark ? '#181825' : '#f8fafc'};
            color: ${dark ? '#cdd6f4' : '#1e293b'};
        `;
        textarea.addEventListener('click', () => textarea.select());
        body.appendChild(textarea);

        function getSelected() {
            return checkboxes.filter(x => x.cb.checked).map(x => x.brush);
        }
        function buildJson(brushList) {
            return JSON.stringify(brushList.map(b => ({ name: b.name, pattern: b.pattern, brushSize: b.brushSize })), null, 2);
        }
        function updateOutput() {
            const sel = getSelected();
            headerTitle.textContent = `\uD83D\uDCE4 Export Brushes (${sel.length} of ${scriptState.brushes.length})`;
            textarea.value = sel.length ? buildJson(sel) : '';
        }
        updateOutput();

        // ── Buttons ──
        const btnRow = document.createElement('div');
        btnRow.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;';

        const copyBtn = document.createElement('button');
        copyBtn.style.cssText = `
            padding: 7px 16px; border-radius: 8px; border: none; cursor: pointer; font-size: 13px; font-weight: 600;
            background: ${dark ? '#585b70' : '#e2e8f0'}; color: ${dark ? '#cdd6f4' : '#1e293b'};
            transition: background 0.15s;
        `;
        copyBtn.textContent = '\uD83D\uDCCB Copy to Clipboard';
        copyBtn.onmouseenter = () => copyBtn.style.background = dark ? '#6c7086' : '#cbd5e1';
        copyBtn.onmouseleave = () => copyBtn.style.background = dark ? '#585b70' : '#e2e8f0';
        copyBtn.onclick = () => {
            const json = buildJson(getSelected());
            if (!json) return;
            navigator.clipboard.writeText(json).then(() => {
                copyBtn.textContent = '\u2705 Copied!';
                setTimeout(() => { copyBtn.textContent = '\uD83D\uDCCB Copy to Clipboard'; }, 1800);
            }).catch(() => {
                textarea.select();
                document.execCommand('copy');
                copyBtn.textContent = '\u2705 Copied!';
                setTimeout(() => { copyBtn.textContent = '\uD83D\uDCCB Copy to Clipboard'; }, 1800);
            });
        };

        const dlBtn = document.createElement('button');
        dlBtn.style.cssText = `
            padding: 7px 16px; border-radius: 8px; border: none; cursor: pointer; font-size: 13px; font-weight: 600;
            background: ${dark ? '#89b4fa' : '#3b82f6'}; color: ${dark ? '#1e1e2e' : '#ffffff'};
            transition: background 0.15s;
        `;
        dlBtn.textContent = '\uD83D\uDCBE Download';
        dlBtn.onmouseenter = () => dlBtn.style.background = dark ? '#74c7ec' : '#2563eb';
        dlBtn.onmouseleave = () => dlBtn.style.background = dark ? '#89b4fa' : '#3b82f6';
        dlBtn.onclick = () => {
            const json = buildJson(getSelected());
            if (!json) return;
            const blob = new Blob([json], { type: 'application/json' });
            const a = document.createElement('a');
            a.href = URL.createObjectURL(blob);
            a.download = `gpc-brushes-${new Date().toISOString().slice(0,10)}.json`;
            document.body.appendChild(a);
            a.click();
            setTimeout(() => { a.remove(); URL.revokeObjectURL(a.href); }, 1000);
        };

        btnRow.appendChild(copyBtn);
        btnRow.appendChild(dlBtn);
        body.appendChild(btnRow);
        modal.appendChild(body);
        overlay.appendChild(modal);
        document.body.appendChild(overlay);
    }

    function showImportBrushesModal() {
        const existing = document.getElementById('gpc-brush-import-modal');
        if (existing) { existing.remove(); return; }

        const dark = document.body.classList.contains('dark') ||
                     window.matchMedia('(prefers-color-scheme: dark)').matches;

        const overlay = document.createElement('div');
        overlay.id = 'gpc-brush-import-modal';
        overlay.style.cssText = `
            position: fixed; inset: 0; z-index: 100000;
            background: rgba(0,0,0,0.5); display: flex;
            align-items: center; justify-content: center;
            font-family: system-ui, -apple-system, sans-serif;
        `;
        overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });

        const modal = document.createElement('div');
        modal.style.cssText = `
            background: ${dark ? '#1e1e2e' : '#ffffff'};
            color: ${dark ? '#cdd6f4' : '#1e293b'};
            border-radius: 12px; padding: 0; width: 480px; max-width: 95vw;
            box-shadow: 0 20px 60px rgba(0,0,0,0.3); overflow: hidden;
        `;

        // ── Header ──
        const header = document.createElement('div');
        header.style.cssText = `
            padding: 14px 20px; display: flex; align-items: center;
            justify-content: space-between;
            background: ${dark ? '#313244' : '#f1f5f9'};
            border-bottom: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
        `;
        header.innerHTML = `<span style="font-weight:700;font-size:15px;">\uD83D\uDCE5 Import Brushes</span>`;
        const closeBtn = document.createElement('button');
        closeBtn.textContent = '\u2715';
        closeBtn.style.cssText = `background:none;border:none;font-size:18px;cursor:pointer;color:${dark ? '#a6adc8' : '#64748b'};padding:4px 8px;border-radius:4px;`;
        closeBtn.onmouseenter = () => closeBtn.style.background = dark ? '#45475a' : '#e2e8f0';
        closeBtn.onmouseleave = () => closeBtn.style.background = 'none';
        closeBtn.onclick = () => overlay.remove();
        header.appendChild(closeBtn);
        modal.appendChild(header);

        const body = document.createElement('div');
        body.style.cssText = 'padding: 16px 20px; display: flex; flex-direction: column; gap: 12px;';

        const hint = document.createElement('div');
        hint.style.cssText = `font-size:13px;color:${dark ? '#a6adc8' : '#64748b'};`;
        hint.textContent = 'Paste brush JSON or upload a file. Select which brushes to add — they will be appended to your existing list.';
        body.appendChild(hint);

        const statusEl = document.createElement('div');
        statusEl.style.cssText = `font-size:12px;font-weight:600;min-height:16px;color:${dark ? '#a6e3a1' : '#16a34a'};`;
        body.appendChild(statusEl);

        // ── JSON input ──
        const textarea = document.createElement('textarea');
        textarea.placeholder = 'Paste JSON here\u2026';
        textarea.style.cssText = `
            width: 100%; min-height: 120px; max-height: 200px; box-sizing: border-box;
            padding: 10px; font-family: monospace; font-size: 12px;
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            border-radius: 8px; resize: vertical; outline: none;
            background: ${dark ? '#181825' : '#f8fafc'};
            color: ${dark ? '#cdd6f4' : '#1e293b'};
        `;
        body.appendChild(textarea);

        // ── Checklist (shown after parse) ──
        const listWrap = document.createElement('div');
        listWrap.style.cssText = `
            display: none; flex-direction: column; gap: 2px;
            max-height: 160px; overflow-y: auto;
            border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            border-radius: 8px; padding: 6px 8px;
            background: ${dark ? '#181825' : '#f8fafc'};
        `;

        const selRow = document.createElement('div');
        selRow.style.cssText = 'display:flex;gap:10px;margin-bottom:4px;';
        const selAll = document.createElement('button');
        selAll.textContent = 'Select All';
        selAll.style.cssText = `background:none;border:none;cursor:pointer;font-size:11px;font-weight:600;padding:0;color:${dark ? '#89b4fa' : '#3b82f6'};`;
        const selNone = document.createElement('button');
        selNone.textContent = 'Select None';
        selNone.style.cssText = selAll.style.cssText;
        selRow.appendChild(selAll);
        selRow.appendChild(selNone);
        listWrap.appendChild(selRow);

        let parsedBrushes = [];
        let checkboxes = [];

        function buildChecklist(brushes) {
            // Clear old rows (keep selRow)
            while (listWrap.children.length > 1) listWrap.removeChild(listWrap.lastChild);
            checkboxes = [];
            brushes.forEach((brush) => {
                const row = document.createElement('label');
                row.style.cssText = `display:flex;align-items:center;gap:8px;padding:3px 2px;cursor:pointer;border-radius:4px;font-size:13px;`;
                row.onmouseenter = () => row.style.background = dark ? '#313244' : '#f1f5f9';
                row.onmouseleave = () => row.style.background = 'none';

                const cb = document.createElement('input');
                cb.type = 'checkbox';
                cb.checked = true;
                cb.style.cssText = 'flex-shrink:0;cursor:pointer;accent-color:#3b82f6;';

                const nameSpan = document.createElement('span');
                nameSpan.textContent = (typeof brush.name === 'string' && brush.name.trim()) ? brush.name.trim() : 'Unnamed brush';
                nameSpan.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';

                row.appendChild(cb);
                row.appendChild(nameSpan);
                listWrap.appendChild(row);
                checkboxes.push({ cb, brush });
            });
            selAll.onclick  = () => { checkboxes.forEach(x => { x.cb.checked = true;  }); };
            selNone.onclick = () => { checkboxes.forEach(x => { x.cb.checked = false; }); };
            listWrap.style.display = 'flex';
        }

        function parseAndPreview(text) {
            let parsed;
            try { parsed = JSON.parse(text.trim()); } catch (e) {
                statusEl.style.color = dark ? '#f38ba8' : '#dc2626';
                statusEl.textContent = '\u274C Invalid JSON: ' + e.message;
                listWrap.style.display = 'none';
                return;
            }
            if (!Array.isArray(parsed)) {
                statusEl.style.color = dark ? '#f38ba8' : '#dc2626';
                statusEl.textContent = '\u274C Expected a JSON array of brush objects.';
                listWrap.style.display = 'none';
                return;
            }
            parsedBrushes = parsed.filter(b => Array.isArray(b.pattern) && typeof b.brushSize === 'number');
            const skipped = parsed.length - parsedBrushes.length;
            if (parsedBrushes.length === 0) {
                statusEl.style.color = dark ? '#f38ba8' : '#dc2626';
                statusEl.textContent = '\u274C No valid brush objects found.';
                listWrap.style.display = 'none';
                return;
            }
            statusEl.style.color = dark ? '#a6adc8' : '#64748b';
            statusEl.textContent = `Found ${parsedBrushes.length} brush${parsedBrushes.length !== 1 ? 'es' : ''}${skipped ? ` (${skipped} invalid skipped)` : ''} \u2014 select which to add:`;
            buildChecklist(parsedBrushes);
        }

        textarea.addEventListener('input', () => {
            if (textarea.value.trim()) parseAndPreview(textarea.value);
            else { listWrap.style.display = 'none'; statusEl.textContent = ''; }
        });

        body.appendChild(listWrap);

        // ── Buttons ──
        const btnRow = document.createElement('div');
        btnRow.style.cssText = 'display:flex;gap:8px;justify-content:space-between;align-items:center;';

        const fileInput = document.createElement('input');
        fileInput.type = 'file';
        fileInput.accept = '.json,application/json';
        fileInput.style.display = 'none';
        fileInput.addEventListener('change', () => {
            const file = fileInput.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = (e) => { textarea.value = e.target.result; parseAndPreview(e.target.result); };
            reader.readAsText(file);
            fileInput.value = '';
        });
        body.appendChild(fileInput);

        const uploadBtn = document.createElement('button');
        uploadBtn.style.cssText = `
            padding: 7px 14px; border-radius: 8px; border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
            cursor: pointer; font-size: 13px; font-weight: 600;
            background: ${dark ? '#313244' : '#f1f5f9'}; color: ${dark ? '#cdd6f4' : '#1e293b'};
            transition: background 0.15s;
        `;
        uploadBtn.textContent = '\uD83D\uDCC1 Upload File';
        uploadBtn.onmouseenter = () => uploadBtn.style.background = dark ? '#45475a' : '#e2e8f0';
        uploadBtn.onmouseleave = () => uploadBtn.style.background = dark ? '#313244' : '#f1f5f9';
        uploadBtn.onclick = () => fileInput.click();

        const importBtn = document.createElement('button');
        importBtn.style.cssText = `
            padding: 7px 16px; border-radius: 8px; border: none; cursor: pointer; font-size: 13px; font-weight: 600;
            background: ${dark ? '#89b4fa' : '#3b82f6'}; color: ${dark ? '#1e1e2e' : '#ffffff'};
            transition: background 0.15s;
        `;
        importBtn.textContent = '\u2705 Import Selected';
        importBtn.onmouseenter = () => importBtn.style.background = dark ? '#74c7ec' : '#2563eb';
        importBtn.onmouseleave = () => importBtn.style.background = dark ? '#89b4fa' : '#3b82f6';
        importBtn.onclick = () => {
            const selected = checkboxes.filter(x => x.cb.checked).map(x => x.brush);
            if (selected.length === 0) {
                statusEl.style.color = dark ? '#f38ba8' : '#dc2626';
                statusEl.textContent = '\u274C No brushes selected.';
                return;
            }
            selected.forEach(b => {
                scriptState.brushes.push({
                    id: scriptState.nextId++,
                    name: (typeof b.name === 'string' && b.name.trim()) ? b.name.trim() : `Brush ${scriptState.nextId - 1}`,
                    pattern: b.pattern,
                    brushSize: b.brushSize
                });
            });
            saveBrushes();
            statusEl.style.color = dark ? '#a6e3a1' : '#16a34a';
            statusEl.textContent = `\u2705 Added ${selected.length} brush${selected.length !== 1 ? 'es' : ''}. Total: ${scriptState.brushes.length}.`;
            textarea.value = '';
            listWrap.style.display = 'none';
            parsedBrushes = [];
            checkboxes = [];
            if (scriptState.dropdownOpen) renderDropdown();
        };

        btnRow.appendChild(uploadBtn);
        btnRow.appendChild(importBtn);
        body.appendChild(btnRow);
        modal.appendChild(body);
        overlay.appendChild(modal);
        document.body.appendChild(overlay);
        setTimeout(() => textarea.focus(), 50);
    }

    function renderDropdown() {
        let dropdown = document.getElementById('brush-swap-dropdown');
        if (!dropdown) return;

        // Clear existing items
        const itemsContainer = dropdown.querySelector('.brush-swap-items');
        itemsContainer.innerHTML = '';

        if (scriptState.brushes.length === 0) {
            const emptyMsg = document.createElement('div');
            emptyMsg.className = 'text-center text-gray-500 dark:text-gray-400 text-xs py-3 px-2';
            emptyMsg.textContent = 'No saved brushes';
            itemsContainer.appendChild(emptyMsg);
            return;
        }

        scriptState.brushes.forEach((brush, idx) => {
            const item = document.createElement('div');
            const isActive = brush.id === scriptState.activeBrushId;
            item.className = 'flex items-center gap-2 p-1.5 border border-gray-200 dark:border-gray-600 rounded bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
                + (isActive ? ' brush-swap-item-active' : '');
            item.dataset.brushId = brush.id;
            item.dataset.brushIdx = idx;

            // Preview grid (wrapped with click-to-expand)
            const previewWrap = document.createElement('div');
            previewWrap.className = 'brush-swap-preview-wrap';
            const preview = createBrushPreview(brush);
            previewWrap.appendChild(preview);
            previewWrap.addEventListener('click', (e) => {
                e.stopPropagation();
                previewWrap.classList.toggle('expanded');
            });
            // Fast tooltip that follows mouse
            let prevTip = null;
            previewWrap.addEventListener('mouseenter', (e) => {
                prevTip = document.createElement('div');
                prevTip.className = 'brush-swap-quick-tip';
                prevTip.textContent = 'click to expand';
                prevTip.style.left = (e.clientX + 12) + 'px';
                prevTip.style.top = (e.clientY - 8) + 'px';
                document.body.appendChild(prevTip);
            });
            previewWrap.addEventListener('mousemove', (e) => {
                if (prevTip) {
                    prevTip.style.left = (e.clientX + 12) + 'px';
                    prevTip.style.top = (e.clientY - 8) + 'px';
                }
            });
            previewWrap.addEventListener('mouseleave', () => {
                if (prevTip) { prevTip.remove(); prevTip = null; }
            });
            item.appendChild(previewWrap);

            // Name and controls
            const infoContainer = document.createElement('div');
            infoContainer.className = 'flex-1 flex flex-col gap-1';

            // Name display / edit
            const nameContainer = document.createElement('div');
            nameContainer.className = 'flex items-center gap-1 flex-1';

            if (scriptState.isRenaming === brush.id) {
                // Rename input mode
                const input = document.createElement('input');
                input.type = 'text';
                input.className = 'flex-1 px-1 py-0.5 text-xs border border-gray-500 dark:border-gray-400 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100';
                input.value = brush.name;
                input.maxLength = 30;

                input.addEventListener('blur', () => {
                    renameBrush(brush.id, input.value);
                    scriptState.isRenaming = null;
                });

                input.addEventListener('keydown', (e) => {
                    if (e.key === 'Enter') {
                        renameBrush(brush.id, input.value);
                        scriptState.isRenaming = null;
                    }
                });

                nameContainer.appendChild(input);
                setTimeout(() => input.focus(), 0);
            } else {
                // Normal name display with pencil icon
                const nameSpan = document.createElement('span');
                nameSpan.className = 'flex-1 text-xs font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap overflow-hidden text-ellipsis';
                nameSpan.textContent = brush.name;
                nameContainer.appendChild(nameSpan);

                const pencilBtn = document.createElement('button');
                pencilBtn.className = 'bg-none border-none cursor-pointer p-0 text-xs opacity-60 hover:opacity-100 transition-opacity flex-shrink-0';
                pencilBtn.title = 'Rename brush';
                pencilBtn.innerHTML = '✏️';
                pencilBtn.addEventListener('click', (e) => {
                    e.stopPropagation();
                    scriptState.isRenaming = brush.id;
                    renderDropdown();
                });
                nameContainer.appendChild(pencilBtn);
            }

            infoContainer.appendChild(nameContainer);

            // Load and Delete buttons
            const buttonsContainer = document.createElement('div');
            buttonsContainer.className = 'flex gap-1 flex-shrink-0';

            const loadBtn = document.createElement('button');
            loadBtn.className = 'px-1.5 py-0.5 text-xs border border-gray-300 dark:border-gray-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 cursor-pointer rounded transition-colors hover:bg-blue-50 dark:hover:bg-blue-900 hover:border-blue-400 dark:hover:border-blue-400';
            loadBtn.textContent = 'Load';
            loadBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                loadBrush(brush.id);
            });

            const deleteBtn = document.createElement('button');
            deleteBtn.className = 'px-1 py-0.5 text-xs bg-none border border-gray-300 dark:border-gray-500 opacity-60 hover:opacity-100 transition-opacity cursor-pointer rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 hover:bg-red-50 dark:hover:bg-red-900 hover:border-red-400 dark:hover:border-red-400';
            deleteBtn.title = 'Delete brush';
            deleteBtn.innerHTML = '✕';
            deleteBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                deleteBrush(brush.id);
            });

            buttonsContainer.appendChild(loadBtn);
            buttonsContainer.appendChild(deleteBtn);
            infoContainer.appendChild(buttonsContainer);

            item.appendChild(infoContainer);

            // Drag handle (right side)
            const dragHandle = document.createElement('div');
            dragHandle.className = 'brush-swap-drag-handle';
            dragHandle.title = 'Drag to reorder';
            for (let d = 0; d < 4; d++) {
                const dot = document.createElement('div');
                dot.className = 'brush-swap-drag-handle-dot';
                dragHandle.appendChild(dot);
            }
            setupDragReorder(dragHandle, item, idx, itemsContainer);
            item.appendChild(dragHandle);

            itemsContainer.appendChild(item);
        });
    }

    function toggleDropdown() {
        const dropdown = document.getElementById('brush-swap-dropdown');
        if (!dropdown) return;

        scriptState.dropdownOpen = !scriptState.dropdownOpen;

        if (scriptState.dropdownOpen) {
            // Detect if the paint menu is docked to top
            const paintIsTop = localStorage.getItem('gpc-paint-is-top') === 'true';
            const resizeHandle = dropdown.querySelector('.brush-swap-resize-handle');
            if (paintIsTop) {
                dropdown.style.bottom = 'auto';
                dropdown.style.top = '100%';
                dropdown.style.marginBottom = '0';
                dropdown.style.marginTop = '8px';
                if (resizeHandle) resizeHandle.className = 'brush-swap-resize-handle brush-swap-resize-br';
            } else {
                dropdown.style.top = 'auto';
                dropdown.style.bottom = '100%';
                dropdown.style.marginTop = '0';
                dropdown.style.marginBottom = '8px';
                if (resizeHandle) resizeHandle.className = 'brush-swap-resize-handle brush-swap-resize-tr';
            }
            // Apply stored resize dimensions if any
            try {
                const stored = JSON.parse(localStorage.getItem(RESIZE_STORAGE_KEY));
                if (stored) {
                    dropdown.style.maxWidth = stored.w + 'px';
                    dropdown.style.maxHeight = stored.h + 'px';
                }
            } catch {}
            dropdown.classList.add('open');
            renderDropdown();
        } else {
            dropdown.classList.remove('open');
            scriptState.isRenaming = null;
        }
    }

    // ============================================
    // DOM INITIALIZATION
    // ============================================

    function injectCSS() {
        const style = document.createElement('style');
        style.textContent = `
            /* Paintbrush icon button */
            #brush-swap-toggle {
                opacity: 0.85;
                transition: all 0.2s ease;
            }

            #brush-swap-toggle:hover {
                opacity: 1;
            }

            #brush-swap-toggle:active {
                opacity: 0.7;
            }

            /* Dropdown container */
            #brush-swap-dropdown {
                position: absolute;
                bottom: 100%;
                right: 0;
                border-radius: 4px;
                margin-bottom: 8px;
                max-width: 300px;
                max-height: 0;
                overflow: hidden;
                opacity: 0;
                pointer-events: none;
                transition: max-height 0.3s ease, opacity 0.3s ease;
                z-index: 1000;
            }

            #brush-swap-dropdown.open {
                max-height: 600px;
                opacity: 1;
                overflow-y: auto;
                pointer-events: auto;
            }

            /* Disable all transitions during resize drag to prevent animation lag */
            #brush-swap-dropdown.brush-swap-resizing {
                transition: none !important;
                overflow-y: auto !important;
            }

            /* Items container */
            .brush-swap-items {
                display: flex;
                flex-direction: column;
                gap: 4px;
                padding: 8px;
                min-width: 250px;
            }

            /* Preview grid */
            .brush-swap-preview-grid {
                display: grid;
                gap: 1px;
                flex-shrink: 0;
                background: var(--color-gray-100, white);
                padding: 2px;
                border: 1px solid var(--color-gray-400, #ddd);
                border-radius: 2px;
            }

            .brush-swap-quick-tip {
                position: fixed;
                pointer-events: none;
                z-index: 100001;
                background: rgba(0,0,0,0.8);
                color: #fff;
                padding: 3px 7px;
                border-radius: 4px;
                font-size: 10px;
                font-weight: 500;
                white-space: nowrap;
                font-family: system-ui, sans-serif;
            }

            .brush-swap-preview-wrap {
                width: 36px;
                height: 36px;
                overflow: hidden;
                flex-shrink: 0;
                border-radius: 3px;
                cursor: pointer;
                position: relative;
                display: flex;
                align-items: center;
                justify-content: center;
            }

            .brush-swap-preview-wrap.expanded {
                width: auto;
                height: auto;
                max-width: 120px;
                max-height: 120px;
            }

            .brush-swap-preview-wrap.expanded .brush-swap-preview-grid {
                max-width: 100%;
                max-height: 100%;
            }

            .brush-swap-preview-cell {
                background: var(--color-gray-100, white);
                border: 0.5px solid var(--color-gray-300, #eee);
            }

            .brush-swap-preview-cell.active {
                background: var(--color-gray-800, #333);
            }

            .brush-swap-preview-cell.center {
                background: #ff6b6b;
            }

            /* Scrollbar styling for dropdown */
            #brush-swap-dropdown::-webkit-scrollbar {
                width: 6px;
            }

            #brush-swap-dropdown::-webkit-scrollbar-track {
                background: transparent;
            }

            #brush-swap-dropdown::-webkit-scrollbar-thumb {
                background: #888;
                border-radius: 3px;
            }

            #brush-swap-dropdown::-webkit-scrollbar-thumb:hover {
                background: #555;
            }

            /* Dark mode scrollbar */
            @media (prefers-color-scheme: dark) {
                #brush-swap-dropdown::-webkit-scrollbar-thumb {
                    background: #555;
                }

                #brush-swap-dropdown::-webkit-scrollbar-thumb:hover {
                    background: #777;
                }
            }

            /* Scroll-swap toast */
            #brush-swap-toast {
                position: fixed;
                pointer-events: none;
                z-index: 10000;
                background: rgba(0, 0, 0, 0.82);
                color: #fff;
                padding: 6px 10px;
                border-radius: 6px;
                font-size: 11px;
                font-weight: 600;
                font-family: system-ui, sans-serif;
                white-space: nowrap;
                opacity: 0;
                transition: opacity 0.15s ease;
                transform: translate(12px, -50%);
                display: flex;
                align-items: center;
                gap: 8px;
            }
            #brush-swap-toast.visible {
                opacity: 1;
            }
            #brush-swap-toast .toast-preview {
                flex-shrink: 0;
            }
            #brush-swap-toast .toast-preview .brush-swap-preview-cell {
                background: rgba(255,255,255,0.2);
                border-color: rgba(255,255,255,0.1);
            }
            #brush-swap-toast .toast-preview .brush-swap-preview-cell.active {
                background: #fff;
            }
            #brush-swap-toast .toast-preview .brush-swap-preview-cell.center {
                background: #ff6b6b;
            }
            #brush-swap-toast .toast-preview .brush-swap-preview-grid {
                background: transparent;
                border-color: rgba(255,255,255,0.15);
            }

            /* Active brush highlight */
            .brush-swap-item-active {
                outline: 2px solid var(--color-blue-500, #3b82f6) !important;
                outline-offset: -1px;
                background: var(--color-blue-50, rgba(59,130,246,0.08)) !important;
            }

            /* Drag handle */
            .brush-swap-drag-handle {
                display: flex;
                flex-direction: column;
                justify-content: center;
                align-items: center;
                width: 14px;
                cursor: grab;
                flex-shrink: 0;
                opacity: 0.35;
                transition: opacity 0.15s;
                user-select: none;
                padding: 2px 0;
            }
            .brush-swap-drag-handle:hover {
                opacity: 0.8;
            }
            .brush-swap-drag-handle:active {
                cursor: grabbing;
                opacity: 1;
            }
            .brush-swap-drag-handle-dot {
                width: 3px;
                height: 3px;
                border-radius: 50%;
                background: var(--color-gray-500, #9ca3af);
                margin: 1px 0;
            }

            /* Dragging visual */
            .brush-swap-item-dragging {
                opacity: 0.4;
            }
            .brush-swap-drop-indicator {
                height: 2px;
                background: var(--color-blue-500, #3b82f6);
                border-radius: 1px;
                margin: -2px 0;
                pointer-events: none;
            }

            /* Resize handle — single corner, right side */
            .brush-swap-resize-handle {
                position: absolute;
                right: 0;
                width: 14px;
                height: 14px;
                z-index: 1001;
                opacity: 0;
                transition: opacity 0.15s;
            }
            #brush-swap-dropdown:hover .brush-swap-resize-handle {
                opacity: 0.5;
            }
            .brush-swap-resize-handle:hover {
                opacity: 1 !important;
            }
            .brush-swap-resize-handle::after {
                content: '';
                position: absolute;
                width: 7px;
                height: 7px;
                border-style: solid;
                border-color: var(--color-gray-500, #6b7280);
            }
            /* top-right: open upward */
            .brush-swap-resize-tr {
                top: 0;
                cursor: ne-resize;
            }
            .brush-swap-resize-tr::after {
                top: 2px; right: 2px;
                border-width: 2px 2px 0 0;
            }
            /* bottom-right: open downward (paint menu at top) */
            .brush-swap-resize-br {
                bottom: 0;
                cursor: se-resize;
            }
            .brush-swap-resize-br::after {
                bottom: 2px; right: 2px;
                border-width: 0 2px 2px 0;
            }

            /* Export/Import footer */
            .brush-swap-footer {
                display: flex;
                justify-content: flex-end;
                gap: 4px;
                padding: 6px 8px;
                border-top: 1px solid var(--color-gray-200, #e5e7eb);
            }
            :is(.dark) .brush-swap-footer,
            .dark .brush-swap-footer {
                border-top-color: #374151;
            }
            .brush-swap-footer-btn {
                background: none;
                border: 1px solid var(--color-gray-300, #d1d5db);
                border-radius: 5px;
                cursor: pointer;
                font-size: 11px;
                line-height: 1;
                padding: 3px 8px;
                color: var(--color-gray-500, #6b7280);
                transition: background 0.12s, color 0.12s;
                white-space: nowrap;
                display: inline-flex;
                align-items: center;
                gap: 4px;
            }
            .brush-swap-footer-btn:hover {
                background: var(--color-gray-100, #f3f4f6);
                color: var(--color-gray-800, #1f2937);
                border-color: var(--color-gray-400, #9ca3af);
            }
            :is(.dark) .brush-swap-footer-btn,
            .dark .brush-swap-footer-btn {
                border-color: #4b5563;
                color: #9ca3af;
            }
            :is(.dark) .brush-swap-footer-btn:hover,
            .dark .brush-swap-footer-btn:hover {
                background: #374151;
                color: #e5e7eb;
                border-color: #6b7280;
            }
        `;
        document.head.appendChild(style);
    }

    function createUI(bottomControlsElement) {
        // Find commitBtn to position next to it
        const commitBtn = bottomControlsElement.querySelector('#commitBtn') ||
                         bottomControlsElement.querySelector('button');

        if (!commitBtn) {
            console.warn('Brush Swap: Could not find commitBtn');
            return;
        }

        // Create wrapper for button and dropdown
        const wrapper = document.createElement('div');
        wrapper.className = 'relative inline-block';

        // Create toggle button (paintbrush icon)
        const toggleBtn = document.createElement('button');
        toggleBtn.id = 'brush-swap-toggle';
        toggleBtn.className = 'bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 cursor-pointer px-2.5 py-1.5 text-xs leading-none font-semibold text-gray-800 dark:text-gray-200 ml-2 inline-flex items-center justify-center rounded hover:bg-gray-200 dark:hover:bg-gray-600 hover:border-gray-600 dark:hover:border-gray-500 active:bg-gray-300 dark:active:bg-gray-800';
        toggleBtn.title = 'Toggle saved brushes';
        toggleBtn.innerHTML = '<span style="font-size: 10px; font-weight: 600; display: flex; align-items: center; gap: 4px;">▲ brushes</span>';
        toggleBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            toggleDropdown();
        });

        // ── Scroll-to-swap: mouse wheel over toggle button cycles brushes ──
        const toast = document.createElement('div');
        toast.id = 'brush-swap-toast';
        document.body.appendChild(toast);
        let toastTimer = null;

        function showSwapToast(brush, x, y) {
            toast.innerHTML = '';
            // Add preview
            const previewWrap = document.createElement('span');
            previewWrap.className = 'toast-preview';
            previewWrap.appendChild(createBrushPreview(brush));
            toast.appendChild(previewWrap);
            // Add name
            const nameSpan = document.createElement('span');
            nameSpan.textContent = brush.name;
            toast.appendChild(nameSpan);

            toast.style.left = x + 'px';
            toast.style.top = y + 'px';
            toast.classList.add('visible');
            clearTimeout(toastTimer);
            toastTimer = setTimeout(() => toast.classList.remove('visible'), 900);
        }

        toggleBtn.addEventListener('wheel', (e) => {
            if (scriptState.brushes.length === 0) return;
            e.preventDefault();
            e.stopPropagation();

            const dir = e.deltaY > 0 ? 1 : -1;
            const len = scriptState.brushes.length;

            // Initialize index to current brush if not set
            if (scriptState.scrollIndex < 0 || scriptState.scrollIndex >= len) {
                scriptState.scrollIndex = dir > 0 ? 0 : len - 1;
            } else {
                scriptState.scrollIndex = ((scriptState.scrollIndex + dir) % len + len) % len;
            }

            const brush = scriptState.brushes[scriptState.scrollIndex];
            applyBrushToEditor(brush);
            showSwapToast(brush, e.clientX, e.clientY);

            if (DEBUG) console.log(`Brush Swap: Scrolled to "${brush.name}" (index ${scriptState.scrollIndex})`);
        }, { passive: false });

        // Create dropdown container
        const dropdown = document.createElement('div');
        dropdown.id = 'brush-swap-dropdown';
        dropdown.className = 'bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 shadow-lg';
        dropdown.style.position = 'absolute'; // ensure positioned for resize handles

        // Single resize handle — class swapped in toggleDropdown based on paint menu position
        const resizeHandle = document.createElement('div');
        resizeHandle.className = 'brush-swap-resize-handle brush-swap-resize-tr'; // default: top-right
        dropdown.appendChild(resizeHandle);

        // Stored dimensions (persisted so they survive open/close)
        let storedSize = null;
        try { storedSize = JSON.parse(localStorage.getItem(RESIZE_STORAGE_KEY)); } catch {}

        function applyStoredSize() {
            if (storedSize) {
                dropdown.style.width = storedSize.w + 'px';
                dropdown.style.maxWidth = storedSize.w + 'px';
                dropdown.style.maxHeight = storedSize.h + 'px';
            }
        }

        function setupResize(handle) {
            handle.addEventListener('mousedown', (e) => {
                e.preventDefault();
                e.stopPropagation();
                const startX = e.clientX;
                const startY = e.clientY;
                const rect = dropdown.getBoundingClientRect();
                const startW = rect.width;
                const startH = rect.height;
                const isBottom = handle.classList.contains('brush-swap-resize-br');
                dropdown.style.userSelect = 'none';
                dropdown.classList.add('brush-swap-resizing');

                const onMove = (ev) => {
                    const dx = ev.clientX - startX;
                    const dy = ev.clientY - startY;
                    // Top-right: drag up = taller. Bottom-right: drag down = taller.
                    const newH = Math.max(150, isBottom ? startH + dy : startH - dy);
                    const newW = Math.max(200, startW + dx);
                    dropdown.style.width = newW + 'px';
                    dropdown.style.maxWidth = newW + 'px';
                    dropdown.style.maxHeight = newH + 'px';
                };

                const onUp = () => {
                    dropdown.style.userSelect = '';
                    dropdown.classList.remove('brush-swap-resizing');
                    document.removeEventListener('mousemove', onMove, true);
                    document.removeEventListener('mouseup', onUp, true);
                    const r = dropdown.getBoundingClientRect();
                    storedSize = { w: Math.round(r.width), h: Math.round(r.height) };
                    localStorage.setItem(RESIZE_STORAGE_KEY, JSON.stringify(storedSize));
                };

                document.addEventListener('mousemove', onMove, true);
                document.addEventListener('mouseup', onUp, true);
            });
        }

        setupResize(resizeHandle);
        
        const itemsContainer = document.createElement('div');
        itemsContainer.className = 'brush-swap-items';
        dropdown.appendChild(itemsContainer);

        // Footer: export/import buttons
        const dropdownFooter = document.createElement('div');
        dropdownFooter.className = 'brush-swap-footer';

        const exportBtn = document.createElement('button');
        exportBtn.className = 'brush-swap-footer-btn';
        exportBtn.innerHTML = '\uD83D\uDCE4 Export';
        exportBtn.addEventListener('click', (e) => { e.stopPropagation(); showExportBrushesModal(); });

        const importBtn = document.createElement('button');
        importBtn.className = 'brush-swap-footer-btn';
        importBtn.innerHTML = '\uD83D\uDCE5 Import';
        importBtn.addEventListener('click', (e) => { e.stopPropagation(); showImportBrushesModal(); });

        const resetSizeBtn = document.createElement('button');
        resetSizeBtn.className = 'brush-swap-footer-btn';
        resetSizeBtn.innerHTML = '\u21BA';
        resetSizeBtn.title = 'Reset size';
        resetSizeBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            storedSize = null;
            localStorage.removeItem(RESIZE_STORAGE_KEY);
            dropdown.style.width = '';
            dropdown.style.maxWidth = '';
            dropdown.style.maxHeight = '';
        });

        dropdownFooter.appendChild(exportBtn);
        dropdownFooter.appendChild(importBtn);
        dropdownFooter.appendChild(resetSizeBtn);
        dropdown.appendChild(dropdownFooter);

        // Assemble and insert
        wrapper.appendChild(toggleBtn);
        wrapper.appendChild(dropdown);

        // Insert after commitBtn
        commitBtn.parentNode.insertBefore(wrapper, commitBtn.nextSibling);

        // Close dropdown on click outside
        document.addEventListener('click', (e) => {
            if (!wrapper.contains(e.target) && scriptState.dropdownOpen) {
                toggleDropdown();
            }
        });
    }

    function hookToggleBrushEditor() {
        const originalToggle = _pw.toggleBrushEditor;

        if (typeof originalToggle === 'function') {
            _pw.toggleBrushEditor = function() {
                // Call original toggle
                originalToggle.call(this);

                // Add dimension dropdown after modal opens; also install drag/fill enhancements
                setTimeout(() => {
                    addBrushDimensionDropdown();
                    _installBrushEditorEnhancements();
                }, 50);

                // Opera GX / Blink failsafe: after the close animation (200ms), ensure the
                // overlay has pointer-events:none so it can't silently swallow map clicks.
                setTimeout(() => {
                    const overlay = document.getElementById('brushEditorMenu');
                    if (overlay && overlay.classList.contains('hidden')) {
                        overlay.style.pointerEvents = 'none';
                        overlay.style.visibility = 'hidden';
                    }
                }, 350);
                // When opening, restore pointer-events so the overlay is interactive
                setTimeout(() => {
                    const overlay = document.getElementById('brushEditorMenu');
                    if (overlay && !overlay.classList.contains('hidden')) {
                        overlay.style.pointerEvents = '';
                        overlay.style.visibility = '';
                    }
                }, 10);
            };
            if (DEBUG) console.log('Brush Swap: Hooked toggleBrushEditor');
        }
    }

    // ============================================

    function hookSaveBrushToPreset() {
        if (typeof _pw.saveBrushToPreset !== 'function') {
            console.warn('Brush Swap: saveBrushToPreset not yet available, retrying...');
            return false;
        }

        const originalSave = _pw.saveBrushToPreset;

        _pw.saveBrushToPreset = function(slotIndex) {
            // Call original function
            originalSave.call(this, slotIndex);

            // After save, capture brush from DOM grid
            const brushData = captureBrushFromDOM();
            if (brushData) {
                const newBrush = addBrush(brushData.pattern, brushData.brushSize);
                if (DEBUG) console.log('Brush Swap: Saved brush', newBrush);
                renderDropdown();
            } else {
                console.warn('Brush Swap: Failed to capture brush from DOM');
            }
        };

        if (DEBUG) console.log('Brush Swap: Successfully hooked saveBrushToPreset');
        return true;
    }

    // ============================================
    // INITIALIZATION
    // ============================================

    function init() {
        // Load saved brushes from localStorage
        loadBrushes();

        // Inject CSS
        injectCSS();

        // Wait for bottomControls and saveBrushToPreset to be ready
        let attempts = 0;
        const maxAttempts = 120; // 60 seconds at 500ms intervals

        const initInterval = setInterval(() => {
            attempts++;

            const bottomControls = document.getElementById('bottomControls');
            const hasSaveBrushToPreset = typeof _pw.saveBrushToPreset === 'function';

            if (bottomControls && hasSaveBrushToPreset && attempts <= maxAttempts) {
                clearInterval(initInterval);

                // Set default brush size to 9x9 — must wait for async config fetch
                // which overwrites BrushSize from userConfig.brushSize
                const DEFAULT_BRUSH_SIZE = 9;
                function applyDefaultBrushSize() {
                    _setPageVar('BrushSize', DEFAULT_BRUSH_SIZE);
                    _pw.BrushSize = DEFAULT_BRUSH_SIZE;
                    _runInPage('if(typeof generateBrushGrid==="function")generateBrushGrid(currentBrushPattern)');
                    // Update the dimension dropdown if it exists
                    const sel = document.getElementById('brush-swap-dimension-select');
                    if (sel) sel.value = DEFAULT_BRUSH_SIZE;
                }
                // Apply immediately, then re-apply after config fetch likely completes
                applyDefaultBrushSize();
                let configChecks = 0;
                const configWait = setInterval(() => {
                    configChecks++;
                    // userConfig gets written to localStorage when server fetch completes
                    const saved = localStorage.getItem('userConfig');
                    if (saved || configChecks > 20) {
                        clearInterval(configWait);
                        applyDefaultBrushSize();
                    }
                }, 250);

                // Create UI
                createUI(bottomControls);

                // Hook into saveBrushToPreset (now guaranteed to exist)
                hookSaveBrushToPreset();

                // Hook into toggleBrushEditor for dimension dropdown
                hookToggleBrushEditor();

                if (DEBUG) console.log('Brush Swap initialized successfully');
            } else if (attempts > maxAttempts) {
                clearInterval(initInterval);
                console.warn('Brush Swap: Could not initialize - bottomControls or saveBrushToPreset not found', {
                    hasBottomControls: !!bottomControls,
                    hasSaveBrushToPreset: hasSaveBrushToPreset
                });
            }
        }, 500);
    }

    // Start when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
            })();
            _featureStatus.paintBrushSwap = 'ok';
            console.log('[GeoPixelcons++] ✅ Paint Brush Swap loaded');
        } catch (err) {
            _featureStatus.paintBrushSwap = 'error';
            dbgPush(`Paint Brush Swap init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Paint Brush Swap' });
            console.error('[GeoPixelcons++] ❌ Paint Brush Swap failed:', err);
        }
    }

    // ============================================================
    //  FEATURE: Regions Highscore [regionsHighscore]
    // ============================================================
    if (_settings.regionsHighscore) {
        try {
            (function _init_regionsHighscore() {

    // ==================== CONFIGURATION ====================
    const GRID_SIZE = 25;
    const TILE_SIZE = 1000;
    const USERNAME_BATCH_SIZE = 10;
    const SELECTION_COLOR = 'rgba(59, 130, 246, 0.3)';
    const SELECTION_BORDER_COLOR = 'rgba(59, 130, 246, 0.8)';

    // ==================== STATE ====================
    let isSelectionModeActive = false;
    let isDragging = false;
    let selectionStart = null;
    let selectionEnd = null;
    let selectionCanvas = null;
    let selectionCtx = null;
    let highscoreButton = null;
    let _map = null; // resolved MapLibre map object (not the DOM element)

    // ==================== MAP ACCESS ====================
    function _getMap() {
        if (_map) return _map;
        try { const m = (0, eval)('map'); if (m && typeof m.scrollZoom !== 'undefined') return (_map = m); } catch {}
        if (typeof unsafeWindow !== 'undefined') { try { const m = unsafeWindow.eval('map'); if (m && typeof m.scrollZoom !== 'undefined') return (_map = m); } catch {} }
        return null;
    }

    // ==================== INITIALIZATION ====================
    function waitForGeoPixels() {
        return new Promise((resolve) => {
            const watchStartedAt = Date.now();
            let firstCheck = true;
            let warnedSlow = false;
            const check = () => {
                if (firstCheck) {
                    firstCheck = false;
                    dbgPush('Regions Highscore: waiting for map/turf/tileImageCache/#controls-left to become available -- polling every 500ms.', { uiComponent: 'Regions Highscore' });
                }

                const mapReady = !!_getMap();
                const turfReady = typeof turf !== 'undefined';
                const tileCacheReady = typeof tileImageCache !== 'undefined';
                const controlsReady = !!document.getElementById('controls-left');

                if (mapReady && turfReady && tileCacheReady && controlsReady) {
                    dbgPush('Regions Highscore: all init conditions met after ' + (Date.now() - watchStartedAt) + 'ms -- proceeding.', { uiComponent: 'Regions Highscore' });
                    resolve();
                } else {
                    if (!warnedSlow && (Date.now() - watchStartedAt) >= 15000) {
                        warnedSlow = true;
                        const missing = [];
                        if (!mapReady) missing.push('map');
                        if (!turfReady) missing.push('turf');
                        if (!tileCacheReady) missing.push('tileImageCache');
                        if (!controlsReady) missing.push('#controls-left');
                        dbgPush('Regions Highscore: still waiting after 15s -- still unmet: ' + missing.join(', ') + '. This feature has no fallback if it never inits, so polling continues.', { uiComponent: 'Regions Highscore' });
                        console.error('[GeoPixelcons++] Regions Highscore: waitForGeoPixels still unmet after 15s -- missing: ' + missing.join(', '));
                    }
                    setTimeout(check, 500);
                }
            };
            check();
        });
    }

    async function init() {
        await waitForGeoPixels();
        console.log('[Regions Highscore] Initializing...');

        createSelectionCanvas();
        createHighscoreButton();
        setupEventListeners();

        console.log('[Regions Highscore] Ready!');
    }

    // ==================== UI COMPONENTS ====================
    function createHighscoreButton() {
        highscoreButton = document.createElement('button');
        highscoreButton.id = 'gpc-highscore-trigger';
        highscoreButton.style.display = 'none';
        highscoreButton.addEventListener('click', toggleSelectionMode);
        document.body.appendChild(highscoreButton);
    }

    function createSelectionCanvas() {
        selectionCanvas = document.createElement('canvas');
        selectionCanvas.id = 'highscore-selection-canvas';
        selectionCanvas.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            pointer-events: none;
            z-index: 1000;
        `;
        document.body.appendChild(selectionCanvas);

        selectionCtx = selectionCanvas.getContext('2d');

        // Sync canvas size with viewport
        const syncCanvasSize = () => {
            const dpr = window.devicePixelRatio || 1;
            selectionCanvas.width = window.innerWidth * dpr;
            selectionCanvas.height = window.innerHeight * dpr;
            selectionCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
        };

        syncCanvasSize();
        window.addEventListener('resize', syncCanvasSize);

        // Redraw on map events
        ['move', 'rotate', 'zoom'].forEach((ev) => {
            _map.on(ev, () => {
                if (isDragging) drawSelectionPreview();
            });
        });
    }

    function toggleSelectionMode() {
        isSelectionModeActive = !isSelectionModeActive;

        if (isSelectionModeActive) {
            highscoreButton.style.backgroundColor = '#3b82f6';
            highscoreButton.style.color = 'white';
            highscoreButton.style.boxShadow = '0 0 10px rgba(59, 130, 246, 0.5)';
            document.body.style.cursor = 'crosshair';
            disableMapInteractions();
            showNotification('Click and drag to select a region');
        } else {
            resetSelectionMode();
        }
    }

    function disableMapInteractions() {
        const m = _getMap(); if (!m) return;
        m.dragPan.disable();
        m.scrollZoom.disable();
        m.boxZoom.disable();
        m.doubleClickZoom.disable();
        m.touchZoomRotate.disable();
    }

    function enableMapInteractions() {
        const m = _getMap(); if (!m) return;
        m.dragPan.enable();
        m.scrollZoom.enable();
        m.boxZoom.enable();
        // Note: doubleClickZoom is intentionally NOT re-enabled — the native site disables it
        m.touchZoomRotate.enable();
    }

    function resetSelectionMode() {
        isSelectionModeActive = false;
        isDragging = false;
        selectionStart = null;
        selectionEnd = null;

        if (highscoreButton) {
            highscoreButton.style.backgroundColor = 'white';
            highscoreButton.style.color = 'black';
            highscoreButton.style.boxShadow = '';
        }

        document.body.style.cursor = '';
        enableMapInteractions();
        clearSelectionCanvas();
    }

    function clearSelectionCanvas() {
        if (selectionCtx && selectionCanvas) {
            selectionCtx.clearRect(0, 0, window.innerWidth, window.innerHeight);
        }
    }

    // ==================== EVENT HANDLERS ====================
    function setupEventListeners() {
        document.addEventListener('mousedown', handleMouseDown);
        document.addEventListener('mousemove', handleMouseMove);
        document.addEventListener('mouseup', handleMouseUp);
        document.addEventListener('keydown', handleKeyDown);
    }

    function handleMouseDown(e) {
        if (!isSelectionModeActive) return;
        if (e.button !== 0) return; // Only left click

        // Check if clicking on UI elements
        if (e.target.closest('#controls-left') || e.target.closest('#controls-right') || e.target.closest('.modal-container')) {
            return;
        }

        isDragging = true;
        selectionStart = screenPointToGrid(e.clientX, e.clientY);
        selectionEnd = selectionStart;

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

    function handleMouseMove(e) {
        if (!isDragging || !selectionStart) return;

        selectionEnd = screenPointToGrid(e.clientX, e.clientY);
        drawSelectionPreview();
    }

    async function handleMouseUp(e) {
        if (!isDragging || !selectionStart || !selectionEnd) return;

        isDragging = false;

        const bounds = getSelectionBounds();
        const width = bounds.maxX - bounds.minX + 1;
        const height = bounds.maxY - bounds.minY + 1;

        if (width < 2 || height < 2) {
            showNotification('Selection too small. Please select a larger area.');
            clearSelectionCanvas();
            resetSelectionMode();
            return;
        }

        // Reset mode but keep selection visible during computation
        isSelectionModeActive = false;
        if (highscoreButton) {
            highscoreButton.style.backgroundColor = 'white';
            highscoreButton.style.color = 'black';
            highscoreButton.style.boxShadow = '';
        }
        document.body.style.cursor = '';
        enableMapInteractions();

        // Show loading modal
        const modal = createLeaderboardModal(bounds, null, true);
        const progressEl = modal.querySelector('.rhs-progress-text');

        const updateProgress = (text) => {
            if (progressEl) progressEl.textContent = text;
        };

        try {
            const userCounts = await computeRegionPixels(bounds, updateProgress);
            updateProgress('Fetching usernames...');
            const leaderboard = await buildLeaderboard(userCounts);
            updateLeaderboardModal(modal, bounds, leaderboard);
        } catch (error) {
            console.error('[Regions Highscore] Error computing leaderboard:', error);
            showNotification('Error computing leaderboard: ' + error.message);
            modal.close();
        }

        clearSelectionCanvas();
        selectionStart = null;
        selectionEnd = null;
    }

    function handleKeyDown(e) {
        if (e.key === 'Escape') {
            if (isSelectionModeActive || isDragging) {
                resetSelectionMode();
            }
        }
    }

    // ==================== COORDINATE HELPERS ====================
    function screenPointToGrid(clientX, clientY) {
        // _map.unproject expects point relative to map container
        const mapContainer = _map.getContainer();
        const rect = mapContainer.getBoundingClientRect();
        const point = [clientX - rect.left, clientY - rect.top];
        
        const lngLat = _map.unproject(point);
        const merc = turf.toMercator([lngLat.lng, lngLat.lat]);

        return {
            gridX: Math.round(merc[0] / GRID_SIZE),
            gridY: Math.round(merc[1] / GRID_SIZE),
        };
    }

    function gridToScreen(gridX, gridY) {
        const mercX = gridX * GRID_SIZE;
        const mercY = gridY * GRID_SIZE;
        const lngLat = turf.toWgs84([mercX, mercY]);
        const point = _map.project(lngLat);
        
        // Convert map-relative coordinates to screen coordinates
        const mapContainer = _map.getContainer();
        const rect = mapContainer.getBoundingClientRect();
        
        return {
            x: point.x + rect.left,
            y: point.y + rect.top
        };
    }

    function getSelectionBounds() {
        return {
            minX: Math.min(selectionStart.gridX, selectionEnd.gridX),
            maxX: Math.max(selectionStart.gridX, selectionEnd.gridX),
            minY: Math.min(selectionStart.gridY, selectionEnd.gridY),
            maxY: Math.max(selectionStart.gridY, selectionEnd.gridY),
        };
    }

    // ==================== DRAWING ====================
    function drawSelectionPreview() {
        clearSelectionCanvas();
        if (!selectionStart || !selectionEnd) return;

        const bounds = getSelectionBounds();

        // Convert grid bounds to screen coordinates
        const topLeft = gridToScreen(bounds.minX - 0.5, bounds.maxY + 0.5);
        const bottomRight = gridToScreen(bounds.maxX + 0.5, bounds.minY - 0.5);

        const x = topLeft.x;
        const y = topLeft.y;
        const width = bottomRight.x - topLeft.x;
        const height = bottomRight.y - topLeft.y;

        selectionCtx.fillStyle = SELECTION_COLOR;
        selectionCtx.fillRect(x, y, width, height);

        selectionCtx.strokeStyle = SELECTION_BORDER_COLOR;
        selectionCtx.lineWidth = 2;
        selectionCtx.strokeRect(x, y, width, height);

        // Draw size indicator
        const selWidth = bounds.maxX - bounds.minX + 1;
        const selHeight = bounds.maxY - bounds.minY + 1;
        const sizeText = `${selWidth} × ${selHeight}`;

        selectionCtx.font = 'bold 14px sans-serif';
        selectionCtx.fillStyle = 'white';
        selectionCtx.strokeStyle = 'black';
        selectionCtx.lineWidth = 3;

        const textX = x + width / 2;
        const textY = y + height / 2;

        selectionCtx.textAlign = 'center';
        selectionCtx.textBaseline = 'middle';
        selectionCtx.strokeText(sizeText, textX, textY);
        selectionCtx.fillText(sizeText, textX, textY);
    }

    // ==================== PIXEL COMPUTATION ====================
    async function computeRegionPixels(bounds, updateProgress) {
        const userCounts = new Map();
        const { minX, maxX, minY, maxY } = bounds;

        // Determine which tiles we need (more efficient iteration)
        const neededTiles = new Set();
        const startTileX = Math.floor(minX / TILE_SIZE) * TILE_SIZE;
        const endTileX = Math.floor(maxX / TILE_SIZE) * TILE_SIZE;
        const startTileY = Math.floor(minY / TILE_SIZE) * TILE_SIZE;
        const endTileY = Math.floor(maxY / TILE_SIZE) * TILE_SIZE;

        for (let tx = startTileX; tx <= endTileX; tx += TILE_SIZE) {
            for (let ty = startTileY; ty <= endTileY; ty += TILE_SIZE) {
                neededTiles.add(`${tx},${ty}`);
            }
        }

        const tilesArray = [...neededTiles];
        console.log(`[Regions Highscore] Need ${tilesArray.length} tiles for region ${maxX - minX + 1}×${maxY - minY + 1}`);
        console.log(`[Regions Highscore] Selection bounds: X ${minX} to ${maxX}, Y ${minY} to ${maxY}`);
        console.log(`[Regions Highscore] Tiles needed:`, tilesArray);

        let processedTiles = 0;
        let totalPixelsFound = 0;

        for (const tileKey of tilesArray) {
            const [tileX, tileY] = tileKey.split(',').map(Number);

            // Update progress
            processedTiles++;
            if (updateProgress) {
                updateProgress(`Processing tile ${processedTiles}/${tilesArray.length}...`);
            }

            // Yield to UI
            await new Promise(resolve => setTimeout(resolve, 0));

            // Try to get from cache first
            let userBitmap = null;
            const cached = tileImageCache.get(tileKey);
            console.log(`[Regions Highscore] Cache lookup for ${tileKey}:`, cached ? 'FOUND' : 'NOT FOUND');
            if (cached) {
                console.log(`[Regions Highscore] Cache entry keys:`, Object.keys(cached));
                console.log(`[Regions Highscore] Cache entry:`, cached);
            }
            if (cached && cached.userBitmap) {
                userBitmap = cached.userBitmap;
                console.log(`[Regions Highscore] Using cached userBitmap, size: ${userBitmap.width}x${userBitmap.height}`);
            } else {
                // Fetch from API
                console.log(`[Regions Highscore] Fetching tile ${tileKey} from API...`);
                if (updateProgress) {
                    updateProgress(`Fetching tile ${tileKey}...`);
                }
                try {
                    const tileData = await fetchTileData(tileX, tileY);
                    console.log(`[Regions Highscore] API response for ${tileKey}:`, tileData);
                    if (tileData && tileData.userBitmap) {
                        userBitmap = tileData.userBitmap;
                        console.log(`[Regions Highscore] Fetched userBitmap, size: ${userBitmap.width}x${userBitmap.height}`);
                    }
                } catch (err) {
                    console.warn(`[Regions Highscore] Failed to fetch tile ${tileKey}:`, err);
                    continue;
                }
            }

            if (!userBitmap) continue;

            // Debug: check if bitmap has ANY non-zero data by sampling various points
            const debugCanvas = new OffscreenCanvas(userBitmap.width, userBitmap.height);
            const debugCtx = debugCanvas.getContext('2d', { willReadFrequently: true });
            debugCtx.drawImage(userBitmap, 0, 0);
            const fullData = debugCtx.getImageData(0, 0, userBitmap.width, userBitmap.height).data;
            let nonZeroInFullBitmap = 0;
            for (let i = 0; i < fullData.length; i += 4) {
                if (fullData[i] !== 0 || fullData[i+1] !== 0 || fullData[i+2] !== 0) {
                    nonZeroInFullBitmap++;
                    if (nonZeroInFullBitmap <= 3) {
                        const pixelIndex = i / 4;
                        const bmpX = pixelIndex % userBitmap.width;
                        const bmpY = Math.floor(pixelIndex / userBitmap.width);
                        // Convert back to grid coordinates both ways
                        const gridXFromBmp = tileX + bmpX;
                        const gridYInverted = tileY + (TILE_SIZE - 1 - bmpY);
                        const gridYDirect = tileY + bmpY;
                        console.log(`[Regions Highscore] Non-zero pixel at bitmap (${bmpX}, ${bmpY}): RGB(${fullData[i]},${fullData[i+1]},${fullData[i+2]})`);
                        console.log(`  - Grid coords if Y inverted: (${gridXFromBmp}, ${gridYInverted})`);
                        console.log(`  - Grid coords if Y direct: (${gridXFromBmp}, ${gridYDirect})`);
                    }
                }
            }
            console.log(`[Regions Highscore] Total non-zero pixels in FULL bitmap: ${nonZeroInFullBitmap}`);
            
            // Check specific pixel (-351700, 218914) that user mentioned
            const testGridX = -351700;
            const testGridY = 218914;
            if (testGridX >= tileX && testGridX < tileX + TILE_SIZE && testGridY >= tileY && testGridY < tileY + TILE_SIZE) {
                const testLocalX = testGridX - tileX;
                const testLocalYInverted = TILE_SIZE - 1 - (testGridY - tileY);
                const testLocalYDirect = testGridY - tileY;
                
                console.log(`[Regions Highscore] Test pixel (-351700, 218914):`);
                console.log(`  - If Y inverted: local (${testLocalX}, ${testLocalYInverted})`);
                console.log(`  - If Y direct: local (${testLocalX}, ${testLocalYDirect})`);
                
                const testIdxInverted = (testLocalYInverted * userBitmap.width + testLocalX) * 4;
                const testIdxDirect = (testLocalYDirect * userBitmap.width + testLocalX) * 4;
                
                console.log(`  - Inverted value: RGB(${fullData[testIdxInverted]},${fullData[testIdxInverted+1]},${fullData[testIdxInverted+2]},${fullData[testIdxInverted+3]})`);
                console.log(`  - Direct value: RGB(${fullData[testIdxDirect]},${fullData[testIdxDirect+1]},${fullData[testIdxDirect+2]},${fullData[testIdxDirect+3]})`);
            }

            // Calculate the region of this tile that overlaps with selection
            const tileMinX = Math.max(minX, tileX);
            const tileMaxX = Math.min(maxX, tileX + TILE_SIZE - 1);
            const tileMinY = Math.max(minY, tileY);
            const tileMaxY = Math.min(maxY, tileY + TILE_SIZE - 1);

            const regionWidth = tileMaxX - tileMinX + 1;
            const regionHeight = tileMaxY - tileMinY + 1;

            if (regionWidth <= 0 || regionHeight <= 0) continue;

            // Read the entire relevant region at once (much faster than 1x1)
            // Y is NOT inverted in the bitmap - use direct coordinates
            const localStartX = tileMinX - tileX;
            const localStartY = tileMinY - tileY;

            console.log(`[Regions Highscore] Tile ${tileKey}: reading region ${regionWidth}x${regionHeight} at local (${localStartX}, ${localStartY})`);
            console.log(`[Regions Highscore] Tile bounds: X ${tileMinX}-${tileMaxX}, Y ${tileMinY}-${tileMaxY}`);

            const tempCanvas = new OffscreenCanvas(regionWidth, regionHeight);
            const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
            tempCtx.drawImage(
                userBitmap,
                localStartX, localStartY, regionWidth, regionHeight,
                0, 0, regionWidth, regionHeight
            );

            const imageData = tempCtx.getImageData(0, 0, regionWidth, regionHeight);
            const data = imageData.data;

            // Debug: log sample pixels to understand the data format
            console.log(`[Regions Highscore] ImageData size: ${imageData.width}x${imageData.height}, data length: ${data.length}`);
            const samplePixels = [];
            for (let s = 0; s < Math.min(10, regionWidth * regionHeight); s++) {
                const idx = s * 4;
                samplePixels.push(`(${data[idx]},${data[idx+1]},${data[idx+2]},${data[idx+3]})`);
            }
            console.log(`[Regions Highscore] First 10 pixels (RGBA):`, samplePixels.join(' '));

            // Process pixels in chunks to avoid blocking UI
            const CHUNK_SIZE = 50000;
            const totalPixels = regionWidth * regionHeight;
            let nonZeroCount = 0;

            for (let i = 0; i < totalPixels; i++) {
                const offset = i * 4;
                const r = data[offset];
                const g = data[offset + 1];
                const b = data[offset + 2];
                const a = data[offset + 3];

                // User ID is encoded in RGB; check if RGB is non-zero (not alpha)
                const userId = (r << 16) | (g << 8) | b;
                if (userId > 0) {
                    userCounts.set(userId, (userCounts.get(userId) || 0) + 1);
                    totalPixelsFound++;
                    if (nonZeroCount < 3) {
                        console.log(`[Regions Highscore] Found user pixel: userId=${userId} (R=${r},G=${g},B=${b},A=${a})`);
                    }
                    nonZeroCount++;
                }

                // Yield every CHUNK_SIZE pixels to keep UI responsive
                if (i > 0 && i % CHUNK_SIZE === 0) {
                    await new Promise(resolve => setTimeout(resolve, 0));
                }
            }
            
            console.log(`[Regions Highscore] Found ${nonZeroCount} non-zero pixels in tile region`);
        }

        console.log(`[Regions Highscore] Total pixels with users found: ${totalPixelsFound}`);
        console.log(`[Regions Highscore] Unique users: ${userCounts.size}`);

        return userCounts;
    }

    async function fetchTileData(tileX, tileY) {
        const response = await fetch('https://geopixels.net/GetPixelsCached', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                Tiles: [{ x: tileX, y: tileY, timestamp: 0 }],
            }),
        });

        if (!response.ok) {
            throw new Error(`API returned ${response.status}`);
        }

        const data = await response.json();
        const tileKey = `tile_${tileX}_${tileY}`;
        const tileInfo = data.Tiles[tileKey];

        console.log(`[Regions Highscore] API tile key: ${tileKey}`);
        console.log(`[Regions Highscore] Available tiles in response:`, Object.keys(data.Tiles));
        console.log(`[Regions Highscore] Tile info:`, tileInfo);

        if (!tileInfo) return null;

        // Handle full tile with WebP images
        if (tileInfo.Type === 'full' && tileInfo.UserWebP) {
            const userBitmap = await decodeWebPToBitmap(tileInfo.UserWebP);
            return { userBitmap };
        }

        // Handle delta (partial update) - we need to process deltas
        if (tileInfo.Pixels && tileInfo.Pixels.length > 0) {
            // Create bitmap from deltas
            const userBitmap = await createBitmapFromDeltas(tileInfo.Pixels, tileX, tileY);
            return { userBitmap };
        }

        return null;
    }

    async function decodeWebPToBitmap(base64Data) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.onload = () => {
                createImageBitmap(img).then(resolve).catch(reject);
            };
            img.onerror = reject;
            img.src = `data:image/webp;base64,${base64Data}`;
        });
    }

    async function createBitmapFromDeltas(deltas, tileX, tileY) {
        const canvas = new OffscreenCanvas(TILE_SIZE, TILE_SIZE);
        const ctx = canvas.getContext('2d');

        for (const delta of deltas) {
            const [gridX, gridY, color, userId] = delta;
            // Y is NOT inverted - use direct coordinates
            const localX = gridX - tileX;
            const localY = gridY - tileY;

            // Encode userId as RGB
            const r = (userId >> 16) & 0xff;
            const g = (userId >> 8) & 0xff;
            const b = userId & 0xff;

            ctx.fillStyle = `rgb(${r},${g},${b})`;
            ctx.fillRect(localX, localY, 1, 1);
        }

        return createImageBitmap(canvas);
    }

    // ==================== LEADERBOARD ====================
    async function buildLeaderboard(userCounts) {
        // Sort by pixel count descending
        const sorted = [...userCounts.entries()].sort((a, b) => b[1] - a[1]);

        // Fetch usernames
        const userIds = sorted.map(([id]) => id);
        const usernames = await fetchUsernames(userIds);

        return sorted.map(([userId, count], index) => ({
            rank: index + 1,
            userId,
            username: usernames.get(userId) || `User #${userId}`,
            pixelCount: count,
        }));
    }

    async function fetchUsernames(userIds) {
        const usernames = new Map();

        // Batch requests
        for (let i = 0; i < userIds.length; i += USERNAME_BATCH_SIZE) {
            const batch = userIds.slice(i, i + USERNAME_BATCH_SIZE);

            const promises = batch.map(async (userId) => {
                try {
                    const response = await fetch('https://geopixels.net/GetUserProfile', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ targetId: userId }),
                    });

                    if (response.ok) {
                        const data = await response.json();
                        return { userId, name: data.name || `User #${userId}` };
                    }
                } catch (err) {
                    console.warn(`[Regions Highscore] Failed to fetch user ${userId}:`, err);
                }
                return { userId, name: `User #${userId}` };
            });

            const results = await Promise.all(promises);
            for (const { userId, name } of results) {
                usernames.set(userId, name);
            }
        }

        return usernames;
    }

    // ==================== THEME HELPERS ====================
    function isDarkMode() {
        return getComputedStyle(document.documentElement).colorScheme === 'dark';
    }

    function getThemeColors() {
        const dark = isDarkMode();
        return {
            modalBg: dark ? '#1e2939' : 'white',
            overlayBg: dark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.5)',
            text: dark ? '#f3f4f6' : '#333',
            textSecondary: dark ? '#d1d5db' : '#666',
            textMuted: dark ? '#99a1af' : '#888',
            textSubtle: dark ? '#6a7282' : '#999',
            border: dark ? '#364153' : '#eee',
            headerBg: dark ? '#101828' : '#f0f0f0',
            summaryBg: dark ? '#101828' : '#f8f9fa',
            summaryText: dark ? '#d1d5db' : '#555',
            closeBtnColor: dark ? '#99a1af' : '#666',
            closeBtnHoverBg: dark ? '#364153' : '#f0f0f0',
            closeBtnHoverColor: dark ? '#f3f4f6' : '#333',
            notificationBg: dark ? '#1e2939' : '#333',
            notificationText: dark ? '#f3f4f6' : 'white',
        };
    }

    // ==================== ADJUST BOUNDS MODAL ====================
    function showAdjustModal(currentBounds, onConfirm) {
        const existing = document.querySelector('.rhs-adjust-overlay');
        if (existing) existing.remove();

        const t = getThemeColors();

        const overlay = document.createElement('div');
        overlay.className = 'rhs-adjust-overlay';
        overlay.style.cssText = `
            position: fixed; inset: 0; z-index: 10001;
            background: ${t.overlayBg};
            display: flex; align-items: center; justify-content: center;
            font-family: system-ui, sans-serif;
        `;

        const inputStyle = `width: 100%; margin-top: 2px; padding: 6px 8px; border-radius: 6px; border: 1px solid ${t.border}; background: ${t.headerBg}; color: ${t.text}; font-size: 13px; font-family: monospace;`;

        const box = document.createElement('div');
        box.style.cssText = `
            background: ${t.modalBg}; color: ${t.text}; border-radius: 12px;
            box-shadow: 0 16px 48px rgba(0,0,0,0.5);
            padding: 24px; min-width: 320px; display: flex; flex-direction: column; gap: 14px;
        `;

        box.innerHTML = `
            <h3 style="margin: 0; font-size: 16px; font-weight: 700;">Adjust Region Bounds</h3>
            <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
                <label style="font-size: 12px; color: ${t.textSecondary};">X1 (min)
                    <input id="rhs-adj-x1" type="number" value="${currentBounds.minX}" style="${inputStyle}" />
                </label>
                <label style="font-size: 12px; color: ${t.textSecondary};">X2 (max)
                    <input id="rhs-adj-x2" type="number" value="${currentBounds.maxX}" style="${inputStyle}" />
                </label>
                <label style="font-size: 12px; color: ${t.textSecondary};">Y1 (min)
                    <input id="rhs-adj-y1" type="number" value="${currentBounds.minY}" style="${inputStyle}" />
                </label>
                <label style="font-size: 12px; color: ${t.textSecondary};">Y2 (max)
                    <input id="rhs-adj-y2" type="number" value="${currentBounds.maxY}" style="${inputStyle}" />
                </label>
            </div>
            <div id="rhs-adj-error" style="font-size: 12px; color: #ef4444; display: none;"></div>
            <div style="display: flex; gap: 10px; justify-content: flex-end;">
                <button id="rhs-adj-cancel" style="padding: 8px 16px; border-radius: 8px; border: 1px solid ${t.border}; background: ${t.headerBg}; color: ${t.textSecondary}; cursor: pointer; font-size: 13px;">Cancel</button>
                <button id="rhs-adj-confirm" style="padding: 8px 16px; border-radius: 8px; border: none; background: #3b82f6; color: white; cursor: pointer; font-size: 13px; font-weight: 600;">Apply</button>
            </div>
        `;

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

        const close = () => overlay.remove();
        overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
        box.querySelector('#rhs-adj-cancel').onclick = close;

        box.querySelector('#rhs-adj-confirm').onclick = () => {
            const x1 = parseInt(box.querySelector('#rhs-adj-x1').value);
            const x2 = parseInt(box.querySelector('#rhs-adj-x2').value);
            const y1 = parseInt(box.querySelector('#rhs-adj-y1').value);
            const y2 = parseInt(box.querySelector('#rhs-adj-y2').value);
            const errEl = box.querySelector('#rhs-adj-error');

            if ([x1, x2, y1, y2].some(isNaN)) {
                errEl.textContent = 'All fields must be valid numbers.';
                errEl.style.display = 'block';
                return;
            }

            const newBounds = {
                minX: Math.min(x1, x2), maxX: Math.max(x1, x2),
                minY: Math.min(y1, y2), maxY: Math.max(y1, y2),
            };
            const w = newBounds.maxX - newBounds.minX + 1;
            const h = newBounds.maxY - newBounds.minY + 1;

            if (w < 2 || h < 2) {
                errEl.textContent = 'Region must be at least 2×2.';
                errEl.style.display = 'block';
                return;
            }

            close();
            onConfirm(newBounds);
        };

        const escH = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escH); } };
        document.addEventListener('keydown', escH);
    }

    async function rerunLeaderboard(newBounds) {
        // Remove any existing modal
        const existing = document.querySelector('.rhs-modal-container');
        if (existing) existing.remove();

        const modal = createLeaderboardModal(newBounds, null, true);
        const progressEl = modal.querySelector('.rhs-progress-text');
        const updateProgress = (text) => { if (progressEl) progressEl.textContent = text; };

        try {
            const userCounts = await computeRegionPixels(newBounds, updateProgress);
            updateProgress('Fetching usernames...');
            const leaderboard = await buildLeaderboard(userCounts);
            updateLeaderboardModal(modal, newBounds, leaderboard);
        } catch (error) {
            console.error('[Regions Highscore] Error:', error);
            showNotification('Error computing leaderboard: ' + error.message);
            modal.close();
        }
    }

    // ==================== MODAL ====================
    function createLeaderboardModal(bounds, leaderboard = null, loading = false) {
        // Remove existing modal if any
        const existing = document.querySelector('.rhs-modal-container');
        if (existing) existing.remove();

        const width = bounds.maxX - bounds.minX + 1;
        const height = bounds.maxY - bounds.minY + 1;
        const totalPixels = width * height;

        const t = getThemeColors();

        const modalContainer = document.createElement('div');
        modalContainer.className = 'rhs-modal-container';
        modalContainer.style.cssText = `
            position: fixed;
            inset: 0;
            z-index: 10000;
            background: ${t.overlayBg};
            display: flex;
            align-items: center;
            justify-content: center;
        `;

        const modal = document.createElement('div');
        modal.className = 'rhs-modal';
        modal.style.cssText = `
            position: relative;
            background: ${t.modalBg};
            color: ${t.text};
            border-radius: 12px;
            box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
            padding: 24px;
            min-width: 400px;
            max-width: 90vw;
            max-height: 80vh;
            display: flex;
            flex-direction: column;
        `;

        // Close button
        const closeBtn = document.createElement('button');
        closeBtn.innerHTML = '✕';
        closeBtn.style.cssText = `
            position: absolute;
            top: 12px;
            right: 12px;
            background: none;
            border: none;
            font-size: 20px;
            cursor: pointer;
            color: ${t.closeBtnColor};
            width: 32px;
            height: 32px;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            transition: all 0.2s;
        `;
        closeBtn.onmouseover = () => { closeBtn.style.background = t.closeBtnHoverBg; closeBtn.style.color = t.closeBtnHoverColor; };
        closeBtn.onmouseout = () => { closeBtn.style.background = 'none'; closeBtn.style.color = t.closeBtnColor; };

        // Header
        const header = document.createElement('div');
        header.style.cssText = 'margin-bottom: 16px; padding-right: 32px;';
        header.innerHTML = `
            <h2 style="margin: 0 0 8px 0; font-size: 24px; font-weight: bold; color: ${t.text};">📊 Region Leaderboard</h2>
            <p style="margin: 0; color: ${t.textSecondary}; font-size: 14px;">Selected area: ${width} × ${height} pixels (${totalPixels.toLocaleString()} total)</p>
            <p style="margin: 4px 0 0 0; color: ${t.textMuted}; font-size: 12px; font-family: monospace;">X: ${bounds.minX} to ${bounds.maxX} | Y: ${bounds.minY} to ${bounds.maxY}</p>
        `;

        const adjustBtn = document.createElement('button');
        adjustBtn.textContent = 'Adjust…';
        adjustBtn.style.cssText = `
            margin-top: 8px; padding: 5px 12px; border-radius: 6px; border: 1px solid ${t.border};
            background: ${t.headerBg}; color: ${t.textSecondary}; font-size: 12px; cursor: pointer;
            transition: background 0.15s; white-space: nowrap;
        `;
        adjustBtn.onmouseover = () => { adjustBtn.style.background = t.closeBtnHoverBg; };
        adjustBtn.onmouseout  = () => { adjustBtn.style.background = t.headerBg; };
        adjustBtn.onclick = () => {
            showAdjustModal(bounds, (newBounds) => {
                rerunLeaderboard(newBounds);
            });
        };
        header.appendChild(adjustBtn);

        // Content area
        const content = document.createElement('div');
        content.className = 'rhs-modal-content';
        content.style.cssText = `
            flex: 1;
            overflow-y: auto;
            min-height: 200px;
        `;

        if (loading) {
            content.innerHTML = `
                <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 200px; color: ${t.textSecondary};">
                    <div style="font-size: 32px; margin-bottom: 16px;">⏳</div>
                    <div class="rhs-progress-text">Calculating leaderboard...</div>
                    <div style="font-size: 12px; margin-top: 8px; color: ${t.textSubtle};">This may take a moment for large regions</div>
                </div>
            `;
        } else if (leaderboard) {
            content.appendChild(createLeaderboardTable(leaderboard));
        }

        modal.appendChild(closeBtn);
        modal.appendChild(header);
        modal.appendChild(content);
        modalContainer.appendChild(modal);
        document.body.appendChild(modalContainer);

        // Close handlers
        const closeModal = () => modalContainer.remove();
        closeBtn.onclick = closeModal;
        modalContainer.onclick = (e) => { if (e.target === modalContainer) closeModal(); };

        const escHandler = (e) => {
            if (e.key === 'Escape') {
                closeModal();
                document.removeEventListener('keydown', escHandler);
            }
        };
        document.addEventListener('keydown', escHandler);

        modal.close = closeModal;
        return modal;
    }

    function updateLeaderboardModal(modal, bounds, leaderboard) {
        const content = modal.querySelector('.rhs-modal-content');
        if (!content) return;

        content.innerHTML = '';

        if (leaderboard.length === 0) {
            const t = getThemeColors();
            content.innerHTML = `
                <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; color: ${t.textSecondary};">
                    <div style="font-size: 32px; margin-bottom: 16px;">🤷</div>
                    <div>No pixels found in this region</div>
                </div>
            `;
        } else {
            content.appendChild(createLeaderboardTable(leaderboard));
        }
    }

    function createLeaderboardTable(leaderboard) {
        const t = getThemeColors();
        const container = document.createElement('div');

        // Summary
        const totalPixels = leaderboard.reduce((sum, entry) => sum + entry.pixelCount, 0);
        const summary = document.createElement('div');
        summary.style.cssText = `margin-bottom: 16px; padding: 12px; background: ${t.summaryBg}; border-radius: 8px; font-size: 14px; color: ${t.summaryText};`;
        summary.innerHTML = `<strong>${leaderboard.length}</strong> users placed <strong>${totalPixels.toLocaleString()}</strong> pixels in this region`;
        container.appendChild(summary);

        // Table
        const table = document.createElement('table');
        table.style.cssText = `width: 100%; border-collapse: collapse; font-size: 14px; color: ${t.text};`;

        // Header row
        const thead = document.createElement('thead');
        thead.innerHTML = `
            <tr style="background: ${t.headerBg}; text-align: left;">
                <th style="padding: 10px 12px; font-weight: 600; width: 60px;">Rank</th>
                <th style="padding: 10px 12px; font-weight: 600;">Username</th>
                <th style="padding: 10px 12px; font-weight: 600; text-align: right; width: 100px;">Pixels</th>
                <th style="padding: 10px 12px; font-weight: 600; text-align: right; width: 80px;">%</th>
            </tr>
        `;
        table.appendChild(thead);

        // Body rows
        const tbody = document.createElement('tbody');

        for (const entry of leaderboard) {
            const row = document.createElement('tr');
            row.style.cssText = `
                border-bottom: 1px solid ${t.border};
                ${entry.rank <= 3 ? 'background: ' + getRankBackground(entry.rank) + ';' : ''}
            `;

            const percent = ((entry.pixelCount / totalPixels) * 100).toFixed(1);
            const rankEmoji = getRankEmoji(entry.rank);

            row.innerHTML = `
                <td style="padding: 10px 12px; font-weight: ${entry.rank <= 3 ? 'bold' : 'normal'};">${rankEmoji} ${entry.rank}</td>
                <td style="padding: 10px 12px;">${escapeHtml(entry.username)}</td>
                <td style="padding: 10px 12px; text-align: right; font-family: monospace;">${entry.pixelCount.toLocaleString()}</td>
                <td style="padding: 10px 12px; text-align: right; color: ${t.textSecondary};">${percent}%</td>
            `;

            tbody.appendChild(row);
        }

        table.appendChild(tbody);
        container.appendChild(table);

        return container;
    }

    function getRankEmoji(rank) {
        switch (rank) {
            case 1: return '🥇';
            case 2: return '🥈';
            case 3: return '🥉';
            default: return '';
        }
    }

    function getRankBackground(rank) {
        switch (rank) {
            case 1: return 'rgba(255, 215, 0, 0.15)';
            case 2: return 'rgba(192, 192, 192, 0.15)';
            case 3: return 'rgba(205, 127, 50, 0.15)';
            default: return 'transparent';
        }
    }

    function escapeHtml(text) {
        const div = document.createElement('div');
        div.textContent = text;
        return div.innerHTML;
    }

    // ==================== NOTIFICATIONS ====================
    function showNotification(message) {
        // Use GeoPixels' notification system if available
        if (typeof showAnnouncement === 'function') {
            showAnnouncement(message);
            return;
        }

        // Fallback notification
        const t = getThemeColors();
        const notification = document.createElement('div');
        notification.style.cssText = `
            position: fixed;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            background: ${t.notificationBg};
            color: ${t.notificationText};
            padding: 12px 24px;
            border-radius: 8px;
            z-index: 10001;
            font-size: 14px;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
        `;
        notification.textContent = message;
        document.body.appendChild(notification);

        setTimeout(() => {
            notification.style.transition = 'opacity 0.3s';
            notification.style.opacity = '0';
            setTimeout(() => notification.remove(), 300);
        }, 3000);
    }

    // ==================== PROCESS WITH BOUNDS (for flyout) =========
    async function processWithBounds(bounds) {
        const width = bounds.maxX - bounds.minX + 1;
        const height = bounds.maxY - bounds.minY + 1;
        if (width < 2 || height < 2) { showNotification('Selection too small. Please select a larger area.'); return; }
        const modal = createLeaderboardModal(bounds, null, true);
        const progressEl = modal.querySelector('.rhs-progress-text');
        const updateProgress = (text) => { if (progressEl) progressEl.textContent = text; };
        try {
            const userCounts = await computeRegionPixels(bounds, updateProgress);
            updateProgress('Fetching usernames...');
            const leaderboard = await buildLeaderboard(userCounts);
            updateLeaderboardModal(modal, bounds, leaderboard);
        } catch (error) {
            console.error('[Regions Highscore] Error computing leaderboard:', error);
            showNotification('Error computing leaderboard: ' + error.message);
            try { modal.close(); } catch {}
        }
    }

    // ==================== START ====================
    init();

    // Expose API for flyout
    _regionsHighscore = { processWithBounds, toggleSelectionMode };
            })();
            _featureStatus.regionsHighscore = 'ok';
            console.log('[GeoPixelcons++] \u2705 Regions Highscore loaded');
        } catch (err) {
            _featureStatus.regionsHighscore = 'error';
            dbgPush(`Regions Highscore init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Regions Highscore' });
            console.error('[GeoPixelcons++] ❌ Regions Highscore failed:', err);
        }
    }

    // ============================================================
    //  FEATURE: Region Screenshot [regionScreenshot]
    // ============================================================
    if (_settings.regionScreenshot) {
        try {
            (function _init_regionScreenshot() {

    // ==================== CONFIGURATION ====================
    const GRID_SIZE = 25;
    const TILE_SIZE = 1000;
    const MAX_REGION_PIXELS = 15000 * 15000; // 225M px — hard limit to prevent OOM
    const SELECTION_COLOR = 'rgba(16, 185, 129, 0.25)';
    const SELECTION_BORDER_COLOR = 'rgba(16, 185, 129, 0.9)';
    const DEFAULT_BACKGROUND_COLOR = '#ffffff';
    const BACKGROUND_PREF_KEY = 'gpc-region-screenshot-background';

    // ==================== STATE ====================
    let isSelectionModeActive = false;
    let isDragging = false;
    let selectionStart = null;
    let selectionEnd = null;
    let selectionCanvas = null;
    let selectionCtx = null;
    let screenshotButton = null;
    let _map = null; // resolved MapLibre map object (not the DOM element)

    // ==================== BACKGROUND PREFERENCES ====================
    function loadBackgroundPreference() {
        try {
            const pref = JSON.parse(localStorage.getItem(BACKGROUND_PREF_KEY) || 'null');
            const mode = pref && pref.mode === 'solid' ? 'solid' : 'transparent';
            const color = pref && /^#[0-9A-Fa-f]{6}$/.test(pref.color || '')
                ? pref.color.toLowerCase()
                : DEFAULT_BACKGROUND_COLOR;
            return { mode, color };
        } catch (_) {
            return { mode: 'transparent', color: DEFAULT_BACKGROUND_COLOR };
        }
    }

    function saveBackgroundPreference(mode, color) {
        try {
            localStorage.setItem(BACKGROUND_PREF_KEY, JSON.stringify({
                mode: mode === 'solid' ? 'solid' : 'transparent',
                color: /^#[0-9A-Fa-f]{6}$/.test(color || '') ? color.toLowerCase() : DEFAULT_BACKGROUND_COLOR,
            }));
        } catch (_) {}
    }

    function composeCanvasWithBackground(canvas, mode, color) {
        if (mode !== 'solid') return canvas;
        const exportCanvas = document.createElement('canvas');
        exportCanvas.width = canvas.width;
        exportCanvas.height = canvas.height;
        const exportCtx = exportCanvas.getContext('2d');
        exportCtx.fillStyle = /^#[0-9A-Fa-f]{6}$/.test(color || '') ? color : DEFAULT_BACKGROUND_COLOR;
        exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
        exportCtx.drawImage(canvas, 0, 0);
        return exportCanvas;
    }

    // ==================== MAP ACCESS ====================
    function _getMap() {
        if (_map) return _map;
        try { const m = (0, eval)('map'); if (m && typeof m.scrollZoom !== 'undefined') return (_map = m); } catch {}
        if (typeof unsafeWindow !== 'undefined') { try { const m = unsafeWindow.eval('map'); if (m && typeof m.scrollZoom !== 'undefined') return (_map = m); } catch {} }
        return null;
    }

    // ==================== INITIALIZATION ====================
    // Polls (every 500ms, forever) for four unrelated readiness signals: the
    // resolved map object, two page globals (turf, tileImageCache), and the
    // #controls-left DOM element. A MutationObserver can't detect globals
    // becoming defined, so this keeps the original poll-and-reschedule shape
    // rather than forcing the MutationObserver pattern used for single DOM
    // elements elsewhere in this codebase (see Ghost Palette Search's
    // injectSyncGhostBtn). This feature has no fallback if it never
    // initializes, so retrying is unbounded by design -- the 15s mark only
    // emits one loud diagnostic (dbgPush + console.error) naming exactly
    // which condition(s) are still unmet, then polling continues silently.
    function waitForGeoPixels() {
        const startedAt = Date.now();
        let firstCheck = true;
        let loggedSlowStart = false;
        return new Promise((resolve) => {
            const check = () => {
                if (firstCheck) {
                    firstCheck = false;
                    dbgPush('Region Screenshot: starting poll for map / turf / tileImageCache / #controls-left readiness (every 500ms).', { uiComponent: 'Region Screenshot' });
                }

                const mapReady = !!_getMap();
                const turfReady = typeof turf !== 'undefined';
                const tileImageCacheReady = typeof tileImageCache !== 'undefined';
                const controlsLeftReady = !!document.getElementById('controls-left');

                if (mapReady && turfReady && tileImageCacheReady && controlsLeftReady) {
                    dbgPush('Region Screenshot: all readiness conditions met after ' + (Date.now() - startedAt) + 'ms -- resolving.', { uiComponent: 'Region Screenshot' });
                    resolve();
                    return;
                }

                if (!loggedSlowStart && (Date.now() - startedAt) >= 15000) {
                    loggedSlowStart = true;
                    const msg = 'Region Screenshot: still waiting after 15s -- map=' + mapReady + ', turf=' + turfReady +
                        ', tileImageCache=' + tileImageCacheReady + ', #controls-left=' + controlsLeftReady +
                        '. Will keep retrying every 500ms (this is a one-time diagnostic, not a giveup).';
                    dbgPush(msg, { uiComponent: 'Region Screenshot' });
                    console.error('[GeoPixelcons++] ' + msg);
                }

                setTimeout(check, 500);
            };
            check();
        });
    }

    async function init() {
        await waitForGeoPixels();
        console.log('[Region Screenshot] Initializing...');
        createSelectionCanvas();
        createScreenshotButton();
        setupEventListeners();
        console.log('[Region Screenshot] Ready!');
    }

    // ==================== UI COMPONENTS ====================
    function createScreenshotButton() {
        screenshotButton = document.createElement('button');
        screenshotButton.id = 'gpc-screenshot-trigger';
        screenshotButton.style.display = 'none';
        screenshotButton.addEventListener('click', toggleSelectionMode);
        document.body.appendChild(screenshotButton);
    }

    function createSelectionCanvas() {
        selectionCanvas = document.createElement('canvas');
        selectionCanvas.id = 'screenshot-selection-canvas';
        selectionCanvas.style.cssText = `
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            pointer-events: none;
            z-index: 1000;
        `;
        document.body.appendChild(selectionCanvas);
        selectionCtx = selectionCanvas.getContext('2d');

        const syncSize = () => {
            const dpr = window.devicePixelRatio || 1;
            selectionCanvas.width = window.innerWidth * dpr;
            selectionCanvas.height = window.innerHeight * dpr;
            selectionCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
        };
        syncSize();
        window.addEventListener('resize', syncSize);

        ['move', 'rotate', 'zoom'].forEach((ev) => {
            _map.on(ev, () => {
                if (isDragging) drawSelectionPreview();
            });
        });
    }

    // ==================== SELECTION MODE ====================
    function toggleSelectionMode() {
        isSelectionModeActive = !isSelectionModeActive;
        if (isSelectionModeActive) {
            screenshotButton.style.backgroundColor = '#10b981';
            screenshotButton.style.color = 'white';
            screenshotButton.style.boxShadow = '0 0 10px rgba(16, 185, 129, 0.6)';
            document.body.style.cursor = 'crosshair';
            disableMapInteractions();
            showNotification('Click and drag to select a region to screenshot');
        } else {
            resetSelectionMode();
        }
    }

    function disableMapInteractions() {
        const m = _getMap(); if (!m) return;
        m.dragPan.disable();
        m.scrollZoom.disable();
        m.boxZoom.disable();
        m.doubleClickZoom.disable();
        m.touchZoomRotate.disable();
    }

    function enableMapInteractions() {
        const m = _getMap(); if (!m) return;
        m.dragPan.enable();
        m.scrollZoom.enable();
        m.boxZoom.enable();
        // Note: doubleClickZoom is intentionally NOT re-enabled — the native site disables it
        m.touchZoomRotate.enable();
    }

    function resetSelectionMode() {
        isSelectionModeActive = false;
        isDragging = false;
        selectionStart = null;
        selectionEnd = null;
        if (screenshotButton) {
            screenshotButton.style.backgroundColor = 'white';
            screenshotButton.style.color = 'black';
            screenshotButton.style.boxShadow = '';
        }
        document.body.style.cursor = '';
        enableMapInteractions();
        clearSelectionCanvas();
    }

    function clearSelectionCanvas() {
        if (selectionCtx && selectionCanvas) {
            selectionCtx.clearRect(0, 0, window.innerWidth, window.innerHeight);
        }
    }

    // ==================== EVENT HANDLERS ====================
    function setupEventListeners() {
        document.addEventListener('mousedown', handleMouseDown);
        document.addEventListener('mousemove', handleMouseMove);
        document.addEventListener('mouseup', handleMouseUp);
        document.addEventListener('keydown', handleKeyDown);
    }

    function handleMouseDown(e) {
        if (!isSelectionModeActive) return;
        if (e.button !== 0) return;
        if (
            e.target.closest('#controls-left') ||
            e.target.closest('#controls-right') ||
            e.target.closest('.rsc-modal-container')
        ) return;

        isDragging = true;
        selectionStart = screenPointToGrid(e.clientX, e.clientY);
        selectionEnd = selectionStart;
        e.preventDefault();
        e.stopPropagation();
    }

    function handleMouseMove(e) {
        if (!isDragging || !selectionStart) return;
        selectionEnd = screenPointToGrid(e.clientX, e.clientY);
        drawSelectionPreview();
    }

    async function handleMouseUp(e) {
        if (!isDragging || !selectionStart || !selectionEnd) return;
        isDragging = false;

        const bounds = getSelectionBounds();
        const width = bounds.maxX - bounds.minX + 1;
        const height = bounds.maxY - bounds.minY + 1;

        if (width < 2 || height < 2) {
            showNotification('Selection too small — please drag a larger area.');
            clearSelectionCanvas();
            resetSelectionMode();
            return;
        }

        if (width * height > MAX_REGION_PIXELS) {
            showNotification(`Region too large (${width}×${height}). Maximum is ~15000×15000 px.`);
            clearSelectionCanvas();
            resetSelectionMode();
            return;
        }

        // Exit selection mode
        isSelectionModeActive = false;
        if (screenshotButton) {
            screenshotButton.style.backgroundColor = 'white';
            screenshotButton.style.color = 'black';
            screenshotButton.style.boxShadow = '';
        }
        document.body.style.cursor = '';
        enableMapInteractions();

        // Show loading modal
        const modal = createPreviewModal(bounds, null, true);
        const progressEl = modal.querySelector('.rsc-progress-text');
        const updateProgress = (text) => { if (progressEl) progressEl.textContent = text; };

        try {
            const screenshotCanvas = await renderRegionToCanvas(bounds, updateProgress);
            updatePreviewModal(modal, bounds, screenshotCanvas);
        } catch (err) {
            console.error('[Region Screenshot] Error:', err);
            showNotification('Error capturing screenshot: ' + err.message);
            modal.closeModal();
        }

        clearSelectionCanvas();
        selectionStart = null;
        selectionEnd = null;
    }

    function handleKeyDown(e) {
        if (e.key === 'Escape' && (isSelectionModeActive || isDragging)) {
            resetSelectionMode();
        }
    }

    // ==================== COORDINATE HELPERS ====================
    function screenPointToGrid(clientX, clientY) {
        const mapContainer = _map.getContainer();
        const rect = mapContainer.getBoundingClientRect();
        const lngLat = _map.unproject([clientX - rect.left, clientY - rect.top]);
        const merc = turf.toMercator([lngLat.lng, lngLat.lat]);
        return {
            gridX: Math.round(merc[0] / GRID_SIZE),
            gridY: Math.round(merc[1] / GRID_SIZE),
        };
    }

    function gridToScreen(gridX, gridY) {
        const lngLat = turf.toWgs84([gridX * GRID_SIZE, gridY * GRID_SIZE]);
        const point = _map.project(lngLat);
        const rect = _map.getContainer().getBoundingClientRect();
        return { x: point.x + rect.left, y: point.y + rect.top };
    }

    function getSelectionBounds() {
        return {
            minX: Math.min(selectionStart.gridX, selectionEnd.gridX),
            maxX: Math.max(selectionStart.gridX, selectionEnd.gridX),
            minY: Math.min(selectionStart.gridY, selectionEnd.gridY),
            maxY: Math.max(selectionStart.gridY, selectionEnd.gridY),
        };
    }

    // ==================== SELECTION DRAWING ====================
    function drawSelectionPreview() {
        clearSelectionCanvas();
        if (!selectionStart || !selectionEnd) return;

        const bounds = getSelectionBounds();
        const topLeft = gridToScreen(bounds.minX - 0.5, bounds.maxY + 0.5);
        const bottomRight = gridToScreen(bounds.maxX + 0.5, bounds.minY - 0.5);

        const x = topLeft.x;
        const y = topLeft.y;
        const w = bottomRight.x - topLeft.x;
        const h = bottomRight.y - topLeft.y;

        selectionCtx.fillStyle = SELECTION_COLOR;
        selectionCtx.fillRect(x, y, w, h);

        selectionCtx.strokeStyle = SELECTION_BORDER_COLOR;
        selectionCtx.lineWidth = 2;
        selectionCtx.setLineDash([6, 3]);
        selectionCtx.strokeRect(x, y, w, h);
        selectionCtx.setLineDash([]);

        const selW = bounds.maxX - bounds.minX + 1;
        const selH = bounds.maxY - bounds.minY + 1;
        const sizeText = `${selW} × ${selH}`;

        selectionCtx.font = 'bold 14px sans-serif';
        selectionCtx.textAlign = 'center';
        selectionCtx.textBaseline = 'middle';
        selectionCtx.lineWidth = 3;
        selectionCtx.strokeStyle = 'rgba(0,0,0,0.6)';
        selectionCtx.strokeText(sizeText, x + w / 2, y + h / 2);
        selectionCtx.fillStyle = 'white';
        selectionCtx.fillText(sizeText, x + w / 2, y + h / 2);
    }

    // ==================== SCREENSHOT RENDERING ====================
    async function renderRegionToCanvas(bounds, updateProgress) {
        const { minX, maxX, minY, maxY } = bounds;
        const outWidth  = maxX - minX + 1;
        const outHeight = maxY - minY + 1;

        // Output canvas — transparent background, 1px = 1 grid cell
        const outputCanvas = new OffscreenCanvas(outWidth, outHeight);
        const outputCtx = outputCanvas.getContext('2d');
        outputCtx.clearRect(0, 0, outWidth, outHeight);

        // Find all tiles that overlap with the selection
        const startTileX = Math.floor(minX / TILE_SIZE) * TILE_SIZE;
        const endTileX   = Math.floor(maxX / TILE_SIZE) * TILE_SIZE;
        const startTileY = Math.floor(minY / TILE_SIZE) * TILE_SIZE;
        const endTileY   = Math.floor(maxY / TILE_SIZE) * TILE_SIZE;

        const neededTiles = [];
        for (let tx = startTileX; tx <= endTileX; tx += TILE_SIZE) {
            for (let ty = startTileY; ty <= endTileY; ty += TILE_SIZE) {
                neededTiles.push([tx, ty]);
            }
        }

        console.log(`[Region Screenshot] ${neededTiles.length} tile(s) needed for ${outWidth}×${outHeight} region`);

        let processed = 0;
        for (const [tileX, tileY] of neededTiles) {
            processed++;
            const tileKey = `${tileX},${tileY}`;
            updateProgress && updateProgress(`Processing tile ${processed}/${neededTiles.length}…`);

            // Yield to browser
            await new Promise(r => setTimeout(r, 0));

            // ---- Try cache first ----
            let colorBitmap = null;
            let deltas = null;

            const cached = tileImageCache.get(tileKey);
            if (cached) {
                colorBitmap = cached.colorBitmap || null;
                deltas = cached.deltas || null;
            }

            // ---- Fallback: fetch from API ----
            if (!colorBitmap) {
                updateProgress && updateProgress(`Fetching tile ${tileKey} from server…`);
                try {
                    const fetched = await fetchTileColorBitmap(tileX, tileY);
                    colorBitmap = fetched.colorBitmap;
                    deltas = fetched.deltas;
                } catch (err) {
                    console.warn(`[Region Screenshot] Could not load tile ${tileKey}:`, err);
                    continue;
                }
            }

            if (!colorBitmap) continue;

            // ---- Compute overlapping region in tile local coords ----
            const tileMinX = Math.max(minX, tileX);
            const tileMaxX = Math.min(maxX, tileX + TILE_SIZE - 1);
            const tileMinY = Math.max(minY, tileY);
            const tileMaxY = Math.min(maxY, tileY + TILE_SIZE - 1);

            const regionW = tileMaxX - tileMinX + 1;
            const regionH = tileMaxY - tileMinY + 1;
            if (regionW <= 0 || regionH <= 0) continue;

            const localStartX = tileMinX - tileX;
            const localStartY = tileMinY - tileY;

            // Destination position on output canvas
            const destX = tileMinX - minX;
            const destY = tileMinY - minY;

            // Draw this tile's color section onto the output canvas
            outputCtx.drawImage(
                colorBitmap,
                localStartX, localStartY, regionW, regionH,
                destX, destY, regionW, regionH
            );

            // ---- Apply any recent in-memory deltas on top ----
            // These are pixel updates that have arrived since the last full sync
            // and may not yet be baked into the colorBitmap.
            if (deltas && deltas.length > 0) {
                for (const delta of deltas) {
                    const gx = delta.gridX;
                    const gy = delta.gridY;

                    // Skip if outside this tile's contributing region
                    if (gx < tileMinX || gx > tileMaxX || gy < tileMinY || gy > tileMaxY) continue;

                    const ox = gx - minX;
                    const oy = gy - minY;

                    if (delta.color === '#00000000' || delta.color === null) {
                        // Erased pixel — clear it
                        outputCtx.clearRect(ox, oy, 1, 1);
                    } else if (delta.color) {
                        outputCtx.fillStyle = delta.color;
                        outputCtx.fillRect(ox, oy, 1, 1);
                    }
                }
            }
        }

        updateProgress && updateProgress('Finalizing…');

        // Transfer to a regular (main-thread) canvas so we can export it.
        // Flip vertically: grid Y increases northward but canvas Y increases downward,
        // so without a flip the image is upside-down.
        const regularCanvas = document.createElement('canvas');
        regularCanvas.width = outWidth;
        regularCanvas.height = outHeight;
        const regularCtx = regularCanvas.getContext('2d');
        regularCtx.save();
        regularCtx.translate(0, outHeight);
        regularCtx.scale(1, -1);
        regularCtx.drawImage(outputCanvas, 0, 0);
        regularCtx.restore();

        return regularCanvas;
    }

    // ---- API tile fetch (fallback when not cached) ----
    async function fetchTileColorBitmap(tileX, tileY) {
        const response = await fetch('https://geopixels.net/GetPixelsCached', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ Tiles: [{ x: tileX, y: tileY, timestamp: 0 }] }),
        });

        if (!response.ok) throw new Error(`API returned ${response.status}`);

        const data = await response.json();
        const tileKey = `tile_${tileX}_${tileY}`;
        const tileInfo = data.Tiles && data.Tiles[tileKey];

        if (!tileInfo) return { colorBitmap: null, deltas: null };

        // Full tile with WebP
        if (tileInfo.Type === 'full' && tileInfo.ColorWebP) {
            const colorBitmap = await decodeWebP(tileInfo.ColorWebP);
            // Process any bundled deltas
            const deltas = buildDeltasFromRaw(tileInfo.Deltas || []);
            return { colorBitmap, deltas };
        }

        // Delta-only tile — build a small bitmap from the delta array
        if (tileInfo.Pixels && tileInfo.Pixels.length > 0) {
            const colorBitmap = await buildColorBitmapFromDeltas(tileInfo.Pixels, tileX, tileY);
            return { colorBitmap, deltas: null };
        }

        return { colorBitmap: null, deltas: null };
    }

    async function decodeWebP(base64Data) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.onload = () => createImageBitmap(img).then(resolve).catch(reject);
            img.onerror = reject;
            img.src = `data:image/webp;base64,${base64Data}`;
        });
    }

    function buildDeltasFromRaw(rawDeltas) {
        return rawDeltas.map(p => {
            const [gridX, gridY, color] = p;
            if (color === -1) return { gridX, gridY, color: null };
            const r = (color >> 16) & 0xff;
            const g = (color >> 8) & 0xff;
            const b = color & 0xff;
            return { gridX, gridY, color: `rgb(${r},${g},${b})` };
        });
    }

    async function buildColorBitmapFromDeltas(rawDeltas, tileX, tileY) {
        const canvas = new OffscreenCanvas(TILE_SIZE, TILE_SIZE);
        const ctx = canvas.getContext('2d');
        for (const [gridX, gridY, color] of rawDeltas) {
            if (color === -1) continue;
            const r = (color >> 16) & 0xff;
            const g = (color >> 8) & 0xff;
            const b = color & 0xff;
            ctx.fillStyle = `rgb(${r},${g},${b})`;
            ctx.fillRect(gridX - tileX, gridY - tileY, 1, 1);
        }
        return createImageBitmap(canvas);
    }

    // ==================== ADJUST BOUNDS MODAL ====================
    function showAdjustModal(currentBounds, onConfirm) {
        const existing = document.querySelector('.rsc-adjust-overlay');
        if (existing) existing.remove();

        const overlay = document.createElement('div');
        overlay.className = 'rsc-adjust-overlay';
        overlay.style.cssText = `
            position: fixed; inset: 0; z-index: 10001;
            background: rgba(0,0,0,0.55);
            display: flex; align-items: center; justify-content: center;
            font-family: system-ui, sans-serif;
        `;

        const box = document.createElement('div');
        box.style.cssText = `
            background: #1e1e2e; color: #cdd6f4; border-radius: 12px;
            box-shadow: 0 16px 48px rgba(0,0,0,0.5);
            padding: 24px; min-width: 320px; display: flex; flex-direction: column; gap: 14px;
        `;

        box.innerHTML = `
            <h3 style="margin: 0; font-size: 16px; font-weight: 700; color: #cba6f7;">Adjust Region Bounds</h3>
            <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
                <label style="font-size: 12px; color: #a6adc8;">X1 (min)
                    <input id="rsc-adj-x1" type="number" value="${currentBounds.minX}" style="width: 100%; margin-top: 2px; padding: 6px 8px; border-radius: 6px; border: 1px solid #45475a; background: #313244; color: #cdd6f4; font-size: 13px; font-family: monospace;" />
                </label>
                <label style="font-size: 12px; color: #a6adc8;">X2 (max)
                    <input id="rsc-adj-x2" type="number" value="${currentBounds.maxX}" style="width: 100%; margin-top: 2px; padding: 6px 8px; border-radius: 6px; border: 1px solid #45475a; background: #313244; color: #cdd6f4; font-size: 13px; font-family: monospace;" />
                </label>
                <label style="font-size: 12px; color: #a6adc8;">Y1 (min)
                    <input id="rsc-adj-y1" type="number" value="${currentBounds.minY}" style="width: 100%; margin-top: 2px; padding: 6px 8px; border-radius: 6px; border: 1px solid #45475a; background: #313244; color: #cdd6f4; font-size: 13px; font-family: monospace;" />
                </label>
                <label style="font-size: 12px; color: #a6adc8;">Y2 (max)
                    <input id="rsc-adj-y2" type="number" value="${currentBounds.maxY}" style="width: 100%; margin-top: 2px; padding: 6px 8px; border-radius: 6px; border: 1px solid #45475a; background: #313244; color: #cdd6f4; font-size: 13px; font-family: monospace;" />
                </label>
            </div>
            <div id="rsc-adj-error" style="font-size: 12px; color: #f38ba8; display: none;"></div>
            <div style="display: flex; gap: 10px; justify-content: flex-end;">
                <button id="rsc-adj-cancel" style="padding: 8px 16px; border-radius: 8px; border: 1px solid #45475a; background: #313244; color: #a6adc8; cursor: pointer; font-size: 13px;">Cancel</button>
                <button id="rsc-adj-confirm" style="padding: 8px 16px; border-radius: 8px; border: none; background: #cba6f7; color: #1e1e2e; cursor: pointer; font-size: 13px; font-weight: 600;">Apply</button>
            </div>
        `;

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

        const close = () => overlay.remove();
        overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
        box.querySelector('#rsc-adj-cancel').onclick = close;

        box.querySelector('#rsc-adj-confirm').onclick = () => {
            const x1 = parseInt(box.querySelector('#rsc-adj-x1').value);
            const x2 = parseInt(box.querySelector('#rsc-adj-x2').value);
            const y1 = parseInt(box.querySelector('#rsc-adj-y1').value);
            const y2 = parseInt(box.querySelector('#rsc-adj-y2').value);
            const errEl = box.querySelector('#rsc-adj-error');

            if ([x1, x2, y1, y2].some(isNaN)) {
                errEl.textContent = 'All fields must be valid numbers.';
                errEl.style.display = 'block';
                return;
            }

            const newBounds = {
                minX: Math.min(x1, x2), maxX: Math.max(x1, x2),
                minY: Math.min(y1, y2), maxY: Math.max(y1, y2),
            };
            const w = newBounds.maxX - newBounds.minX + 1;
            const h = newBounds.maxY - newBounds.minY + 1;

            if (w < 2 || h < 2) {
                errEl.textContent = 'Region must be at least 2×2.';
                errEl.style.display = 'block';
                return;
            }
            if (w * h > MAX_REGION_PIXELS) {
                errEl.textContent = `Region too large (${w}×${h}).`;
                errEl.style.display = 'block';
                return;
            }

            close();
            onConfirm(newBounds);
        };

        const escH = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escH); } };
        document.addEventListener('keydown', escH);
    }

    async function rerunScreenshot(newBounds) {
        // Remove any existing modal
        const existing = document.querySelector('.rsc-modal-container');
        if (existing) existing.remove();

        const modal = createPreviewModal(newBounds, null, true);
        const progressEl = modal.querySelector('.rsc-progress-text');
        const updateProgress = (text) => { if (progressEl) progressEl.textContent = text; };

        try {
            const screenshotCanvas = await renderRegionToCanvas(newBounds, updateProgress);
            updatePreviewModal(modal, newBounds, screenshotCanvas);
        } catch (err) {
            console.error('[Region Screenshot] Error:', err);
            showNotification('Error capturing screenshot: ' + err.message);
            modal.closeModal();
        }
    }

    // ==================== MODAL ====================
    function createPreviewModal(bounds, screenshotCanvas, loading = false) {
        // Remove any existing modal
        const existing = document.querySelector('.rsc-modal-container');
        if (existing) existing.remove();

        const w = bounds.maxX - bounds.minX + 1;
        const h = bounds.maxY - bounds.minY + 1;

        // ---- Overlay ----
        const overlay = document.createElement('div');
        overlay.className = 'rsc-modal-container';
        overlay.style.cssText = `
            position: fixed;
            inset: 0;
            z-index: 10000;
            background: rgba(0, 0, 0, 0.6);
            display: flex;
            align-items: center;
            justify-content: center;
        `;

        // ---- Modal box ----
        const modal = document.createElement('div');
        modal.className = 'rsc-modal';
        modal.style.cssText = `
            position: relative;
            background: #1e1e2e;
            color: #cdd6f4;
            border-radius: 14px;
            box-shadow: 0 24px 60px rgba(0,0,0,0.55);
            padding: 24px;
            min-width: 380px;
            max-width: min(90vw, 700px);
            max-height: 90vh;
            display: flex;
            flex-direction: column;
            gap: 16px;
            font-family: system-ui, sans-serif;
        `;

        // ---- Header ----
        const header = document.createElement('div');
        header.style.cssText = 'display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;';
        header.innerHTML = `
            <div>
                <h2 style="margin: 0 0 6px 0; font-size: 20px; font-weight: 700; color: #cba6f7;">📷 Region Screenshot</h2>
                <p style="margin: 0; font-size: 13px; color: #a6adc8;">${w} × ${h} px &nbsp;|&nbsp; X: ${bounds.minX} → ${bounds.maxX} &nbsp;|&nbsp; Y: ${bounds.minY} → ${bounds.maxY}</p>
            </div>
        `;

        const adjustBtn = document.createElement('button');
        adjustBtn.textContent = 'Adjust…';
        adjustBtn.style.cssText = `
            flex-shrink: 0; padding: 5px 12px; border-radius: 6px; border: 1px solid #45475a;
            background: #313244; color: #a6adc8; font-size: 12px; cursor: pointer;
            transition: background 0.15s; white-space: nowrap;
        `;
        adjustBtn.onmouseover = () => { adjustBtn.style.background = '#45475a'; };
        adjustBtn.onmouseout  = () => { adjustBtn.style.background = '#313244'; };
        adjustBtn.onclick = () => {
            showAdjustModal(bounds, (newBounds) => {
                rerunScreenshot(newBounds);
            });
        };
        header.insertBefore(adjustBtn, null);

        const closeBtn = document.createElement('button');
        closeBtn.innerHTML = '✕';
        closeBtn.style.cssText = `
            flex-shrink: 0;
            background: #313244;
            border: none;
            color: #a6adc8;
            font-size: 16px;
            width: 32px;
            height: 32px;
            border-radius: 8px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            transition: background 0.15s;
        `;
        closeBtn.onmouseover = () => { closeBtn.style.background = '#45475a'; };
        closeBtn.onmouseout  = () => { closeBtn.style.background = '#313244'; };
        header.appendChild(closeBtn);

        // ---- Content area ----
        const content = document.createElement('div');
        content.className = 'rsc-modal-content';
        content.style.cssText = `
            flex: 1;
            overflow: auto;
            display: flex;
            flex-direction: column;
            gap: 14px;
        `;

        if (loading) {
            content.innerHTML = `
                <div style="display: flex; flex-direction: column; align-items: center; justify-content: center;
                            min-height: 180px; color: #a6adc8; gap: 12px;">
                    <div style="font-size: 36px;">⏳</div>
                    <div class="rsc-progress-text" style="font-size: 14px;">Preparing screenshot…</div>
                    <div style="font-size: 12px; color: #6c7086;">Large regions may take a moment</div>
                </div>
            `;
        } else if (screenshotCanvas) {
            buildPreviewContent(content, bounds, screenshotCanvas);
        }

        // ---- Assemble ----
        modal.appendChild(header);
        modal.appendChild(content);
        overlay.appendChild(modal);
        document.body.appendChild(overlay);

        const closeModal = () => overlay.remove();
        closeBtn.onclick = closeModal;
        overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };

        const escHandler = (e) => {
            if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', escHandler); }
        };
        document.addEventListener('keydown', escHandler);

        modal.closeModal = closeModal;
        return modal;
    }

    function updatePreviewModal(modal, bounds, screenshotCanvas) {
        const content = modal.querySelector('.rsc-modal-content');
        if (!content) return;
        content.innerHTML = '';
        buildPreviewContent(content, bounds, screenshotCanvas);
    }

    // Reads pixel data from canvas and returns [[hex, count], ...] sorted by count desc.
    function extractPaletteFromCanvas(canvas) {
        try {
            const ctx = canvas.getContext('2d');
            const { width, height } = canvas;
            const data = ctx.getImageData(0, 0, width, height).data;
            const counts = new Map();
            for (let i = 0; i < data.length; i += 4) {
                const a = data[i + 3];
                if (a < 128) continue;
                const hex = '#' + [data[i], data[i + 1], data[i + 2]]
                    .map(v => v.toString(16).toUpperCase().padStart(2, '0')).join('');
                counts.set(hex, (counts.get(hex) || 0) + 1);
            }
            return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]);
        } catch (err) {
            console.error('[Region Screenshot] extractPaletteFromCanvas error:', err);
            return [];
        }
    }

    function buildPreviewContent(container, bounds, canvas) {
        const w = bounds.maxX - bounds.minX + 1;
        const h = bounds.maxY - bounds.minY + 1;

        // ---- Checkerboard preview wrapper (shows transparency) ----
        const previewWrapper = document.createElement('div');
        previewWrapper.style.cssText = `
            border-radius: 10px;
            overflow: hidden;
            max-height: 55vh;
            background: repeating-conic-gradient(#313244 0% 25%, #45475a 0% 50%) 0 0 / 16px 16px;
            display: flex;
            align-items: center;
            justify-content: center;
            border: 2px solid #45475a;
        `;

        const imgEl = document.createElement('img');
        imgEl.style.cssText = `
            max-width: 100%;
            max-height: 55vh;
            image-rendering: pixelated;
            object-fit: contain;
        `;
        const backgroundPref = loadBackgroundPreference();
        let backgroundMode = backgroundPref.mode;
        let backgroundColor = backgroundPref.color;

        const getExportCanvas = () => {
            return composeCanvasWithBackground(canvas, backgroundMode, backgroundColor);
        };

        const updatePreviewBackground = () => {
            imgEl.src = getExportCanvas().toDataURL('image/png');
            if (info) {
                info.textContent = backgroundMode === 'solid'
                    ? `${w}×${h} pixels • PNG with ${backgroundColor.toUpperCase()} background`
                    : `${w}×${h} pixels • PNG with transparent background`;
            }
        };

        previewWrapper.appendChild(imgEl);
        container.appendChild(previewWrapper);

        // ---- Background controls ----
        const bgRow = document.createElement('div');
        bgRow.style.cssText = 'display:flex;align-items:center;gap:10px;justify-content:center;flex-wrap:wrap;font-size:12px;color:#a6adc8;';

        const bgLabel = document.createElement('label');
        bgLabel.style.cssText = 'display:flex;align-items:center;gap:6px;font-weight:600;';
        bgLabel.textContent = 'Background';

        const bgSelect = document.createElement('select');
        bgSelect.style.cssText = 'padding:5px 8px;border-radius:6px;border:1px solid #45475a;background:#313244;color:#cdd6f4;font-size:12px;cursor:pointer;';
        [
            { value: 'transparent', text: 'Transparent' },
            { value: 'solid', text: 'Solid color' },
        ].forEach(({ value, text }) => {
            const opt = document.createElement('option');
            opt.value = value;
            opt.textContent = text;
            bgSelect.appendChild(opt);
        });

        const bgColorInput = document.createElement('input');
        bgColorInput.type = 'color';
        bgColorInput.value = backgroundColor;
        bgColorInput.title = 'Background color';
        bgColorInput.style.cssText = 'width:34px;height:28px;padding:2px;border-radius:6px;border:1px solid #45475a;background:#313244;cursor:pointer;display:none;';
        bgSelect.value = backgroundMode;
        bgColorInput.style.display = backgroundMode === 'solid' ? '' : 'none';

        bgSelect.addEventListener('change', () => {
            backgroundMode = bgSelect.value;
            bgColorInput.style.display = backgroundMode === 'solid' ? '' : 'none';
            saveBackgroundPreference(backgroundMode, backgroundColor);
            updatePreviewBackground();
        });
        bgColorInput.addEventListener('input', () => {
            backgroundColor = bgColorInput.value;
            saveBackgroundPreference(backgroundMode, backgroundColor);
            updatePreviewBackground();
        });

        bgRow.appendChild(bgLabel);
        bgRow.appendChild(bgSelect);
        bgRow.appendChild(bgColorInput);
        container.appendChild(bgRow);

        // ---- Info row ----
        const info = document.createElement('p');
        info.style.cssText = 'margin: 0; font-size: 12px; color: #6c7086; text-align: center;';
        container.appendChild(info);
        updatePreviewBackground();

        // ---- Buttons ----
        const btnRow = document.createElement('div');
        btnRow.style.cssText = 'display: flex; gap: 10px; justify-content: stretch;';

        // Download button
        const downloadBtn = document.createElement('button');
        downloadBtn.innerHTML = '⬇ Download PNG';
        downloadBtn.style.cssText = `
            flex: 1;
            padding: 10px 16px;
            background: #cba6f7;
            color: #1e1e2e;
            border: none;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            transition: opacity 0.15s;
        `;
        downloadBtn.onmouseover = () => { downloadBtn.style.opacity = '0.85'; };
        downloadBtn.onmouseout  = () => { downloadBtn.style.opacity = '1'; };
        downloadBtn.onclick = () => {
            const link = document.createElement('a');
            const ts = (() => { try { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}-${String(d.getMinutes()).padStart(2,'0')}-${String(d.getSeconds()).padStart(2,'0')}`; } catch(_) { return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); } })();
            link.download = `geopixels-screenshot-${ts}.png`;
            link.href = getExportCanvas().toDataURL('image/png');
            link.click();
        };

        // Copy button
        const copyBtn = document.createElement('button');
        copyBtn.innerHTML = '📋 Copy to Clipboard';
        copyBtn.style.cssText = `
            flex: 1;
            padding: 10px 16px;
            background: #89dceb;
            color: #1e1e2e;
            border: none;
            border-radius: 8px;
            font-size: 14px;
            font-weight: 600;
            cursor: pointer;
            transition: opacity 0.15s;
        `;
        copyBtn.onmouseover = () => { copyBtn.style.opacity = '0.85'; };
        copyBtn.onmouseout  = () => { copyBtn.style.opacity = '1'; };
        copyBtn.onclick = () => {
            if (!navigator.clipboard || !window.ClipboardItem) {
                showNotification('Clipboard API not supported in this browser.');
                return;
            }
            getExportCanvas().toBlob(async (blob) => {
                try {
                    await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
                    copyBtn.innerHTML = '✅ Copied!';
                    setTimeout(() => { copyBtn.innerHTML = '📋 Copy to Clipboard'; }, 2000);
                } catch (err) {
                    console.error('[Region Screenshot] Clipboard write failed:', err);
                    showNotification('Could not write to clipboard: ' + err.message);
                }
            }, 'image/png');
        };

        btnRow.appendChild(downloadBtn);
        btnRow.appendChild(copyBtn);
        container.appendChild(btnRow);

        // ---- Show Palette button ----
        const paletteBtn = document.createElement('button');
        paletteBtn.textContent = '🎨 Show Palette';
        paletteBtn.style.cssText = `
            width: 100%; padding: 8px 16px;
            background: #45475a; color: #cdd6f4;
            border: none; border-radius: 8px; font-size: 13px;
            font-weight: 600; cursor: pointer; transition: opacity 0.15s;
        `;
        paletteBtn.onmouseover = () => { paletteBtn.style.opacity = '0.82'; };
        paletteBtn.onmouseout  = () => { paletteBtn.style.opacity = '1'; };

        const paletteSection = document.createElement('div');
        paletteSection.style.cssText = `
            display: none;
            background: #181825; border: 1px solid #45475a;
            border-radius: 8px; padding: 10px 12px;
        `;

        let _paletteLoaded = false;
        paletteBtn.onclick = () => {
            const open = paletteSection.style.display !== 'none';
            if (open) {
                paletteSection.style.display = 'none';
                paletteBtn.textContent = '🎨 Show Palette';
            } else {
                paletteSection.style.display = '';
                paletteBtn.textContent = '🎨 Hide Palette';
                if (!_paletteLoaded) {
                    _paletteLoaded = true;
                    renderPaletteSection(paletteSection, canvas);
                }
            }
        };

        container.appendChild(paletteBtn);
        container.appendChild(paletteSection);
    }

    function renderPaletteSection(container, canvas) {
        const palette = extractPaletteFromCanvas(canvas);

        if (!palette.length) {
            container.innerHTML = '<div style="font-size:12px;color:#6c7086;text-align:center;padding:4px;">No opaque pixels found in selection.</div>';
            return;
        }

        const hexList = palette.map(([hex]) => hex).join(', ');

        // Header row: color count + copy button
        const headerRow = document.createElement('div');
        headerRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;';

        const colorCount = document.createElement('span');
        colorCount.style.cssText = 'font-size:12px;color:#a6adc8;';
        colorCount.textContent = palette.length + ' color' + (palette.length !== 1 ? 's' : '');

        const copyHexBtn = document.createElement('button');
        copyHexBtn.textContent = '📋 Copy hex list';
        copyHexBtn.style.cssText = 'padding:3px 8px;border-radius:5px;border:none;background:#585b70;color:#cdd6f4;font-size:11px;cursor:pointer;';
        copyHexBtn.onclick = () => {
            const copyFallback = () => {
                try {
                    const ta = document.createElement('textarea');
                    ta.value = hexList; ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;';
                    document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
                    copyHexBtn.textContent = '✅ Copied!';
                    setTimeout(() => { copyHexBtn.textContent = '📋 Copy hex list'; }, 2000);
                } catch (_) {}
            };
            if (navigator.clipboard && navigator.clipboard.writeText) {
                navigator.clipboard.writeText(hexList).then(() => {
                    copyHexBtn.textContent = '✅ Copied!';
                    setTimeout(() => { copyHexBtn.textContent = '📋 Copy hex list'; }, 2000);
                }).catch(copyFallback);
            } else { copyFallback(); }
        };

        headerRow.appendChild(colorCount);
        headerRow.appendChild(copyHexBtn);
        container.appendChild(headerRow);

        // Textarea with comma-separated hex values
        const hexTextarea = document.createElement('textarea');
        hexTextarea.value = hexList;
        hexTextarea.readOnly = true;
        hexTextarea.style.cssText = `
            width: 100%; height: 48px; resize: vertical;
            background: #1e1e2e; border: 1px solid #45475a; border-radius: 6px;
            padding: 6px 8px; font-family: monospace; font-size: 11px;
            color: #cdd6f4; box-sizing: border-box; margin-bottom: 8px;
        `;
        container.appendChild(hexTextarea);

        // Scrollable color list
        const colorList = document.createElement('div');
        colorList.style.cssText = 'display:flex;flex-direction:column;gap:2px;max-height:180px;overflow-y:auto;';

        palette.forEach(([hex, count]) => {
            const row = document.createElement('div');
            row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:2px 0;';

            const swatch = document.createElement('span');
            swatch.style.cssText = `
                display:inline-block;width:14px;height:14px;border-radius:3px;
                background:${hex};border:1px solid rgba(255,255,255,0.18);flex-shrink:0;
            `;

            const hexLabel = document.createElement('span');
            hexLabel.style.cssText = 'font-family:monospace;font-size:12px;color:#cdd6f4;flex:1;';
            hexLabel.textContent = hex;

            const countLabel = document.createElement('span');
            countLabel.style.cssText = 'font-size:11px;color:#6c7086;white-space:nowrap;';
            countLabel.textContent = count.toLocaleString() + ' px';

            row.appendChild(swatch);
            row.appendChild(hexLabel);
            row.appendChild(countLabel);
            colorList.appendChild(row);
        });

        container.appendChild(colorList);
    }

    // ==================== NOTIFICATIONS ====================
    function showNotification(message) {
        if (typeof showAnnouncement === 'function') {
            showAnnouncement(message);
            return;
        }

        const notification = document.createElement('div');
        notification.style.cssText = `
            position: fixed;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            background: #313244;
            color: #cdd6f4;
            padding: 12px 24px;
            border-radius: 10px;
            z-index: 10002;
            font-size: 14px;
            box-shadow: 0 4px 16px rgba(0,0,0,0.4);
            font-family: system-ui, sans-serif;
            max-width: 400px;
            text-align: center;
        `;
        notification.textContent = message;
        document.body.appendChild(notification);

        setTimeout(() => {
            notification.style.transition = 'opacity 0.3s';
            notification.style.opacity = '0';
            setTimeout(() => notification.remove(), 300);
        }, 3500);
    }

    // ==================== PROCESS WITH BOUNDS (for flyout) =========
    async function processWithBounds(bounds) {
        const width = bounds.maxX - bounds.minX + 1;
        const height = bounds.maxY - bounds.minY + 1;
        if (width < 2 || height < 2) { showNotification('Selection too small — please select a larger area.'); return; }
        if (width * height > MAX_REGION_PIXELS) { showNotification(`Region too large (${width}×${height}). Maximum is ~15000×15000 px.`); return; }
        const modal = createPreviewModal(bounds, null, true);
        const progressEl = modal.querySelector('.rsc-progress-text');
        const updateProgress = (text) => { if (progressEl) progressEl.textContent = text; };
        try {
            const screenshotCanvas = await renderRegionToCanvas(bounds, updateProgress);
            updatePreviewModal(modal, bounds, screenshotCanvas);
        } catch (err) {
            console.error('[Region Screenshot] Error:', err);
            showNotification('Error capturing screenshot: ' + err.message);
            try { modal.closeModal(); } catch {}
        }
    }

    async function silentDownload(bounds) {
        const width = bounds.maxX - bounds.minX + 1;
        const height = bounds.maxY - bounds.minY + 1;
        if (width < 2 || height < 2 || width * height > MAX_REGION_PIXELS) return;
        try {
            const canvas = await renderRegionToCanvas(bounds, () => {});
            const backgroundPref = loadBackgroundPreference();
            const exportCanvas = composeCanvasWithBackground(canvas, backgroundPref.mode, backgroundPref.color);
            const link = document.createElement('a');
            const ts = (() => { try { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}-${String(d.getMinutes()).padStart(2,'0')}-${String(d.getSeconds()).padStart(2,'0')}`; } catch(_) { return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); } })();
            link.download = `geopixels-screenshot-${ts}.png`;
            link.href = exportCanvas.toDataURL('image/png');
            link.click();
        } catch (err) {
            console.error('[Region Screenshot] Silent download error:', err);
        }
    }

    // ==================== START ====================
    init();

    // Expose API for flyout
    _regionScreenshot = { processWithBounds, toggleSelectionMode, silentDownload };
            })();
            _featureStatus.regionScreenshot = 'ok';
            console.log('[GeoPixelcons++] \u2705 Region Screenshot loaded');
        } catch (err) {
            _featureStatus.regionScreenshot = 'error';
            dbgPush(`Region Screenshot init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Region Screenshot' });
            console.error('[GeoPixelcons++] ❌ Region Screenshot failed:', err);
        }
    }

    // ============================================================
    //  FEATURE: Bulk Purchase Colors [bulkPurchaseColors]
    // ============================================================
    // gppBulkPurchaseOpenModal — true top-level (shared build.js scope)
    // reference to openBulkModal below, kept null unless this feature is
    // enabled. openBulkModal() itself is declared INSIDE this feature's own
    // _init_bulkPurchaseColors() IIFE (deliberately encapsulated, like every
    // other feature file), so it is NOT reachable as a bare identifier from
    // other files — a cross-feature caller (Ghost++'s "Buy all colours"
    // button, gpp-palette.js) must go through this reference instead of
    // `typeof openBulkModal`, which would always read 'undefined' regardless
    // of whether this feature is actually enabled.
    let gppBulkPurchaseOpenModal = null;
    // Same cross-feature-reachability reasoning as gppBulkPurchaseOpenModal
    // above — Ghost++'s "Buy all colors" button (gpp-palette.js) goes
    // through this instead of the Bulk Purchase Preview modal, per explicit
    // product decision to mirror this feature's own legacy native-ghost-menu
    // flow (see openProfilePanelWithColors / handlePurchaseUnowned below).
    let gppBulkPurchaseOpenProfilePanel = null;
    if (_settings.bulkPurchaseColors) {
        try {
            (function _init_bulkPurchaseColors() {

    // ─── Constants ────────────────────────────────────────────────────────────────
    const PIXELS_PER_COLOR = 100; // Informational cost shown in the preview
    const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;

    // ─── Dark mode detection (geopixels++ compatibility) ──────────────────────────
    function isDarkMode() {
        return getComputedStyle(document.documentElement).colorScheme === 'dark';
    }

    function t() {
        const dark = isDarkMode();
        return {
            panelBg:      dark ? '#1e2939' : '#fff',
            text:         dark ? '#f3f4f6' : '#1f2937',
            textMed:      dark ? '#e5e7eb' : '#374151',
            textSec:      dark ? '#d1d5db' : '#6b7280',
            textMuted:    dark ? '#99a1af' : '#9ca3af',
            textOwned:    dark ? '#6a7282' : '#b0b0b0',
            border:       dark ? '#364153' : '#e5e7eb',
            borderLight:  dark ? '#364153' : '#ececec',
            inputBorder:  dark ? '#4a5565' : '#d1d5db',
            rowBg:        dark ? '#101828' : '#fff',
            rowOwnedBg:   dark ? '#1e2939' : '#f3f4f6',
            sepBg:        dark ? '#101828' : '#f9fafb',
            progressBg:   dark ? '#364153' : '#e5e7eb',
            cancelBg:     dark ? '#364153' : '#e5e7eb',
            cancelText:   dark ? '#e5e7eb' : '#374151',
            closeBg:      dark ? '#364153' : '#f3f4f6',
            closeText:    dark ? '#d1d5db' : '#6b7280',
            queueBg:      dark ? '#101828' : '#fff',
            queueBorder:  dark ? '#364153' : '#e5e7eb',
        };
    }

    // ─── Credential access ────────────────────────────────────────────────────────
    //
    // The page declares `tokenUser`, `userID`, and `subject` with `let` in
    // index121.js. Top-level `let` is NOT a property of `window`, and
    // _pw.eval() cannot reach them either (different script scope).
    //
    // Solution: inject a <script> tag that registers a live getter on
    // window._gpAuth from within the page's own global scope.
    //
    (function installAuthBridge() {
        const s = document.createElement('script');
        s.textContent = `
            Object.defineProperty(window, '_gpAuth', {
                configurable: true,
                get: function() {
                    return {
                        token:   typeof tokenUser !== 'undefined' ? tokenUser : null,
                        userId:  typeof userID   !== 'undefined' ? userID   : null,
                        subject: typeof subject  !== 'undefined' ? subject  : null,
                    };
                }
            });
        `;
        (document.head || document.documentElement).appendChild(s);
        s.remove();
    })();

    /** Return auth credentials, or null if the user is not yet logged in. */
    function getAuth() {
        const a = _pw._gpAuth;
        const token   = (a && a.token)  || localStorage.getItem('tokenUser');
        const userId  = (a && a.userId != null) ? a.userId : parseInt(localStorage.getItem('userID') || '', 10);
        const subject = (a && a.subject) || '';
        if (!token || isNaN(userId)) return null;
        return { token, userId, subject };
    }

    // ─── Color sanitization ───────────────────────────────────────────────────────

    /**
     * Normalise a single raw token to an uppercase "#RRGGBB" string.
     * Returns null for anything that cannot be interpreted as a valid RGB colour.
     *
     * Accepted formats:
     *   - Hex with hash:    #FF0000
     *   - Hex without hash: FF0000
     *   - 3-digit shorthand:#F00 / F00
     *   - Decimal integer:  16711680
     */
    function sanitizeToken(token) {
        // Strip surrounding quotes that may appear in copy-pasted strings
        token = (token || '').trim().replace(/^["'`]+|["'`]+$/g, '').trim();
        if (!token) return null;

        // Pure decimal integer (digits only, no a-f)
        if (/^\d+$/.test(token)) {
            const n = parseInt(token, 10);
            if (n < 0 || n > 0xFFFFFF) return null;
            return '#' + n.toString(16).toUpperCase().padStart(6, '0');
        }

        const stripped = token.replace(/^#/, '');

        // 6-digit hex
        if (/^[0-9A-Fa-f]{6}$/.test(stripped)) {
            return '#' + stripped.toUpperCase();
        }

        // 3-digit shorthand → expand to 6-digit
        if (/^[0-9A-Fa-f]{3}$/.test(stripped)) {
            const expanded = stripped.split('').map(c => c + c).join('');
            return '#' + expanded.toUpperCase();
        }

        return null;
    }

    /**
     * Split raw textarea input (comma-, space-, or newline-separated) into
     * sanitized, deduplicated colour strings. Returns { valid, invalid }.
     */
    function parseColorInput(raw) {
        const tokens = (raw || '').split(/[\s,\n]+/).filter(Boolean);
        const seen = new Set();
        const valid = [];
        const invalid = [];

        for (const t of tokens) {
            const c = sanitizeToken(t);
            if (c) {
                if (!seen.has(c)) { seen.add(c); valid.push(c); }
            } else {
                invalid.push(t);
            }
        }

        return { valid, invalid };
    }

    // ─── Owned-colour helpers ─────────────────────────────────────────────────────

    /**
     * Build a Set of uppercase "#RRGGBB" hex strings from window.Colors,
     * which the site keeps in sync with the authenticated user's colour list.
     */
    function buildOwnedSet() {
        const set = new Set();
        // `Colors` is a top-level `let` in the page script. With @grant none it is
        // accessible as a bare name in most environments; use try/catch as guard.
        let colors;
        try { colors = Colors; } catch (_) { colors = window.Colors; }
        if (Array.isArray(colors)) {
            colors.forEach(h => {
                if (h && typeof h === 'string') set.add(h.toUpperCase());
            });
        }
        return set;
    }

    /**
     * Fetch a fresh copy of the user's data from the server and return a Set
     * of owned hex strings. Falls back to window.Colors on any error.
     */
    async function fetchOwnedHexSet() {
        const auth = getAuth();
        if (!auth) {
            console.warn('[BulkPurchase] Credentials not yet captured, falling back to local Colors.');
            return buildOwnedSet();
        }
        try {
            const resp = await window.fetch('/GetUserData', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ userId: auth.userId, token: auth.token }),
            });
            if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
            const data = await resp.json();

            const set = new Set();
            const raw = data.colors;

            // Server returns a comma-separated decimal string, e.g. "16777215, 0, 65280"
            if (typeof raw === 'string') {
                raw.split(',').forEach(s => {
                    const n = parseInt(s.trim(), 10);
                    // n >= 0 deliberately includes 0 (== #000000)
                    if (!isNaN(n) && n >= 0 && n <= 0xFFFFFF) {
                        set.add('#' + n.toString(16).toUpperCase().padStart(6, '0'));
                    }
                });
            } else if (Array.isArray(raw)) {
                raw.forEach(n => {
                    if (typeof n === 'number' && n >= 0 && n <= 0xFFFFFF) {
                        set.add('#' + n.toString(16).toUpperCase().padStart(6, '0'));
                    }
                });
            }

            // Merge local Colors as belt-and-suspenders
            buildOwnedSet().forEach(h => set.add(h));
            return set;
        } catch (err) {
            console.warn('[BulkPurchase] GetUserData failed, falling back to local Colors:', err);
            return buildOwnedSet();
        }
    }

    // ─── Local hex → integer helper ───────────────────────────────────────────────

    /** Convert "#RRGGBB" to its integer equivalent. */
    function hexToInt(hex) {
        return parseInt(hex.replace(/^#/, ''), 16);
    }

    // ─── Ghost palette DOM reader ─────────────────────────────────────────────────

    /**
     * Extract unique hex colours from the rendered #ghostColorPalette buttons.
     *
     * Two strategies, tried in order per-button:
     *   1. data-color-rgba="rgba(R,G,B,1)"  — set by ghost22.js, always present
     *   2. title first-line                 — also set by ghost22.js, may vary in format
     *
     * Using `data-color-rgba` as primary avoids any dependency on the title format,
     * which has changed across script versions (2-line, 3-line, etc.).
     */
    function getGhostColorsFromDOM() {
        const swatches = document.querySelectorAll('#ghostColorPalette button[data-color-rgba], #ghostColorPalette button[title]');
        const seen = new Set();
        const colors = []; // { hex, count }

        swatches.forEach(btn => {
            let hex = null;
            let count = 0;

            // Strategy 1: parse data-color-rgba="rgba(R,G,B,1)"
            const rgba = btn.dataset.colorRgba;
            if (rgba) {
                const m = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
                if (m) {
                    hex = '#' +
                        parseInt(m[1]).toString(16).toUpperCase().padStart(2, '0') +
                        parseInt(m[2]).toString(16).toUpperCase().padStart(2, '0') +
                        parseInt(m[3]).toString(16).toUpperCase().padStart(2, '0');
                }
            }

            // Strategy 2: title first line (e.g. "#D5BFB2" or "#D5BFB2\n42 pixels")
            if (!hex && btn.title) {
                const candidate = btn.title.split(/[\r\n]+/)[0].trim().toUpperCase();
                if (/^#[0-9A-F]{6}$/.test(candidate)) hex = candidate;
            }

            // Parse pixel count from title second line (e.g. "42 pixels")
            if (btn.title) {
                const lines = btn.title.split(/[\r\n]+/);
                for (let i = 1; i < lines.length; i++) {
                    const cm = lines[i].match(/(\d+)\s*pixel/i);
                    if (cm) { count = parseInt(cm[1], 10); break; }
                }
            }

            if (hex && /^#[0-9A-F]{6}$/.test(hex) && !seen.has(hex)) {
                seen.add(hex);
                colors.push({ hex, count });
            }
        });

        // Sort by pixel count descending (most used first)
        colors.sort((a, b) => b.count - a.count);

        return colors.map(c => c.hex);
    }

    /** Returns a Map of hex → pixel count from the ghost template palette DOM. */
    function getGhostPixelCounts() {
        const swatches = document.querySelectorAll('#ghostColorPalette button[data-color-rgba], #ghostColorPalette button[title]');
        const counts = new Map();

        swatches.forEach(btn => {
            let hex = null;
            let count = 0;

            const rgba = btn.dataset.colorRgba;
            if (rgba) {
                const m = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
                if (m) {
                    hex = '#' +
                        parseInt(m[1]).toString(16).toUpperCase().padStart(2, '0') +
                        parseInt(m[2]).toString(16).toUpperCase().padStart(2, '0') +
                        parseInt(m[3]).toString(16).toUpperCase().padStart(2, '0');
                }
            }

            if (!hex && btn.title) {
                const candidate = btn.title.split(/[\r\n]+/)[0].trim().toUpperCase();
                if (/^#[0-9A-F]{6}$/.test(candidate)) hex = candidate;
            }

            if (btn.title) {
                const lines = btn.title.split(/[\r\n]+/);
                for (let i = 1; i < lines.length; i++) {
                    const cm = lines[i].match(/(\d+)\s*pixel/i);
                    if (cm) { count = parseInt(cm[1], 10); break; }
                }
            }

            if (hex && /^#[0-9A-F]{6}$/.test(hex)) {
                counts.set(hex, count);
            }
        });

        return counts;
    }

    /** Returns hex → ghost-template progress stats parsed from the rendered ghost palette. */
    function getGhostColorStats() {
        const swatches = document.querySelectorAll('#ghostColorPalette button[data-color-rgba], #ghostColorPalette button[title]');
        const stats = new Map();

        swatches.forEach(btn => {
            let hex = null;

            const rgba = btn.dataset.colorRgba;
            if (rgba) {
                const m = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
                if (m) {
                    hex = '#' +
                        parseInt(m[1]).toString(16).toUpperCase().padStart(2, '0') +
                        parseInt(m[2]).toString(16).toUpperCase().padStart(2, '0') +
                        parseInt(m[3]).toString(16).toUpperCase().padStart(2, '0');
                }
            }

            const lines = (btn.title || '').split(/[\r\n]+/).map(l => l.trim()).filter(Boolean);
            if (!hex && /^#[0-9A-F]{6}$/.test(lines[0] || '')) hex = lines[0].toUpperCase();
            if (!hex || !/^#[0-9A-F]{6}$/.test(hex)) return;

            let completed = 0;
            let total = 0;
            let hasProgress = false;
            const progressMatch = (lines[1] || '').match(/^([\d,]+)\s*\/\s*([\d,]+)/);
            if (progressMatch) {
                hasProgress = true;
                completed = parseInt(progressMatch[1].replace(/,/g, ''), 10);
                total     = parseInt(progressMatch[2].replace(/,/g, ''), 10);
            } else {
                for (let i = 1; i < lines.length; i++) {
                    const countMatch = lines[i].match(/([\d,]+)\s*pixel/i);
                    if (countMatch) { total = parseInt(countMatch[1].replace(/,/g, ''), 10); break; }
                }
            }

            const remaining = Math.max(0, total - completed);
            const remainingPercent = total > 0 ? (remaining / total) * 100 : 0;
            const percent = total > 0 ? (completed / total) * 100 : 0;
            stats.set(hex, { completed, total, remaining, remainingPercent, percent, hasProgress });
        });

        return stats;
    }

    // ─── Confirmation / preview modal ─────────────────────────────────────────────

    /** The single shared overlay element (created once and reused). */
    let _bulkOverlay = null;
    /** Original ordered list passed to openBulkModal (preserved for results display). */
    let _pendingColors = [];

    /** Per-status visual style config. */
    function getStatusStyles() {
        const dark = isDarkMode();
        return {
            pending:   { label: '',                              bg: dark ? '#101828' : '#f9fafb', border: dark ? '#364153' : '#e5e7eb', textColor: dark ? '#6a7282' : '#9ca3af' },
            owned:     { label: 'Already Owned',                 bg: dark ? '#422006' : '#fefce8', border: dark ? '#a16207' : '#fde047', textColor: dark ? '#fbbf24' : '#92400e' },
            purchased: { label: 'Purchased ✓',                  bg: dark ? '#052e16' : '#f0fdf4', border: dark ? '#16a34a' : '#86efac', textColor: dark ? '#4ade80' : '#166534' },
            failed:    { label: 'Failed',                        bg: dark ? '#450a0a' : '#fef2f2', border: dark ? '#dc2626' : '#fca5a5', textColor: dark ? '#f87171' : '#991b1b' },
            skipped:   { label: 'Skipped (Insufficient Pixels)', bg: dark ? '#0f172a' : '#f1f5f9', border: dark ? '#475569' : '#cbd5e1', textColor: dark ? '#94a3b8' : '#64748b' },
        };
    }

    function buildColorRow(hex, status, ghostPixelCount) {
        const STATUS_STYLES = getStatusStyles();
        const s = STATUS_STYLES[status] || STATUS_STYLES.pending;
        const c = t();
        const row = document.createElement('div');
        row.dataset.gpColor = hex;
        row.style.cssText = `display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0.75rem;` +
            `background:${s.bg};border:1px solid ${s.border};border-radius:0.5rem;`;

        const swatch = document.createElement('div');
        swatch.style.cssText = `width:1.75rem;height:1.75rem;border-radius:0.25rem;border:1px solid ${c.inputBorder};flex-shrink:0;background:${hex};`;

        const hexLabel = document.createElement('span');
        hexLabel.style.cssText = `font-family:monospace;font-size:0.875rem;color:${c.textMed};flex:1;`;
        hexLabel.textContent = hex;

        // Ghost template pixel count label
        const ghostLabel = document.createElement('span');
        ghostLabel.className = 'gp-row-ghost';
        if (ghostPixelCount != null) {
            ghostLabel.style.cssText = `font-size:0.7rem;font-weight:500;color:${ghostPixelCount > 0 ? (isDarkMode() ? '#a78bfa' : '#7c3aed') : (isDarkMode() ? '#6b7280' : '#9ca3af')};white-space:nowrap;`;
            ghostLabel.textContent = ghostPixelCount > 0 ? `${ghostPixelCount} px` : 'unused';
        }

        const badge = document.createElement('span');
        badge.className = 'gp-row-badge';
        badge.style.cssText = `font-size:0.7rem;font-weight:600;color:${s.textColor};white-space:nowrap;`;
        badge.textContent = status === 'pending' ? `${PIXELS_PER_COLOR} px` : s.label;

        row.appendChild(swatch);
        row.appendChild(hexLabel);
        if (ghostPixelCount != null) row.appendChild(ghostLabel);
        row.appendChild(badge);
        return row;
    }

    function updateColorRow(hex, status) {
        // Scoped to the modal list only — queue rows use data-gp-queue-color
        const list = document.getElementById('gp-bulk-list');
        if (!list) return;
        const row = list.querySelector(`[data-gp-color="${hex}"]`);
        if (!row) return;
        const STATUS_STYLES = getStatusStyles();
        const s = STATUS_STYLES[status] || STATUS_STYLES.pending;
        row.style.background = s.bg;
        row.style.borderColor = s.border;
        const badge = row.querySelector('.gp-row-badge');
        if (badge) { badge.textContent = s.label; badge.style.color = s.textColor; }
    }

    function ensureBulkModal() {
        if (_bulkOverlay) return;

        const c = t();

        _bulkOverlay = document.createElement('div');
        _bulkOverlay.id = 'gp-bulk-overlay';
        // Overlay sits above everything — including z-50 profile panel and z-40 ghost modal
        _bulkOverlay.style.cssText =
            'position:fixed;inset:0;z-index:10000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0.55);';

        _bulkOverlay.innerHTML = `
<div id="gp-bulk-panel"
     style="background:${c.panelBg};color:${c.text};border-radius:1rem;box-shadow:0 20px 60px rgba(0,0,0,0.3);width:90%;max-width:28rem;max-height:85vh;display:flex;flex-direction:column;padding:1.5rem;gap:1rem;overflow:hidden;">

    <!-- Header -->
    <div style="display:flex;align-items:center;justify-content:space-between;flex-shrink:0;">
        <h2 id="gp-bulk-title" style="margin:0;font-size:1.25rem;font-weight:700;color:${c.text};">Bulk Purchase Preview</h2>
        <button id="gp-bulk-close"
                style="width:2rem;height:2rem;border-radius:50%;border:none;background:${c.closeBg};cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;color:${c.closeText};"
                title="Close">\u2715</button>
    </div>

    <!-- Subtitle -->
    <p id="gp-bulk-subtitle" style="margin:0;font-size:0.85rem;color:${c.textSec};flex-shrink:0;"></p>

    <!-- Colour list (all colors in original order, owned grayed out) -->
    <div id="gp-bulk-list"
         style="flex:1 1 auto;overflow-y:auto;display:flex;flex-direction:column;gap:0.5rem;padding-right:0.25rem;min-height:0;"></div>

    <!-- Progress bar (shown during purchase) -->
    <div id="gp-bulk-progress-wrap"
         style="flex-shrink:0;display:none;">
        <div style="width:100%;height:0.75rem;background:${c.progressBg};border-radius:9999px;overflow:hidden;">
            <div id="gp-bulk-progress-bar"
                 style="height:100%;width:0%;background:#3b82f6;border-radius:9999px;transition:width 0.2s ease;"></div>
        </div>
        <p id="gp-bulk-progress-text"
           style="margin:0.25rem 0 0;font-size:0.75rem;color:${c.textSec};text-align:center;"></p>
    </div>

    <!-- Action buttons -->
    <div style="display:flex;gap:0.75rem;flex-shrink:0;">
        <button id="gp-bulk-cancel"
                style="flex:1;padding:0.5rem 1rem;background:${c.cancelBg};border:none;border-radius:0.5rem;font-weight:600;cursor:pointer;font-size:0.9rem;color:${c.cancelText};">
            Cancel
        </button>
        <button id="gp-bulk-confirm"
                style="flex:1;padding:0.5rem 1rem;background:#3b82f6;color:#fff;border:none;border-radius:0.5rem;font-weight:600;cursor:pointer;font-size:0.9rem;">
            Purchase All
        </button>
    </div>
</div>`;

        document.body.appendChild(_bulkOverlay);

        document.getElementById('gp-bulk-close').addEventListener('click', closeBulkModal);
        document.getElementById('gp-bulk-cancel').addEventListener('click', closeBulkModal);
        _bulkOverlay.addEventListener('click', e => { if (e.target === _bulkOverlay) closeBulkModal(); });
        document.getElementById('gp-bulk-confirm').addEventListener('click', onBulkConfirm);
    }

    /**
     * Open the preview modal for a given list of "#RRGGBB" hex strings.
     * All colors are shown in original order; already-owned ones get an
     * "Already Owned" badge and are non-destructively skipped on confirm.
     */
    function openBulkModal(colors) {
        ensureBulkModal();

        _pendingColors = colors;

        const ownedSet = buildOwnedSet();
        const toBuyCount  = colors.filter(c => !ownedSet.has(c)).length;
        const ownedCount  = colors.length - toBuyCount;

        // Grab ghost template pixel counts if a template is loaded
        const ghostCounts = getGhostPixelCounts();
        const hasGhost = ghostCounts.size > 0;

        // --- Populate list (original order, owned shown in-place) ---
        const list = document.getElementById('gp-bulk-list');
        list.innerHTML = '';
        colors.forEach(hex => {
            const gpc = hasGhost ? (ghostCounts.get(hex) ?? 0) : undefined;
            list.appendChild(buildColorRow(hex, ownedSet.has(hex) ? 'owned' : 'pending', hasGhost ? gpc : undefined));
        });

        // --- Header / subtitle ---
        document.getElementById('gp-bulk-title').textContent = 'Bulk Purchase Preview';
        const parts = [`${toBuyCount} to purchase · est. ${(toBuyCount * PIXELS_PER_COLOR).toLocaleString()} Pixels`];
        if (ownedCount > 0) parts.push(`${ownedCount} already owned (will skip)`);
        document.getElementById('gp-bulk-subtitle').textContent = parts.join(' · ');

        // --- Ghost template legend ---
        let legend = document.getElementById('gp-bulk-ghost-legend');
        if (legend) legend.remove();
        if (hasGhost) {
            legend = document.createElement('p');
            legend.id = 'gp-bulk-ghost-legend';
            const purpleColor = isDarkMode() ? '#a78bfa' : '#7c3aed';
            legend.style.cssText = `margin:0;font-size:0.75rem;color:${isDarkMode() ? '#9ca3af' : '#6b7280'};flex-shrink:0;`;
            legend.innerHTML = `<span style="color:${purpleColor};font-weight:600;">Purple</span> = pixels used in the loaded ghost template`;
            const subtitle = document.getElementById('gp-bulk-subtitle');
            subtitle.insertAdjacentElement('afterend', legend);
        }

        // --- Reset progress bar ---
        document.getElementById('gp-bulk-progress-wrap').style.display = 'none';
        document.getElementById('gp-bulk-progress-bar').style.width = '0%';
        document.getElementById('gp-bulk-progress-text').textContent = '';

        // --- Reset action buttons ---
        const confirmBtn = document.getElementById('gp-bulk-confirm');
        const cancelBtn  = document.getElementById('gp-bulk-cancel');
        confirmBtn.style.display = '';
        confirmBtn.textContent = 'Purchase All';
        confirmBtn.disabled = toBuyCount === 0;
        confirmBtn.style.opacity = toBuyCount === 0 ? '0.5' : '1';
        confirmBtn.style.cursor  = toBuyCount === 0 ? 'not-allowed' : 'pointer';
        cancelBtn.textContent = 'Cancel';
        cancelBtn.disabled = false;

        _bulkOverlay.style.display = 'flex';
    }

    function closeBulkModal() {
        if (_bulkOverlay) _bulkOverlay.style.display = 'none';
        _pendingColors = [];
        // Sync the profile card queue now that the modal is gone
        if (document.getElementById('gp-bulk-queue-list')) refreshColorQueue();
    }

    async function onBulkConfirm() {
        const confirmBtn = document.getElementById('gp-bulk-confirm');
        const cancelBtn  = document.getElementById('gp-bulk-cancel');
        const closeBtn   = document.getElementById('gp-bulk-close');

        // Lock UI during purchase
        confirmBtn.disabled = true;
        confirmBtn.style.opacity = '0.5';
        confirmBtn.style.cursor = 'not-allowed';
        cancelBtn.disabled = true;
        closeBtn.disabled = true;

        const colors = [..._pendingColors];
        const results = await executeBulkPurchase(colors);

        // Silently strip purchased colors from textarea (queue refreshes when modal closes)
        const textarea = document.getElementById('gp-bulk-textarea');
        if (textarea) {
            const { valid } = parseColorInput(textarea.value);
            const purchasedSet = new Set(colors.filter(h => results.get(h) === 'purchased'));
            const remaining = valid.filter(c => !purchasedSet.has(c));
            textarea.value = remaining.length ? remaining.join(', ') : '';
        }

        // Switch to results view
        showBulkResults(colors, results);

        closeBtn.disabled = false;
        cancelBtn.disabled = false;
        cancelBtn.textContent = 'Close';
    }

    // ─── Purchase logic ───────────────────────────────────────────────────────────

    /**
     * Attempt to purchase each non-owned color in order.
     * On HTTP 402 (insufficient pixels), stops immediately and marks the current
     * color plus all remaining unattempted colors as 'skipped'.
     * Returns a Map<hex, 'owned'|'purchased'|'failed'|'skipped'>.
     */
    async function executeBulkPurchase(colors) {
        const progressWrap = document.getElementById('gp-bulk-progress-wrap');
        const progressBar  = document.getElementById('gp-bulk-progress-bar');
        const progressText = document.getElementById('gp-bulk-progress-text');

        progressWrap.style.display = 'block';

        const auth = getAuth();
        if (!auth) {
            progressText.textContent = 'Error: credentials not captured yet.';
            if (_pw.showAlert) _pw.showAlert('Error', 'Credentials not ready. Place a pixel first to initialise auth, then retry.');
            return new Map();
        }

        const ownedSet = buildOwnedSet();
        const results  = new Map();
        colors.forEach(hex => { if (ownedSet.has(hex)) results.set(hex, 'owned'); });

        const toPurchase = colors.filter(hex => !ownedSet.has(hex));
        let stoppedAt = -1;

        for (let i = 0; i < toPurchase.length; i++) {
            const hex = toPurchase[i];

            const pct = Math.round((i / toPurchase.length) * 100);
            progressBar.style.width = pct + '%';
            progressText.textContent = `Purchasing ${i + 1} of ${toPurchase.length}: ${hex}`;

            try {
                const resp = await window.fetch('/MakePurchase', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        Token:   auth.token,
                        UserId:  auth.userId,
                        Subject: auth.subject,
                        type:    'ExtraColor',
                        amount:  hexToInt(hex),
                    }),
                });

                if (resp.status === 200) {
                    results.set(hex, 'purchased');
                    updateColorRow(hex, 'purchased');
                } else if (resp.status === 402) {
                    // Insufficient pixels — stop the loop here
                    results.set(hex, 'skipped');
                    updateColorRow(hex, 'skipped');
                    stoppedAt = i;
                    break;
                } else {
                    results.set(hex, 'failed');
                    updateColorRow(hex, 'failed');
                    console.warn(`[BulkPurchase] Failed ${hex}: HTTP ${resp.status}`);
                }
            } catch (err) {
                results.set(hex, 'failed');
                updateColorRow(hex, 'failed');
                console.error('[BulkPurchase] Error purchasing', hex, err);
            }
        }

        // Mark everything that was never attempted (after the 402) as skipped
        if (stoppedAt >= 0) {
            for (let j = stoppedAt + 1; j < toPurchase.length; j++) {
                const hex = toPurchase[j];
                results.set(hex, 'skipped');
                updateColorRow(hex, 'skipped');
            }
        }

        progressBar.style.width = '100%';

        // Auto-enable newly purchased colors in the page's active palette,
        // mirroring what the native single-color purchase does.
        const successCount = toPurchase.filter(hex => results.get(hex) === 'purchased').length;
        if (successCount > 0) {
            try {
                let colorsLen;
                try { colorsLen = Colors.length; } catch (_) { colorsLen = (_pw.Colors || []).length; }
                const s = document.createElement('script');
                s.textContent = `(function(){try{for(var i=${colorsLen};i<${colorsLen + successCount};i++){if(!activeColors.includes(i))activeColors.push(i);}localStorage.setItem('activeColors',JSON.stringify(activeColors));}catch(e){}})();`;
                (document.head || document.documentElement).appendChild(s);
                s.remove();
            } catch (e) {
                console.warn('[BulkPurchase] Could not auto-enable purchased colors:', e);
            }
        }

        if (typeof window.synchronize === 'function') window.synchronize();

        return results;
    }

    /**
     * Switch the open modal into a results view.
     * The colour rows are already updated in real-time; this just updates the
     * title/subtitle and hides the confirm button.
     */
    function showBulkResults(original, results) {
        const purchased = original.filter(h => results.get(h) === 'purchased').length;
        const owned     = original.filter(h => results.get(h) === 'owned').length;
        const failed    = original.filter(h => results.get(h) === 'failed').length;
        const skipped   = original.filter(h => results.get(h) === 'skipped').length;

        document.getElementById('gp-bulk-title').textContent = 'Purchase Complete';

        const parts = [];
        if (purchased > 0) parts.push(`${purchased} purchased`);
        if (owned     > 0) parts.push(`${owned} already owned`);
        if (skipped   > 0) parts.push(`${skipped} skipped — insufficient Pixels`);
        if (failed    > 0) parts.push(`${failed} failed`);
        document.getElementById('gp-bulk-subtitle').textContent = parts.join(' · ');

        document.getElementById('gp-bulk-confirm').style.display = 'none';
    }

    // ─── Profile card queue helpers ───────────────────────────────────────────────

    /** Remove a single hex value from the textarea and fire 'input' to refresh the queue. */
    function removeColorFromTextarea(hex) {
        const textarea = document.getElementById('gp-bulk-textarea');
        if (!textarea) return;
        const { valid } = parseColorInput(textarea.value);
        const remaining = valid.filter(c => c !== hex);
        textarea.value = remaining.length ? remaining.join(', ') : '';
        textarea.dispatchEvent(new Event('input'));
    }

    /**
     * Re-render the right-side queue list from the current textarea contents.
     * Unowned colors come first (with Buy buttons); owned are grayed at the bottom.
     */
    function refreshColorQueue() {
        const textarea  = document.getElementById('gp-bulk-textarea');
        const list      = document.getElementById('gp-bulk-queue-list');
        const emptyHint = document.getElementById('gp-bulk-empty-hint');
        const buyAllBtn = document.getElementById('gp-bulk-buy-all-btn');
        const infoEl    = document.getElementById('gp-bulk-parse-info');
        const sortEl    = document.getElementById('gp-bulk-sort-select');
        if (!textarea || !list) return;

        const { valid, invalid } = parseColorInput(textarea.value);
        const ownedSet = buildOwnedSet();
        const ghostStats = getGhostColorStats();
        const hasProgressStats = Array.from(ghostStats.values()).some(s => s.hasProgress);
        const progressSortValues = new Set(['mostRemaining', 'leastRemaining', 'mostPct', 'leastPct']);
        if (sortEl) {
            Array.from(sortEl.options).forEach(opt => {
                const isProgressSort = progressSortValues.has(opt.value);
                opt.hidden = isProgressSort && !hasProgressStats;
                opt.disabled = isProgressSort && !hasProgressStats;
            });
            if (!hasProgressStats && progressSortValues.has(sortEl.value)) sortEl.value = 'default';
        }
        const sortValue = sortEl ? sortEl.value : 'default';

        function sortColors(colors) {
            const getStats = hex => ghostStats.get(hex) || { total: 0, remaining: 0, remainingPercent: 0 };
            const originalIndex = new Map(valid.map((hex, idx) => [hex, idx]));
            const tie = (a, b) => (originalIndex.get(a) ?? 0) - (originalIndex.get(b) ?? 0);
            return [...colors].sort((a, b) => {
                const sa = getStats(a), sb = getStats(b);
                switch (sortValue) {
                    case 'default':        return (sb.total - sa.total) || tie(a, b);
                    case 'leastUsed':      return (sa.total - sb.total) || tie(a, b);
                    case 'mostRemaining':  return (sb.remaining - sa.remaining) || tie(a, b);
                    case 'leastRemaining': return (sa.remaining - sb.remaining) || tie(a, b);
                    case 'mostPct':        return (sb.remainingPercent - sa.remainingPercent) || tie(a, b);
                    case 'leastPct':       return (sa.remainingPercent - sb.remainingPercent) || tie(a, b);
                    case 'byColor':        return a.localeCompare(b) || tie(a, b);
                    case 'byColorRev':     return b.localeCompare(a) || tie(a, b);
                    default:               return tie(a, b);
                }
            });
        }

        const unowned  = sortColors(valid.filter(c => !ownedSet.has(c)));
        const owned    = sortColors(valid.filter(c =>  ownedSet.has(c)));

        // Parse-info label (below textarea)
        if (infoEl) {
            if (!textarea.value.trim()) {
                infoEl.textContent = '';
            } else {
                const parts = [`${unowned.length} to purchase`];
                if (owned.length   > 0) parts.push(`${owned.length} already owned`);
                if (invalid.length > 0) parts.push(`${invalid.length} unrecognised`);
                infoEl.textContent = parts.join(' · ');
            }
        }

        // Buy All button state
        if (buyAllBtn) {
            const n = unowned.length;
            buyAllBtn.textContent = `🛒 Buy All (${n})`;
            buyAllBtn.disabled       = n === 0;
            buyAllBtn.style.opacity  = n === 0 ? '0.5' : '1';
            buyAllBtn.style.cursor   = n === 0 ? 'not-allowed' : 'pointer';
        }

        // Rebuild list
        list.innerHTML = '';
        if (valid.length === 0) {
            if (emptyHint) emptyHint.style.display = 'block';
            return;
        }
        if (emptyHint) emptyHint.style.display = 'none';

        // Grab ghost pixel counts for queue display
        const hasGhost = ghostStats.size > 0;
        const ghostStat = hex => ghostStats.get(hex) || { completed: 0, total: 0, percent: 0 };

        unowned.forEach(hex => list.appendChild(buildQueueRow(hex, false, hasGhost ? ghostStat(hex) : undefined)));

        if (owned.length > 0) {
            const sep = document.createElement('div');
            sep.className = 'bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700 text-gray-500 dark:text-gray-400';
            sep.style.cssText =
                `font-size:0.6rem;color:var(--color-gray-500,#6b7280);text-align:center;padding:0.2rem 0;` +
                `border-top:1px solid var(--color-gray-300,#d1d5db);border-bottom:1px solid var(--color-gray-300,#d1d5db);` +
                `background:var(--color-gray-100,#f3f4f6);letter-spacing:0.05em;user-select:none;`;
            sep.textContent = '── Already Owned ──';
            list.appendChild(sep);
            owned.forEach(hex => list.appendChild(buildQueueRow(hex, true, hasGhost ? ghostStat(hex) : undefined)));
        }
    }

    /** Build a single color row for the profile queue. */
    function buildQueueRow(hex, isOwned, ghostStats) {
        const row = document.createElement('div');
        row.dataset.gpQueueColor = hex;
        row.className = isOwned
            ? 'gp-bulk-queue-row gp-bulk-queue-row-owned bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-700'
            : 'gp-bulk-queue-row bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700';
        // Fixed height + no gap = button stays in same screen position as rows are removed
        row.style.cssText =
            'display:flex;align-items:center;height:1.625rem;padding:0 0.35rem;' +
            `background:${isOwned ? 'var(--color-gray-100,#f3f4f6)' : 'var(--color-white,#fff)'};` +
            `border-bottom:1px solid ${isOwned ? 'var(--color-gray-300,#d1d5db)' : 'var(--color-gray-200,#e5e7eb)'};` +
            `${isOwned ? 'opacity:0.45;' : ''}`;

        const swatch = document.createElement('div');
        swatch.style.cssText =
            `width:0.875rem;height:0.875rem;border-radius:2px;flex-shrink:0;` +
            `background:${hex};border:1px solid rgba(0,0,0,0.12);margin-right:0.35rem;`;

        const label = document.createElement('span');
        label.style.cssText =
            `font-family:monospace;font-size:0.68rem;flex:1;overflow:hidden;` +
            `color:${isOwned ? 'var(--color-gray-500,#6b7280)' : 'var(--color-gray-700,#374151)'};letter-spacing:-0.01em;`;
        label.textContent = hex;

        row.appendChild(swatch);
        row.appendChild(label);

        // Ghost template pixel count
        if (ghostStats != null) {
            const ghostLabel = document.createElement('span');
            const total = ghostStats.total || 0;
            const completed = ghostStats.completed || 0;
            const percent = Math.round((ghostStats.percent || 0) * 10) / 10;
            const percentText = Number.isInteger(percent) ? String(percent) : percent.toFixed(1);
            ghostLabel.style.cssText =
                `font-size:0.6rem;font-weight:600;white-space:nowrap;flex-shrink:0;padding:0 0.25rem;` +
                `color:var(--color-gray-900,#f9fafb);`;
            if (total > 0 && ghostStats.hasProgress) {
                const shellColor = 'var(--color-gray-900,#f9fafb)';
                const progressColor = 'var(--color-purple-300,#c4b5fd)';
                const pctColor = 'var(--color-blue-300,#93c5fd)';
                const add = (text, color) => {
                    const span = document.createElement('span');
                    span.textContent = text;
                    span.style.color = color;
                    ghostLabel.appendChild(span);
                };
                add(`${total.toLocaleString()} px `, shellColor);
                add('(', shellColor);
                add(`${completed.toLocaleString()} px / ${total.toLocaleString()} px`, progressColor);
                add(' | ', shellColor);
                add(`${percentText}%`, pctColor);
                add(')', shellColor);
            } else {
                ghostLabel.textContent = total > 0 ? `${total.toLocaleString()} px` : '—';
            }
            row.appendChild(ghostLabel);
        }

        if (isOwned) {
            const badge = document.createElement('span');
            badge.style.cssText =
                `font-size:0.6rem;color:var(--color-gray-500,#6b7280);white-space:nowrap;flex-shrink:0;padding-left:0.25rem;`;
            badge.textContent = 'owned';
            row.appendChild(badge);
        } else {
            const btn = document.createElement('button');
            // Fixed width so the button is always at the same X — critical for spam-clicking
            btn.style.cssText =
                'width:2.25rem;height:1.25rem;flex-shrink:0;background:#3b82f6;color:#fff;border:none;' +
                'border-radius:3px;font-size:0.65rem;font-weight:700;cursor:pointer;' +
                'display:flex;align-items:center;justify-content:center;letter-spacing:0.02em;';
            btn.textContent = 'BUY';
            btn.addEventListener('mouseover', () => { if (!btn.disabled) btn.style.background = '#2563eb'; });
            btn.addEventListener('mouseout',  () => { if (!btn.disabled) btn.style.background = '#3b82f6'; });
            btn.addEventListener('click', () => buyIndividualColor(hex, btn));
            row.appendChild(btn);
        }

        return row;
    }

    /** Purchase one color immediately; on success remove it from the textarea and queue. */
    async function buyIndividualColor(hex, btn) {
        btn.disabled      = true;
        btn.style.opacity = '0.5';
        btn.style.cursor  = 'not-allowed';
        btn.textContent   = '…';

        const auth = getAuth();
        if (!auth) {
            if (_pw.showAlert) _pw.showAlert('Error', 'Not ready yet — credentials not captured. Try placing a pixel first, then retry.');
            btn.disabled = false; btn.style.opacity = '1';
            btn.style.cursor = 'pointer'; btn.textContent = 'Buy';
            return;
        }

        try {
            const resp = await window.fetch('/MakePurchase', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    Token:   auth.token,
                    UserId:  auth.userId,
                    Subject: auth.subject,
                    type:    'ExtraColor',
                    amount:  hexToInt(hex),
                }),
            });

            if (resp.status === 200) {
                // Remove from textarea → fires 'input' → refreshColorQueue removes the row
                removeColorFromTextarea(hex);
                if (typeof window.synchronize === 'function') window.synchronize();
                if (_pw.showAlert) _pw.showAlert('Success', `${hex} purchased successfully!`);
            } else if (resp.status === 402) {
                if (_pw.showAlert) _pw.showAlert('Error', 'Insufficient Pixels to purchase this color.');
                btn.disabled = false; btn.style.opacity = '1';
                btn.style.cursor = 'pointer'; btn.textContent = 'Buy';
            } else {
                const text = await resp.text().catch(() => '');
                if (_pw.showAlert) _pw.showAlert('Error', `Failed to purchase ${hex}. ${text}`.trim());
                btn.disabled = false; btn.style.opacity = '1';
                btn.style.cursor = 'pointer'; btn.textContent = 'Buy';
            }
        } catch (err) {
            console.error('[BulkPurchase] Network error:', err);
            if (_pw.showAlert) _pw.showAlert('Error', 'Network error during purchase.');
            btn.disabled = false; btn.style.opacity = '1';
            btn.style.cursor = 'pointer'; btn.textContent = 'Buy';
        }
    }

    // ─── Profile panel injection ──────────────────────────────────────────────────

    function injectProfileSection() {
        if (document.getElementById('gp-bulk-profile-card')) return;

        // Locate "Unlock Extra Color" card by its unique child element
        const freeColorNotice = document.getElementById('freeColorNotice');
        if (!freeColorNotice) return;

        // Walk up to the card wrapper (p-4 bg-gray-100 rounded-xl shadow)
        const extraColorCard = freeColorNotice.closest('div[class*="p-4"]');
        if (!extraColorCard) return;

        // The grid that contains all upgrade cards
        const grid = extraColorCard.parentElement;
        if (!grid) return;

        const c = t();

        const card = document.createElement('div');
        card.id = 'gp-bulk-profile-card';
        card.className = 'p-4 bg-gray-100 rounded-xl shadow flex flex-col gap-3';
        card.style.gridColumn = '1 / -1';

        card.innerHTML = `
<div style="font-weight:600;font-size:1rem;color:${c.text};margin-bottom:0.125rem;">Bulk Purchase Colors</div>
<div style="display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap;">

    <!-- Left: textarea input -->
    <div style="flex:1;min-width:11rem;display:flex;flex-direction:column;gap:0.5rem;">
        <div class="text-sm text-gray-500">Comma, space, or newline &mdash; hex or decimal</div>
        <textarea id="gp-bulk-textarea"
                  rows="6"
                  placeholder="#FF0000, #00FF00&#10;FF0000 00FF00&#10;16711680"
                  class="w-full border rounded-lg px-3 py-2 text-sm font-mono resize-y"
                  style="outline:none;transition:box-shadow 0.15s;"
                  onfocus="this.style.boxShadow='0 0 0 2px #3b82f6'"
                  onblur="this.style.boxShadow='none'"
        ></textarea>
        <p id="gp-bulk-parse-info" style="margin:0;font-size:0.75rem;color:${c.textMuted};min-height:1rem;"></p>
    </div>

    <!-- Right: live color queue -->
    <div style="flex:1;min-width:12rem;display:flex;flex-direction:column;gap:0.5rem;">
        <button id="gp-bulk-buy-all-btn"
                style="width:100%;padding:0.5rem 0.75rem;background:#3b82f6;color:#fff;
                       border:none;border-radius:0.5rem;font-weight:600;font-size:0.875rem;
                       cursor:not-allowed;opacity:0.5;text-align:center;"
                disabled>
            &#x1F6D2; Buy All (0)
        </button>
         <div style="display:flex;align-items:center;gap:0.4rem;">
            <select id="gp-bulk-sort-select"
                  class="border rounded-md bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-200"
                    title="Scroll while hovering to change sort order"
                  style="flex:1;min-width:0;border:1px solid var(--color-gray-300,#d1d5db);border-radius:0.375rem;
                      background:var(--color-white,#fff);color:var(--color-gray-700,#374151);font-size:0.75rem;padding:0.25rem 0.35rem;cursor:pointer;">
              <option value="default">Sort by: Most used (default)</option>
              <option value="leastUsed">Sort by: Least used</option>
              <option value="mostRemaining">Sort by: Most remaining</option>
              <option value="leastRemaining">Sort by: Least remaining</option>
              <option value="mostPct">Sort by: Most % remaining</option>
              <option value="leastPct">Sort by: Least % remaining</option>
                <option value="byColor">Sort by: Color</option>
                <option value="byColorRev">Sort by: Color reversed</option>
            </select>
        </div>
        <div id="gp-bulk-queue-list"
              class="border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900"
             style="max-height:260px;overflow-y:auto;display:flex;flex-direction:column;
                  border:1px solid var(--color-gray-200,#e5e7eb);border-radius:0.375rem;overflow-x:hidden;
                  background:var(--color-white,#fff);"></div>
        <p id="gp-bulk-empty-hint"
           style="margin:0;font-size:0.75rem;color:${c.textMuted};text-align:center;">Enter colors on the left</p>
    </div>

</div>`;

        grid.appendChild(card);

        const textarea = document.getElementById('gp-bulk-textarea');
        textarea.addEventListener('input', refreshColorQueue);

        const bulkSortSelect = document.getElementById('gp-bulk-sort-select');
        if (bulkSortSelect) {
            bulkSortSelect.addEventListener('change', refreshColorQueue);
            bulkSortSelect.addEventListener('wheel', e => {
                e.preventDefault();
                const dir = e.deltaY > 0 ? 1 : -1;
                let next = bulkSortSelect.selectedIndex + dir;
                while (next >= 0 && next < bulkSortSelect.options.length && bulkSortSelect.options[next].disabled) next += dir;
                next = Math.min(Math.max(next, 0), bulkSortSelect.options.length - 1);
                if (next !== bulkSortSelect.selectedIndex) {
                    bulkSortSelect.selectedIndex = next;
                    refreshColorQueue();
                }
            }, { passive: false });
        }

        document.getElementById('gp-bulk-buy-all-btn').addEventListener('click', () => {
            const { valid } = parseColorInput(textarea.value);
            if (valid.length === 0) return;
            openBulkModal(valid);
        });

        // "Add Ghost Template Colors" button — fetches colors from the active ghost image palette
        const ghostFetchBtn = document.createElement('button');
        ghostFetchBtn.id = 'gp-bulk-ghost-fetch-btn';
        ghostFetchBtn.className = 'px-3 py-2 text-white text-sm rounded-lg shadow transition cursor-pointer';
        ghostFetchBtn.style.background = '#7c3aed';
        ghostFetchBtn.style.border = 'none';
        ghostFetchBtn.style.fontWeight = '600';
        ghostFetchBtn.textContent = '👻 Add Ghost Template Colors';
        ghostFetchBtn.title = 'Fetch colors from the current ghost template and populate the text field';
        ghostFetchBtn.addEventListener('mouseover', () => { ghostFetchBtn.style.background = '#6d28d9'; });
        ghostFetchBtn.addEventListener('mouseout',  () => { ghostFetchBtn.style.background = '#7c3aed'; });
        ghostFetchBtn.addEventListener('click', () => {
            const ghostColors = getGhostColorsFromDOM();
            if (ghostColors.length === 0) {
                alert('No ghost palette colors found. Make sure a ghost image is loaded.');
                return;
            }
            const existing = textarea.value.trim();
            textarea.value = existing
                ? existing + ', ' + ghostColors.join(', ')
                : ghostColors.join(', ');
            textarea.dispatchEvent(new Event('input'));
        });

        // Insert below the parse info line, inside the left column
        const parseInfo = document.getElementById('gp-bulk-parse-info');
        if (parseInfo && parseInfo.parentElement) {
            parseInfo.parentElement.appendChild(ghostFetchBtn);
        }

        refreshColorQueue();

        // Watch ghost palette for changes (load/unload template) to refresh queue ghost counts
        const ghostPalette = document.getElementById('ghostColorPalette');
        if (ghostPalette) {
            new MutationObserver(() => {
                if (document.getElementById('gp-bulk-queue-list')) refreshColorQueue();
            }).observe(ghostPalette, { childList: true });
        }
    }

    // ─── Ghost modal injection ─────────────────────────────────────────────────────

    function injectGhostButton() {
        if (document.getElementById('gp-ghost-buy-btn')) return;

        // The "Match My Palette" button is a reliable anchor inside the ghost modal
        const anchorBtn = document.getElementById('filterByUserPaletteBtn');
        if (!anchorBtn) return;

        const btn = document.createElement('button');
        btn.id = 'gp-ghost-buy-btn';
        btn.className = 'px-3 py-2 text-white text-sm rounded-lg shadow transition cursor-pointer';
        btn.style.background = '#7c3aed';
        btn.title = "Find ghost-image colors you don't own yet and open the bulk-purchase flow";
        btn.textContent = 'Bulk Purchase Colors';

        btn.addEventListener('mouseover', () => { btn.style.background = '#6d28d9'; });
        btn.addEventListener('mouseout', () => { btn.style.background = '#7c3aed'; });
        btn.addEventListener('click', handlePurchaseUnowned);

        // Insert after the existing button row so it appears as a natural addition
        anchorBtn.parentElement.appendChild(btn);
    }

    /**
     * Reveals the profile panel's Bulk Purchase Colors card and
     * autopopulates its textarea with the given "#RRGGBB" hex colors,
     * scrolling it into view — the shared tail end of handlePurchaseUnowned
     * (the legacy native-ghost-menu "Bulk Purchase Colors" button's flow),
     * factored out so Ghost++'s own "Buy all colors" button can reuse it
     * with a color list it already computed itself, without needing to
     * scrape #ghostColorPalette or close any modal of its own.
     */
    function openProfilePanelWithColors(colors) {
        const profileOverlay = document.getElementById('profileOverlay');
        if (profileOverlay && profileOverlay.classList.contains('hidden') &&
                typeof _pw.toggleProfile === 'function') {
            _pw.toggleProfile();
        }

        // Give the profile panel time to animate in, then populate the textarea
        setTimeout(() => {
            injectProfileSection();

            const textarea = document.getElementById('gp-bulk-textarea');
            if (textarea) {
                // Always wipe first so repeated presses don't accumulate stale colors
                textarea.value = colors.join(', ');
                textarea.dispatchEvent(new Event('input'));
                textarea.scrollIntoView({ behavior: 'smooth', block: 'center' });
            }

            // Flash the card with a yellow glow to draw the user's eye
            const card = document.getElementById('gp-bulk-profile-card');
            if (card) {
                card.style.transition = 'box-shadow 0.15s ease';
                card.style.boxShadow  = '0 0 0 3px #fbbf24, 0 0 18px 6px rgba(251,191,36,0.55)';
                setTimeout(() => { card.style.boxShadow = 'none'; }, 900);
            }
        }, 300);
    }

    async function handlePurchaseUnowned() {
        const btn = document.getElementById('gp-ghost-buy-btn');
        if (btn) {
            btn.disabled = true;
            btn.textContent = 'Checking…';
        }

        try {
            // Read directly from the rendered palette DOM — reliable source of truth.
            // ghost22.js sets swatch.title = `${colorData.hex}\n${totalCount} pixels`
            // so the first line before \n is always the canonical hex value.
            const ghostColors = getGhostColorsFromDOM();

            if (ghostColors.length === 0) {
                if (_pw.showAlert) {
                    _pw.showAlert('Info', 'No ghost palette colors found. Make sure a ghost image is loaded and its color palette is visible in the modal.');
                }
                return;
            }

            // Fetch fresh ownership data from the server
            const ownedSet = await fetchOwnedHexSet();
            const unowned = ghostColors.filter(h => !ownedSet.has(h));

            if (unowned.length === 0) {
                if (_pw.showAlert) {
                    _pw.showAlert('Info', 'You already own all colors used in this ghost image!');
                }
                return;
            }

            // Close the ghost modal
            const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
            if (typeof _pw.toggleGhostModal === 'function') _pw.toggleGhostModal(false);

            openProfilePanelWithColors(unowned);

        } finally {
            if (btn) {
                btn.disabled = false;
                btn.textContent = 'Bulk Purchase Colors';
            }
        }
    }

    // ─── DOM observation and entry point ──────────────────────────────────────────

    /**
     * Watch the DOM for relevant containers and inject our UI whenever they appear.
     * Both the profile panel and the ghost modal already exist in the HTML on load,
     * but the observer also handles any future dynamic additions gracefully.
     */
    function observeAndInject() {
        // Try immediately (elements may already be in the DOM)
        injectProfileSection();
        injectGhostButton();

        // Re-check on every DOM change (guards against dynamic re-renders)
        const observer = new MutationObserver(() => {
            injectProfileSection();
            injectGhostButton();
        });

        observer.observe(document.body, { childList: true, subtree: true });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', observeAndInject);
    } else {
        observeAndInject();
    }

    // Expose to the shared top-level scope — see the gppBulkPurchaseOpenModal
    // declaration above this feature block for why this indirection exists.
    gppBulkPurchaseOpenModal = openBulkModal;
    gppBulkPurchaseOpenProfilePanel = openProfilePanelWithColors;

            })();
            _featureStatus.bulkPurchaseColors = 'ok';
            console.log('[GeoPixelcons++] ✅ Bulk Purchase Colors loaded');
        } catch (err) {
            _featureStatus.bulkPurchaseColors = 'error';
            dbgPush(`Bulk Purchase Colors init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Bulk Purchase Colors' });
            console.error('[GeoPixelcons++] ❌ Bulk Purchase Colors failed:', err);
        }
    }

    // ============================================================
    //  EXTENSION: Auto-open Menus on Hover [extAutoHoverMenus]
    // ============================================================
    if (_settings.extAutoHoverMenus) {
        try {
            (function _ext_autoHoverMenus() {

    const VERTICAL_ZONE_PX = 250;
    const PER_BUTTON_COOLDOWN_MS = 400;

    const buttonsState = new WeakMap();
    let trackedButtons = [];
    // Tracks whether #controls-left has EVER been found across the lifetime of
    // this feature. scanAndAttach() is not one-shot -- it's also re-invoked
    // repeatedly by the debounced MutationObserver re-scan below, and by then
    // #controls-left always already exists in practice. The bounded
    // synchronous-check -> MutationObserver-watch -> 15s-giveup pattern below
    // is only meaningful for the very first invocation, before controls-left
    // has ever existed.
    let controlsLeftEverFound = false;

    function isMenuOpen(info) {
        const { button, parent } = info;
        if (!button) return false;
        // aria-expanded is the most reliable cross-version signal
        if (button.getAttribute('aria-expanded') === 'true') return true;
        if (parent && parent.getAttribute('aria-expanded') === 'true') return true;
        // active class check
        if (button.classList.contains('active') || (parent && parent.classList.contains('active'))) return true;
        // Live dropdown lookup — re-query each check so dynamically-injected menus are found
        const dd = info.dropdown ||
            (parent ? (parent.querySelector('.dropdown-menu') ||
                       parent.querySelector('[role="menu"]') ||
                       null) : null);
        if (dd) {
            if (dd.offsetParent !== null) return true;
            if (dd.classList.contains('show') || dd.classList.contains('open')) return true;
        }
        // Fallback: any visible child list or [class*="dropdown"] sibling
        if (parent) {
            const candidates = parent.querySelectorAll('ul, [class*="dropdown"], [class*="menu"]');
            for (const el of candidates) {
                if (el !== button && el.offsetParent !== null && el.childElementCount > 0) return true;
            }
        }
        return false;
    }

    function tryOpen(info) {
        // _gpcOpened: we opened this menu and the mouse hasn't fully left the
        // button+dropdown area yet. Guard against toggle-close even when
        // isMenuOpen() fails to detect the open state (e.g. modGroupBtn/menuGroupBtn).
        if (info._gpcOpened) return;
        const now = Date.now();
        const last = buttonsState.get(info.button) || 0;
        if (now - last < PER_BUTTON_COOLDOWN_MS) return;
        if (isMenuOpen(info)) return;
        try {
            info.button.click();
            buttonsState.set(info.button, now);
            info._gpcOpened = true;
        } catch (_) {}
    }

    function scanAndAttach() {
        const controlsLeft = document.getElementById('controls-left');
        if (!controlsLeft) {
            if (controlsLeftEverFound) {
                // Steady state: controls-left previously existed and has now
                // vanished from under us. Don't start a brand-new watch/giveup
                // cycle -- the existing debounced re-scan (installMutationObserver)
                // will simply call scanAndAttach() again on its own schedule.
                dbgPush('Auto-open Menus on Hover: scanAndAttach() re-invoked but #controls-left is missing even though it was found previously -- unexpected DOM state, skipping this scan.', { uiComponent: 'Auto-open Menus on Hover' });
                return;
            }
            dbgPush('Auto-open Menus on Hover: #controls-left NOT found on the first synchronous check -- watching for it to appear.', { uiComponent: 'Auto-open Menus on Hover' });
            const watchStartedAt = Date.now();
            const observer = new MutationObserver(() => {
                const found = document.getElementById('controls-left');
                if (!found) return;
                observer.disconnect();
                clearTimeout(giveUpTimer);
                controlsLeftEverFound = true;
                dbgPush('Auto-open Menus on Hover: #controls-left appeared ' + (Date.now() - watchStartedAt) + 'ms after watching started -- scanning now.', { uiComponent: 'Auto-open Menus on Hover' });
                scanAndAttach();
            });
            observer.observe(document.body, { childList: true, subtree: true });
            const giveUpTimer = setTimeout(() => {
                observer.disconnect();
                dbgPush('Auto-open Menus on Hover: gave up after 15s -- #controls-left was never found, hover-to-open menus could not be attached at all.', { uiComponent: 'Auto-open Menus on Hover' });
                console.error('[GeoPixelcons++] Auto-open Menus on Hover: never found #controls-left.');
            }, 15000);
            return;
        }
        controlsLeftEverFound = true;

        const buttons = Array.from(
            controlsLeft.querySelectorAll('button[id$="GroupBtn"], button[id$="plusplusBtn"]')
        );

        // Detach any previously attached listeners before rebuilding
        trackedButtons.forEach(info => {
            if (info.button && info._hoverHandler) {
                info.button.removeEventListener('mouseenter', info._hoverHandler);
            }
            if (info._parentEl && info._parentLeaveHandler) {
                info._parentEl.removeEventListener('mouseleave', info._parentLeaveHandler);
            }
        });

        trackedButtons = buttons.map(button => {
            // Use `div.relative` specifically — menuGroupBtn and modGroupBtn have
            // `relative` in their OWN class list, so `button.closest('.relative')`
            // would return the button itself (wrong parent, no dropdown found).
            const parent = button.closest('div.relative') || button.parentElement;
            const dropdown = parent
                ? (parent.querySelector('.dropdown-menu') ||
                   parent.querySelector('[role="menu"]') ||
                   parent.querySelector('ul') ||
                   null)
                : null;
            const info = { button, parent, dropdown, _gpcOpened: false };

            // mouseenter on the button: open the menu if it isn't already ours.
            info._hoverHandler = () => {
                tryOpen(info);
            };
            button.addEventListener('mouseenter', info._hoverHandler);

            // mouseleave on the parent container (button + dropdown together):
            // when the pointer leaves the ENTIRE area, the site will close the dropdown
            // on its own, so reset our opened flag so re-hovering can reopen it.
            const parentEl = parent || button;
            info._parentEl = parentEl;
            info._parentLeaveHandler = (e) => {
                // relatedTarget is where the mouse went — if it's outside our container,
                // the user has fully left and we can allow reopening on next entry.
                if (!parentEl.contains(e.relatedTarget)) {
                    info._gpcOpened = false;
                }
            };
            parentEl.addEventListener('mouseleave', info._parentLeaveHandler);

            return info;
        });
    }

    function installMutationObserver() {
        const body = document.body;
        if (!body) return;
        const observer = new MutationObserver(() => {
            clearTimeout(scanDebounceTimer);
            scanDebounceTimer = setTimeout(scanAndAttach, 150);
        });
        // Only watch childList — attribute changes on body fire constantly and caused
        // scanAndAttach to re-register mouseenter handlers in a tight loop in older versions.
        observer.observe(body, { childList: true, subtree: true });
    }

    let scanDebounceTimer = null;

    function init() {
        scanAndAttach();
        installMutationObserver();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

            })();
            _featureStatus.extAutoHoverMenus = 'ok';
            console.log('[GeoPixelcons++] ✅ Auto-open Menus on Hover loaded');
        } catch (err) {
            _featureStatus.extAutoHoverMenus = 'error';
            dbgPush(`Auto-open Menus on Hover init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Auto-open Menus on Hover' });
            console.error('[GeoPixelcons++] ❌ Auto-open Menus on Hover failed:', err);
        }
    }

    // ============================================================
    //  EXTENSION: Auto-Go to Last Location [extGoToLastLocation]
    // ============================================================
    if (_settings.extGoToLastLocation) {
        try {
            (function _ext_goToLastLocation() {

    const SPAWN_LNG_MIN = -75;
    const SPAWN_LNG_MAX = -73;
    const SPAWN_LAT_MIN = 39;
    const SPAWN_LAT_MAX = 41;

    let hasClicked = false;
    let observer = null;

    function checkAndClick() {
        if (hasClicked) return;

        const button = document.getElementById('lastLocationButton');

        let mapObj = null;
        try {
            mapObj = eval('map');
        } catch (e) {
            return;
        }

        if (button && typeof window.goToLocation === 'function' && mapObj && typeof mapObj.getCenter === 'function') {
            try {
                const center = mapObj.getCenter();
                const lng = center.lng;
                const lat = center.lat;

                if (lng >= SPAWN_LNG_MIN && lng <= SPAWN_LNG_MAX && lat >= SPAWN_LAT_MIN && lat <= SPAWN_LAT_MAX) {
                    hasClicked = true;
                    if (observer) observer.disconnect();
                    button.click();
                }
            } catch (e) {}
        }
    }

    checkAndClick();

    if (!hasClicked) {
        observer = new MutationObserver(() => {
            checkAndClick();
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        setTimeout(() => {
            if (!hasClicked && observer) {
                observer.disconnect();
            }
        }, 10000);
    }

            })();
            _featureStatus.extGoToLastLocation = 'ok';
            console.log('[GeoPixelcons++] ✅ Auto-Go to Last Location loaded');
        } catch (err) {
            _featureStatus.extGoToLastLocation = 'error';
            dbgPush(`Auto-Go to Last Location init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Auto-Go to Last Location' });
            console.error('[GeoPixelcons++] ❌ Auto-Go to Last Location failed:', err);
        }
    }

    // ============================================================
    //  EXTENSION: Pill Hover Labels [extPillHoverLabels]
    // ============================================================
    if (_settings.extPillHoverLabels) {
        try {
            (function _ext_pillHoverLabels() {

    const PROCESSED_ATTR = 'data-gpc-pill';

    function transformButton(btn) {
        if (btn.hasAttribute(PROCESSED_ATTR)) return;
        // Skip buttons that are GeoPixelcons++ pills already
        if (btn.classList.contains('gpc-pill-btn')) return;
        // Only target round 40px submenu buttons (rounded-full or rounded-xl for GeoPixels++ select buttons)
        if (!btn.classList.contains('w-10') || !btn.classList.contains('h-10')) return;
        if (!btn.classList.contains('rounded-full') && !btn.classList.contains('rounded-xl')) return;
        // Must be inside a dropdown-menu
        if (!btn.closest('.dropdown-menu')) return;
        // Must be inside controls-left
        if (!btn.closest('#controls-left')) return;
        // Skip buttons that are hidden (mod tools, etc.) — they'll be
        // picked up by the MutationObserver when they become visible
        if (btn.classList.contains('hidden')) return;

        btn.setAttribute(PROCESSED_ATTR, '1');

        const label = btn.title || btn.getAttribute('aria-label') || '';
        if (!label) return;

        // Save the original icon content — could be text/emoji or an SVG element
        const svg = btn.querySelector('svg');
        const iconText = btn.textContent.trim();

        // Create icon span
        const iconSpan = document.createElement('span');
        iconSpan.style.cssText = 'width:40px;min-width:40px;display:flex;align-items:center;justify-content:center;flex-shrink:0;line-height:40px;pointer-events:none;';
        if (svg) {
            iconSpan.appendChild(svg.cloneNode(true));
        } else {
            iconSpan.textContent = iconText;
        }

        // Create label span — use CSS var for text color so it follows the active theme
        const labelSpan = document.createElement('span');
        labelSpan.style.cssText = 'white-space:nowrap;font-size:12px;font-weight:600;color:var(--color-gray-700, #374151);opacity:0;transition:opacity .2s .05s;padding-right:12px;pointer-events:none;';
        labelSpan.textContent = label;

        // Restyle the button — use CSS custom properties so GeoPixels++ themes apply
        btn.innerHTML = '';
        btn.style.position = 'relative';
        btn.style.width = '40px';
        btn.style.height = '40px';
        btn.style.borderRadius = '9999px';
        btn.style.background = 'var(--color-white, #fff)';
        btn.style.boxShadow = '0 1px 3px rgba(0,0,0,.12)';
        btn.style.alignItems = 'center';
        btn.style.justifyContent = 'flex-start';
        btn.style.border = 'none';
        btn.style.cursor = 'pointer';
        btn.style.overflow = 'hidden';
        btn.style.transition = 'width .25s cubic-bezier(.4,0,.2,1), background .15s';
        btn.style.padding = '0';
        btn.style.fontSize = '16px';
        btn.style.flexShrink = '0';
        // Only set display to flex if not hidden
        if (!btn.classList.contains('hidden')) {
            btn.style.display = 'flex';
        }
        // Keep original classes needed for visibility toggling but drop sizing
        btn.classList.remove('w-10', 'h-10');

        btn.appendChild(iconSpan);
        btn.appendChild(labelSpan);

        btn.addEventListener('mouseenter', () => {
            const textW = labelSpan.scrollWidth + 12;
            btn.style.width = (40 + textW) + 'px';
            labelSpan.style.opacity = '1';
            btn.style.background = 'var(--color-gray-100, #f3f4f6)';
        });
        btn.addEventListener('mouseleave', () => {
            btn.style.width = '40px';
            labelSpan.style.opacity = '0';
            btn.style.background = 'var(--color-white, #fff)';
        });
    }

    function scanAll() {
        const container = document.getElementById('controls-left');
        if (!container) return;
        // Match both rounded-full (native) and rounded-xl (GeoPixels++ select buttons)
        container.querySelectorAll('.dropdown-menu button.rounded-full, .dropdown-menu button.rounded-xl').forEach(transformButton);
        // Re-check already-processed buttons whose content was externally replaced
        // (e.g. togglePrimaryMode replaces innerHTML, destroying our pill structure).
        // Skip hidden buttons — a button can also carry PROCESSED_ATTR because
        // another feature (e.g. Ghost++'s gppReplaceNativeOpener) reused it as
        // an "already handled, don't touch" marker on a button it hid rather
        // than transformed, and that button will never have span children.
        // Without this guard, the missing-span check below strips the marker
        // on every mutation and re-invites transformButton() to reprocess it.
        container.querySelectorAll('.dropdown-menu button[' + PROCESSED_ATTR + ']').forEach(btn => {
            if (btn.classList.contains('hidden')) return;
            if (!btn.querySelector('span')) {
                // Our spans were destroyed — reset and re-transform
                btn.removeAttribute(PROCESSED_ATTR);
                btn.classList.add('w-10', 'h-10');
                transformButton(btn);
            }
        });
    }

    // Runs the feature's real, ongoing business logic once #controls-left is
    // known to exist: an initial scan plus a permanent MutationObserver that
    // re-scans on childList/attributes mutations within that same container.
    function startPillWatch(container) {
        scanAll();

        // Watch for dynamically added buttons
        const observer = new MutationObserver(() => {
            clearTimeout(debounce);
            debounce = setTimeout(scanAll, 150);
        });
        let debounce = null;
        observer.observe(container, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
    }

    // Mirrors gpp-ui-shell.js's gppReplaceNativeOpener / Ghost Palette
    // Search's injectSyncGhostBtn retry pattern (synchronous check ->
    // MutationObserver watch -> bounded giveup) instead of the old bare
    // setTimeout(init, 500) infinite poll, which had no giveup and logged
    // nothing at any step.
    function init() {
        const watchStartedAt = Date.now();
        const immediate = document.getElementById('controls-left');
        if (immediate) {
            dbgPush('Pill Hover Labels: #controls-left found on the first synchronous check -- starting scan now.', { uiComponent: 'Pill Hover Labels' });
            startPillWatch(immediate);
            return;
        }
        dbgPush('Pill Hover Labels: #controls-left NOT found on the first synchronous check -- watching for it to appear.', { uiComponent: 'Pill Hover Labels' });
        // #controls-left doesn't exist yet, so it can't be watched directly --
        // watch document.body instead, matching gppReplaceNativeOpener's own
        // fallback-to-body pattern.
        const observer = new MutationObserver(() => {
            const found = document.getElementById('controls-left');
            if (!found) return;
            observer.disconnect();
            clearTimeout(giveUpTimer);
            dbgPush('Pill Hover Labels: #controls-left appeared ' + (Date.now() - watchStartedAt) + 'ms after watching started -- starting scan now.', { uiComponent: 'Pill Hover Labels' });
            startPillWatch(found);
        });
        observer.observe(document.body, { childList: true, subtree: true });
        // 15s matches the same give-up duration Ghost Palette Search / Ghost++'s
        // gppReplaceNativeOpener already use for their own late-mounting-element watchers.
        const giveUpTimer = setTimeout(() => {
            observer.disconnect();
            dbgPush('Pill Hover Labels: gave up after 15s -- #controls-left was never found, so pill hover labels could not be initialized at all.', { uiComponent: 'Pill Hover Labels' });
            console.error('[GeoPixelcons++] Pill Hover Labels: never found #controls-left.');
        }, 15000);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

            })();
            _featureStatus.extPillHoverLabels = 'ok';
            console.log('[GeoPixelcons++] ✅ Pill Hover Labels loaded');
        } catch (err) {
            _featureStatus.extPillHoverLabels = 'error';
            dbgPush(`Pill Hover Labels init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Pill Hover Labels' });
            console.error('[GeoPixelcons++] ❌ Pill Hover Labels failed:', err);
        }
    }

    // ============================================================
    //  EXTENSION: Janitor View [extJanitorView]
    // ============================================================
    if (_settings.extJanitorView) {
        try {
            (function _ext_janitorView() {

    function revealModBtn() {
        const btn = document.getElementById('modGroupBtn');
        if (btn && btn.classList.contains('hidden')) {
            btn.classList.remove('hidden');
            return true;
        }
        return false;
    }

    function init() {
        if (revealModBtn()) return;

        // Button may not exist yet — watch for it
        const observer = new MutationObserver(() => {
            if (revealModBtn()) {
                observer.disconnect();
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['class']
        });

        // Safety cleanup
        setTimeout(() => observer.disconnect(), 30000);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

            })();
            _featureStatus.extJanitorView = 'ok';
            console.log('[GeoPixelcons++] ✅ Janitor View loaded');
        } catch (err) {
            _featureStatus.extJanitorView = 'error';
            dbgPush(`Janitor View init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Janitor View' });
            console.error('[GeoPixelcons++] ❌ Janitor View failed:', err);
        }
    }

    // ============================================================
    //  EXTENSION: Map Movement Lock [extMapMovementLock]
    // ============================================================
    if (_settings.extMapMovementLock) {
        try {
            (function _ext_mapMovementLock() {

    const LOCK_STORAGE_KEY = 'gpc-map-movement-locked';
    const BUTTON_ID = 'gpc-map-movement-lock-btn';

    function installMapLockBridge() {
        const script = document.createElement('script');
        script.textContent = `
(function(){
if(window.__gpcMapMovementLockBridge)return;
window.__gpcMapMovementLockBridge=true;
var storageKey='${LOCK_STORAGE_KEY}';
var handlerNames=['dragPan','scrollZoom','boxZoom','doubleClickZoom','touchZoomRotate','keyboard','dragRotate','touchPitch'];
var movementMethods=['panBy','panTo','zoomIn','zoomOut','zoomTo','setZoom','setCenter','easeTo','flyTo','jumpTo','fitBounds','fitScreenCoordinates','rotateTo','resetNorth','snapToNorth','setBearing','setPitch'];
var state={locked:localStorage.getItem(storageKey)==='1',snapshotTaken:false,handlerWasEnabled:{}};
window.__gpcMapMovementLocked=state.locked;
function getMap(){try{return typeof map!=='undefined'&&map&&typeof map.getCanvas==='function'?map:null;}catch(e){return null;}}
function applyPageScrollLock(){
    var root=document.documentElement;
    var body=document.body;
    if(root)root.classList.toggle('gpc-map-movement-page-locked',state.locked);
    if(body)body.classList.toggle('gpc-map-movement-page-locked',state.locked);
}
function installGuards(m){
    if(!m||m.__gpcMapMovementLockGuarded)return;
    Object.defineProperty(m,'__gpcMapMovementLockGuarded',{value:true,configurable:true});
    movementMethods.forEach(function(name){
        if(typeof m[name]!=='function'||m[name].__gpcMapMovementLockGuarded)return;
        var original=m[name];
        var guarded=function(){
            if(window.__gpcMapMovementLocked)return this;
            return original.apply(this,arguments);
        };
        Object.defineProperty(guarded,'__gpcMapMovementLockGuarded',{value:true,configurable:true});
        Object.defineProperty(guarded,'__gpcMapMovementLockOriginal',{value:original,configurable:true});
        m[name]=guarded;
    });
    handlerNames.forEach(function(name){
        var handler=m[name];
        if(!handler||typeof handler.enable!=='function'||handler.__gpcMapMovementLockGuarded)return;
        var originalEnable=handler.enable;
        var guardedEnable=function(){
            if(window.__gpcMapMovementLocked)return this;
            return originalEnable.apply(this,arguments);
        };
        Object.defineProperty(handler,'__gpcMapMovementLockGuarded',{value:true,configurable:true});
        Object.defineProperty(handler,'__gpcMapMovementLockOriginalEnable',{value:originalEnable,configurable:true});
        handler.enable=guardedEnable;
    });
}
function disableHandlers(m){
    if(!state.snapshotTaken){
        state.handlerWasEnabled={};
        handlerNames.forEach(function(name){
            var handler=m[name];
            state.handlerWasEnabled[name]=!!(handler&&typeof handler.isEnabled==='function'&&handler.isEnabled());
        });
        state.snapshotTaken=true;
    }
    handlerNames.forEach(function(name){
        var handler=m[name];
        if(handler&&typeof handler.disable==='function')handler.disable();
    });
    if(typeof m.stop==='function')m.stop();
}
function restoreHandlers(m){
    if(!state.snapshotTaken)return;
    handlerNames.forEach(function(name){
        var handler=m[name];
        if(!state.handlerWasEnabled[name]||!handler)return;
        var originalEnable=handler.__gpcMapMovementLockOriginalEnable||handler.enable;
        if(typeof originalEnable==='function')originalEnable.apply(handler);
    });
    state.handlerWasEnabled={};
    state.snapshotTaken=false;
}
function applyLockState(){
    applyPageScrollLock();
    var m=getMap();
    if(!m)return false;
    installGuards(m);
    if(state.locked)disableHandlers(m);
    else restoreHandlers(m);
    return true;
}
window.__gpcGetMapMovementLocked=function(){return state.locked;};
window.__gpcSetMapMovementLocked=function(locked){
    state.locked=!!locked;
    window.__gpcMapMovementLocked=state.locked;
    localStorage.setItem(storageKey,state.locked?'1':'0');
    applyLockState();
    document.dispatchEvent(new CustomEvent('gpc:mapMovementLockChanged',{detail:{locked:state.locked}}));
    return state.locked;
};
window.__gpcToggleMapMovementLocked=function(){return window.__gpcSetMapMovementLocked(!state.locked);};
var wait=setInterval(function(){if(applyLockState())clearInterval(wait);},250);
setTimeout(function(){clearInterval(wait);},30000);
applyLockState();
})();`;
        document.head.appendChild(script);
        script.remove();
    }

    function installStyles() {
        if (document.getElementById('gpc-map-movement-lock-style')) return;
        const style = document.createElement('style');
        style.id = 'gpc-map-movement-lock-style';
        style.textContent = `
            #${BUTTON_ID} {
                width: 40px;
                height: 40px;
                border-radius: 9999px;
                display: flex;
                align-items: center;
                justify-content: center;
                cursor: pointer;
                user-select: none;
                border: 1px solid var(--color-gray-200, #e5e7eb);
                background: var(--color-gray-100, #fff);
                color: var(--color-gray-700, #374151);
                box-shadow: 0 1px 3px rgba(0,0,0,0.16);
                font-size: 18px;
                line-height: 1;
                transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.15s;
            }
            #${BUTTON_ID}:hover {
                background: var(--color-gray-200, #f3f4f6);
            }
            #${BUTTON_ID}[data-locked="true"] {
                background: var(--color-green-100, #dcfce7);
                color: var(--color-green-900, #14532d);
                border-color: var(--color-green-500, #22c55e);
            }
            #${BUTTON_ID}[data-locked="true"]:hover {
                background: var(--color-green-200, #bbf7d0);
            }
            #${BUTTON_ID}:active {
                transform: scale(0.96);
            }
            html.gpc-map-movement-page-locked,
            body.gpc-map-movement-page-locked {
                width: 100% !important;
                height: 100% !important;
                max-width: 100vw !important;
                max-height: 100dvh !important;
                overflow: hidden !important;
                overscroll-behavior: none !important;
            }
            html.gpc-map-movement-page-locked {
                scrollbar-width: none;
            }
            html.gpc-map-movement-page-locked::-webkit-scrollbar,
            body.gpc-map-movement-page-locked::-webkit-scrollbar {
                display: none;
            }
        `;
        document.head.appendChild(style);
    }

    function getLocked() {
        const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        if (typeof pageWindow.__gpcGetMapMovementLocked === 'function') {
            return !!pageWindow.__gpcGetMapMovementLocked();
        }
        return localStorage.getItem(LOCK_STORAGE_KEY) === '1';
    }

    function setLocked(locked) {
        const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        if (typeof pageWindow.__gpcSetMapMovementLocked === 'function') {
            return !!pageWindow.__gpcSetMapMovementLocked(locked);
        }
        localStorage.setItem(LOCK_STORAGE_KEY, locked ? '1' : '0');
        return locked;
    }

    function renderButton(btn) {
        const locked = getLocked();
        btn.dataset.locked = locked ? 'true' : 'false';
        btn.setAttribute('aria-pressed', locked ? 'true' : 'false');
        btn.textContent = locked ? '🔒' : '🔓';
        const kbLabel = getKeybindLabel();
        btn.title = locked ? `Map movement locked – click or press ${kbLabel} to unlock` : `Lock map movement (${kbLabel})`;
        btn.setAttribute('aria-label', locked ? 'Unlock map movement' : 'Lock map movement');
    }

    function createButton(controlsRight) {
        let btn = document.getElementById(BUTTON_ID);
        if (btn) return btn;
        btn = document.createElement('button');
        btn.id = BUTTON_ID;
        btn.type = 'button';
        btn.addEventListener('click', () => {
            setLocked(!getLocked());
            renderButton(btn);
        });
        document.addEventListener('gpc:mapMovementLockChanged', () => renderButton(btn));
        renderButton(btn);

        const zoomIn = document.getElementById('zoomIn');
        if (zoomIn && zoomIn.parentElement === controlsRight) controlsRight.insertBefore(btn, zoomIn);
        else controlsRight.prepend(btn);
        return btn;
    }

    // Reads the map-lock keybind from settings, falling back to Ctrl+Shift+L.
    function getMapLockKeybind() {
        try {
            const s = JSON.parse(localStorage.getItem('geopixelcons_settings') || '{}');
            const kb = s.keybinds && s.keybinds.mapMovementLock;
            if (kb && typeof kb.key === 'string' && kb.key.length > 0) return kb;
        } catch (_) {}
        return { key: 'L', ctrl: true, shift: true };
    }

    function getKeybindLabel() {
        const kb = getMapLockKeybind();
        const parts = [];
        if (kb.ctrl) parts.push('Ctrl');
        if (kb.shift) parts.push('Shift');
        parts.push((kb.key || 'L').toUpperCase());
        return parts.join('+');
    }

    // Toggles the lock. Registered in the capture phase with stopImmediatePropagation
    // so the site's keybind handler never also fires on this combo.
    function installKeyboardShortcut() {
        const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        if (pageWindow.__gpcMapLockShortcutInstalled) return;
        pageWindow.__gpcMapLockShortcutInstalled = true;
        document.addEventListener('keydown', (e) => {
            const kb = getMapLockKeybind();
            if (e.altKey || e.metaKey) return;
            if (kb.ctrl !== e.ctrlKey) return;
            if (kb.shift !== e.shiftKey) return;
            if (e.key.toUpperCase() !== (kb.key || 'L').toUpperCase()) return;
            const t = e.target;
            if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
            e.preventDefault();
            e.stopImmediatePropagation();
            setLocked(!getLocked());
            const btn = document.getElementById(BUTTON_ID);
            if (btn) renderButton(btn);
        }, true);
    }

    function init() {
        installMapLockBridge();
        installStyles();
        installKeyboardShortcut();

        const controlsRight = document.getElementById('controls-right');
        if (controlsRight) {
            createButton(controlsRight);
            return;
        }

        const observer = new MutationObserver(() => {
            const container = document.getElementById('controls-right');
            if (!container) return;
            observer.disconnect();
            createButton(container);
        });
        observer.observe(document.body, { childList: true, subtree: true });
        setTimeout(() => observer.disconnect(), 15000);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

            })();
            _featureStatus.extMapMovementLock = 'ok';
            console.log('[GeoPixelcons++] ✅ Map Movement Lock loaded');
        } catch (err) {
            _featureStatus.extMapMovementLock = 'error';
            dbgPush(`Map Movement Lock init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Map Movement Lock' });
            console.error('[GeoPixelcons++] ❌ Map Movement Lock failed:', err);
        }
    }

    // ============================================================
    //  EXTENSION: Guild Search Button [extGuildSearch]
    // ============================================================
    if (_settings.extGuildSearch) {
        try {
            (function _ext_guildSearch() {

    const BUTTON_ID = 'gpc-guild-search-btn';

    function installStyles() {
        if (document.getElementById('gpc-guild-search-style')) return;
        const style = document.createElement('style');
        style.id = 'gpc-guild-search-style';
        style.textContent = `
            #${BUTTON_ID} {
                width: 40px;
                height: 40px;
                border-radius: 9999px;
                display: flex;
                align-items: center;
                justify-content: center;
                cursor: pointer;
                user-select: none;
                background: var(--color-white, #fff);
                color: var(--color-gray-700, #374151);
                border: none;
                box-shadow: 0 1px 3px rgba(0,0,0,0.16);
                font-size: 18px;
                line-height: 1;
                transition: background 0.15s;
            }
            #${BUTTON_ID}:hover {
                background: var(--color-gray-100, #f3f4f6);
            }
            .dark #${BUTTON_ID} {
                background: var(--color-gray-700, #374151);
                color: var(--color-gray-200, #e5e7eb);
            }
            .dark #${BUTTON_ID}:hover {
                background: var(--color-gray-600, #4b5563);
            }
        `;
        document.head.appendChild(style);
    }

    function doGuildSearch() {
        const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        if (typeof pageWindow.toggleGuildSearchModal === 'function') {
            pageWindow.toggleGuildSearchModal();
            return;
        }
        const script = document.createElement('script');
        script.textContent = 'if(typeof toggleGuildSearchModal==="function")toggleGuildSearchModal();';
        document.head.appendChild(script);
        script.remove();
    }

    function createButton(parent) {
        if (document.getElementById(BUTTON_ID)) return;
        const btn = document.createElement('button');
        btn.id = BUTTON_ID;
        btn.type = 'button';
        btn.className = 'w-10 h-10 rounded-full';
        btn.title = 'Search Guilds';
        btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>';
        btn.addEventListener('click', doGuildSearch);

        const guildBtn = document.getElementById('guildMenuBtn');
        if (guildBtn && guildBtn.parentElement) {
            guildBtn.parentElement.insertBefore(btn, guildBtn.nextSibling);
        } else {
            parent.appendChild(btn);
        }
    }

    function init() {
        installStyles();

        const guildBtn = document.getElementById('guildMenuBtn');
        if (guildBtn) {
            createButton(guildBtn.parentElement);
            return;
        }

        const observer = new MutationObserver(() => {
            const gb = document.getElementById('guildMenuBtn');
            if (!gb) return;
            observer.disconnect();
            createButton(gb.parentElement);
        });
        observer.observe(document.body, { childList: true, subtree: true });
        setTimeout(() => observer.disconnect(), 30000);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

            })();
            _featureStatus.extGuildSearch = 'ok';
            console.log('[GeoPixelcons++] ✅ Guild Search button loaded');
        } catch (err) {
            _featureStatus.extGuildSearch = 'error';
            dbgPush(`Guild Search init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Guild Search' });
            console.error('[GeoPixelcons++] ❌ Guild Search button failed:', err);
        }
    }


    // ============================================================
    //  EXTENSION: Log Out Button [extLogOutButton]
    // ============================================================
    if (_settings.extLogOutButton) {
        try {
            (function _ext_logOutButton() {

    const BUTTON_ID = 'gpc-logout-btn';

    function installStyles() {
        if (document.getElementById('gpc-logout-style')) return;
        const style = document.createElement('style');
        style.id = 'gpc-logout-style';
        style.textContent = `
            #${BUTTON_ID} {
                width: 40px;
                height: 40px;
                border-radius: 9999px;
                display: flex;
                align-items: center;
                justify-content: center;
                cursor: pointer;
                user-select: none;
                border: 1px solid var(--color-red-300, #fca5a5);
                background: var(--color-red-50, #fff1f2);
                color: var(--color-red-600, #dc2626);
                box-shadow: 0 1px 3px rgba(0,0,0,0.16);
                font-size: 18px;
                line-height: 1;
                transition: background 0.15s, border-color 0.15s, transform 0.12s;
            }
            #${BUTTON_ID}:hover {
                background: var(--color-red-100, #ffe4e6);
            }
            #${BUTTON_ID}:active {
                transform: scale(0.94);
            }
        `;
        document.head.appendChild(style);
    }

    function doLogOut() {
        const pageWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        if (typeof pageWindow.logOut === 'function') {
            pageWindow.logOut();
            return;
        }
        const script = document.createElement('script');
        script.textContent = 'if(typeof logOut==="function")logOut();';
        document.head.appendChild(script);
        script.remove();
    }

    function isLoggedIn() {
        const div = document.getElementById('g_id_signin');
        return !!(div && div.classList.contains('hidden'));
    }

    function watchSignInDiv(btn) {
        const div = document.getElementById('g_id_signin');
        if (div) {
            const obs = new MutationObserver(() => {
                btn.style.display = isLoggedIn() ? '' : 'none';
            });
            obs.observe(div, { attributes: true, attributeFilter: ['class'] });
            return;
        }
        // g_id_signin not yet in DOM — watch for it
        const bodyObs = new MutationObserver(() => {
            const d = document.getElementById('g_id_signin');
            if (!d) return;
            bodyObs.disconnect();
            btn.style.display = isLoggedIn() ? '' : 'none';
            const obs = new MutationObserver(() => {
                btn.style.display = isLoggedIn() ? '' : 'none';
            });
            obs.observe(d, { attributes: true, attributeFilter: ['class'] });
        });
        bodyObs.observe(document.body, { childList: true, subtree: true });
        setTimeout(() => bodyObs.disconnect(), 30000);
    }

    function createButton(controlsRight) {
        if (document.getElementById(BUTTON_ID)) return;
        const btn = document.createElement('button');
        btn.id = BUTTON_ID;
        btn.type = 'button';
        btn.title = 'Log Out';
        // Exit / log-out icon (arrow leaving a door)
        btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>';
        btn.style.display = isLoggedIn() ? '' : 'none';
        btn.addEventListener('click', doLogOut);
        controlsRight.appendChild(btn);
        watchSignInDiv(btn);
    }

    function init() {
        installStyles();

        const controlsRight = document.getElementById('controls-right');
        if (controlsRight) {
            createButton(controlsRight);
            return;
        }

        const observer = new MutationObserver(() => {
            const container = document.getElementById('controls-right');
            if (!container) return;
            observer.disconnect();
            createButton(container);
        });
        observer.observe(document.body, { childList: true, subtree: true });
        setTimeout(() => observer.disconnect(), 15000);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

            })();
            _featureStatus.extLogOutButton = 'ok';
            console.log('[GeoPixelcons++] ✅ Log Out Button loaded');
        } catch (err) {
            _featureStatus.extLogOutButton = 'error';
            dbgPush(`Log Out Button init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Log Out Button' });
            console.error('[GeoPixelcons++] ❌ Log Out Button failed:', err);
        }
    }


    // ============================================================
    //  FEATURE: Theme Editor [themeEditor]
    // ============================================================
    if (_settings.themeEditor) {
        try {
            (function _init_themeEditor() {

    // ─── Constants ───────────────────────────────────────────────────
    const TE_STORAGE_KEY = 'geoThemeEditor_themes';
    const TE_ACTIVE_KEY = 'geoThemeEditor_active';
    const TE_BASE_URL = 'https://geopixels.net';
    const TE_MINOR_ROAD_KEY = 'geoThemeEditor_minorRoadsHidden_v1';
    const TE_FEEDBACK_KEY = 'geoThemeEditor_themeFeedback_v2';

    const HIDE_MINOR_ROAD_KEYS = {
        dark: ['highway_path::line-color','highway_minor::line-color','highway_name_other::text-color','highway_name_other::text-halo-color'],
        light: ['road_path_pedestrian::line-color','road_minor::line-color','highway-name-minor::text-color']
    };

    const BUNDLED_THEMES = {"default_light":{"base":"light","name":"Default","overrides":{}},"default_dark":{"base":"dark","name":"Default Dark","overrides":{}},"fjord":{"base":"dark","name":"Fjord","overrides":{"background::background-color":"#45516E","water::fill-color":"#38435C","waterway::line-color":"#2f3436","water_name::text-color":"#90a3b7","water_name::text-halo-color":"#45516E","landcover_ice_shelf::fill-color":"#2f3647","landcover_glacier::fill-color":"#2f3647","landcover_wood::fill-color":"#3a3d46","landuse_park::fill-color":"#394854","landuse_residential::fill-color":"#3a3e47","building::fill-color":"#1c232f","building::fill-outline-color":"#2d3440","aeroway-area::fill-color":"#374455","aeroway-taxiway::line-color":"#527386","road_area_pier::fill-color":"#45516E","road_pier::line-color":"#45516E","highway_path::line-color":"#485866","highway_minor::line-color":"#6b7f8f","highway_major_inner::line-color":"#8fa0b0","highway_major_casing::line-color":"#5a6b7d","highway_major_subtle::line-color":"#4a5b6d","highway_motorway_inner::line-color":"#a0b0c0","highway_motorway_casing::line-color":"#6a7b8d","highway_motorway_subtle::line-color":"#5a6b7d","railway::line-color":"#5a6b7d","railway_dashline::line-color":"#3a4b5d","railway_transit::line-color":"#5a6b7d","railway_transit_dashline::line-color":"#3a4b5d","boundary_state::line-color":"#7a8b9d","boundary_country_z0-4::line-color":"#8a9baf","boundary_country_z5-::line-color":"#8a9baf","highway_name_other::text-color":"#b0c0d0","highway_name_other::text-halo-color":"#1a2a3a","highway_name_motorway::text-color":"#c0d0e0","place_other::text-color":"#a0b0c0","place_other::text-halo-color":"#1a2a3a","place_village::text-color":"#a0b0c0","place_village::text-halo-color":"#1a2a3a","place_town::text-color":"#b0c0d0","place_town::text-halo-color":"#1a2a3a","place_city::text-color":"#c0d0e0","place_city::text-halo-color":"#1a2a3a","place_city_large::text-color":"#d0e0f0","place_city_large::text-halo-color":"#1a2a3a","place_state::text-color":"#a0b0c0","place_state::text-halo-color":"#1a2a3a","place_country_major::text-color":"#c0d0e0","place_country_major::text-halo-color":"#1a2a3a","place_country_minor::text-color":"#a0b0c0","place_country_minor::text-halo-color":"#1a2a3a","place_country_other::text-color":"#909fa0","place_country_other::text-halo-color":"#1a2a3a"}},"debug_black":{"base":"dark","name":"Debug Black","overrides":{"background::background-color":"#000000","water::fill-color":"#000000","waterway::line-color":"#000000","water_name::text-color":"#000000","water_name::text-halo-color":"#000000","landcover_ice_shelf::fill-color":"#000000","landcover_glacier::fill-color":"#000000","landcover_wood::fill-color":"#000000","landuse_park::fill-color":"#000000","landuse_residential::fill-color":"#000000","building::fill-color":"#000000","building::fill-outline-color":"#000000","aeroway-area::fill-color":"#000000","aeroway-taxiway::line-color":"#000000","road_area_pier::fill-color":"#000000","road_pier::line-color":"#000000","highway_path::line-color":"#000000","highway_minor::line-color":"#000000","highway_major_inner::line-color":"#000000","highway_major_casing::line-color":"#000000","highway_major_subtle::line-color":"#000000","highway_motorway_inner::line-color":"#000000","highway_motorway_casing::line-color":"#000000","highway_motorway_subtle::line-color":"#000000","railway::line-color":"#000000","railway_dashline::line-color":"#000000","railway_transit::line-color":"#000000","railway_transit_dashline::line-color":"#000000","boundary_state::line-color":"#000000","boundary_country_z0-4::line-color":"#000000","boundary_country_z5-::line-color":"#000000","highway_name_other::text-color":"#000000","highway_name_other::text-halo-color":"#000000","highway_name_motorway::text-color":"#000000","place_other::text-color":"#000000","place_other::text-halo-color":"#000000","place_village::text-color":"#000000","place_village::text-halo-color":"#000000","place_town::text-color":"#000000","place_town::text-halo-color":"#000000","place_city::text-color":"#000000","place_city::text-halo-color":"#000000","place_city_large::text-color":"#000000","place_city_large::text-halo-color":"#000000","place_state::text-color":"#000000","place_state::text-halo-color":"#000000","place_country_major::text-color":"#000000","place_country_major::text-halo-color":"#000000","place_country_minor::text-color":"#000000","place_country_minor::text-halo-color":"#000000","place_country_other::text-color":"#000000","place_country_other::text-halo-color":"#000000"}},"debug_white":{"base":"light","name":"Debug White","overrides":{"background::background-color":"#ffffff","water::fill-color":"#ffffff","waterway_river::line-color":"#ffffff","waterway_other::line-color":"#ffffff","water_name_point_label::text-color":"#ffffff","water_name_point_label::text-halo-color":"#ffffff","water_name_line_label::text-color":"#ffffff","water_name_line_label::text-halo-color":"#ffffff","landcover_ice::fill-color":"#ffffff","landcover_wood::fill-color":"#ffffff","park::fill-color":"#ffffff","landuse_residential::fill-color":"#ffffff","building::fill-color":"#ffffff","aeroway_fill::fill-color":"#ffffff","road_path_pedestrian::line-color":"#ffffff","road_minor::line-color":"#ffffff","road_secondary_tertiary::line-color":"#ffffff","road_trunk_primary::line-color":"#ffffff","road_trunk_primary_casing::line-color":"#ffffff","road_motorway::line-color":"#ffffff","road_motorway_casing::line-color":"#ffffff","road_motorway_link::line-color":"#ffffff","road_major_rail::line-color":"#ffffff","road_major_rail_hatching::line-color":"#ffffff","road_transit_rail::line-color":"#ffffff","road_transit_rail_hatching::line-color":"#ffffff","boundary_3::line-color":"#ffffff","boundary_2::line-color":"#ffffff","boundary_disputed::line-color":"#ffffff","highway-name-minor::text-color":"#ffffff","highway-name-major::text-color":"#ffffff","label_other::text-color":"#ffffff","label_other::text-halo-color":"#ffffff","label_village::text-color":"#ffffff","label_village::text-halo-color":"#ffffff","label_town::text-color":"#ffffff","label_town::text-halo-color":"#ffffff","label_city::text-color":"#ffffff","label_city::text-halo-color":"#ffffff","label_city_capital::text-color":"#ffffff","label_city_capital::text-halo-color":"#ffffff","label_state::text-color":"#ffffff","label_state::text-halo-color":"#ffffff","label_country_1::text-color":"#ffffff","label_country_1::text-halo-color":"#ffffff","label_country_2::text-color":"#ffffff","label_country_2::text-halo-color":"#ffffff","label_country_3::text-color":"#ffffff","label_country_3::text-halo-color":"#ffffff"}},"ayu_mirage":{"base":"light","name":"Ayu Mirage","overrides":{"background::background-color":"#f3f4f6","park::fill-color":"#e6eec8","landuse_residential::fill-color":"hsla(35,57%,88%,0.49)","landcover_wood::fill-color":"#e6eec8","landcover_ice::fill-color":"rgba(224, 236, 236, 1)","waterway_river::line-color":"#dbe6f0","waterway_other::line-color":"#dbe6f0","water::fill-color":"#dbe6f0","building::fill-color":"#e6e1cf","building::fill-outline-color":"#f3f4f6","road_path_pedestrian::line-color":"#cfccc6","road_minor::line-color":"#cfccc6","road_secondary_tertiary::line-color":"#cfccc6","road_trunk_primary::line-color":"#ffae57","road_trunk_primary_casing::line-color":"#ffae57","road_motorway::line-color":"#ffae57","road_motorway_casing::line-color":"#ffae57","road_motorway_link::line-color":"#ffae57","road_major_rail::line-color":"#ffae57","road_major_rail_hatching::line-color":"#ffae57","road_transit_rail::line-color":"#cfccc6","road_transit_rail_hatching::line-color":"#cfccc6","boundary_3::line-color":"#5c6773","boundary_2::line-color":"#5c6773","boundary_disputed::line-color":"#5c6773","highway-name-minor::text-color":"#5c6773","highway-name-major::text-color":"#5c6773","label_other::text-color":"#5c6773","label_other::text-halo-color":"#f3f4f6","label_village::text-color":"#5c6773","label_village::text-halo-color":"#f3f4f6","label_town::text-color":"#5c6773","label_town::text-halo-color":"#f3f4f6","label_city::text-color":"#5c6773","label_city::text-halo-color":"#f3f4f6","label_city_capital::text-color":"#5c6773","label_city_capital::text-halo-color":"#f3f4f6","label_state::text-color":"#5c6773","label_state::text-halo-color":"#f3f4f6","label_country_1::text-color":"#5c6773","label_country_1::text-halo-color":"#f3f4f6","label_country_2::text-color":"#5c6773","label_country_2::text-halo-color":"#f3f4f6","label_country_3::text-color":"#5c6773","label_country_3::text-halo-color":"#f3f4f6"}},"cute_pink":{"base":"light","name":"Cute & Pink","overrides":{"background::background-color":"#fff0f5","park::fill-color":"#ffe6f2","landcover_wood::fill-color":"#ffe6f2","waterway_river::line-color":"#cceeff","waterway_other::line-color":"#cceeff","water::fill-color":"#cceeff","road_path_pedestrian::line-color":"#ffb3d9","road_minor::line-color":"#ffb3d9","road_secondary_tertiary::line-color":"#ffb3d9","road_trunk_primary::line-color":"#ffb3d9","road_trunk_primary_casing::line-color":"#ffb3d9","road_motorway::line-color":"#ffb3d9","road_motorway_casing::line-color":"#ffb3d9","road_motorway_link::line-color":"#ffb3d9","road_major_rail::line-color":"#ffb3d9","road_transit_rail::line-color":"#ffb3d9","building::fill-color":"#ffdaeb","building::fill-outline-color":"#fff0f5","boundary_3::line-color":"#993366","boundary_2::line-color":"#993366","boundary_disputed::line-color":"#993366","highway-name-minor::text-color":"#993366","highway-name-major::text-color":"#993366","label_other::text-color":"#993366","label_other::text-halo-color":"#fff0f5","label_village::text-color":"#993366","label_village::text-halo-color":"#fff0f5","label_town::text-color":"#993366","label_town::text-halo-color":"#fff0f5","label_city::text-color":"#993366","label_city::text-halo-color":"#fff0f5","label_city_capital::text-color":"#993366","label_city_capital::text-halo-color":"#fff0f5","label_state::text-color":"#993366","label_state::text-halo-color":"#fff0f5","label_country_1::text-color":"#993366","label_country_1::text-halo-color":"#fff0f5","label_country_2::text-color":"#993366","label_country_2::text-halo-color":"#fff0f5","label_country_3::text-color":"#993366","label_country_3::text-halo-color":"#fff0f5"}},"discord_gold":{"base":"dark","name":"Discord Gold","overrides":{"background::background-color":"#171717","water::fill-color":"#23272A","waterway::line-color":"hsl(232, 23%, 28%)","water_name::text-color":"hsl(38, 60%, 50%)","water_name::text-halo-color":"hsl(232, 5%, 19%)","landcover_ice_shelf::fill-color":"hsl(232, 33%, 34%)","landuse_residential::fill-color":"transparent","landcover_wood::fill-color":"hsla(232, 18%, 30%, 0.57)","landuse_park::fill-color":"hsl(204, 17%, 35%)","building::fill-color":"hsla(232, 47%, 18%, 0.65)","highway_path::line-color":"hsl(211, 29%, 38%)","highway_minor::line-color":"hsl(224, 22%, 45%)","highway_major_casing::line-color":"hsl(224, 22%, 45%)","highway_major_inner::line-color":"#36393F","highway_major_subtle::line-color":"#38393E","highway_motorway_casing::line-color":"hsl(224, 22%, 45%)","highway_motorway_inner::line-color":"hsl(224, 20%, 29%)","highway_motorway_subtle::line-color":"hsla(239, 45%, 69%, 0.2)","railway::line-color":"hsl(200, 10%, 18%)","railway_dashline::line-color":"hsl(224, 20%, 41%)","boundary_state::line-color":"hsla(195, 47%, 62%, 0.26)","boundary_country_z0-4::line-color":"hsl(214, 63%, 76%)","boundary_country_z5-::line-color":"hsl(214, 63%, 76%)","highway_name_other::text-color":"hsl(38, 70%, 60%)","highway_name_other::text-halo-color":"hsl(232, 9%, 23%)","highway_name_motorway::text-color":"hsl(38, 70%, 60%)","place_other::text-color":"hsl(38, 65%, 60%)","place_other::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_village::text-color":"hsl(38, 70%, 45%)","place_village::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_town::text-color":"hsl(38, 75%, 65%)","place_town::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_city::text-color":"hsl(38, 75%, 65%)","place_city::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_city_large::text-color":"hsl(38, 75%, 65%)","place_city_large::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_state::text-color":"rgb(113, 129, 144)","place_state::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_other::text-color":"rgb(153, 153, 153)","place_country_other::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_minor::text-color":"rgb(153, 153, 153)","place_country_minor::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_major::text-color":"rgb(153, 153, 153)","place_country_major::text-halo-color":"hsla(228, 60%, 21%, 0.7)"}},"monokai":{"base":"dark","name":"Monokai","overrides":{"background::background-color":"#000000","water::fill-color":"#2D2E28","waterway::line-color":"hsl(232, 23%, 28%)","water_name::text-color":"hsl(223, 21%, 52%)","water_name::text-halo-color":"hsl(232, 5%, 19%)","landcover_ice_shelf::fill-color":"hsl(70, 15%, 35%)","landuse_residential::fill-color":"transparent","landcover_wood::fill-color":"hsla(232, 18%, 30%, 0.57)","landuse_park::fill-color":"hsl(204, 17%, 35%)","building::fill-color":"hsla(232, 47%, 18%, 0.65)","highway_path::line-color":"hsl(211, 29%, 38%)","highway_minor::line-color":"hsl(70, 20%, 40%)","highway_major_casing::line-color":"hsl(70, 20%, 40%)","highway_major_inner::line-color":"#3A3E38","highway_major_subtle::line-color":"#273a2d","highway_motorway_casing::line-color":"hsl(70, 20%, 40%)","highway_motorway_inner::line-color":"hsl(70, 18%, 28%)","highway_motorway_subtle::line-color":"hsla(239, 45%, 69%, 0.2)","railway::line-color":"hsl(40, 20%, 18%)","railway_dashline::line-color":"hsl(224, 20%, 41%)","boundary_state::line-color":"hsla(195, 47%, 62%, 0.26)","boundary_country_z0-4::line-color":"hsl(214, 63%, 76%)","boundary_country_z5-::line-color":"hsl(214, 63%, 76%)","highway_name_other::text-color":"hsl(223, 31%, 61%)","highway_name_other::text-halo-color":"hsl(232, 9%, 23%)","place_other::text-color":"hsl(195, 37%, 73%)","place_other::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_village::text-color":"hsl(195, 41%, 49%)","place_village::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_town::text-color":"hsl(195, 25%, 76%)","place_town::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_city::text-color":"hsl(195, 25%, 76%)","place_city::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_city_large::text-color":"hsl(195, 25%, 76%)","place_city_large::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_state::text-color":"rgb(140, 130, 100)","place_state::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_other::text-color":"rgb(153, 153, 153)","place_country_other::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_minor::text-color":"rgb(153, 153, 153)","place_country_minor::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_major::text-color":"rgb(153, 153, 153)","place_country_major::text-halo-color":"hsla(228, 60%, 21%, 0.7)"}},"obsidian":{"base":"dark","name":"Obsidian","overrides":{"background::background-color":"#1c1c1c","water::fill-color":"#262626","waterway::line-color":"#262626","water_name::text-color":"#a0a0a0","water_name::text-halo-color":"#1c1c1c","landcover_ice_shelf::fill-color":"rgb(12,12,12)","landcover_glacier::fill-color":"hsl(0, 1%, 2%)","landuse_residential::fill-color":"hsl(0, 2%, 5%)","landcover_wood::fill-color":"#2a2a2a","landuse_park::fill-color":"#2a2a2a","building::fill-color":"#242424","building::fill-outline-color":"#1c1c1c","highway_path::line-color":"#3d3d3d","highway_minor::line-color":"#3d3d3d","highway_major_casing::line-color":"#555555","highway_major_inner::line-color":"#555555","highway_major_subtle::line-color":"#555555","highway_motorway_casing::line-color":"#555555","highway_motorway_inner::line-color":"#555555","highway_motorway_subtle::line-color":"#555555","railway::line-color":"rgb(35,35,35)","railway_dashline::line-color":"rgb(12,12,12)","boundary_state::line-color":"#a0a0a0","boundary_country_z0-4::line-color":"#a0a0a0","boundary_country_z5-::line-color":"#a0a0a0","highway_name_other::text-color":"#a0a0a0","highway_name_other::text-halo-color":"#1c1c1c","highway_name_motorway::text-color":"#a0a0a0","place_other::text-color":"#a0a0a0","place_other::text-halo-color":"#1c1c1c","place_village::text-color":"#a0a0a0","place_village::text-halo-color":"#1c1c1c","place_town::text-color":"#a0a0a0","place_town::text-halo-color":"#1c1c1c","place_city::text-color":"#a0a0a0","place_city::text-halo-color":"#1c1c1c","place_city_large::text-color":"#a0a0a0","place_city_large::text-halo-color":"#1c1c1c","place_state::text-color":"#a0a0a0","place_state::text-halo-color":"#1c1c1c","place_country_other::text-color":"#a0a0a0","place_country_other::text-halo-color":"#1c1c1c","place_country_minor::text-color":"#a0a0a0","place_country_minor::text-halo-color":"#1c1c1c","place_country_major::text-color":"#a0a0a0","place_country_major::text-halo-color":"#1c1c1c"}},"vintage_sepia":{"base":"light","name":"Vintage Sepia","overrides":{"background::background-color":"#f4e4bc","park::fill-color":"#c5d5a7","landcover_wood::fill-color":"#c5d5a7","waterway_river::line-color":"#d2c29d","waterway_other::line-color":"#d2c29d","water::fill-color":"#d2c29d","road_path_pedestrian::line-color":"#a89f91","road_minor::line-color":"#a89f91","road_secondary_tertiary::line-color":"#a89f91","road_trunk_primary::line-color":"#8f8170","road_trunk_primary_casing::line-color":"#8f8170","road_motorway::line-color":"#8f8170","road_motorway_casing::line-color":"#8f8170","road_motorway_link::line-color":"#8f8170","road_major_rail::line-color":"#8f8170","road_transit_rail::line-color":"#a89f91","building::fill-color":"#e8d5a8","building::fill-outline-color":"#f4e4bc","boundary_3::line-color":"#5b4a42","boundary_2::line-color":"#5b4a42","boundary_disputed::line-color":"#5b4a42","highway-name-minor::text-color":"#5b4a42","highway-name-major::text-color":"#5b4a42","label_other::text-color":"#5b4a42","label_other::text-halo-color":"#f4e4bc","label_village::text-color":"#5b4a42","label_village::text-halo-color":"#f4e4bc","label_town::text-color":"#5b4a42","label_town::text-halo-color":"#f4e4bc","label_city::text-color":"#5b4a42","label_city::text-halo-color":"#f4e4bc","label_city_capital::text-color":"#5b4a42","label_city_capital::text-halo-color":"#f4e4bc","label_state::text-color":"#5b4a42","label_state::text-halo-color":"#f4e4bc","label_country_1::text-color":"#5b4a42","label_country_1::text-halo-color":"#f4e4bc","label_country_2::text-color":"#5b4a42","label_country_2::text-halo-color":"#f4e4bc","label_country_3::text-color":"#5b4a42","label_country_3::text-halo-color":"#f4e4bc"}}};

    const EDITABLE_LAYERS = {
        dark: [
            { group: 'Base', layers: [{ id: 'background', prop: 'background-color', label: 'Background' }] },
            { group: 'Water', layers: [{ id: 'water', prop: 'fill-color', label: 'Water Fill' },{ id: 'waterway', prop: 'line-color', label: 'Waterways' },{ id: 'water_name', prop: 'text-color', label: 'Water Labels' },{ id: 'water_name', prop: 'text-halo-color', label: 'Water Label Halo' }] },
            { group: 'Land & Nature', layers: [{ id: 'landcover_ice_shelf', prop: 'fill-color', label: 'Ice Shelf' },{ id: 'landcover_glacier', prop: 'fill-color', label: 'Glaciers' },{ id: 'landcover_wood', prop: 'fill-color', label: 'Forests / Wood' },{ id: 'landuse_park', prop: 'fill-color', label: 'Parks' },{ id: 'landuse_residential', prop: 'fill-color', label: 'Residential' }] },
            { group: 'Buildings & Areas', layers: [{ id: 'building', prop: 'fill-color', label: 'Building Fill' },{ id: 'building', prop: 'fill-outline-color', label: 'Building Outline' },{ id: 'aeroway-area', prop: 'fill-color', label: 'Airport Area' },{ id: 'road_area_pier', prop: 'fill-color', label: 'Pier Area' }] },
            { group: 'Roads', layers: [{ id: 'highway_path', prop: 'line-color', label: 'Paths' },{ id: 'highway_minor', prop: 'line-color', label: 'Minor Roads' },{ id: 'highway_major_inner', prop: 'line-color', label: 'Major Roads' },{ id: 'highway_major_casing', prop: 'line-color', label: 'Major Road Casing' },{ id: 'highway_major_subtle', prop: 'line-color', label: 'Major Roads (Subtle)' },{ id: 'highway_motorway_inner', prop: 'line-color', label: 'Motorway' },{ id: 'highway_motorway_casing', prop: 'line-color', label: 'Motorway Casing' },{ id: 'highway_motorway_subtle', prop: 'line-color', label: 'Motorway (Subtle)' },{ id: 'road_pier', prop: 'line-color', label: 'Pier Roads' },{ kind: 'mult', label: 'Road Width', mkey: '*::line-width-mult', def: 1, min: 0.25, max: 3, step: 0.05 }] },
            { group: 'Railways', layers: [{ id: 'railway', prop: 'line-color', label: 'Railways' },{ id: 'railway_dashline', prop: 'line-color', label: 'Railway Dashes' },{ id: 'railway_transit', prop: 'line-color', label: 'Transit Rail' },{ id: 'railway_transit_dashline', prop: 'line-color', label: 'Transit Dashes' }] },
            { group: 'Boundaries', layers: [{ id: 'boundary_state', prop: 'line-color', label: 'State Borders' },{ id: 'boundary_country_z0-4', prop: 'line-color', label: 'Country Borders (Far)' },{ id: 'boundary_country_z5-', prop: 'line-color', label: 'Country Borders (Near)' }] },
            { group: 'Labels', layers: [{ id: 'highway_name_other', prop: 'text-color', label: 'Road Labels' },{ id: 'highway_name_other', prop: 'text-halo-color', label: 'Road Label Halo' },{ id: 'highway_name_motorway', prop: 'text-color', label: 'Motorway Labels' },{ id: 'place_other', prop: 'text-color', label: 'Hamlet / Neighborhood' },{ id: 'place_other', prop: 'text-halo-color', label: 'Hamlet Halo' },{ id: 'place_village', prop: 'text-color', label: 'Villages' },{ id: 'place_village', prop: 'text-halo-color', label: 'Village Halo' },{ id: 'place_town', prop: 'text-color', label: 'Towns' },{ id: 'place_town', prop: 'text-halo-color', label: 'Town Halo' },{ id: 'place_city', prop: 'text-color', label: 'Cities' },{ id: 'place_city', prop: 'text-halo-color', label: 'City Halo' },{ id: 'place_city_large', prop: 'text-color', label: 'Major Cities' },{ id: 'place_city_large', prop: 'text-halo-color', label: 'Major City Halo' },{ id: 'place_state', prop: 'text-color', label: 'States' },{ id: 'place_state', prop: 'text-halo-color', label: 'State Halo' },{ id: 'place_country_major', prop: 'text-color', label: 'Countries (Major)' },{ id: 'place_country_major', prop: 'text-halo-color', label: 'Country Halo (Major)' },{ id: 'place_country_minor', prop: 'text-color', label: 'Countries (Minor)' },{ id: 'place_country_other', prop: 'text-color', label: 'Countries (Other)' }] },
        ],
        light: [
            { group: 'Base', layers: [{ id: 'background', prop: 'background-color', label: 'Background' }] },
            { group: 'Water', layers: [{ id: 'water', prop: 'fill-color', label: 'Water Fill' },{ id: 'waterway_river', prop: 'line-color', label: 'Rivers' },{ id: 'waterway_other', prop: 'line-color', label: 'Other Waterways' },{ id: 'water_name_point_label', prop: 'text-color', label: 'Water Labels' },{ id: 'water_name_point_label', prop: 'text-halo-color', label: 'Water Label Halo' }] },
            { group: 'Land & Nature', layers: [{ id: 'landcover_ice', prop: 'fill-color', label: 'Ice / Snow' },{ id: 'landcover_wood', prop: 'fill-color', label: 'Forests / Wood' },{ id: 'landcover_grass', prop: 'fill-color', label: 'Grass' },{ id: 'park', prop: 'fill-color', label: 'Parks' },{ id: 'landuse_residential', prop: 'fill-color', label: 'Residential' },{ kind: 'opacity', label: 'Marsh / Wetland (tufts)', okey: 'landcover_wetland::fill-opacity', def: 0.8 }] },
            { group: 'Buildings & Areas', layers: [{ id: 'building', prop: 'fill-color', label: 'Building Fill' },{ id: 'aeroway_fill', prop: 'fill-color', label: 'Airport Area' }] },
            { group: 'Roads', layers: [{ label: 'Paths / Pedestrian', keys: ['road_path_pedestrian::line-color','bridge_path_pedestrian::line-color','tunnel_path_pedestrian::line-color'] },{ label: 'Minor Roads', keys: ['road_minor::line-color','bridge_street::line-color','tunnel_minor::line-color'] },{ label: 'Minor Casing', keys: ['road_minor_casing::line-color','bridge_street_casing::line-color','tunnel_street_casing::line-color'] },{ label: 'Service / Track', keys: ['road_service_track::line-color','bridge_service_track::line-color','tunnel_service_track::line-color'] },{ label: 'Service / Track Casing', keys: ['road_service_track_casing::line-color','bridge_service_track_casing::line-color','tunnel_service_track_casing::line-color'] },{ label: 'Secondary / Tertiary', keys: ['road_secondary_tertiary::line-color','bridge_secondary_tertiary::line-color','tunnel_secondary_tertiary::line-color'] },{ label: 'Secondary / Tertiary Casing', keys: ['road_secondary_tertiary_casing::line-color','bridge_secondary_tertiary_casing::line-color','tunnel_secondary_tertiary_casing::line-color'] },{ label: 'Trunk / Primary', keys: ['road_trunk_primary::line-color','bridge_trunk_primary::line-color','tunnel_trunk_primary::line-color'] },{ label: 'Trunk / Primary Casing', keys: ['road_trunk_primary_casing::line-color','bridge_trunk_primary_casing::line-color','tunnel_trunk_primary_casing::line-color'] },{ label: 'Ramps / Links', keys: ['road_link::line-color','bridge_link::line-color','tunnel_link::line-color'] },{ label: 'Ramps / Links Casing', keys: ['road_link_casing::line-color','bridge_link_casing::line-color','tunnel_link_casing::line-color'] },{ label: 'Motorway', keys: ['road_motorway::line-color','bridge_motorway::line-color','tunnel_motorway::line-color'] },{ label: 'Motorway Casing', keys: ['road_motorway_casing::line-color','bridge_motorway_casing::line-color','tunnel_motorway_casing::line-color'] },{ label: 'Motorway Links', keys: ['road_motorway_link::line-color','bridge_motorway_link::line-color','tunnel_motorway_link::line-color'] },{ label: 'Motorway Link Casing', keys: ['road_motorway_link_casing::line-color','bridge_motorway_link_casing::line-color','tunnel_motorway_link_casing::line-color'] },{ kind: 'mult', label: 'Road Width', mkey: '*::line-width-mult', def: 1, min: 0.25, max: 3, step: 0.05 }] },
            { group: 'Railways', layers: [{ id: 'road_major_rail', prop: 'line-color', label: 'Railways' },{ id: 'road_major_rail_hatching', prop: 'line-color', label: 'Railway Hatching' },{ id: 'road_transit_rail', prop: 'line-color', label: 'Transit Rail' },{ id: 'road_transit_rail_hatching', prop: 'line-color', label: 'Transit Hatching' }] },
            { group: 'Boundaries', layers: [{ id: 'boundary_3', prop: 'line-color', label: 'State Borders' },{ id: 'boundary_2', prop: 'line-color', label: 'Country Borders' },{ id: 'boundary_disputed', prop: 'line-color', label: 'Disputed Borders' }] },
            { group: 'Labels', layers: [{ id: 'highway-name-minor', prop: 'text-color', label: 'Road Labels' },{ id: 'highway-name-major', prop: 'text-color', label: 'Major Road Labels' },{ id: 'label_other', prop: 'text-color', label: 'Hamlet / Neighborhood' },{ id: 'label_other', prop: 'text-halo-color', label: 'Hamlet Halo' },{ id: 'label_village', prop: 'text-color', label: 'Villages' },{ id: 'label_village', prop: 'text-halo-color', label: 'Village Halo' },{ id: 'label_town', prop: 'text-color', label: 'Towns' },{ id: 'label_town', prop: 'text-halo-color', label: 'Town Halo' },{ id: 'label_city', prop: 'text-color', label: 'Cities' },{ id: 'label_city', prop: 'text-halo-color', label: 'City Halo' },{ id: 'label_city_capital', prop: 'text-color', label: 'Capital Cities' },{ id: 'label_city_capital', prop: 'text-halo-color', label: 'Capital City Halo' },{ id: 'label_state', prop: 'text-color', label: 'States' },{ id: 'label_state', prop: 'text-halo-color', label: 'State Halo' },{ id: 'label_country_1', prop: 'text-color', label: 'Countries (Major)' },{ id: 'label_country_1', prop: 'text-halo-color', label: 'Country Halo (Major)' },{ id: 'label_country_2', prop: 'text-color', label: 'Countries (Minor)' },{ id: 'label_country_3', prop: 'text-color', label: 'Countries (Other)' }] },
        ],
    };

    const SIMPLE_LAYERS = {
        dark: [
            { group: 'Base', layers: [{ label: 'Background', keys: ['background::background-color'] }] },
            { group: 'Water', layers: [{ label: 'Water', keys: ['water::fill-color','waterway::line-color'] },{ label: 'Water Labels', keys: ['water_name::text-color'] },{ label: 'Water Label Halo', keys: ['water_name::text-halo-color'] }] },
            { group: 'Land & Nature', layers: [{ label: 'Nature / Parks', keys: ['landcover_wood::fill-color','landuse_park::fill-color','landcover_ice_shelf::fill-color','landcover_glacier::fill-color'] },{ label: 'Residential', keys: ['landuse_residential::fill-color'] }] },
            { group: 'Buildings & Areas', layers: [{ label: 'Buildings', keys: ['building::fill-color','building::fill-outline-color'] },{ label: 'Airports / Piers', keys: ['aeroway-area::fill-color','road_area_pier::fill-color'] }] },
            { group: 'Roads', layers: [{ label: 'Minor Roads', keys: ['highway_path::line-color','highway_minor::line-color','road_pier::line-color'] },{ label: 'Major Roads', keys: ['highway_major_inner::line-color','highway_major_casing::line-color','highway_major_subtle::line-color'] },{ label: 'Motorways', keys: ['highway_motorway_inner::line-color','highway_motorway_casing::line-color','highway_motorway_subtle::line-color'] },{ kind: 'mult', label: 'Road Width', mkey: '*::line-width-mult', def: 1, min: 0.25, max: 3, step: 0.05 }] },
            { group: 'Railways', layers: [{ label: 'All Railways', keys: ['railway::line-color','railway_dashline::line-color','railway_transit::line-color','railway_transit_dashline::line-color'] }] },
            { group: 'Boundaries', layers: [{ label: 'All Borders', keys: ['boundary_state::line-color','boundary_country_z0-4::line-color','boundary_country_z5-::line-color'] }] },
            { group: 'Labels', layers: [{ label: 'Road Labels', keys: ['highway_name_other::text-color','highway_name_motorway::text-color'] },{ label: 'Road Label Halo', keys: ['highway_name_other::text-halo-color'] },{ label: 'Place Labels', keys: ['place_other::text-color','place_village::text-color','place_town::text-color','place_city::text-color','place_city_large::text-color','place_state::text-color','place_country_major::text-color','place_country_minor::text-color','place_country_other::text-color'] },{ label: 'Place Label Halo', keys: ['place_other::text-halo-color','place_village::text-halo-color','place_town::text-halo-color','place_city::text-halo-color','place_city_large::text-halo-color','place_state::text-halo-color','place_country_major::text-halo-color'] }] },
        ],
        light: [
            { group: 'Base', layers: [{ label: 'Background', keys: ['background::background-color'] }] },
            { group: 'Water', layers: [{ label: 'Water', keys: ['water::fill-color','waterway_river::line-color','waterway_other::line-color'] },{ label: 'Water Labels', keys: ['water_name_point_label::text-color','water_name_line_label::text-color'] },{ label: 'Water Label Halo', keys: ['water_name_point_label::text-halo-color','water_name_line_label::text-halo-color'] }] },
            { group: 'Land & Nature', layers: [{ label: 'Nature / Parks', keys: ['landcover_wood::fill-color','landcover_grass::fill-color','park::fill-color','landcover_ice::fill-color'] },{ label: 'Residential', keys: ['landuse_residential::fill-color'] },{ kind: 'opacity', label: 'Marsh / Wetland (tufts)', okey: 'landcover_wetland::fill-opacity', def: 0.8 }] },
            { group: 'Buildings & Areas', layers: [{ label: 'Buildings', keys: ['building::fill-color'] },{ label: 'Airports', keys: ['aeroway_fill::fill-color'] }] },
            { group: 'Roads', layers: [{ label: 'Minor Roads', keys: ['road_path_pedestrian::line-color','bridge_path_pedestrian::line-color','tunnel_path_pedestrian::line-color','bridge_path_pedestrian_casing::line-color','road_minor::line-color','bridge_street::line-color','tunnel_minor::line-color','road_minor_casing::line-color','bridge_street_casing::line-color','tunnel_street_casing::line-color','road_service_track::line-color','bridge_service_track::line-color','tunnel_service_track::line-color','road_service_track_casing::line-color','bridge_service_track_casing::line-color','tunnel_service_track_casing::line-color','road_link::line-color','bridge_link::line-color','tunnel_link::line-color','road_link_casing::line-color','bridge_link_casing::line-color','tunnel_link_casing::line-color'] },{ label: 'Major Roads', keys: ['road_secondary_tertiary::line-color','bridge_secondary_tertiary::line-color','tunnel_secondary_tertiary::line-color','road_secondary_tertiary_casing::line-color','bridge_secondary_tertiary_casing::line-color','tunnel_secondary_tertiary_casing::line-color','road_trunk_primary::line-color','bridge_trunk_primary::line-color','tunnel_trunk_primary::line-color','road_trunk_primary_casing::line-color','bridge_trunk_primary_casing::line-color','tunnel_trunk_primary_casing::line-color'] },{ label: 'Motorways', keys: ['road_motorway::line-color','bridge_motorway::line-color','tunnel_motorway::line-color','road_motorway_casing::line-color','bridge_motorway_casing::line-color','tunnel_motorway_casing::line-color','road_motorway_link::line-color','bridge_motorway_link::line-color','tunnel_motorway_link::line-color','road_motorway_link_casing::line-color','bridge_motorway_link_casing::line-color','tunnel_motorway_link_casing::line-color'] },{ kind: 'mult', label: 'Road Width', mkey: '*::line-width-mult', def: 1, min: 0.25, max: 3, step: 0.05 }] },
            { group: 'Railways', layers: [{ label: 'All Railways', keys: ['road_major_rail::line-color','road_major_rail_hatching::line-color','road_transit_rail::line-color','road_transit_rail_hatching::line-color'] }] },
            { group: 'Boundaries', layers: [{ label: 'All Borders', keys: ['boundary_3::line-color','boundary_2::line-color','boundary_disputed::line-color'] }] },
            { group: 'Labels', layers: [{ label: 'Road Labels', keys: ['highway-name-minor::text-color','highway-name-major::text-color'] },{ label: 'Place Labels', keys: ['label_other::text-color','label_village::text-color','label_town::text-color','label_city::text-color','label_city_capital::text-color','label_state::text-color','label_country_1::text-color','label_country_2::text-color','label_country_3::text-color'] },{ label: 'Place Label Halo', keys: ['label_other::text-halo-color','label_village::text-halo-color','label_town::text-halo-color','label_city::text-halo-color','label_city_capital::text-halo-color','label_state::text-halo-color','label_country_1::text-halo-color'] }] },
        ],
    };

    const BASE_DEFAULTS = {
        dark: {'background::background-color':'#0c0c0c','water::fill-color':'#1b1b1d','waterway::line-color':'#1b1b1d','water_name::text-color':'#000000','water_name::text-halo-color':'#454545','landcover_ice_shelf::fill-color':'#0c0c0c','landcover_glacier::fill-color':'#050505','landcover_wood::fill-color':'#202020','landuse_park::fill-color':'#202020','landuse_residential::fill-color':'#0d0d0d','building::fill-color':'#0a0a0a','building::fill-outline-color':'#1b1b1d','aeroway-area::fill-color':'#000000','road_area_pier::fill-color':'#0c0c0c','road_pier::line-color':'#0c0c0c','highway_path::line-color':'#1b1b1d','highway_minor::line-color':'#181818','highway_major_inner::line-color':'#121212','highway_major_casing::line-color':'#3c3c3c','highway_major_subtle::line-color':'#2a2a2a','highway_motorway_inner::line-color':'#000000','highway_motorway_casing::line-color':'#3c3c3c','highway_motorway_subtle::line-color':'#181818','railway::line-color':'#232323','railway_dashline::line-color':'#0c0c0c','railway_transit::line-color':'#232323','railway_transit_dashline::line-color':'#0c0c0c','boundary_state::line-color':'#363636','boundary_country_z0-4::line-color':'#3b3b3b','boundary_country_z5-::line-color':'#3b3b3b','highway_name_other::text-color':'#504e4e','highway_name_other::text-halo-color':'#000000','highway_name_motorway::text-color':'#5e5e5e','place_other::text-color':'#656565','place_other::text-halo-color':'#000000','place_village::text-color':'#656565','place_village::text-halo-color':'#000000','place_town::text-color':'#656565','place_town::text-halo-color':'#000000','place_city::text-color':'#656565','place_city::text-halo-color':'#000000','place_city_large::text-color':'#656565','place_city_large::text-halo-color':'#000000','place_state::text-color':'#656565','place_state::text-halo-color':'#000000','place_country_other::text-color':'#656565','place_country_other::text-halo-color':'#000000','place_country_minor::text-color':'#656565','place_country_minor::text-halo-color':'#000000','place_country_major::text-color':'#656565','place_country_major::text-halo-color':'#000000'},
        light: {'background::background-color':'#f8f4f0','water::fill-color':'#9ebdff','waterway_river::line-color':'#a0c8f0','waterway_other::line-color':'#a0c8f0','water_name_point_label::text-color':'#495e91','water_name_point_label::text-halo-color':'#ffffff','water_name_line_label::text-color':'#495e91','water_name_line_label::text-halo-color':'#ffffff','landcover_ice::fill-color':'#e0ecec','landcover_wood::fill-color':'#a4d898','landcover_grass::fill-color':'#b0d59a','park::fill-color':'#d8e8c8','landuse_residential::fill-color':'#e8dece','building::fill-color':'#d4cfc9','aeroway_fill::fill-color':'#e5e4e0','road_path_pedestrian::line-color':'#ffffff','road_minor::line-color':'#ffffff','road_secondary_tertiary::line-color':'#ffeeaa','road_trunk_primary::line-color':'#ffeeaa','road_trunk_primary_casing::line-color':'#e9ac77','road_motorway::line-color':'#ffcc88','road_motorway_casing::line-color':'#e9ac77','road_motorway_link::line-color':'#ffcc88','road_minor_casing::line-color':'#cfcdca','road_service_track::line-color':'#ffffff','road_service_track_casing::line-color':'#cfcdca','road_secondary_tertiary_casing::line-color':'#e9ac77','road_link::line-color':'#ffeeaa','road_link_casing::line-color':'#e9ac77','road_motorway_link_casing::line-color':'#e9ac77','road_major_rail::line-color':'#bbbbbb','road_major_rail_hatching::line-color':'#bbbbbb','road_transit_rail::line-color':'#bbbbbb','road_transit_rail_hatching::line-color':'#bbbbbb','boundary_3::line-color':'#b3b3b3','boundary_2::line-color':'#696969','boundary_disputed::line-color':'#696969','highway-name-minor::text-color':'#666666','highway-name-major::text-color':'#666666','label_other::text-color':'#333333','label_other::text-halo-color':'#ffffff','label_village::text-color':'#000000','label_village::text-halo-color':'#ffffff','label_town::text-color':'#000000','label_town::text-halo-color':'#ffffff','label_city::text-color':'#000000','label_city::text-halo-color':'#ffffff','label_city_capital::text-color':'#000000','label_city_capital::text-halo-color':'#ffffff','label_state::text-color':'#333333','label_state::text-halo-color':'#ffffff','label_country_1::text-color':'#000000','label_country_1::text-halo-color':'#ffffff','label_country_2::text-color':'#000000','label_country_2::text-halo-color':'#ffffff','label_country_3::text-color':'#000000','label_country_3::text-halo-color':'#ffffff'}
    };

    function getEditableLayers(base) { return EDITABLE_LAYERS[base] || EDITABLE_LAYERS.dark; }
    function getSimpleLayers(base) { return SIMPLE_LAYERS[base] || SIMPLE_LAYERS.dark; }
    function getDefaultColor(key, base) { return (BASE_DEFAULTS[base] || BASE_DEFAULTS.dark)[key] || '#808080'; }

    // ─── Storage Helpers ─────────────────────────────────────────────
    function teLoadThemes() {
        let themes = {};
        try { themes = JSON.parse(localStorage.getItem(TE_STORAGE_KEY)) || {}; } catch {}
        for (const [key, bundled] of Object.entries(BUNDLED_THEMES)) {
            const name = bundled.name;
            if (!themes[name] || !themes[name].bundled) {
                themes[name] = { overrides: { ...bundled.overrides }, base: bundled.base, bundled: true, createdAt: themes[name]?.createdAt || Date.now(), updatedAt: Date.now() };
            }
            if (name === 'Debug White' && !themes[name].overrides['*::all-color']) themes[name].overrides['*::all-color'] = '#ffffff';
            if (name === 'Debug Black' && !themes[name].overrides['*::all-color']) themes[name].overrides['*::all-color'] = '#000000';
        }
        if (!localStorage.getItem(TE_MINOR_ROAD_KEY)) {
            for (const bundled of Object.values(BUNDLED_THEMES)) {
                const theme = themes[bundled.name];
                if (!theme || !theme.bundled || bundled.name === 'Debug White' || bundled.name === 'Debug Black') continue;
                const keys = HIDE_MINOR_ROAD_KEYS[theme.base || 'dark'] || [];
                for (const k of keys) { const cur = theme.overrides[k]; const orig = bundled.overrides[k]; if (cur == null || cur === orig) theme.overrides[k] = 'transparent'; }
                theme.updatedAt = Date.now();
            }
            localStorage.setItem(TE_MINOR_ROAD_KEY, '1');
        }
        if (!localStorage.getItem(TE_FEEDBACK_KEY)) {
            const dg = themes['Discord Gold']; if (dg?.bundled) { dg.overrides['background::background-color'] = '#171717'; dg.overrides['landuse_residential::fill-color'] = 'transparent'; dg.updatedAt = Date.now(); }
            const cp = themes['Cute & Pink']; if (cp?.bundled) { cp.overrides['landuse_residential::fill-color'] = 'transparent'; cp.updatedAt = Date.now(); }
            const mk = themes['Monokai']; if (mk?.bundled) { mk.overrides['background::background-color'] = '#000000'; mk.overrides['landuse_residential::fill-color'] = 'transparent'; mk.overrides['highway_major_subtle::line-color'] = '#273a2d'; mk.updatedAt = Date.now(); }
            localStorage.setItem(TE_FEEDBACK_KEY, '1');
        }
        teSaveThemes(themes);
        return themes;
    }
    function teSaveThemes(themes) { localStorage.setItem(TE_STORAGE_KEY, JSON.stringify(themes)); }
    function teGetActive() { return localStorage.getItem(TE_ACTIVE_KEY) || ''; }
    function teSetActive(name) { localStorage.setItem(TE_ACTIVE_KEY, name); }

    // ─── Color Helpers ───────────────────────────────────────────────
    function colorToHex(color) {
        if (!color || typeof color !== 'string') return '#000000';
        if (/^#[0-9A-Fa-f]{6}$/.test(color)) return color;
        if (/^#[0-9A-Fa-f]{3}$/.test(color)) { const [,r,g,b] = color.match(/^#(.)(.)(.)$/); return '#'+r+r+g+g+b+b; }
        const tmp = document.createElement('div'); tmp.style.color = color; document.body.appendChild(tmp);
        const computed = getComputedStyle(tmp).color; document.body.removeChild(tmp);
        const m = computed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
        if (!m) return '#000000';
        const hex = n => parseInt(n,10).toString(16).padStart(2,'0');
        return '#'+hex(m[1])+hex(m[2])+hex(m[3]);
    }
    function isColorDark(hex) { const h = colorToHex(hex).replace('#',''); const r = parseInt(h.substring(0,2),16)/255; const g = parseInt(h.substring(2,4),16)/255; const b = parseInt(h.substring(4,6),16)/255; return (0.299*r+0.587*g+0.114*b) < 0.4; }

    // ─── Base Style Templates ────────────────────────────────────────
    let lightStyleTemplate = null, darkStyleTemplate = null;
    async function fetchStyle(endpoint) { let text = await fetch(TE_BASE_URL + endpoint).then(r => r.text()); text = text.replaceAll('http://localhost:5039', TE_BASE_URL); return JSON.parse(text); }
    async function getBaseStyle(base) {
        if (base === 'dark') { if (!darkStyleTemplate) { try { darkStyleTemplate = await fetchStyle('/styleDark'); } catch { return null; } } return JSON.parse(JSON.stringify(darkStyleTemplate)); }
        if (!lightStyleTemplate) { try { lightStyleTemplate = await fetchStyle('/style'); } catch { return null; } } return JSON.parse(JSON.stringify(lightStyleTemplate));
    }

    // ─── Style Builder ───────────────────────────────────────────────
    // Scale a MapLibre line-width value by a multiplier, preserving zoom scaling.
    // Handles: plain number, legacy {base,stops} function, and expression arrays.
    function scaleLineWidth(v, m) {
        if (v == null || !(m > 0)) return v;
        if (typeof v === 'number') return v * m;
        if (Array.isArray(v)) return ['*', v, m];
        if (typeof v === 'object' && Array.isArray(v.stops)) return Object.assign({}, v, { stops: v.stops.map(s => [s[0], typeof s[1] === 'number' ? s[1] * m : s[1]]) });
        return v;
    }

    function buildStyle(baseStyle, overrides) {
        const style = JSON.parse(JSON.stringify(baseStyle));
        const allColor = overrides['*::all-color'];
        const widthMult = parseFloat(overrides['*::line-width-mult']);
        const scaleRoads = !isNaN(widthMult) && widthMult > 0 && widthMult !== 1;
        for (const layer of style.layers) {
            if (allColor && layer.paint) {
                if (layer.paint['background-color'] != null) layer.paint['background-color'] = allColor;
                if (layer.paint['fill-color'] != null) layer.paint['fill-color'] = allColor;
                if (layer.paint['fill-outline-color'] != null) layer.paint['fill-outline-color'] = allColor;
                if (layer.paint['line-color'] != null) layer.paint['line-color'] = allColor;
                if (layer.paint['text-color'] != null) layer.paint['text-color'] = allColor;
                if (layer.paint['text-halo-color'] != null) layer.paint['text-halo-color'] = allColor;
                if (layer.type === 'raster' && layer.paint['raster-opacity'] != null) layer.paint['raster-opacity'] = 0;
                if (layer.type === 'symbol') layer.paint['icon-opacity'] = 0;
            }
            const check = (prop) => { const key = layer.id+'::'+prop; if (overrides[key]) { if (!layer.paint) layer.paint = {}; layer.paint[prop] = overrides[key]; } };
            const checkNum = (prop) => { const key = layer.id+'::'+prop; const val = overrides[key]; if (val != null && val !== '') { const num = parseFloat(val); if (!isNaN(num)) { if (!layer.paint) layer.paint = {}; layer.paint[prop] = num; } } };
            if (layer.type === 'background') check('background-color');
            if (layer.type === 'fill') { check('fill-color'); check('fill-outline-color'); checkNum('fill-opacity'); }
            if (layer.type === 'line') { check('line-color'); if (scaleRoads && layer['source-layer'] === 'transportation' && layer.paint && layer.paint['line-width'] != null) layer.paint['line-width'] = scaleLineWidth(layer.paint['line-width'], widthMult); }
            if (layer.type === 'symbol') { check('text-color'); check('text-halo-color'); }
        }
        return style;
    }

    function readColorsFromStyle(style, base) {
        const overrides = {};
        const readKey = (key) => {
            const idx = key.indexOf('::'); if (idx < 0) return;
            const id = key.slice(0, idx), prop = key.slice(idx + 2);
            const layer = style.layers.find(l => l.id === id);
            if (layer?.paint?.[prop] != null) { let val = layer.paint[prop]; if (typeof val === 'object' && !Array.isArray(val) && val.stops) val = val.stops[val.stops.length-1][1]; if (typeof val === 'string') overrides[key] = colorToHex(val); }
        };
        for (const group of getEditableLayers(base || 'dark')) {
            for (const entry of group.layers) {
                if (entry.kind) continue;
                if (entry.keys) entry.keys.forEach(readKey);
                else readKey(entry.id + '::' + entry.prop);
            }
        }
        return overrides;
    }

    function readAllColorsFromStyle(style) {
        const propMap = { background: ['background-color'], fill: ['fill-color','fill-outline-color'], line: ['line-color'], symbol: ['text-color','text-halo-color'] };
        const overrides = {};
        for (const layer of style.layers) { const props = propMap[layer.type]; if (!props || !layer.paint) continue; for (const p of props) { let val = layer.paint[p]; if (val == null) continue; if (typeof val === 'object' && !Array.isArray(val) && val.stops) val = val.stops[val.stops.length-1][1]; if (typeof val === 'string') overrides[layer.id+'::'+p] = colorToHex(val); } }
        return overrides;
    }

    // ─── Map Integration ─────────────────────────────────────────────
    function teGetMap() {
        try { const m = (0, eval)('map'); if (m && typeof m.setStyle === 'function') return m; } catch {}
        if (typeof unsafeWindow !== 'undefined') { try { const m = unsafeWindow.eval('map'); if (m && typeof m.setStyle === 'function') return m; } catch {} }
        return null;
    }
    function teGetUserConfig() { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.userConfig) return unsafeWindow.userConfig; if (window.userConfig) return window.userConfig; try { return JSON.parse(localStorage.getItem('userConfig')); } catch { return null; } }
    function inferBase(style) { if (!style?.layers) return 'dark'; const ids = new Set(style.layers.map(l => l.id)); if (ids.has('road_minor') || ids.has('highway-name-minor') || ids.has('boundary_3')) return 'light'; return 'dark'; }

    function setStyleCustomInPage(style) {
        const json = JSON.stringify(style).replace(/</g, '\\u003c');
        const code = 'styleCustom = ' + json;
        try { if (window.wrappedJSObject?.eval) { window.wrappedJSObject.eval(code); return true; } } catch {}
        try { const s = document.createElement('script'); s.textContent = '(function(){try{'+code+'}catch(e){}})();'; (document.head||document.documentElement).appendChild(s); s.remove(); return true; } catch {}
        try { (0, eval)(code); return true; } catch {}
        return false;
    }

    function applyStyleInPlace(map, style) {
        const liveStyle = map.getStyle(); if (!liveStyle?.layers) return false;
        const liveIds = new Set(liveStyle.layers.map(l => l.id));
        const allowed = new Set(['background-color','fill-color','fill-outline-color','line-color','text-color','text-halo-color','icon-opacity','raster-opacity','fill-opacity','line-width']);
        for (const layer of style.layers||[]) { if (!liveIds.has(layer.id) || !layer.paint) continue; for (const [prop,value] of Object.entries(layer.paint)) { if (!allowed.has(prop)) continue; try { map.setPaintProperty(layer.id, prop, value); } catch {} } }
        return true;
    }

    function triggerRepaint() { try { (0, eval)('try{if(typeof drawCachedTilesOnMap==="function")drawCachedTilesOnMap()}catch(e){};try{if(typeof synchronize==="function")synchronize("partial")}catch(e){};try{if(typeof refresh==="function")refresh()}catch(e){}'); } catch {} }

    function applyStyleToMap(style, targetBase) {
        localStorage.setItem('customTheme', JSON.stringify(style));
        const uc = teGetUserConfig() || {}; uc.theme = 'custom'; localStorage.setItem('userConfig', JSON.stringify(uc));
        const map = teGetMap(); if (!map) { teShowToast('Map not found — please wait for the page to load.', true); return; }
        const liveStyle = map.getStyle ? map.getStyle() : null;
        const liveBase = inferBase(liveStyle); const desiredBase = targetBase || inferBase(style);
        if (liveStyle && liveBase === desiredBase) { applyStyleInPlace(map, style); setStyleCustomInPage(style); return; }
        const applyFull = (attempt = 0) => {
            try { if (map.isStyleLoaded && !map.isStyleLoaded()) { if (attempt < 40) return setTimeout(() => applyFull(attempt+1), 75); }
                map.setStyle(style); setStyleCustomInPage(style);
                const kick = () => { triggerRepaint(); setTimeout(triggerRepaint, 120); setTimeout(triggerRepaint, 450); };
                try { map.once('styledata', kick); } catch {} try { map.once('idle', kick); } catch {}
            } catch (e) { if (attempt < 40) return setTimeout(() => applyFull(attempt+1), 75);
                const json = JSON.stringify(style).replace(/</g, '\\u003c');
                try { const s = document.createElement('script'); s.textContent = '(function(){try{styleCustom='+json+';applyTheme("custom")}catch(e){}})();'; (document.head||document.documentElement).appendChild(s); s.remove(); triggerRepaint(); setTimeout(triggerRepaint,120); setTimeout(triggerRepaint,450); } catch { teShowToast('Failed to switch base style.', true); }
            }
        };
        applyFull();
    }

    function persistTheme(style) { localStorage.setItem('customTheme', JSON.stringify(style)); const uc = teGetUserConfig() || {}; uc.theme = 'custom'; localStorage.setItem('userConfig', JSON.stringify(uc)); }

    // ─── Toast ───────────────────────────────────────────────────────
    function teShowToast(msg, isError) {
        const existing = document.getElementById('gte-toast'); if (existing) existing.remove();
        const toast = document.createElement('div'); toast.id = 'gte-toast'; toast.textContent = msg;
        Object.assign(toast.style, { position:'fixed',bottom:'20px',left:'50%',transform:'translateX(-50%)',background:isError?'#f38ba8':'#a6e3a1',color:'#1e1e2e',padding:'8px 18px',borderRadius:'8px',fontSize:'12px',fontWeight:'600',zIndex:'100001',boxShadow:'0 4px 12px rgba(0,0,0,.3)',transition:'opacity .3s',fontFamily:"'Segoe UI',system-ui,sans-serif" });
        document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 2500);
    }

    // ─── HTML Utils ──────────────────────────────────────────────────
    function teEscHTML(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
    function teEscAttr(str) { return str.replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }

    // ─── CSS ─────────────────────────────────────────────────────────
    function teInjectCSS() {
        if (document.getElementById('gte-style')) return;
        const css = document.createElement('style'); css.id = 'gte-style';
        css.textContent = `
            #gte-modal { position:fixed; z-index:100000; background:#1e1e2e; color:#cdd6f4; border:1px solid #45475a; border-radius:12px; box-shadow:0 8px 32px rgba(0,0,0,.55); width:380px; max-height:80vh; display:flex; flex-direction:column; font-family:'Segoe UI',system-ui,sans-serif; font-size:13px; user-select:none; }
            #gte-modal.gte-hidden { display:none; }
            #gte-titlebar { display:flex; align-items:center; justify-content:space-between; padding:10px 14px; background:#181825; border-radius:12px 12px 0 0; cursor:grab; flex-shrink:0; }
            #gte-titlebar:active { cursor:grabbing; }
            #gte-titlebar h2 { margin:0; font-size:14px; font-weight:600; color:#cba6f7; }
            #gte-close-btn { background:none; border:none; color:#6c7086; cursor:pointer; font-size:18px; line-height:1; padding:0 4px; }
            #gte-close-btn:hover { color:#f38ba8; }
            #gte-tabs { display:flex; border-bottom:1px solid #313244; flex-shrink:0; }
            .gte-tab { flex:1; padding:8px 0; text-align:center; background:none; border:none; color:#6c7086; cursor:pointer; font-size:12px; font-weight:500; border-bottom:2px solid transparent; transition:color .15s,border-color .15s; }
            .gte-tab:hover { color:#bac2de; }
            .gte-tab.gte-active { color:#cba6f7; border-bottom-color:#cba6f7; }
            #gte-body { overflow-y:auto; padding:12px 14px; flex:1; }
            #gte-body::-webkit-scrollbar { width:6px; }
            #gte-body::-webkit-scrollbar-thumb { background:#45475a; border-radius:3px; }
            .gte-panel { display:none; }
            .gte-panel.gte-active { display:block; }
            .gte-group-header { font-size:11px; font-weight:700; text-transform:uppercase; color:#89b4fa; margin:12px 0 6px; letter-spacing:.5px; }
            .gte-group-header:first-child { margin-top:0; }
            .gte-color-row { display:flex; align-items:center; justify-content:space-between; padding:4px 0; }
            .gte-color-label { font-size:12px; color:#a6adc8; }
            .gte-color-input-wrap { display:flex; align-items:center; gap:6px; }
            .gte-color-input { -webkit-appearance:none; appearance:none; width:32px; height:24px; border:1px solid #45475a; border-radius:4px; cursor:pointer; background:none; padding:0; }
            .gte-color-input::-webkit-color-swatch-wrapper { padding:0; }
            .gte-color-input::-webkit-color-swatch { border:none; border-radius:3px; }
            .gte-hex-display { font-family:'Cascadia Code','Consolas',monospace; font-size:11px; color:#6c7086; width:62px; text-align:right; }
            .gte-hex-display.gte-hidden-color { color:#f38ba8; font-style:italic; }
            .gte-range { -webkit-appearance:none; appearance:none; width:80px; height:4px; border-radius:2px; background:#45475a; outline:none; cursor:pointer; accent-color:#cba6f7; padding:0; }
            .gte-range::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:12px; height:12px; border-radius:50%; background:#cba6f7; cursor:pointer; border:none; }
            .gte-range::-moz-range-thumb { width:12px; height:12px; border-radius:50%; background:#cba6f7; cursor:pointer; border:none; }
            .gte-vis-btn { background:none; border:none; cursor:pointer; font-size:14px; padding:0 2px; line-height:1; opacity:0.6; transition:opacity .15s; }
            .gte-vis-btn:hover { opacity:1; }
            .gte-vis-btn.gte-layer-hidden { opacity:0.35; }
            .gte-reset-btn { background:none; border:none; cursor:pointer; font-size:11px; padding:0 2px; line-height:1; opacity:0.5; transition:opacity .15s; color:#89b4fa; }
            .gte-reset-btn:hover { opacity:1; }
            .gte-name-row { display:flex; gap:8px; margin-bottom:12px; }
            .gte-name-input { flex:1; padding:6px 10px; border-radius:6px; background:#313244; color:#cdd6f4; border:1px solid #45475a; font-size:13px; outline:none; }
            .gte-name-input:focus { border-color:#cba6f7; }
            .gte-name-input::placeholder { color:#585b70; }
            .gte-preview-row { display:flex; align-items:center; gap:8px; margin-bottom:10px; padding:6px 8px; background:#313244; border-radius:6px; }
            .gte-preview-row input[type=checkbox] { accent-color:#cba6f7; width:15px; height:15px; cursor:pointer; }
            .gte-preview-row label { font-size:12px; color:#a6adc8; cursor:pointer; user-select:none; }
            .gte-mode-toggle { display:flex; align-items:center; gap:8px; margin-bottom:10px; padding:6px 8px; background:#313244; border-radius:6px; }
            .gte-mode-toggle span { font-size:12px; color:#a6adc8; }
            .gte-mode-toggle span.gte-mode-active { color:#cba6f7; font-weight:600; }
            .gte-mode-switch { position:relative; width:36px; height:18px; background:#585b70; border-radius:9px; cursor:pointer; transition:background .2s; border:none; padding:0; }
            .gte-mode-switch.gte-on { background:#cba6f7; }
            .gte-mode-switch::after { content:''; position:absolute; top:2px; left:2px; width:14px; height:14px; background:#fff; border-radius:50%; transition:transform .2s; }
            .gte-mode-switch.gte-on::after { transform:translateX(18px); }
            .gte-btn { padding:7px 14px; border:none; border-radius:6px; font-size:12px; font-weight:600; cursor:pointer; transition:filter .15s; }
            .gte-btn:hover { filter:brightness(1.15); }
            .gte-btn-primary { background:#cba6f7; color:#1e1e2e; }
            .gte-btn-secondary { background:#45475a; color:#cdd6f4; }
            .gte-btn-danger { background:#f38ba8; color:#1e1e2e; }
            .gte-btn-sm { padding:4px 10px; font-size:11px; }
            .gte-btn-row { display:flex; gap:8px; margin-top:12px; flex-wrap:wrap; }
            .gte-theme-card { display:flex; align-items:center; justify-content:space-between; padding:8px 10px; margin-bottom:6px; background:#313244; border-radius:8px; border:1px solid transparent; transition:border-color .15s; }
            .gte-theme-card.gte-active-theme { border-color:#a6e3a1; }
            .gte-theme-card-name { display:flex; align-items:center; gap:6px; font-size:13px; font-weight:500; color:#cdd6f4; overflow:hidden; max-width:180px; }
            .gte-theme-name-text { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
            .gte-theme-badge { font-size:10px; font-weight:700; letter-spacing:0.2px; text-transform:uppercase; border-radius:999px; padding:2px 6px; line-height:1; flex-shrink:0; border:1px solid transparent; }
            .gte-theme-badge-light { color:#1e1e2e; background:#f9e2af; border-color:#eabf5d; }
            .gte-theme-badge-dark { color:#cdd6f4; background:#313244; border-color:#585b70; }
            .gte-theme-card-actions { display:flex; gap:4px; }
            .gte-empty-msg { color:#585b70; font-size:12px; text-align:center; padding:20px 0; }
            .gte-io-section { margin-top:14px; padding-top:12px; border-top:1px solid #313244; }
        `;
        document.head.appendChild(css);
    }

    // ─── Modal ───────────────────────────────────────────────────────
    let teModal = null;

    function teBuildModal() {
        const modal = document.createElement('div'); modal.id = 'gte-modal'; modal.className = 'gte-hidden';
        modal.innerHTML = '<div id="gte-titlebar"><h2>\ud83c\udfa8 Theme Editor</h2><button id="gte-close-btn">&times;</button></div><div id="gte-tabs"><button class="gte-tab gte-active" data-panel="editor">Editor</button><button class="gte-tab" data-panel="manager">My Themes</button></div><div id="gte-body"><div id="gte-panel-editor" class="gte-panel gte-active"></div><div id="gte-panel-manager" class="gte-panel"></div></div>';
        document.body.appendChild(modal);
        modal.style.top = '60px'; modal.style.right = '20px';
        modal.querySelector('#gte-close-btn').addEventListener('click', () => modal.classList.add('gte-hidden'));
        modal.querySelectorAll('.gte-tab').forEach(tab => tab.addEventListener('click', () => {
            modal.querySelectorAll('.gte-tab').forEach(t => t.classList.remove('gte-active'));
            modal.querySelectorAll('.gte-panel').forEach(p => p.classList.remove('gte-active'));
            tab.classList.add('gte-active'); modal.querySelector('#gte-panel-'+tab.dataset.panel).classList.add('gte-active');
            if (tab.dataset.panel === 'manager') renderManager();
        }));
        // Dragging
        let ox=0,oy=0,sx=0,sy=0;
        const handle = modal.querySelector('#gte-titlebar');
        handle.addEventListener('mousedown', e => { if (e.target.tagName==='BUTTON') return; e.preventDefault(); sx=e.clientX; sy=e.clientY; document.addEventListener('mousemove',drag); document.addEventListener('mouseup',dragEnd); });
        function drag(e) { ox=sx-e.clientX; oy=sy-e.clientY; sx=e.clientX; sy=e.clientY; modal.style.top=Math.max(0,Math.min(window.innerHeight-50,modal.offsetTop-oy))+'px'; modal.style.left=Math.max(0,Math.min(window.innerWidth-100,modal.offsetLeft-ox))+'px'; modal.style.right='auto'; }
        function dragEnd() { document.removeEventListener('mousemove',drag); document.removeEventListener('mouseup',dragEnd); }
        return modal;
    }

    // ─── Editor Panel ────────────────────────────────────────────────
    let curOverrides = {}, curEditName = '', curBase = 'dark', livePreview = false, previewTimer = null, simpleMode = true;

    function scheduleLivePreview() {
        if (!livePreview) return; clearTimeout(previewTimer);
        previewTimer = setTimeout(async () => { const base = await getBaseStyle(curBase); if (!base) return; applyStyleToMap(buildStyle(base, curOverrides), curBase); }, 120);
    }

    // Opacity row — slider (0–100%) + eyeball hide/show. Used for pattern layers
    // (e.g. wetland tufts) where a color picker can't help; restore deletes the
    // override so the base style's own default opacity is preserved exactly.
    function teRenderOpacityRow(panel, entry) {
        const key = entry.okey; const def = (typeof entry.def === 'number') ? entry.def : 1;
        const has = curOverrides[key] != null && curOverrides[key] !== '';
        let val = has ? parseFloat(curOverrides[key]) : def; if (isNaN(val)) val = def;
        const pct = Math.round(val * 100); const isHidden = val === 0;
        const row = document.createElement('div'); row.className = 'gte-color-row';
        row.innerHTML = '<span class="gte-color-label">'+teEscHTML(entry.label)+'</span><div class="gte-color-input-wrap"><button type="button" class="gte-vis-btn '+(isHidden?'gte-layer-hidden':'')+'" title="Toggle visibility">'+(isHidden?'🚫':'👁️')+'</button><input type="range" class="gte-range" min="0" max="100" step="1" value="'+pct+'"><span class="gte-hex-display'+(isHidden?' gte-hidden-color':'')+'">'+(isHidden?'hidden':pct+'%')+'</span><button type="button" class="gte-reset-btn" title="Reset to default">↻</button></div>';
        const range = row.querySelector('.gte-range'), disp = row.querySelector('.gte-hex-display'), visBtn = row.querySelector('.gte-vis-btn'), resetBtn = row.querySelector('.gte-reset-btn');
        const refresh = (v) => { const p = Math.round(v*100); range.value = p; if (v === 0) { disp.textContent = 'hidden'; disp.classList.add('gte-hidden-color'); visBtn.textContent = '🚫'; visBtn.classList.add('gte-layer-hidden'); } else { disp.textContent = p+'%'; disp.classList.remove('gte-hidden-color'); visBtn.textContent = '👁️'; visBtn.classList.remove('gte-layer-hidden'); } };
        range.addEventListener('input', e => { const v = parseInt(e.target.value,10)/100; curOverrides[key] = v; refresh(v); scheduleLivePreview(); });
        visBtn.addEventListener('click', () => { const cur = (curOverrides[key] != null && curOverrides[key] !== '') ? parseFloat(curOverrides[key]) : def; const nv = (cur === 0) ? def : 0; curOverrides[key] = nv; refresh(nv); scheduleLivePreview(); });
        resetBtn.addEventListener('click', () => { delete curOverrides[key]; refresh(def); scheduleLivePreview(); });
        panel.appendChild(row);
    }

    // Multiplier row — slider for a global wildcard override (e.g. road width).
    function teRenderMultRow(panel, entry) {
        const key = entry.mkey; const def = (typeof entry.def === 'number') ? entry.def : 1;
        const min = (typeof entry.min === 'number') ? entry.min : 0.25, max = (typeof entry.max === 'number') ? entry.max : 3, step = (typeof entry.step === 'number') ? entry.step : 0.05;
        const has = curOverrides[key] != null && curOverrides[key] !== '';
        let val = has ? parseFloat(curOverrides[key]) : def; if (isNaN(val)) val = def;
        const row = document.createElement('div'); row.className = 'gte-color-row';
        row.innerHTML = '<span class="gte-color-label">'+teEscHTML(entry.label)+'</span><div class="gte-color-input-wrap"><input type="range" class="gte-range" min="'+min+'" max="'+max+'" step="'+step+'" value="'+val+'"><span class="gte-hex-display">'+val.toFixed(2)+'×</span><button type="button" class="gte-reset-btn" title="Reset to default">↻</button></div>';
        const range = row.querySelector('.gte-range'), disp = row.querySelector('.gte-hex-display'), resetBtn = row.querySelector('.gte-reset-btn');
        range.addEventListener('input', e => { const v = parseFloat(e.target.value); curOverrides[key] = v; disp.textContent = v.toFixed(2)+'×'; scheduleLivePreview(); });
        resetBtn.addEventListener('click', () => { delete curOverrides[key]; range.value = def; disp.textContent = def.toFixed(2)+'×'; scheduleLivePreview(); });
        panel.appendChild(row);
    }

    function renderEditor(overrides, editName, base) {
        curOverrides = overrides || {}; curEditName = editName || ''; curBase = base || 'dark';
        const panel = document.getElementById('gte-panel-editor'); panel.innerHTML = '';

        // Name + base selector
        const nameRow = document.createElement('div'); nameRow.className = 'gte-name-row';
        nameRow.innerHTML = '<input type="text" class="gte-name-input" id="gte-theme-name" placeholder="Theme name\u2026" value="'+teEscAttr(curEditName)+'" maxlength="50"><select id="gte-base-select" class="gte-name-input" style="flex:0 0 auto;width:auto;padding:6px 8px;"><option value="dark" '+(curBase==='dark'?'selected':'')+'>Dark base</option><option value="light" '+(curBase==='light'?'selected':'')+'>Light base</option></select>';
        nameRow.querySelector('#gte-base-select').addEventListener('change', e => { curBase = e.target.value; renderEditor(curOverrides, curEditName, curBase); scheduleLivePreview(); });
        panel.appendChild(nameRow);

        // Live preview
        const previewRow = document.createElement('div'); previewRow.className = 'gte-preview-row';
        previewRow.innerHTML = '<input type="checkbox" id="gte-live-preview" '+(livePreview?'checked':'')+'><label for="gte-live-preview">Live preview</label>';
        previewRow.querySelector('#gte-live-preview').addEventListener('change', e => { livePreview = e.target.checked; if (livePreview) scheduleLivePreview(); });
        panel.appendChild(previewRow);

        // Simple/Full toggle
        const modeRow = document.createElement('div'); modeRow.className = 'gte-mode-toggle';
        modeRow.innerHTML = '<span class="'+(simpleMode?'gte-mode-active':'')+'">Simple</span><button type="button" class="gte-mode-switch '+(simpleMode?'':'gte-on')+'" id="gte-mode-switch"></button><span class="'+(simpleMode?'':'gte-mode-active')+'">Full</span>';
        modeRow.querySelector('#gte-mode-switch').addEventListener('click', () => { simpleMode = !simpleMode; renderEditor(curOverrides, curEditName, curBase); });
        panel.appendChild(modeRow);

        // Color rows
        const layerGroups = simpleMode ? getSimpleLayers(curBase) : getEditableLayers(curBase);
        for (const group of layerGroups) {
            const header = document.createElement('div'); header.className = 'gte-group-header'; header.textContent = group.group; panel.appendChild(header);
            for (const entry of group.layers) {
                if (entry.kind === 'opacity') { teRenderOpacityRow(panel, entry); continue; }
                if (entry.kind === 'mult') { teRenderMultRow(panel, entry); continue; }
                const keys = entry.keys ? entry.keys : [entry.id+'::'+entry.prop];
                const firstKey = keys[0];
                const currentColor = curOverrides[firstKey] || getDefaultColor(firstKey, curBase);
                const isHidden = currentColor === 'transparent';
                const displayColor = isHidden ? getDefaultColor(firstKey, curBase) : currentColor;
                const row = document.createElement('div'); row.className = 'gte-color-row';
                row.innerHTML = '<span class="gte-color-label">'+teEscHTML(simpleMode ? entry.label : entry.label)+'</span><div class="gte-color-input-wrap"><button type="button" class="gte-vis-btn '+(isHidden?'gte-layer-hidden':'')+'" title="Toggle visibility">'+(isHidden?'\ud83d\udeab':'\ud83d\udc41\ufe0f')+'</button><span class="gte-hex-display '+(isHidden?'gte-hidden-color':'')+'">'+(isHidden?'hidden':currentColor)+'</span><input type="color" class="gte-color-input" value="'+displayColor+'" '+(isHidden?'disabled':'')+'><button type="button" class="gte-reset-btn" title="Reset to default">\u21bb</button></div>';
                const colorInput = row.querySelector('.gte-color-input'), hexDisplay = row.querySelector('.gte-hex-display'), visBtn = row.querySelector('.gte-vis-btn'), resetBtn = row.querySelector('.gte-reset-btn');
                colorInput.addEventListener('input', e => { for (const k of keys) curOverrides[k] = e.target.value; hexDisplay.textContent = e.target.value; scheduleLivePreview(); });
                visBtn.addEventListener('click', () => {
                    const nowHidden = curOverrides[firstKey] === 'transparent';
                    if (nowHidden) { const restored = colorInput.value; for (const k of keys) curOverrides[k] = restored; hexDisplay.textContent = restored; hexDisplay.classList.remove('gte-hidden-color'); colorInput.disabled = false; visBtn.textContent = '\ud83d\udc41\ufe0f'; visBtn.classList.remove('gte-layer-hidden'); }
                    else { for (const k of keys) curOverrides[k] = 'transparent'; hexDisplay.textContent = 'hidden'; hexDisplay.classList.add('gte-hidden-color'); colorInput.disabled = true; visBtn.textContent = '\ud83d\udeab'; visBtn.classList.add('gte-layer-hidden'); }
                    scheduleLivePreview();
                });
                resetBtn.addEventListener('click', () => { const def = getDefaultColor(firstKey, curBase); for (const k of keys) curOverrides[k] = def; colorInput.value = def; hexDisplay.textContent = def; hexDisplay.classList.remove('gte-hidden-color'); colorInput.disabled = false; visBtn.textContent = '\ud83d\udc41\ufe0f'; visBtn.classList.remove('gte-layer-hidden'); scheduleLivePreview(); });
                panel.appendChild(row);
            }
        }

        // Action buttons
        const btnRow = document.createElement('div'); btnRow.className = 'gte-btn-row';
        btnRow.innerHTML = '<button class="gte-btn gte-btn-primary" id="gte-save-apply">Save &amp; Apply</button><button class="gte-btn gte-btn-secondary" id="gte-load-current">Load Current</button><button class="gte-btn gte-btn-secondary" id="gte-export-json">Export JSON</button><button class="gte-btn gte-btn-secondary" id="gte-import-json-btn">Import JSON</button><input type="file" id="gte-import-json-file" accept=".json" style="display:none">';
        panel.appendChild(btnRow);

        panel.querySelector('#gte-save-apply').addEventListener('click', async () => {
            const nameInput = document.getElementById('gte-theme-name'); const name = nameInput.value.trim();
            if (!name) { nameInput.style.borderColor = '#f38ba8'; nameInput.focus(); setTimeout(() => nameInput.style.borderColor = '', 1500); return; }
            const themes = teLoadThemes(); themes[name] = { overrides: { ...curOverrides }, base: curBase, createdAt: themes[name]?.createdAt || Date.now(), updatedAt: Date.now() };
            teSaveThemes(themes); teSetActive(name); curEditName = name;
            const base = await getBaseStyle(curBase); if (!base) return;
            const style = buildStyle(base, curOverrides); applyStyleToMap(style, curBase); persistTheme(style);
            teShowToast('Theme "'+name+'" saved & applied!');
        });
        panel.querySelector('#gte-load-current').addEventListener('click', async () => {
            const map = teGetMap(); if (!map) return teShowToast('Map not ready.', true);
            const style = map.getStyle(); if (!style) return teShowToast('No style loaded.', true);
            renderEditor(readColorsFromStyle(style, curBase), curEditName, curBase); teShowToast('Loaded colors from current map.');
        });
        panel.querySelector('#gte-export-json').addEventListener('click', async () => {
            const base = await getBaseStyle(curBase); if (!base) return;
            const style = buildStyle(base, curOverrides); const name = document.getElementById('gte-theme-name').value.trim() || 'custom_theme'; style.name = name;
            const blob = new Blob([JSON.stringify(style, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name.replace(/[^a-z0-9_-]/gi, '_')+'.json'; a.click(); URL.revokeObjectURL(a.href);
        });
        panel.querySelector('#gte-import-json-btn').addEventListener('click', () => panel.querySelector('#gte-import-json-file').click());
        panel.querySelector('#gte-import-json-file').addEventListener('change', async e => {
            const file = e.target.files[0]; if (!file) return;
            try { const text = await file.text(); const style = JSON.parse(text); if (!style.layers || !Array.isArray(style.layers)) return teShowToast('Invalid theme JSON.', true);
                const overrides = readAllColorsFromStyle(style); const name = style.name || file.name.replace(/\.json$/i, '');
                const bg = overrides['background::background-color']; const detectedBase = bg ? (isColorDark(bg) ? 'dark' : 'light') : 'dark';
                renderEditor(overrides, name, detectedBase); teShowToast('Imported "'+name+'" — adjust and Save & Apply.');
            } catch { teShowToast('Failed to parse JSON file.', true); }
            e.target.value = '';
        });
    }

    // ─── Manager Panel ───────────────────────────────────────────────
    function renderManager() {
        const panel = document.getElementById('gte-panel-manager');
        const themes = teLoadThemes(); const activeTheme = teGetActive();
        const names = Object.keys(themes).sort((a, b) => {
            const pa = a === 'Default' ? 0 : a === 'Default Dark' ? 1 : 2;
            const pb = b === 'Default' ? 0 : b === 'Default Dark' ? 1 : 2;
            return pa !== pb ? pa - pb : a.localeCompare(b);
        });
        let html = '';
        if (names.length === 0) { html = '<div class="gte-empty-msg">No saved themes yet.<br>Use the Editor tab to create one!</div>'; }
        else { for (const name of names) { const theme = themes[name]; const isActive = name === activeTheme; const isBundled = theme.bundled; const isLight = (theme.base||'dark') === 'light';
            html += '<div class="gte-theme-card '+(isActive?'gte-active-theme':'')+'"><span class="gte-theme-card-name" title="'+teEscAttr(name)+'"><span class="gte-theme-name-text">'+teEscHTML(name)+(isActive?' \u2713':'')+(isBundled?' \ud83d\udccc':'')+'</span><span class="gte-theme-badge '+(isLight?'gte-theme-badge-light':'gte-theme-badge-dark')+'">'+(isLight?'Light':'Dark')+'</span></span><div class="gte-theme-card-actions"><button class="gte-btn gte-btn-primary gte-btn-sm" data-action="apply" data-name="'+teEscAttr(name)+'">Apply</button><button class="gte-btn gte-btn-secondary gte-btn-sm" data-action="edit" data-name="'+teEscAttr(name)+'">Edit</button>'+(!isBundled?'<button class="gte-btn gte-btn-danger gte-btn-sm" data-action="delete" data-name="'+teEscAttr(name)+'">Delete</button>':'<span class="gte-btn gte-btn-danger gte-btn-sm" style="opacity:0.5;cursor:not-allowed;" title="Built-in">Delete</span>')+'</div></div>'; } }
        html += '<div class="gte-io-section"><button class="gte-btn gte-btn-secondary" id="gte-restore-default" style="width:100%">Restore Default Theme</button></div>';
        panel.innerHTML = html;
        panel.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async () => {
            const action = btn.dataset.action, name = btn.dataset.name;
            if (action === 'apply') await teApplyByName(name);
            if (action === 'edit') teEditTheme(name);
            if (action === 'delete') teDeleteTheme(name);
        }));
        const restoreBtn = panel.querySelector('#gte-restore-default');
        if (restoreBtn) restoreBtn.addEventListener('click', async () => {
            teSetActive(''); localStorage.removeItem('customTheme');
            const uc = teGetUserConfig(); if (uc) uc.theme = 'default'; localStorage.setItem('userConfig', JSON.stringify(uc));
            try { const base = await getBaseStyle('light'); if (base) { const map = teGetMap(); if (map) map.setStyle(base); } } catch {}
            renderManager(); teShowToast('Restored default theme.');
        });
    }

    async function teApplyByName(name) {
        const themes = teLoadThemes(); const theme = themes[name]; if (!theme) return;
        const base = await getBaseStyle(theme.base || 'dark'); if (!base) return;
        const style = buildStyle(base, theme.overrides); applyStyleToMap(style, theme.base || 'dark');
        teSetActive(name); persistTheme(style); renderManager(); teShowToast('Applied "'+name+'".');
    }

    function teEditTheme(name) {
        const themes = teLoadThemes(); const theme = themes[name]; if (!theme) return;
        teModal.querySelectorAll('.gte-tab').forEach(t => t.classList.remove('gte-active'));
        teModal.querySelectorAll('.gte-panel').forEach(p => p.classList.remove('gte-active'));
        teModal.querySelector('[data-panel="editor"]').classList.add('gte-active');
        teModal.querySelector('#gte-panel-editor').classList.add('gte-active');
        renderEditor(theme.overrides, name, theme.base || 'dark');
    }

    function teDeleteTheme(name) {
        const themes = teLoadThemes(); if (themes[name]?.bundled) { teShowToast('Cannot delete built-in themes.', true); return; }
        if (!confirm('Delete theme "'+name+'"?')) return;
        delete themes[name]; teSaveThemes(themes); if (teGetActive() === name) teSetActive(''); renderManager(); teShowToast('Deleted "'+name+'".');
    }

    // ─── Init ────────────────────────────────────────────────────────
    teInjectCSS();
    teModal = teBuildModal();
    renderEditor({}, '', 'dark');

    // Expose API for dropdown flyout
    _themeEditor = {
        loadThemes: teLoadThemes,
        getActiveThemeName: teGetActive,
        applyThemeByName: teApplyByName,
        toggleModal: () => {
            const isHidden = teModal.classList.contains('gte-hidden');
            if (isHidden) { teModal.classList.remove('gte-hidden'); if (!document.getElementById('gte-theme-name')) renderEditor({}, ''); }
            else teModal.classList.add('gte-hidden');
        }
    };

    // Re-apply active theme on load
    (async () => {
        let tries = 0;
        while (!teGetMap() && tries < 60) { await new Promise(r => setTimeout(r, 500)); tries++; }
        const activeName = teGetActive();
        if (activeName) {
            const themes = teLoadThemes();
            if (themes[activeName]) {
                const themeBase = themes[activeName].base || 'dark';
                const base = await getBaseStyle(themeBase);
                if (base) { const style = buildStyle(base, themes[activeName].overrides); applyStyleToMap(style, themeBase); persistTheme(style); }
            }
        }
    })();

            })();
            _featureStatus.themeEditor = 'ok';
            console.log('[GeoPixelcons++] \u2705 Theme Editor loaded');
        } catch (err) {
            _featureStatus.themeEditor = 'error';
            dbgPush(`Theme Editor init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Theme Editor' });
            console.error('[GeoPixelcons++] \u274c Theme Editor failed:', err);
        }
    }

    // ============================================================
    //  MAP MARKERS
    // ============================================================
    if (_settings.mapMarkers) {
        try {
            (() => {
                const MM_META_KEY = 'gpc_mapMarkers_v1';
                const MM_OLD_IMG_KEY = (id) => 'gpc_mmImg_' + id; // legacy GM key (migration only)
                const HANDLE_R    = 6; // fixed px radius — consistent at all zoom levels

                let markers  = [];
                const imgCache    = new Map(); // id → HTMLImageElement (for image-type markers)
                const dataUrlCache = new Map(); // id → dataUrl (GIF markers only)
                // ── Canvas (image markers) ───────────────────────────────
                const mmCanvas = document.createElement('canvas');
                mmCanvas.id = 'gpc-markers-canvas';
                mmCanvas.className = 'inset-0 absolute pointer-events-none';
                document.body.appendChild(mmCanvas);

                // ── DOM overlay (GIF markers) ────────────────────────────
                const overlayEls = new Map(); // id → HTMLImageElement
                const mmOverlay = document.createElement('div');
                mmOverlay.id = 'gpc-markers-overlay';
                Object.assign(mmOverlay.style, {
                    position: 'absolute', inset: '0', pointerEvents: 'none',
                    overflow: 'hidden', zIndex: '2',
                });
                // Append after map container is available; done in waitForMapMM callback below

                let mmModal    = null;
                let placingId  = null; // id of marker currently being placed
                let editingId  = null; // id of marker currently being edited
                let _drag      = null; // active drag state
                let mmCompact  = true; // compact card view toggle
                const expandedCards = new Set(); // marker ids expanded in compact view
                let listDragSrc = null; // drag-to-sort source index

                // ── IndexedDB storage for image/media data ────────────────
                // Replaces GM_setValue for image blobs to stay below the 64 MiB
                // Chrome extension message-passing limit.
                const MM_IDB_NAME  = 'gpc_mapMarkers_imgs';
                const MM_IDB_STORE = 'images';
                let _mmDb = null;

                function openMmDb() {
                    if (_mmDb) return Promise.resolve(_mmDb);
                    return new Promise((resolve, reject) => {
                        const req = indexedDB.open(MM_IDB_NAME, 1);
                        req.onupgradeneeded = (e) => {
                            e.target.result.createObjectStore(MM_IDB_STORE);
                        };
                        req.onsuccess = (e) => { _mmDb = e.target.result; resolve(_mmDb); };
                        req.onerror   = (e) => reject(e.target.error);
                    });
                }
                async function mmDbGet(id) {
                    const db = await openMmDb();
                    return new Promise((resolve) => {
                        const req = db.transaction(MM_IDB_STORE, 'readonly').objectStore(MM_IDB_STORE).get(id);
                        req.onsuccess = () => resolve(req.result || null);
                        req.onerror   = () => resolve(null);
                    });
                }
                async function mmDbSet(id, dataUrl) {
                    const db = await openMmDb();
                    return new Promise((resolve, reject) => {
                        const req = db.transaction(MM_IDB_STORE, 'readwrite').objectStore(MM_IDB_STORE).put(dataUrl, id);
                        req.onsuccess = () => resolve();
                        req.onerror   = (e) => reject(e.target.error);
                    });
                }
                async function mmDbDelete(id) {
                    const db = await openMmDb();
                    return new Promise((resolve) => {
                        const tx = db.transaction(MM_IDB_STORE, 'readwrite');
                        tx.objectStore(MM_IDB_STORE).delete(id);
                        tx.oncomplete = () => resolve();
                        tx.onerror    = () => resolve();
                    });
                }

                // ── Metadata (small JSON — stays in GM_setValue) ──────────
                function loadMeta() {
                    try { markers = JSON.parse(GM_getValue(MM_META_KEY, '[]')); }
                    catch { markers = []; }
                }
                function saveMeta() { GM_setValue(MM_META_KEY, JSON.stringify(markers)); }

                function loadImg(id, dataUrl) {
                    return new Promise(resolve => {
                        const img = new Image();
                        img.onload  = () => { imgCache.set(id, img); resolve(img); };
                        img.onerror = () => resolve(null);
                        img.src = dataUrl;
                    });
                }

                // ── Migration: move old GM_setValue image data → IndexedDB ─
                async function migrateOldGmKeys() {
                    let migrated = 0;
                    for (const m of markers) {
                        const oldVal = GM_getValue(MM_OLD_IMG_KEY(m.id), null);
                        if (oldVal) {
                            try {
                                await mmDbSet(m.id, oldVal);
                                try { GM_deleteValue(MM_OLD_IMG_KEY(m.id)); } catch { GM_setValue(MM_OLD_IMG_KEY(m.id), ''); }
                                migrated++;
                            } catch (err) {
                                console.warn('[GeoPixelcons++] Map Markers: migration failed for', m.id, err);
                            }
                        }
                    }
                    if (migrated > 0) console.log(`[GeoPixelcons++] Map Markers: migrated ${migrated} image(s) from GM_setValue to IndexedDB`);
                }

                async function preloadAll() {
                    await migrateOldGmKeys();
                    for (const m of markers) {
                        if (isGifType(m)) {
                            if (!dataUrlCache.has(m.id)) {
                                const d = await mmDbGet(m.id);
                                if (d) dataUrlCache.set(m.id, d);
                            }
                            // Also load into imgCache so naturalWidth/naturalHeight
                            // are available for aspect-ratio calculations
                            if (!imgCache.has(m.id)) {
                                const d = dataUrlCache.get(m.id) || await mmDbGet(m.id);
                                if (d) await loadImg(m.id, d);
                            }
                        } else if (!imgCache.has(m.id)) {
                            const d = await mmDbGet(m.id);
                            if (d) await loadImg(m.id, d);
                        }
                    }
                }

                // ── Media type helpers ────────────────────────────────────
                function isImageType(m) { return !m.mediaType || m.mediaType === 'image'; }
                function isGifType(m)   { return m.mediaType === 'gif'; }

                // ── Grid helpers ──────────────────────────────────────────
                function getGSize() {
                    const w = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
                    return w.gridSize || (typeof gridSize !== 'undefined' ? gridSize : 25);
                }

                // SW-corner grid rect → screen rect { x, y, w, h }
                // Follows the same half-cell offset convention as geo++ censors.
                function markerScreenRect(m) {
                    const g = getGSize();
                    const nwMerc = [(m.gridX - 0.5) * g, (m.gridY - 0.5 + m.gridH) * g];
                    const seMerc = [(m.gridX - 0.5 + m.gridW) * g, (m.gridY - 0.5) * g];
                    const nw = map.project(turf.toWgs84(nwMerc));
                    const se = map.project(turf.toWgs84(seMerc));
                    return { x: nw.x, y: nw.y, w: se.x - nw.x, h: se.y - nw.y };
                }

                // CSS-pixel variant used exclusively for DOM overlay positioning.
                // map.project() returns CSS pixels relative to the map container — use directly.
                function markerCssRect(m) {
                    const g = getGSize();
                    const nwMerc = [(m.gridX - 0.5) * g, (m.gridY - 0.5 + m.gridH) * g];
                    const seMerc = [(m.gridX - 0.5 + m.gridW) * g, (m.gridY - 0.5) * g];
                    const nw = map.project(turf.toWgs84(nwMerc));
                    const se = map.project(turf.toWgs84(seMerc));
                    return { x: nw.x, y: nw.y, w: se.x - nw.x, h: se.y - nw.y };
                }

                function handlePositions(r) {
                    return {
                        nw: { x: r.x,          y: r.y          },
                        n:  { x: r.x + r.w / 2, y: r.y          },
                        ne: { x: r.x + r.w,     y: r.y          },
                        e:  { x: r.x + r.w,     y: r.y + r.h / 2 },
                        se: { x: r.x + r.w,     y: r.y + r.h   },
                        s:  { x: r.x + r.w / 2, y: r.y + r.h   },
                        sw: { x: r.x,           y: r.y + r.h   },
                        w:  { x: r.x,           y: r.y + r.h / 2 },
                    };
                }

                function screenToGrid(cx, cy) {
                    const g  = getGSize();
                    const br = mmCanvas.getBoundingClientRect();
                    const ll = map.unproject([cx - br.left, cy - br.top]);
                    const mc = turf.toMercator([ll.lng, ll.lat]);
                    return { gridX: Math.round(mc[0] / g), gridY: Math.round(mc[1] / g) };
                }

                // ── DOM overlay helpers (GIF markers) ─────────────────────
                function updateOverlayEl(m) {
                    if (!isGifType(m)) return;
                    let el = overlayEls.get(m.id);
                    if (!m.visible || m.gridX == null) {
                        if (el) el.style.display = 'none';
                        return;
                    }
                    if (!el) {
                        el = document.createElement('img');
                        Object.assign(el.style, { position: 'absolute', display: 'block', objectFit: 'fill',
                            maxWidth: 'none', maxHeight: 'none', imageRendering: 'pixelated' });
                        overlayEls.set(m.id, el);
                        mmOverlay.appendChild(el);
                    }
                    // Always sync src in case dataUrlCache was populated after element creation
                    const src = dataUrlCache.get(m.id) || '';
                    if (el.src !== src) el.src = src;
                    if (!src) { el.style.display = 'none'; return; }
                    const r = markerCssRect(m);
                    el.style.display = (r.w > 0 && r.h > 0) ? 'block' : 'none';
                    el.style.left    = r.x + 'px';
                    el.style.top     = r.y + 'px';
                    el.style.width   = r.w + 'px';
                    el.style.height  = r.h + 'px';
                    el.style.opacity = m.opacity ?? 1;
                }

                function updateAllOverlayEls() {
                    for (const m of markers) updateOverlayEl(m);
                }

                function removeOverlayEl(id) {
                    const el = overlayEls.get(id);
                    if (el) { el.remove(); overlayEls.delete(id); }
                    dataUrlCache.delete(id);
                }

                // ── Drawing ───────────────────────────────────────────────
                function redraw() {
                    const pix = document.getElementById('pixel-canvas');
                    if (!pix) return;
                    if (typeof map === 'undefined' || !map || typeof map.project !== 'function') return;
                    mmCanvas.width  = pix.width;
                    mmCanvas.height = pix.height;
                    const ctx = mmCanvas.getContext('2d');
                    ctx.imageSmoothingEnabled = false;
                    ctx.clearRect(0, 0, mmCanvas.width, mmCanvas.height);

                    for (const m of markers) {
                        if (!m.visible || m.gridX == null) continue;
                        if (!isImageType(m)) continue; // gif/video use DOM overlay
                        const img = imgCache.get(m.id);
                        if (!img || !img.complete) continue;
                        const r = markerScreenRect(m);
                        if (r.w <= 0 || r.h <= 0) continue;
                        if (r.x + r.w < 0 || r.x > mmCanvas.width ||
                            r.y + r.h < 0 || r.y > mmCanvas.height) continue;
                        ctx.globalAlpha = m.opacity ?? 1;
                        ctx.drawImage(img, r.x, r.y, r.w, r.h);
                        ctx.globalAlpha = 1;
                        if (editingId === m.id) drawHandles(ctx, r);
                    }

                    // Edit handles for non-image markers (still use canvas overlay)
                    if (editingId) {
                        const em = markers.find(x => x.id === editingId);
                        if (em && em.gridX != null && !isImageType(em)) drawHandles(ctx, markerScreenRect(em));
                    }

                    // Placement preview
                    if (placingId && _drag && _drag.preview) {
                        const p   = _drag.preview;
                        const pm  = markers.find(m => m.id === placingId);
                        const pi  = pm && isImageType(pm) && imgCache.get(pm.id);
                        if (pi) { ctx.globalAlpha = 0.55; ctx.drawImage(pi, p.x, p.y, p.w, p.h); ctx.globalAlpha = 1; }
                        ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2;
                        ctx.setLineDash([6, 3]); ctx.strokeRect(p.x, p.y, p.w, p.h); ctx.setLineDash([]);
                    }

                    updateAllOverlayEls();
                }

                function drawHandles(ctx, r) {
                    ctx.save();
                    ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 1.5;
                    ctx.setLineDash([5, 3]); ctx.strokeRect(r.x, r.y, r.w, r.h); ctx.setLineDash([]);
                    const hp = handlePositions(r);
                    for (const pos of Object.values(hp)) {
                        ctx.fillStyle   = '#fff';
                        ctx.strokeStyle = '#3b82f6';
                        ctx.lineWidth   = 2;
                        ctx.beginPath();
                        ctx.arc(pos.x, pos.y, HANDLE_R, 0, Math.PI * 2);
                        ctx.fill(); ctx.stroke();
                    }
                    ctx.restore();
                }

                // ── Map wiring ────────────────────────────────────────────
                function waitForMapMM(cb) {
                    let t = 0;
                    function chk() {
                        if (typeof map !== 'undefined' && map && map.on) cb();
                        else if (t++ < 200) setTimeout(chk, 100);
                    }
                    chk();
                }
                waitForMapMM(() => {
                    map.getContainer().appendChild(mmOverlay);
                    ['move', 'rotate', 'zoom'].forEach(ev => map.on(ev, redraw));
                    new ResizeObserver(redraw).observe(map.getContainer());
                    map.once('load', redraw);
                    redraw();
                });

                // ── Mode helpers ──────────────────────────────────────────
                function enterPlace(id) {
                    exitEdit();
                    placingId = id;
                    mmCanvas.style.pointerEvents = 'auto';
                    mmCanvas.style.cursor = 'crosshair';
                    mmNotify('Drag to define image size. Hold Shift to lock aspect ratio.');
                }
                function exitPlace() {
                    placingId = null; _drag = null;
                    mmCanvas.style.pointerEvents = 'none'; mmCanvas.style.cursor = '';
                    redraw();
                }
                function enterEdit(id) {
                    exitPlace();
                    editingId = id;
                    mmCanvas.style.pointerEvents = 'auto'; mmCanvas.style.cursor = 'default';
                    redraw();
                }
                function exitEdit() {
                    editingId = null; _drag = null;
                    mmCanvas.style.pointerEvents = 'none'; mmCanvas.style.cursor = '';
                    redraw();
                    refreshModal(); // sync card buttons ("Done" → "Edit")
                }

                // ── Canvas events ─────────────────────────────────────────
                mmCanvas.addEventListener('wheel', e =>
                    map.getCanvas().dispatchEvent(new WheelEvent(e.type, e)));

                mmCanvas.addEventListener('mousedown', e => {
                    if (e.button !== 0) return;
                    e.preventDefault(); e.stopPropagation();

                    if (placingId) {
                        _drag = { type: 'place', startGrid: screenToGrid(e.clientX, e.clientY), preview: null };
                        return;
                    }
                    if (editingId) {
                        const m = markers.find(x => x.id === editingId);
                        if (!m || m.gridX == null) return;
                        const br  = mmCanvas.getBoundingClientRect();
                        const sx  = e.clientX - br.left, sy = e.clientY - br.top;
                        const hp  = handlePositions(markerScreenRect(m));
                        let hit   = null;
                        for (const [name, pos] of Object.entries(hp)) {
                            const dx = sx - pos.x, dy = sy - pos.y;
                            if (Math.sqrt(dx * dx + dy * dy) <= HANDLE_R + 3) { hit = name; break; }
                        }
                        if (hit) {
                            _drag = { type: 'handle', handle: hit, markerId: editingId,
                                      startGrid: screenToGrid(e.clientX, e.clientY), orig: { ...m } };
                        } else {
                            // Inside the image body → move drag; outside → exit edit
                            const r = markerScreenRect(m);
                            if (sx >= r.x && sx <= r.x + r.w && sy >= r.y && sy <= r.y + r.h) {
                                mmCanvas.style.cursor = 'grabbing';
                                _drag = { type: 'move', markerId: editingId,
                                          startGrid: screenToGrid(e.clientX, e.clientY),
                                          origPos: { gridX: m.gridX, gridY: m.gridY } };
                            } else {
                                exitEdit();
                            }
                        }
                    }
                });

                document.addEventListener('mousemove', e => {
                    // Cursor feedback in edit mode when no drag is active
                    if (!_drag && editingId) {
                        const br = mmCanvas.getBoundingClientRect();
                        const sx = e.clientX - br.left, sy = e.clientY - br.top;
                        if (sx >= 0 && sy >= 0 && sx <= mmCanvas.width && sy <= mmCanvas.height) {
                            const m = markers.find(x => x.id === editingId);
                            if (m && m.gridX != null) {
                                const hp = handlePositions(markerScreenRect(m));
                                let onHandle = false;
                                for (const pos of Object.values(hp)) {
                                    if (Math.hypot(sx - pos.x, sy - pos.y) <= HANDLE_R + 3) { onHandle = true; break; }
                                }
                                if (onHandle) {
                                    mmCanvas.style.cursor = 'nwse-resize';
                                } else {
                                    const r = markerScreenRect(m);
                                    mmCanvas.style.cursor = (sx >= r.x && sx <= r.x + r.w && sy >= r.y && sy <= r.y + r.h) ? 'grab' : 'default';
                                }
                            }
                        }
                    }

                    if (!_drag) return;

                    if (_drag.type === 'place' && placingId) {
                        const cur = screenToGrid(e.clientX, e.clientY);
                        const sg  = _drag.startGrid;
                        const m   = markers.find(x => x.id === placingId);
                        const img = m && imgCache.get(m.id);
                        let gW = Math.abs(cur.gridX - sg.gridX) + 1;
                        let gH = Math.abs(cur.gridY - sg.gridY) + 1;
                        if ((e.shiftKey || (m && m.lockAspect)) && img) {
                            const ar = img.naturalWidth / img.naturalHeight;
                            if (gW / gH > ar) gH = Math.max(1, Math.round(gW / ar));
                            else              gW = Math.max(1, Math.round(gH * ar));
                        }
                        const swX = Math.min(sg.gridX, cur.gridX);
                        const swY = Math.min(sg.gridY, cur.gridY);
                        const g   = getGSize();
                        const nwS = map.project(turf.toWgs84([(swX - 0.5) * g, (swY - 0.5 + gH) * g]));
                        const seS = map.project(turf.toWgs84([(swX - 0.5 + gW) * g, (swY - 0.5) * g]));
                        _drag.preview = { x: nwS.x, y: nwS.y, w: seS.x - nwS.x, h: seS.y - nwS.y };
                        _drag.pending = { gridX: swX, gridY: swY, gridW: gW, gridH: gH };
                        redraw();

                    } else if (_drag.type === 'handle') {
                        const m = markers.find(x => x.id === _drag.markerId);
                        if (!m) return;
                        applyHandleDrag(m, _drag.handle, screenToGrid(e.clientX, e.clientY),
                                        e.shiftKey, _drag.orig, imgCache.get(m.id));
                        redraw();
                    } else if (_drag.type === 'move') {
                        const m = markers.find(x => x.id === _drag.markerId);
                        if (!m) return;
                        const cur = screenToGrid(e.clientX, e.clientY);
                        m.gridX = _drag.origPos.gridX + (cur.gridX - _drag.startGrid.gridX);
                        m.gridY = _drag.origPos.gridY + (cur.gridY - _drag.startGrid.gridY);
                        redraw();
                    }
                });

                document.addEventListener('mouseup', e => {
                    if (!_drag) return;
                    if (_drag.type === 'place' && placingId) {
                        if (!_drag.pending) {
                            mmNotify('Please drag to define the image size.', true);
                            _drag = null; redraw();
                        } else {
                            const m = markers.find(x => x.id === placingId);
                            if (m) { Object.assign(m, _drag.pending); saveMeta(); }
                            const placedId = placingId;
                            exitPlace();
                            enterEdit(placedId); // auto-enter edit after placing
                            refreshModal();
                            mmNotify('Placed! Drag to move, handles to resize.');
                        }
                    } else if (_drag && _drag.type === 'handle') {
                        saveMeta(); refreshModal(); _drag = null;
                    } else if (_drag && _drag.type === 'move') {
                        mmCanvas.style.cursor = 'grab';
                        saveMeta(); refreshModal(); _drag = null;
                    }
                });

                // ── Handle drag math ──────────────────────────────────────
                // Absolute-cursor approach: the dragged edge/corner snaps to cursor grid position.
                // The opposite anchor (corner or edge) stays fixed from orig.
                // In grid coords: gridX=west, gridX+gridW=east, gridY=south, gridY+gridH=north.
                // Screen: NW=top-left, SE=bottom-right (Y increases southward on screen).
                function applyHandleDrag(m, handle, cur, shiftKey, orig, img) {
                    const ar   = img ? img.naturalWidth / img.naturalHeight : null;
                    const lock = shiftKey || m.lockAspect;
                    const E    = orig.gridX + orig.gridW; // fixed east edge
                    const N    = orig.gridY + orig.gridH; // fixed north edge
                    let nx = orig.gridX, ny = orig.gridY, nw = orig.gridW, nh = orig.gridH;

                    switch (handle) {
                        // Corners: both axes change; opposite corner fixed
                        case 'nw': // screen top-left → grid (west, north)
                            nx = cur.gridX; nw = E - cur.gridX; nh = cur.gridY - orig.gridY;
                            if (lock && ar) { if (nw / nh > ar) { nh = nw / ar; } else { nw = nh * ar; nx = E - nw; } }
                            break;
                        case 'ne': // screen top-right → grid (east, north)
                            nw = cur.gridX - orig.gridX; nh = cur.gridY - orig.gridY;
                            if (lock && ar) { if (nw / nh > ar) nh = nw / ar; else nw = nh * ar; }
                            break;
                        case 'sw': // screen bottom-left → grid (west, south)
                            nx = cur.gridX; nw = E - cur.gridX; ny = cur.gridY; nh = N - cur.gridY;
                            if (lock && ar) { if (nw / nh > ar) { nh = nw / ar; ny = N - nh; } else { nw = nh * ar; nx = E - nw; } }
                            break;
                        case 'se': // screen bottom-right → grid (east, south)
                            nw = cur.gridX - orig.gridX; ny = cur.gridY; nh = N - cur.gridY;
                            if (lock && ar) { if (nw / nh > ar) { nh = nw / ar; ny = N - nh; } else nw = nh * ar; }
                            break;
                        // Edges: single axis only, no aspect ratio applied
                        case 'n': nh = cur.gridY - orig.gridY; break;
                        case 's': ny = cur.gridY; nh = N - cur.gridY; break;
                        case 'e': nw = cur.gridX - orig.gridX; break;
                        case 'w': nx = cur.gridX; nw = E - cur.gridX; break;
                    }
                    if (nw < 1) nw = 1; if (nh < 1) nh = 1;
                    m.gridX = Math.round(nx); m.gridY = Math.round(ny);
                    m.gridW = Math.round(nw); m.gridH = Math.round(nh);
                }

                // ── Modal ─────────────────────────────────────────────────
                function openModal() {
                    if (mmModal) {
                        mmModal.style.display = mmModal.style.display === 'none' ? 'flex' : 'none';
                        return;
                    }
                    mmModal = document.createElement('div');
                    Object.assign(mmModal.style, {
                        position: 'fixed', top: '60px', left: '60px', zIndex: '10000',
                        background: 'var(--color-white,#fff)', borderRadius: '12px',
                        boxShadow: '0 8px 32px rgba(0,0,0,.2)',
                        display: 'flex', flexDirection: 'column', width: '330px', maxHeight: '82vh',
                        userSelect: 'none', overflow: 'hidden',
                    });

                    // Header / drag handle
                    const hdr = document.createElement('div');
                    Object.assign(hdr.style, {
                        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                        padding: '11px 14px', background: 'var(--color-gray-50,#f9fafb)',
                        borderBottom: '1px solid var(--color-gray-200,#e5e7eb)',
                        borderRadius: '12px 12px 0 0', cursor: 'grab', flexShrink: '0',
                    });
                    hdr.innerHTML = '<span style="font-weight:700;font-size:13px;color:var(--color-gray-800,#1f2937);">\ud83d\udccc Map Markers</span>';
                    // Compact toggle
                    const compactBtn = document.createElement('button');
                    compactBtn.title = 'Toggle compact view';
                    compactBtn.textContent = mmCompact ? '\u25a1' : '\u2630';
                    Object.assign(compactBtn.style, { background: 'none', border: 'none', cursor: 'pointer',
                        fontSize: '13px', color: 'var(--color-gray-500,#6b7280)', padding: '1px 5px', borderRadius: '4px',
                        marginLeft: 'auto', marginRight: '4px' });
                    compactBtn.onclick = () => {
                        mmCompact = !mmCompact;
                        compactBtn.textContent = mmCompact ? '\u25a1' : '\u2630';
                        refreshModal();
                    };
                    hdr.appendChild(compactBtn);
                    const xBtn = document.createElement('button');
                    xBtn.textContent = '\u2715';
                    Object.assign(xBtn.style, { background: 'none', border: 'none', cursor: 'pointer',
                        fontSize: '13px', color: 'var(--color-gray-500,#6b7280)', padding: '1px 5px', borderRadius: '4px' });
                    xBtn.onclick = () => { mmModal.style.display = 'none'; exitPlace(); exitEdit(); };
                    hdr.appendChild(xBtn);
                    mmModal.appendChild(hdr);

                    // Draggable header
                    let dragOff = null;
                    hdr.addEventListener('mousedown', e => {
                        if (e.target === xBtn || e.target === compactBtn) return;
                        dragOff = { x: e.clientX - mmModal.offsetLeft, y: e.clientY - mmModal.offsetTop };
                        hdr.style.cursor = 'grabbing';
                    });
                    document.addEventListener('mousemove', e => {
                        if (!dragOff) return;
                        mmModal.style.left = (e.clientX - dragOff.x) + 'px';
                        mmModal.style.top  = (e.clientY - dragOff.y) + 'px';
                    });
                    document.addEventListener('mouseup', () => { dragOff = null; hdr.style.cursor = 'grab'; });

                    // ── Shared helpers: register a new marker and enter place mode
                    async function addMarkerFromDataUrl(dataUrl, name, mediaType) {
                        const mt  = mediaType || 'image';
                        const id  = 'mm_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
                        let ar = 1;
                        if (mt === 'gif') {
                            // Use a temporary img to read first-frame dimensions
                            const img = await loadImg(id, dataUrl);
                            ar = img ? img.naturalWidth / img.naturalHeight : 1;
                            dataUrlCache.set(id, dataUrl);
                        } else {
                            const img = await loadImg(id, dataUrl);
                            ar = img ? img.naturalWidth / img.naturalHeight : 1;
                        }
                        const dW = 50;
                        markers.push({ id, name, mediaType: mt,
                            gridX: null, gridY: null, gridW: dW,
                            gridH: Math.max(1, Math.round(dW / ar)),
                            visible: true, opacity: 1, lockAspect: true });
                        await mmDbSet(id, dataUrl);
                        saveMeta();
                        enterPlace(id);
                        refreshModal();
                    }

                    // Add media row — file upload + image URL + video URL
                    const addRow = document.createElement('div');
                    Object.assign(addRow.style, { padding: '9px 14px', display: 'flex', gap: '6px', flexWrap: 'wrap',
                        borderBottom: '1px solid var(--color-gray-100,#f3f4f6)', flexShrink: '0' });
                    function mkAddBtn(label, bg) {
                        const b = document.createElement('button');
                        b.textContent = label;
                        Object.assign(b.style, { flex: '1 1 40%', padding: '7px', background: bg,
                            color: '#fff', border: 'none', borderRadius: '7px', cursor: 'pointer',
                            fontWeight: '700', fontSize: '12px' });
                        return b;
                    }
                    const addBtn = mkAddBtn('+ Image / GIF', '#3b82f6');
                    const fileInput = document.createElement('input');
                    fileInput.type = 'file';
                    fileInput.accept = 'image/png,image/jpeg,image/webp,image/gif';
                    fileInput.style.display = 'none';
                    fileInput.addEventListener('change', async () => {
                        const file = fileInput.files[0];
                        if (!file) return;
                        const dataUrl = await new Promise(res => {
                            const r = new FileReader(); r.onload = ev => res(ev.target.result); r.readAsDataURL(file);
                        });
                        const isGif = file.type === 'image/gif';
                        await addMarkerFromDataUrl(dataUrl, file.name.replace(/\.[^/.]+$/, ''), isGif ? 'gif' : 'image');
                        fileInput.value = '';
                    });
                    addBtn.onclick = () => fileInput.click();

                    const urlBtn = mkAddBtn('\ud83d\udd17 Image URL', '#8b5cf6');
                    urlBtn.onclick = () => {
                        const url = prompt('Enter image URL:');
                        if (!url || !url.trim()) return;
                        mmNotify('Loading\u2026');
                        GM_xmlhttpRequest({
                            method: 'GET', url: url.trim(), responseType: 'blob',
                            onload: (resp) => {
                                const mime = resp.response.type;
                                if (!mime.startsWith('image/')) {
                                    mmNotify('URL does not point to an image.', true);
                                    return;
                                }
                                const isGif = mime === 'image/gif';
                                const reader = new FileReader();
                                reader.onload = async (ev) => {
                                    const name = url.split('/').pop().replace(/\?.*$/, '').replace(/\.[^/.]+$/, '') || 'image';
                                    await addMarkerFromDataUrl(ev.target.result, name, isGif ? 'gif' : 'image');
                                };
                                reader.readAsDataURL(resp.response);
                            },
                            onerror:   () => mmNotify('Failed to load image from URL.', true),
                            ontimeout: () => mmNotify('Request timed out.', true),
                        });
                    };

                    addRow.appendChild(addBtn); addRow.appendChild(fileInput);
                    addRow.appendChild(urlBtn);
                    mmModal.appendChild(addRow);

                    // Scrollable list
                    const list = document.createElement('div');
                    list.id = 'gpc-mm-list';
                    Object.assign(list.style, { flex: '1', overflowY: 'auto', padding: '6px 0' });
                    mmModal.appendChild(list);

                    document.body.appendChild(mmModal);
                    renderList(list);
                }

                function refreshModal() {
                    const l = document.getElementById('gpc-mm-list');
                    if (l) renderList(l);
                    redraw();
                }

                function renderList(list) {
                    list.innerHTML = '';
                    if (!markers.length) {
                        list.innerHTML = '<div style="text-align:center;color:var(--color-gray-400,#9ca3af);padding:22px 14px;font-size:12px;">No markers yet.<br>Upload media above.</div>';
                        return;
                    }
                    let dragOverIdx = null;
                    for (let i = 0; i < markers.length; i++) {
                        const m = markers[i];
                        const wrapper = document.createElement('div');
                        wrapper.setAttribute('data-mm-idx', String(i));
                        wrapper.style.cssText = 'position:relative;';
                        wrapper.appendChild(makeCard(m));

                        // ── Drag-to-sort ───────────────────────────────
                        wrapper.addEventListener('dragstart', e => {
                            listDragSrc = i;
                            e.dataTransfer.effectAllowed = 'move';
                            wrapper.style.opacity = '0.45';
                        });
                        wrapper.addEventListener('dragend', () => {
                            wrapper.style.opacity = '';
                            list.querySelectorAll('[data-mm-drop-indicator]').forEach(el => el.remove());
                        });
                        wrapper.addEventListener('dragover', e => {
                            if (listDragSrc == null || listDragSrc === i) return;
                            e.preventDefault(); e.dataTransfer.dropEffect = 'move';
                            list.querySelectorAll('[data-mm-drop-indicator]').forEach(el => el.remove());
                            const ind = document.createElement('div');
                            ind.setAttribute('data-mm-drop-indicator', '1');
                            ind.style.cssText = 'height:2px;background:#3b82f6;margin:0 10px;border-radius:2px;';
                            const rect = wrapper.getBoundingClientRect();
                            const insertBefore = e.clientY < rect.top + rect.height / 2;
                            if (insertBefore) wrapper.insertAdjacentElement('beforebegin', ind);
                            else             wrapper.insertAdjacentElement('afterend', ind);
                            dragOverIdx = insertBefore ? i : i + 1;
                        });
                        wrapper.addEventListener('dragleave', () => {
                            // Remove indicator only if mouse truly left the list area
                        });
                        wrapper.addEventListener('drop', e => {
                            e.preventDefault();
                            list.querySelectorAll('[data-mm-drop-indicator]').forEach(el => el.remove());
                            if (listDragSrc == null || listDragSrc === dragOverIdx) { listDragSrc = null; return; }
                            const moved = markers.splice(listDragSrc, 1)[0];
                            const dest  = dragOverIdx > listDragSrc ? dragOverIdx - 1 : dragOverIdx;
                            markers.splice(dest, 0, moved);
                            listDragSrc = null; dragOverIdx = null;
                            saveMeta(); refreshModal();
                        });

                        list.appendChild(wrapper);
                    }
                }

                function makeThumb(m, size) {
                    const s = size || 34;
                    const checkers = 'repeating-conic-gradient(#ccc 0% 25%,transparent 0% 50%) 0 0/8px 8px';
                    const sharedStyle = { width: s + 'px', height: s + 'px', borderRadius: '4px',
                        flexShrink: '0', border: '1px solid var(--color-gray-200,#e5e7eb)', background: checkers };
                    if (isGifType(m)) {
                        const el = document.createElement('img');
                        el.src = dataUrlCache.get(m.id) || '';
                        Object.assign(el.style, { ...sharedStyle, objectFit: 'cover', display: 'block' });
                        return el;
                    }
                    // image type: canvas
                    const cvs = document.createElement('canvas');
                    cvs.width = s; cvs.height = s;
                    Object.assign(cvs.style, sharedStyle);
                    const img = imgCache.get(m.id);
                    if (img) {
                        const tc = cvs.getContext('2d');
                        tc.globalAlpha = m.opacity ?? 1;
                        tc.drawImage(img, 0, 0, s, s);
                        tc.globalAlpha = 1;
                    }
                    return cvs;
                }

                function mediaTypeLabel(m) {
                    return isGifType(m) ? '\ud83c\udf9e' : '\ud83d\uddbc';
                }

                function makeCard(m) {
                    const isP = placingId === m.id, isE = editingId === m.id;
                    const isExpanded = !mmCompact || expandedCards.has(m.id);
                    const cardBg     = isE ? 'var(--color-blue-50,#eff6ff)'
                                     : isP ? 'var(--color-green-50,#f0fdf4)'
                                     :       'var(--color-gray-50,#f9fafb)';
                    const cardBorder = isE ? 'var(--color-blue-200,#bfdbfe)'
                                     : isP ? 'var(--color-green-200,#bbf7d0)'
                                     :       'var(--color-gray-200,#e5e7eb)';

                    const card = document.createElement('div');
                    // draggable is NOT set on the card — only the grab handle initiates a drag
                    Object.assign(card.style, {
                        margin: '4px 10px', padding: mmCompact ? '6px 9px' : '9px', borderRadius: '8px',
                        display: 'flex', flexDirection: 'column', gap: '5px',
                        background: cardBg, border: '1px solid ' + cardBorder, cursor: 'default',
                    });

                    // ── Compact top row (always shown) ────────────────────
                    const topRow = document.createElement('div');
                    Object.assign(topRow.style, { display: 'flex', alignItems: 'center', gap: '7px' });

                    // Drag handle — only this element is draggable
                    const dragHdl = document.createElement('span');
                    dragHdl.textContent = '\u2807';
                    dragHdl.title = 'Drag to reorder';
                    dragHdl.draggable = true;
                    dragHdl.style.cssText = 'color:var(--color-gray-400,#9ca3af);cursor:grab;font-size:14px;flex-shrink:0;user-select:none;';
                    topRow.appendChild(dragHdl);

                    topRow.appendChild(makeThumb(m, 34));

                    const infoCol = document.createElement('div');
                    Object.assign(infoCol.style, { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '2px' });

                    const nameLbl = document.createElement('div');
                    nameLbl.textContent = mediaTypeLabel(m) + ' ' + m.name;
                    nameLbl.style.cssText = 'font-size:12px;font-weight:700;color:var(--color-gray-800,#1f2937);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
                    infoCol.appendChild(nameLbl);

                    const coordLbl = document.createElement('div');
                    coordLbl.style.cssText = 'font-size:10px;color:' +
                        (m.gridX != null ? 'var(--color-green-600,#16a34a)' : 'var(--color-gray-400,#9ca3af)') + ';';
                    coordLbl.textContent = m.gridX != null
                        ? `(${m.gridX}, ${m.gridY + m.gridH}) \u2014 ${m.gridW}\u00d7${m.gridH}`
                        : 'Not placed';
                    infoCol.appendChild(coordLbl);
                    topRow.appendChild(infoCol);

                    // Eye + expand/collapse in compact mode
                    const eyeBtn2 = document.createElement('button');
                    eyeBtn2.textContent = m.visible ? '\ud83d\udc41' : '\ud83d\udeab';
                    eyeBtn2.title = m.visible ? 'Hide' : 'Show';
                    Object.assign(eyeBtn2.style, { background: 'none', border: 'none', cursor: 'pointer',
                        fontSize: '14px', padding: '1px', flexShrink: '0' });
                    eyeBtn2.onclick = () => { m.visible = !m.visible; saveMeta(); refreshModal(); };
                    topRow.appendChild(eyeBtn2);

                    if (mmCompact) {
                        const chevron = document.createElement('button');
                        chevron.textContent = isExpanded ? '\u25b2' : '\u25bc';
                        chevron.title = isExpanded ? 'Collapse' : 'Expand controls';
                        Object.assign(chevron.style, { background: 'none', border: 'none', cursor: 'pointer',
                            fontSize: '10px', color: 'var(--color-gray-500,#6b7280)', padding: '1px', flexShrink: '0' });
                        chevron.onclick = (e) => {
                            e.stopPropagation();
                            if (expandedCards.has(m.id)) expandedCards.delete(m.id);
                            else expandedCards.add(m.id);
                            refreshModal();
                        };
                        topRow.appendChild(chevron);
                        // Click anywhere on compact card (except buttons) also toggles expand
                        card.addEventListener('click', (e) => {
                            if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT') return;
                            if (expandedCards.has(m.id)) expandedCards.delete(m.id);
                            else expandedCards.add(m.id);
                            refreshModal();
                        });
                    }
                    card.appendChild(topRow);

                    // ── Full controls (hidden in compact unless expanded) ──
                    if (isExpanded) {
                        // Name input row
                        const nameRow = document.createElement('div');
                        Object.assign(nameRow.style, { display: 'flex', alignItems: 'center', gap: '7px' });
                        const nameEl = document.createElement('input');
                        nameEl.type = 'text'; nameEl.value = m.name;
                        Object.assign(nameEl.style, { flex: '1', padding: '3px 6px',
                            border: '1px solid var(--color-gray-300,#d1d5db)', borderRadius: '4px',
                            fontSize: '12px', fontWeight: '600', color: 'var(--color-gray-800,#1f2937)',
                            background: 'var(--color-white,#fff)' });
                        nameEl.onchange = () => { m.name = nameEl.value; saveMeta(); refreshModal(); };
                        nameRow.appendChild(nameEl);
                        card.appendChild(nameRow);

                        // Opacity + aspect lock
                        {
                            const r2 = document.createElement('div');
                            Object.assign(r2.style, { display: 'flex', alignItems: 'center', gap: '6px' });
                            const opLbl = document.createElement('span');
                            opLbl.textContent = 'Opacity';
                            opLbl.style.cssText = 'font-size:11px;color:var(--color-gray-500,#6b7280);flex-shrink:0;';
                            r2.appendChild(opLbl);
                            const opSlider = document.createElement('input');
                            opSlider.type = 'range'; opSlider.min = '0'; opSlider.max = '1';
                            opSlider.step = '0.05'; opSlider.value = m.opacity ?? 1;
                            opSlider.style.cssText = 'flex:1;cursor:pointer;';
                            opSlider.oninput  = () => { m.opacity = parseFloat(opSlider.value); redraw(); };
                            opSlider.onchange = () => saveMeta();
                            r2.appendChild(opSlider);
                            const lockBtn = document.createElement('button');
                            lockBtn.textContent = m.lockAspect ? '\ud83d\udd12' : '\ud83d\udd13';
                            lockBtn.title = m.lockAspect ? 'Aspect locked' : 'Aspect free';
                            Object.assign(lockBtn.style, { background: 'none', border: 'none', cursor: 'pointer',
                                fontSize: '14px', padding: '1px', flexShrink: '0' });
                            lockBtn.onclick = () => { m.lockAspect = !m.lockAspect; saveMeta(); refreshModal(); };
                            r2.appendChild(lockBtn);
                            if (isImageType(m)) {
                                const resetArBtn = document.createElement('button');
                                resetArBtn.textContent = '\u21ba';
                                resetArBtn.title = 'Reset to natural aspect ratio';
                                Object.assign(resetArBtn.style, { background: 'none', border: 'none', cursor: 'pointer',
                                    fontSize: '14px', padding: '1px', flexShrink: '0' });
                                resetArBtn.onclick = () => {
                                    const imgEl = imgCache.get(m.id);
                                    if (!imgEl) return;
                                    m.gridH = Math.max(1, Math.round(m.gridW / (imgEl.naturalWidth / imgEl.naturalHeight)));
                                    saveMeta(); redraw(); refreshModal();
                                };
                                r2.appendChild(resetArBtn);
                            }
                            card.appendChild(r2);
                        }

                        // Action buttons
                        const r3 = document.createElement('div');
                        r3.style.cssText = 'display:flex;gap:5px;';
                        function mkBtn(txt, bg, fn) {
                            const b = document.createElement('button');
                            b.textContent = txt;
                            Object.assign(b.style, { flex: '1', padding: '5px 3px', border: 'none',
                                borderRadius: '6px', cursor: 'pointer', fontSize: '11px', fontWeight: '700',
                                background: bg, color: '#fff', transition: 'opacity .12s' });
                            b.onmouseenter = () => b.style.opacity = '0.82';
                            b.onmouseleave = () => b.style.opacity = '1';
                            b.onclick = fn; return b;
                        }
                        r3.appendChild(mkBtn(isP ? '\u23f8 Stop' : '\ud83d\udccd Place', isP ? '#f59e0b' : '#10b981', () => {
                            if (isP) exitPlace(); else enterPlace(m.id); refreshModal();
                        }));
                        if (m.gridX != null) {
                            r3.appendChild(mkBtn(isE ? '\u2713 Done' : '\u270f\ufe0f Edit', isE ? '#6366f1' : '#3b82f6', () => {
                                if (isE) exitEdit(); else enterEdit(m.id); refreshModal();
                            }));
                        }
                        r3.appendChild(mkBtn('\ud83d\uddd1 Remove', '#ef4444', () => {
                            if (!confirm('Remove this marker?')) return;
                            const i = markers.findIndex(x => x.id === m.id);
                            if (i !== -1) markers.splice(i, 1);
                            imgCache.delete(m.id);
                            removeOverlayEl(m.id);
                            expandedCards.delete(m.id);
                            mmDbDelete(m.id).catch(() => {});
                            saveMeta();
                            if (editingId === m.id) exitEdit();
                            if (placingId === m.id) exitPlace();
                            refreshModal();
                        }));
                        card.appendChild(r3);
                    }

                    return card;
                }

                function mmNotify(msg, isErr) {
                    const ex = document.getElementById('gpc-mm-toast'); if (ex) ex.remove();
                    const t = document.createElement('div'); t.id = 'gpc-mm-toast'; t.textContent = msg;
                    Object.assign(t.style, { position: 'fixed', top: '68px', left: '50%',
                        transform: 'translateX(-50%)', padding: '8px 18px', borderRadius: '8px',
                        fontSize: '13px', fontWeight: '700', zIndex: '100002',
                        boxShadow: '0 4px 12px rgba(0,0,0,.2)', transition: 'opacity .3s',
                        fontFamily: 'system-ui,sans-serif',
                        background: isErr ? '#fca5a5' : '#bbf7d0',
                        color:      isErr ? '#7f1d1d' : '#166534' });
                    document.body.appendChild(t);
                    setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 2600);
                }

                // ── Bootstrap ─────────────────────────────────────────────
                loadMeta();
                preloadAll().then(redraw);

                _mapMarkers = { openModal };
            })();
            _featureStatus.mapMarkers = 'ok';
            console.log('[GeoPixelcons++] \u2705 Map Markers loaded');
        } catch (err) {
            _featureStatus.mapMarkers = 'error';
            dbgPush(`Map Markers init failed: ${err && err.message ? err.message : String(err)}`, { error: err, uiComponent: 'Map Markers' });
            console.error('[GeoPixelcons++] \u274c Map Markers failed:', err);
        }
    }

    // ============================================================
    //  AUTO-SCREENSHOT ON PAINT (fetch interceptor)
    // ============================================================
    if (_settings.regionScreenshot) {
        try {
            const _targetWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
            const _origFetch = _targetWindow.fetch.bind(_targetWindow);
            _targetWindow.fetch = async function(...args) {
                const response = await _origFetch(...args);
                try {
                    const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
                    if (url.includes('/PlacePixels') && isAutoScreenshotEnabled() && _regionScreenshot) {
                        const coords = loadCachedCoords();
                        if (coords && response.ok) {
                            // Small delay to let the tile cache update
                            setTimeout(() => {
                                _regionScreenshot.silentDownload(coords);
                            }, 800);
                        }
                    }
                } catch {}
                return response;
            };
            console.log('[GeoPixelcons++] \u2705 Auto-screenshot fetch hook installed');
        } catch (err) {
            console.error('[GeoPixelcons++] \u274c Auto-screenshot hook failed:', err);
        }
    }

    console.log('[GeoPixelcons++] v' + VERSION + ' initialized. Features:', _featureStatus);
})();