Torn Script Hub

The central command center for your Torn scripts. Desktop: native sidebar item. Mobile/PDA: native Swiper slide. Dashboard for launch, prefs, library, and lightweight diagnostics. Now with a Watching section to track third-party scripts by GreasyFork ID, with auto version-checking.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn Script Hub
// @namespace    https://greasyfork.org/users/cowboyup
// @version      4.3.0
// @description  The central command center for your Torn scripts. Desktop: native sidebar item. Mobile/PDA: native Swiper slide. Dashboard for launch, prefs, library, and lightweight diagnostics. Now with a Watching section to track third-party scripts by GreasyFork ID, with auto version-checking.
// @author       cowboyup
// @match        https://www.torn.com/*
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @connect      greasyfork.org
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const HUB_VERSION = '4.3.0';
    const EVENTS = Object.freeze({
        REGISTER:   'torn-script-hub:register',
        UNREGISTER: 'torn-script-hub:unregister',
        READY:      'torn-script-hub:ready',
        DORMANT:    'torn-script-hub:dormant',
        ACTIVE:     'torn-script-hub:active',
        OPEN:       'torn-script-hub:open'
    });
    const IDS = Object.freeze({
        ROW:       'tsh-sidebar-row',
        MENU:      'tsh-menu',
        STYLE:     'tsh-style',
        DASHBOARD: 'tsh-dashboard'
    });
    const STORAGE_KEY = 'tsh-onboarded';
    const KNOWN_SCRIPTS_KEY = 'tsh-known-scripts';
    const UI_ENABLED_KEY = 'tsh-ui-enabled';
    const WATCH_LIST_KEY = 'tsh-watch-list';
    const WATCH_VERSIONS_KEY = 'tsh-watch-versions';
    const WATCH_CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12h throttle on auto-checks
    const WATCH_FETCH_TIMEOUT_MS = 8000;
    const DEBUG_LOG_MAX = 40;
    const debugLog = [];

    function pushDebugLog(message) {
        const ts = new Date().toISOString().slice(11, 19);
        debugLog.unshift(`[${ts}] ${message}`);
        if (debugLog.length > DEBUG_LOG_MAX) debugLog.length = DEBUG_LOG_MAX;
    }

    // =========================================================================
    // LIBRARY — Hub-compatible scripts, shown in the Library tab. Version and
    // active/installed status are looked up live from known-scripts data
    // (matched by name) rather than hardcoded, so this stays accurate as
    // scripts update.
    // =========================================================================
    const CONTACT_URL = 'https://www.torn.com/messages.php#/p=compose&XID=1496324';
    const LIBRARY_SCRIPTS = [
        { id: 586532, name: 'Bustr+', description: 'Busting reminder with a jail-page UI and PDA support.' },
        { id: 587751, name: 'No Confirm Market & Bazaar', description: 'Skips the confirmation step when buying on the Item Market or Bazaar.' },
        { id: 581347, name: 'HoF Battle Stat Rank', description: 'Shows your battle stat rank in the Hall of Fame.' },
        { id: 581948, name: 'Crime Chain Tracker', description: 'Tracks your Crimes 2.0 chain, critical fails, and resets.' },
        { id: 591579, name: 'Mobster & Cyclist Alert', description: 'Beeps when a Mobster or Cyclist crime becomes available.' },
        { id: 589775, name: 'OpenMarket', description: 'Overlays item quality % and bonus info on the Item Market, Auction House, and Bazaar.' },
        { id: 587089, name: 'Cracking Heat Badge', description: 'Shows a live heat badge while cracking.' },
        { id: 585434, name: 'Item Market Portfolio', description: 'Tracks the value of your Item Market listings over time.' },
        { id: 582128, name: 'Activity Log Filter & CSV', description: 'Filters your activity log and exports it to CSV.' },
        { id: 588262, name: 'Torn: Travel Inventory', description: 'Shows your travel inventory and item info while abroad.' },
        { id: 583629, name: 'Torn City Map Finder', description: 'Map pins and city-find list above the city map. Optional Public API values; no automated pickup.' },
        { id: 592376, name: 'Torn Rental Ledger', description: 'Per-property + portfolio rental income ledger on yourProperties. Hub settings; Generate User/Log key (user=log,properties).' }
    ];

    const registry = new Map();
    let readyFired = false;
    let mountMode = 'row';
    let dashboardOpen = false;
    let dashboardLaunchPending = false;
    let currentPrefsScriptId = null;

    // =========================================================================
    // UI TOGGLE
    // =========================================================================
    function isUIEnabled() {
        try { return localStorage.getItem(UI_ENABLED_KEY) !== '0'; }
        catch (e) { return true; }
    }
    function setUIEnabled(enabled) {
        try { localStorage.setItem(UI_ENABLED_KEY, enabled ? '1' : '0'); }
        catch (e) {}
        updateButtonState();
        if (!enabled) {
            closeMenu();
            closeDashboard();
            document.dispatchEvent(new CustomEvent(EVENTS.DORMANT));
        } else {
            document.dispatchEvent(new CustomEvent(EVENTS.ACTIVE));
        }
    }
    function updateButtonState() {
        const row = document.getElementById(IDS.ROW);
        if (!row) return;
        if (isUIEnabled()) {
            row.classList.remove('tsh-disabled');
            row.title = 'Torn Script Hub';
        } else {
            row.classList.add('tsh-disabled');
            row.title = 'Script Hub disabled — right-click or Shift+H to re-enable';
        }

        // Watching: red dot on the icon itself when an unseen update exists
        let dot = row.querySelector('.tsh-watch-dot');
        if (hasUnseenWatchUpdates()) {
            if (!dot) {
                const iconWrap = row.querySelector('[class*="svgIconWrap"]') || row.querySelector('.tsh-svg')?.parentElement;
                if (iconWrap) {
                    dot = document.createElement('span');
                    dot.className = 'tsh-watch-dot';
                    iconWrap.style.position = iconWrap.style.position || 'relative';
                    iconWrap.appendChild(dot);
                }
            }
        } else if (dot) {
            dot.remove();
        }
    }

    // =========================================================================
    // KNOWN SCRIPTS
    // =========================================================================
    function getKnownScripts() {
        try {
            const raw = localStorage.getItem(KNOWN_SCRIPTS_KEY);
            return raw ? JSON.parse(raw) : [];
        } catch (e) {
            return [];
        }
    }

    function saveKnownScript(entry) {
        const known = getKnownScripts();
        const exists = known.find(k => k.id === entry.id);
        if (exists) {
            exists.name = entry.name;
            exists.version = entry.version || '';
            exists.order = Number.isFinite(entry.order) ? entry.order : 1000;
        } else {
            known.push({
                id: entry.id,
                name: entry.name,
                version: entry.version || '',
                order: Number.isFinite(entry.order) ? entry.order : 1000
            });
        }
        try {
            localStorage.setItem(KNOWN_SCRIPTS_KEY, JSON.stringify(known));
        } catch (e) {}
    }

    // =========================================================================
    // WATCHLIST — third-party scripts the user adds manually by GreasyFork
    // ID/URL. Unlike LIBRARY_SCRIPTS these never register with the Hub, so
    // their version is checked periodically against GreasyFork's script
    // metadata endpoint instead.
    // =========================================================================
    function getWatchList() {
        try {
            const raw = localStorage.getItem(WATCH_LIST_KEY);
            return raw ? JSON.parse(raw) : [];
        } catch (e) {
            return [];
        }
    }

    function saveWatchList(list) {
        try {
            localStorage.setItem(WATCH_LIST_KEY, JSON.stringify(list));
        } catch (e) {}
    }

    function getWatchState() {
        try {
            const raw = localStorage.getItem(WATCH_VERSIONS_KEY);
            return raw ? JSON.parse(raw) : {};
        } catch (e) {
            return {};
        }
    }

    function saveWatchState(state) {
        try {
            localStorage.setItem(WATCH_VERSIONS_KEY, JSON.stringify(state));
        } catch (e) {}
    }

    function setWatchEntry(id, patch) {
        const state = getWatchState();
        state[id] = Object.assign({}, state[id], patch);
        saveWatchState(state);
        return state[id];
    }

    function removeWatchEntry(id) {
        saveWatchList(getWatchList().filter(e => String(e.id) !== String(id)));
        const state = getWatchState();
        delete state[id];
        saveWatchState(state);
    }

    function hasUnseenWatchUpdates() {
        const state = getWatchState();
        return getWatchList().some(entry => {
            const s = state[entry.id];
            return s && s.version && s.seenVersion && s.version !== s.seenVersion;
        });
    }

    // Accepts a raw ID ("590550"), a full URL, or a path fragment and
    // extracts the numeric GreasyFork script ID.
    function parseScriptId(input) {
        const str = String(input || '').trim();
        if (!str) return null;
        const inUrl = str.match(/scripts\/(\d+)/);
        if (inUrl) return inUrl[1];
        if (/^\d+$/.test(str)) return str;
        const anyDigits = str.match(/(\d{3,})/);
        return anyDigits ? anyDigits[1] : null;
    }

    // GreasyFork exposes clean JSON metadata per script, including name,
    // version, and author — no scraping needed.
    function fetchScriptMeta(id) {
        return new Promise(resolve => {
            if (typeof GM_xmlhttpRequest !== 'function') {
                resolve(null);
                return;
            }
            GM_xmlhttpRequest({
                method: 'GET',
                url: `https://greasyfork.org/scripts/${id}.json`,
                timeout: WATCH_FETCH_TIMEOUT_MS,
                onload: res => {
                    try {
                        const data = JSON.parse(res.responseText);
                        if (!data) { resolve(null); return; }
                        const authorName = typeof data.author === 'string'
                            ? data.author
                            : (data.author && data.author.name) ? data.author.name : '';
                        resolve({
                            name: data.name || `Script ${id}`,
                            version: data.version ? String(data.version) : null,
                            author: authorName || ''
                        });
                    } catch (e) {
                        resolve(null);
                    }
                },
                onerror: () => resolve(null),
                ontimeout: () => resolve(null)
            });
        });
    }

    async function checkWatchEntry(entry, { force = false } = {}) {
        const state = getWatchState();
        const existing = state[entry.id];
        if (!force && existing && existing.lastChecked &&
            (Date.now() - existing.lastChecked) < WATCH_CHECK_INTERVAL_MS) {
            return existing; // throttled, skip network call
        }
        const meta = await fetchScriptMeta(entry.id);
        return setWatchEntry(entry.id, {
            version: (meta && meta.version) || (existing && existing.version) || null,
            seenVersion: existing ? existing.seenVersion : (meta && meta.version),
            lastChecked: Date.now()
        });
    }

    async function checkAllWatchEntries({ force = false } = {}) {
        for (const entry of getWatchList()) {
            await checkWatchEntry(entry, { force });
        }
    }

    async function addWatchEntryFromInput(rawInput, { statusEl, inputEl, addBtn, onDone }) {
        const id = parseScriptId(rawInput);
        if (!id) {
            if (statusEl) statusEl.textContent = 'Couldn\u2019t read a script ID from that — paste the GreasyFork link or just the number.';
            return;
        }
        if (getWatchList().some(e => String(e.id) === String(id))) {
            if (statusEl) statusEl.textContent = 'Already on your watchlist.';
            return;
        }
        if (addBtn) addBtn.disabled = true;
        if (statusEl) statusEl.textContent = 'Looking up script…';

        const meta = await fetchScriptMeta(id);

        if (addBtn) addBtn.disabled = false;

        if (!meta || !meta.version) {
            if (statusEl) statusEl.textContent = 'Couldn\u2019t find that script on GreasyFork. Double-check the ID or link.';
            return;
        }

        const list = getWatchList();
        list.push({ id, name: meta.name, author: meta.author, addedAt: Date.now() });
        saveWatchList(list);
        setWatchEntry(id, { version: meta.version, seenVersion: meta.version, lastChecked: Date.now() });

        if (inputEl) inputEl.value = '';
        if (statusEl) statusEl.textContent = '';
        if (onDone) onDone();
    }

    // =========================================================================
    // QUEUE
    // =========================================================================
    const QUEUE_PROPERTY = '__tornScriptHubQueue';

    function ensureQueue(target) {
        if (!target[QUEUE_PROPERTY] || !Array.isArray(target[QUEUE_PROPERTY])) {
            target[QUEUE_PROPERTY] = [];
        }
        const q = target[QUEUE_PROPERTY];
        if (!q.__tshPatched) {
            const originalPush = q.push.bind(q);
            q.push = function (...items) {
                const result = originalPush(...items);
                items.forEach(item => registerScript(item));
                return result;
            };
            q.__tshPatched = true;
        }
        return q;
    }

    function getQueues() {
        const queues = [];
        if (typeof window !== 'undefined') queues.push(ensureQueue(window));
        if (document.documentElement) queues.push(ensureQueue(document.documentElement));
        return queues;
    }

    function drainQueue() {
        getQueues().forEach(queue => {
            if (!queue.length) return;
            const pending = queue.splice(0, queue.length);
            pending.forEach(registerScript);
        });
    }

    // =========================================================================
    // REGISTRATION
    // =========================================================================
    function normalizeRegistration(detail) {
        if (!detail || typeof detail !== 'object') return null;
        const id   = String(detail.id   || '').trim();
        const name = String(detail.name || '').trim();
        if (!id || !name) return null;
        if (typeof detail.open !== 'function') return null;

        const entry = {
            id, name,
            version: detail.version ? String(detail.version) : '',
            order:   Number.isFinite(detail.order) ? detail.order : 1000,
            open:    detail.open,
            close:   typeof detail.close === 'function' ? detail.close : null,
            active:  detail.active !== false,
            prefs:   null,
            status:  typeof detail.status === 'function' ? detail.status : null
        };

        if (detail.prefs && typeof detail.prefs === 'object') {
            entry.prefs = {
                fields: Array.isArray(detail.prefs.fields) ? detail.prefs.fields : [],
                onSave: typeof detail.prefs.onSave === 'function' ? detail.prefs.onSave : null,
                values: detail.prefs.values || {},
                sections: Array.isArray(detail.prefs.sections) ? detail.prefs.sections : null
            };
            entry.prefs.fields.forEach(f => {
                if (!(f.key in entry.prefs.values)) {
                    entry.prefs.values[f.key] = f.default !== undefined ? f.default : null;
                }
            });
        }

        return entry;
    }

    function registerScript(detail) {
        const script = normalizeRegistration(detail);
        if (!script) return;
        const existed = registry.has(script.id);
        registry.set(script.id, script);
        saveKnownScript(script);
        pushDebugLog(`${existed ? 're-register' : 'register'}: ${script.name} (${script.id}) v${script.version || '?'}`);
        renderMenu();
        if (dashboardOpen) renderDashboard();
    }

    function unregisterScript(detail) {
        if (!detail) return;
        const id = typeof detail === 'string' ? detail : String(detail.id || '').trim();
        if (!id) return;
        const prev = registry.get(id);
        registry.delete(id);
        pushDebugLog(`unregister: ${prev ? prev.name : id} (${id})`);
        renderMenu();
        if (dashboardOpen) renderDashboard();
    }

    document.addEventListener(EVENTS.REGISTER, e => { if (e?.detail) registerScript(e.detail); });
    document.addEventListener(EVENTS.UNREGISTER, e => { if (e?.detail) unregisterScript(e.detail); });
    document.addEventListener(EVENTS.OPEN, e => {
        const scriptId = e?.detail?.scriptId || null;
        requestDashboardOpen(scriptId);
    });

    // =========================================================================
    // HUB ICON
    // =========================================================================
    function createHubIcon() {
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('viewBox', '0 0 128 128');
        svg.setAttribute('class', 'tsh-svg');
        svg.setAttribute('aria-hidden', 'true');
        svg.setAttribute('width', '18');
        svg.setAttribute('height', '18');
        svg.innerHTML = [
            '<path d="M34 14h60l20 20v60l-20 20H34L14 94V34z" fill="none" stroke="currentColor" stroke-width="8" stroke-linejoin="round" opacity="0.35"/>',
            '<circle cx="64" cy="64" r="22" fill="none" stroke="currentColor" stroke-width="10" stroke-dasharray="8 4"/>',
            '<circle cx="64" cy="64" r="8" fill="currentColor"/>',
            '<path d="M26 40V26h14M102 40V26H88M26 88v14h14M102 88v14H88" fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round"/>'
        ].join('');
        return svg;
    }

    // =========================================================================
    // NATIVE-LOOKING MENU ITEMS
    // =========================================================================
    function nativeClassName(element) {
        if (!element || typeof element.className !== 'string') return '';
        return element.className.split(/\s+/).filter(name => name && !name.startsWith('active___')).join(' ');
    }

    function createHubLink(calendar, label) {
        const sourceLink = calendar.querySelector('a');
        const sourceIconWrap = sourceLink?.querySelector('[class*="svgIconWrap"]');
        const sourceDefaultIcon = sourceLink?.querySelector('[class*="defaultIcon"]');
        const sourceLabel = sourceLink?.querySelector('[class*="linkName"]') || sourceLink?.querySelector('span:last-child');

        const link = document.createElement('a');
        link.className = nativeClassName(sourceLink);
        link.href = '#';
        link.setAttribute('role', 'button');
        link.setAttribute('aria-haspopup', 'dialog');
        link.setAttribute('aria-expanded', 'false');
        link.setAttribute('aria-label', 'Torn Script Hub');

        const iconWrap = document.createElement('span');
        iconWrap.className = nativeClassName(sourceIconWrap);
        const defaultIcon = document.createElement('span');
        defaultIcon.className = nativeClassName(sourceDefaultIcon);
        defaultIcon.appendChild(createHubIcon());
        iconWrap.appendChild(defaultIcon);

        const name = document.createElement('span');
        name.className = nativeClassName(sourceLabel);
        name.textContent = label;

        link.append(iconWrap, name);
        link.addEventListener('keydown', e => {
            if (e.key === 'Enter' || e.key === ' ') {
                e.preventDefault();
                requestDashboardOpen();
            } else if (e.key === 'Escape') {
                closeMenu();
                closeDashboard();
            }
        });
        return link;
    }

    function createHubRow(calendar, label) {
        const sourceArea = calendar.querySelector('[class*="area-row"]');
        const row = document.createElement('div');
        row.id = IDS.ROW;
        row.className = nativeClassName(calendar);
        row.dataset.tshHubButton = 'true';
        row.title = 'Torn Script Hub';

        const area = document.createElement('div');
        area.className = nativeClassName(sourceArea);
        area.appendChild(createHubLink(calendar, label));
        row.appendChild(area);
        return row;
    }

    function createNativeRow(calendar) {
        return createHubRow(calendar, `Script Hub v${HUB_VERSION}`);
    }

    function createSwiperSlide(calendar, calendarSlide) {
        const slide = document.createElement('div');
        slide.className = nativeClassName(calendarSlide);
        slide.dataset.tshHubSlide = 'true';
        slide.appendChild(createHubRow(calendar, 'Scripts'));
        return slide;
    }

    // =========================================================================
    // DROPDOWN MENU (quick launch only)
    // =========================================================================
    function getSortedScripts() {
        return Array.from(registry.values())
            .filter(s => s.active)
            .sort((a, b) => {
                if (a.order !== b.order) return a.order - b.order;
                return a.name.localeCompare(b.name);
            });
    }

    function createMenu() {
        const menu = document.createElement('div');
        menu.id = IDS.MENU;
        menu.className = 'tsh-menu';
        menu.setAttribute('role', 'menu');
        return menu;
    }

    function renderMenu() {
        const menu = document.getElementById(IDS.MENU);
        if (!menu) return;
        menu.innerHTML = '';
        const scripts = getSortedScripts();

        if (scripts.length === 0) {
            const empty = document.createElement('div');
            empty.className = 'tsh-empty';
            empty.textContent = 'No active scripts';
            menu.appendChild(empty);
        } else {
            scripts.forEach(script => {
                const item = document.createElement('button');
                item.type = 'button';
                item.className = 'tsh-item';
                item.setAttribute('role', 'menuitem');

                const text = document.createElement('span');
                text.className = 'tsh-item-text';
                text.textContent = script.name;
                item.appendChild(text);

                if (script.version) {
                    const ver = document.createElement('span');
                    ver.className = 'tsh-item-version';
                    ver.textContent = 'v' + script.version;
                    item.appendChild(ver);
                }

                item.addEventListener('click', e => {
                    e.preventDefault();
                    e.stopPropagation();
                    closeMenu();
                    try { script.open(); }
                    catch (err) { console.error('[TSH] Failed to open "' + script.name + '"', err); }
                });
                menu.appendChild(item);
            });
        }

        const hr = document.createElement('div');
        hr.className = 'tsh-divider';
        menu.appendChild(hr);

        const manage = document.createElement('button');
        manage.type = 'button';
        manage.className = 'tsh-item tsh-item-manage';
        manage.innerHTML = '<span class="tsh-item-text">Open Dashboard</span>';
        manage.addEventListener('click', e => {
            e.preventDefault();
            e.stopPropagation();
            closeMenu();
            openDashboard();
        });
        menu.appendChild(manage);
    }

    function positionMenu() {
        const row  = document.getElementById(IDS.ROW);
        const menu = document.getElementById(IDS.MENU);
        if (!row || !menu) return;

        const rect = row.getBoundingClientRect();
        if (rect.width === 0 && rect.height === 0) {
            closeMenu();
            return;
        }

        const menuWidth = menu.offsetWidth || 260;
        const menuHeight = menu.offsetHeight || 200;

        let left, top;
        if (mountMode === 'swiper') {
            left = rect.left;
            if (left + menuWidth > window.innerWidth - 10) left = rect.right - menuWidth;
            left = Math.max(8, left);
            top = rect.top - menuHeight - 8;
            if (top < 8) top = rect.bottom + 8;
        } else {
            left = rect.right + 8;
            if (left + menuWidth > window.innerWidth - 10) left = rect.left - menuWidth - 8;
            left = Math.max(8, left);
            top = rect.top;
            if (top + 280 > window.innerHeight) top = Math.max(8, window.innerHeight - 300);
        }

        menu.style.left = Math.round(left) + 'px';
        menu.style.top  = Math.round(top) + 'px';
    }

    function openMenu() {
        if (!isUIEnabled()) return;
        const row  = document.getElementById(IDS.ROW);
        const menu = document.getElementById(IDS.MENU);
        if (!row || !menu) return;
        renderMenu();
        positionMenu();
        menu.classList.add('tsh-open');
        row.setAttribute('aria-expanded', 'true');
    }

    function closeMenu() {
        const row  = document.getElementById(IDS.ROW);
        const menu = document.getElementById(IDS.MENU);
        if (!row || !menu) return;
        menu.classList.remove('tsh-open');
        row.setAttribute('aria-expanded', 'false');
    }

    // =========================================================================
    // DASHBOARD
    // =========================================================================
    function requestDashboardOpen(scriptId) {
        if (!isUIEnabled()) return;
        if (dashboardLaunchPending) return;
        dashboardLaunchPending = true;
        requestAnimationFrame(() => {
            dashboardLaunchPending = false;
            if (scriptId) currentPrefsScriptId = scriptId;
            openDashboard();
            // If a scriptId was requested, switch to Settings tab
            if (scriptId) {
                const dash = document.getElementById(IDS.DASHBOARD);
                if (dash) {
                    dash.querySelectorAll('.tsh-dash-tab').forEach(t => t.classList.remove('active'));
                    dash.querySelectorAll('.tsh-dash-panel-content').forEach(p => p.classList.remove('active'));
                    const prefsTab = dash.querySelector('[data-tab="prefs"]');
                    const prefsPanel = dash.querySelector('[data-panel="prefs"]');
                    if (prefsTab) prefsTab.classList.add('active');
                    if (prefsPanel) {
                        prefsPanel.classList.add('active');
                        renderPrefsPanel(prefsPanel);
                    }
                }
            }
        });
    }

    function openDashboard() {
        if (!isUIEnabled()) return;
        closeMenu();
        let dash = document.getElementById(IDS.DASHBOARD);
        if (dash) {
            dash.classList.add('tsh-dash-open');
            dashboardOpen = true;
            renderDashboard();
            return;
        }

        dash = document.createElement('div');
        dash.id = IDS.DASHBOARD;
        dash.className = 'tsh-dashboard tsh-dash-open';
        dash.innerHTML = `
            <div class="tsh-dash-backdrop"></div>
            <div class="tsh-dash-panel">
                <div class="tsh-dash-header">
                    <div class="tsh-dash-title">Script Hub <span class="tsh-dash-ver">v${escapeHtml(HUB_VERSION)}</span></div>
                    <button class="tsh-dash-close" aria-label="Close dashboard" title="Close">×</button>
                </div>
                <div class="tsh-dash-tabs">
                    <button data-tab="launch" class="tsh-dash-tab active">Scripts</button>
                    <button data-tab="prefs" class="tsh-dash-tab">Settings</button>
                    <button data-tab="library" class="tsh-dash-tab">Library</button>
                    <button data-tab="debug" class="tsh-dash-tab">Debug</button>
                </div>
                <div class="tsh-dash-body">
                    <div data-panel="launch" class="tsh-dash-panel-content active"></div>
                    <div data-panel="prefs" class="tsh-dash-panel-content"></div>
                    <div data-panel="library" class="tsh-dash-panel-content"></div>
                    <div data-panel="debug" class="tsh-dash-panel-content"></div>
                </div>
            </div>
        `;
        document.body.appendChild(dash);
        dashboardOpen = true;

        dash.querySelector('.tsh-dash-close').addEventListener('click', closeDashboard);
        dash.querySelector('.tsh-dash-backdrop').addEventListener('click', closeDashboard);

        dash.querySelectorAll('.tsh-dash-tab').forEach(tab => {
            tab.addEventListener('click', () => {
                currentPrefsScriptId = null;
                dash.querySelectorAll('.tsh-dash-tab').forEach(t => t.classList.remove('active'));
                dash.querySelectorAll('.tsh-dash-panel-content').forEach(p => p.classList.remove('active'));
                tab.classList.add('active');
                const panel = dash.querySelector(`[data-panel="${tab.dataset.tab}"]`);
                panel.classList.add('active');
                if (tab.dataset.tab === 'prefs') renderPrefsPanel(panel);
                if (tab.dataset.tab === 'launch') renderLaunchPanel(panel);
                if (tab.dataset.tab === 'library') renderLibraryPanel(panel);
                if (tab.dataset.tab === 'debug') renderDebugPanel(panel);
            });
        });

        renderDashboard();
    }

    function closeDashboard() {
        const dash = document.getElementById(IDS.DASHBOARD);
        if (dash) dash.classList.remove('tsh-dash-open');
        dashboardOpen = false;
        currentPrefsScriptId = null;
    }

    function renderDashboard() {
        const dash = document.getElementById(IDS.DASHBOARD);
        if (!dash) return;
        renderLaunchPanel(dash.querySelector('[data-panel="launch"]'));
        renderPrefsPanel(dash.querySelector('[data-panel="prefs"]'));
        renderLibraryPanel(dash.querySelector('[data-panel="library"]'));
        renderDebugPanel(dash.querySelector('[data-panel="debug"]'));
    }

    // ----- Scripts tab -----
    function renderLaunchPanel(container) {
        if (!container) return;
        container.innerHTML = '';

        const known = getKnownScripts();
        const activeMap = new Map(registry.entries());
        const scripts = [];

        known.forEach(k => {
            const active = activeMap.get(k.id);
            if (active) {
                activeMap.delete(k.id);
                scripts.push(active);
            } else {
                scripts.push({
                    id: k.id,
                    name: k.name,
                    version: k.version,
                    order: Number.isFinite(k.order) ? k.order : 1000,
                    active: false,
                    _inactive: true,
                    open: () => {},
                    prefs: null
                });
            }
        });
        activeMap.forEach(active => scripts.push(active));

        scripts.sort((a, b) => {
            if (a._inactive !== b._inactive) return a._inactive ? 1 : -1;
            if (a.order !== b.order) return a.order - b.order;
            return a.name.localeCompare(b.name);
        });

        if (!scripts.length) {
            container.innerHTML = `
                <div class="tsh-dash-empty">
                    No scripts are registered yet.<br>
                    <span>Compatible scripts will appear here automatically.</span>
                </div>
            `;
            return;
        }

        const list = document.createElement('div');
        list.className = 'tsh-script-list';

        scripts.forEach(script => {
            const row = document.createElement('div');
            row.className = 'tsh-script-row' +
                (script.active ? '' : ' tsh-script-row-off') +
                (script._inactive ? ' tsh-script-row-inactive' : '');

            const launch = document.createElement('button');
            launch.type = 'button';
            launch.className = 'tsh-script-launch';
            launch.disabled = !script.active || script._inactive;
            launch.title = script._inactive
                ? `${script.name} is not active on this page`
                : (script.active ? `Launch ${script.name}` : `${script.name} is disabled`);

            const name = document.createElement('span');
            name.className = 'tsh-script-name';
            name.textContent = script.name;
            launch.appendChild(name);

            if (script.version) {
                const version = document.createElement('span');
                version.className = 'tsh-script-version';
                version.textContent = `v${script.version}`;
                launch.appendChild(version);
            }

            if (script.active && !script._inactive) {
                launch.addEventListener('click', () => {
                    closeDashboard();
                    try { script.open(); } catch (e) { console.error(e); }
                });
            }

            row.appendChild(launch);

            if (!script._inactive) {
                const toggle = document.createElement('button');
                toggle.type = 'button';
                toggle.className = 'tsh-script-toggle' + (script.active ? ' active' : '');
                toggle.textContent = script.active ? 'On' : 'Off';
                toggle.title = script.active ? 'Disable script' : 'Enable script';
                toggle.addEventListener('click', e => {
                    e.stopPropagation();
                    const wasActive = script.active;
                    script.active = !wasActive;
                    if (wasActive && script.close) {
                        try { script.close(); }
                        catch (err) { console.error('[TSH] close() failed for', script.id, err); }
                    }
                    renderDashboard();
                    renderMenu();
                });
                row.appendChild(toggle);
            } else {
                const note = document.createElement('span');
                note.className = 'tsh-script-inactive-note';
                note.textContent = 'Not active on this page';
                row.appendChild(note);
            }

            list.appendChild(row);
        });

        container.appendChild(list);
    }

    // ----- Settings tab (two-level) -----
    function renderPrefsPanel(container) {
        if (!container) return;
        container.innerHTML = '';

        if (currentPrefsScriptId) {
            renderScriptPrefsDetail(container, currentPrefsScriptId);
            return;
        }

        const scripts = Array.from(registry.values())
            .filter(s => s.prefs && s.active)
            .sort((a, b) => a.name.localeCompare(b.name));

        if (!scripts.length) {
            container.innerHTML = `
                <div class="tsh-dash-empty">
                    <strong>No settings available</strong><br>
                    <span>Scripts that support preferences will appear here.</span>
                </div>
            `;
            return;
        }

        const list = document.createElement('div');
        list.className = 'tsh-prefs-list';

        scripts.forEach(script => {
            const row = document.createElement('button');
            row.type = 'button';
            row.className = 'tsh-prefs-row';
            row.innerHTML = `
                <span class="tsh-prefs-row-name">${escapeHtml(script.name)}</span>
                <span class="tsh-prefs-row-action">Configure</span>
            `;
            row.addEventListener('click', () => {
                currentPrefsScriptId = script.id;
                renderPrefsPanel(container);
            });
            list.appendChild(row);
        });

        container.appendChild(list);
    }

    function renderScriptPrefsDetail(container, scriptId) {
        const script = registry.get(scriptId);
        if (!script || !script.prefs) {
            currentPrefsScriptId = null;
            renderPrefsPanel(container);
            return;
        }

        container.innerHTML = '';

        const header = document.createElement('div');
        header.className = 'tsh-prefs-detail-header';
        header.innerHTML = `
            <button type="button" class="tsh-prefs-back">← Back</button>
            <span class="tsh-prefs-detail-title">${escapeHtml(script.name)}</span>
        `;
        header.querySelector('.tsh-prefs-back').addEventListener('click', () => {
            currentPrefsScriptId = null;
            renderPrefsPanel(container);
        });
        container.appendChild(header);

        let statusEl = null;
        if (typeof script.status === 'function') {
            try {
                const statusText = script.status();
                if (statusText) {
                    statusEl = document.createElement('div');
                    statusEl.className = 'tsh-prefs-status';
                    statusEl.textContent = statusText;
                    container.appendChild(statusEl);
                }
            } catch (e) {}
        }

        const refreshStatus = () => {
            if (!statusEl || typeof script.status !== 'function') return;
            try {
                const text = script.status();
                if (text) statusEl.textContent = text;
            } catch (e) {}
        };

        const body = document.createElement('div');
        body.className = 'tsh-prefs-detail-body';

        const fields = script.prefs.fields || [];
        const toggles = fields.filter(f => f.type === 'toggle' || f.type === 'checkbox');
        const numbers = fields.filter(f => f.type === 'number');
        const buttons = fields.filter(f => f.type === 'button');
        const others  = fields.filter(f => f.type !== 'toggle' && f.type !== 'checkbox' && f.type !== 'number' && f.type !== 'button');

        // API key / text fields first
        if (others.length) {
            const sec = document.createElement('div');
            sec.className = 'tsh-pref-section';
            const stack = document.createElement('div');
            stack.className = 'tsh-pref-stack';
            others.forEach(f => stack.appendChild(createPrefField(script, f, refreshStatus)));
            sec.appendChild(stack);
            body.appendChild(sec);
        }

        // Action buttons (e.g. Generate key)
        if (buttons.length) {
            const sec = document.createElement('div');
            sec.className = 'tsh-pref-section';
            const row = document.createElement('div');
            row.className = 'tsh-pref-btn-row';
            buttons.forEach(f => row.appendChild(createPrefField(script, f, refreshStatus)));
            sec.appendChild(row);
            body.appendChild(sec);
        }

        if (toggles.length) {
            const sec = document.createElement('div');
            sec.className = 'tsh-pref-section';
            const grid = document.createElement('div');
            grid.className = 'tsh-pref-toggles';
            toggles.forEach(f => grid.appendChild(createPrefField(script, f, refreshStatus)));
            sec.appendChild(grid);
            body.appendChild(sec);
        }

        if (numbers.length) {
            const sec = document.createElement('div');
            sec.className = 'tsh-pref-section';
            const grid = document.createElement('div');
            grid.className = 'tsh-pref-grid';
            numbers.forEach(f => grid.appendChild(createPrefField(script, f, refreshStatus)));
            sec.appendChild(grid);
            body.appendChild(sec);
        }

        container.appendChild(body);

        const footer = document.createElement('div');
        footer.className = 'tsh-prefs-footer';
        footer.textContent = 'Changes are saved automatically.';
        container.appendChild(footer);
    }

    function createPrefField(script, field, onAfterSave) {
        const wrap = document.createElement('div');
        wrap.className = 'tsh-pref-field';

        const labelRow = document.createElement('div');
        labelRow.className = 'tsh-pref-label-row';

        const label = document.createElement('span');
        label.className = 'tsh-pref-label';
        label.textContent = field.label || field.key;
        if (field.hint) {
            label.title = field.hint;
            label.classList.add('tsh-has-hint');
        }
        labelRow.appendChild(label);

        if (field.hint) {
            const hint = document.createElement('span');
            hint.className = 'tsh-pref-hint';
            hint.textContent = 'ⓘ';
            hint.title = field.hint;
            labelRow.appendChild(hint);
        }

        if (field.type !== 'button') {
            wrap.appendChild(labelRow);
        }

        let input;
        const triggerSave = () => {
            if (script.prefs.onSave) {
                const result = script.prefs.onSave(script.prefs.values);
                if (result && typeof result.then === 'function') {
                    result.finally(() => { if (onAfterSave) onAfterSave(); });
                } else if (onAfterSave) {
                    onAfterSave();
                }
            } else if (onAfterSave) {
                onAfterSave();
            }
        };

        if (field.type === 'button') {
            input = document.createElement('button');
            input.type = 'button';
            input.className = 'tsh-pref-btn';
            input.textContent = field.label || field.key;
            if (field.hint) input.title = field.hint;
            input.addEventListener('click', () => {
                if (typeof field.onClick === 'function') {
                    try { field.onClick(); } catch (e) { console.error(e); }
                } else if (field.url) {
                    window.open(field.url, '_blank', 'noopener');
                }
            });
            wrap.appendChild(input);
            return wrap;
        }

        if (field.type === 'toggle' || field.type === 'checkbox') {
            input = document.createElement('button');
            input.type = 'button';
            input.className = 'tsh-pref-toggle' + (script.prefs.values[field.key] ? ' active' : '');
            input.textContent = script.prefs.values[field.key] ? 'On' : 'Off';
            input.setAttribute('aria-pressed', script.prefs.values[field.key] ? 'true' : 'false');
            input.addEventListener('click', () => {
                script.prefs.values[field.key] = !script.prefs.values[field.key];
                input.textContent = script.prefs.values[field.key] ? 'On' : 'Off';
                input.classList.toggle('active', script.prefs.values[field.key]);
                input.setAttribute('aria-pressed', script.prefs.values[field.key] ? 'true' : 'false');
                triggerSave();
            });
        } else if (field.type === 'select' && Array.isArray(field.options)) {
            input = document.createElement('select');
            input.className = 'tsh-pref-select';
            field.options.forEach(opt => {
                const o = document.createElement('option');
                o.value = opt.value !== undefined ? opt.value : opt;
                o.textContent = opt.label !== undefined ? opt.label : opt;
                if (String(script.prefs.values[field.key]) === String(o.value)) o.selected = true;
                input.appendChild(o);
            });
            input.addEventListener('change', () => {
                script.prefs.values[field.key] = input.value;
                triggerSave();
            });
        } else {
            input = document.createElement('input');
            input.className = 'tsh-pref-input';
            input.type = field.type === 'number' ? 'number' : (field.type === 'password' ? 'password' : 'text');
            if (field.min !== undefined) input.min = field.min;
            if (field.max !== undefined) input.max = field.max;
            if (field.step !== undefined) input.step = field.step;
            if (field.placeholder) input.placeholder = field.placeholder;
            input.value = script.prefs.values[field.key] ?? '';
            input.addEventListener('change', () => {
                let val = input.value;
                if (field.type === 'number') {
                    val = Number(val);
                    if (!Number.isFinite(val)) return;
                    if (field.min !== undefined) val = Math.max(field.min, val);
                    if (field.max !== undefined) val = Math.min(field.max, val);
                }
                // Password fields: an empty change must not wipe a previously
                // known value in the shared prefs.values snapshot. Scripts may
                // receive the whole snapshot on every save; blanking the key
                // here would look like "user cleared the secret".
                if (field.type === 'password' && val === '' && script.prefs.values[field.key]) {
                    triggerSave();
                    return;
                }
                script.prefs.values[field.key] = val;
                triggerSave();
            });
        }

        wrap.appendChild(input);
        return wrap;
    }

    // ----- Library -----
    function normalizeScriptName(name) {
        return String(name || '')
            .toLowerCase()
            .replace(/^torn:\s*/i, '')
            .replace(/[^a-z0-9]+/g, ' ')
            .trim();
    }

    function namesMatch(a, b) {
        return normalizeScriptName(a) === normalizeScriptName(b);
    }

    function renderLibraryPanel(container) {
        if (!container) return;
        container.innerHTML = '';

        const known = getKnownScripts();
        const activeScripts = Array.from(registry.values()).filter(s => s.active);

        const wrap = document.createElement('div');
        wrap.className = 'tsh-lib-wrap';

        // ---- Hub-compatible Library section ----
        const introTitle = document.createElement('div');
        introTitle.className = 'tsh-lib-section-title';
        introTitle.textContent = 'Hub-Compatible Scripts';
        wrap.appendChild(introTitle);

        const intro = document.createElement('p');
        intro.className = 'tsh-lib-intro';
        intro.textContent = 'Scripts built to work with Script Hub. Install any of these and they\u2019ll register here automatically.';
        wrap.appendChild(intro);

        const list = document.createElement('div');
        list.className = 'tsh-lib-list';

        LIBRARY_SCRIPTS.forEach(entry => {
            const knownMatch = known.find(k => namesMatch(k.name, entry.name));
            const activeMatch = activeScripts.find(s => namesMatch(s.name, entry.name));
            const isActive = !!activeMatch;

            const row = document.createElement('div');
            row.className = 'tsh-lib-row';

            const info = document.createElement('div');
            info.className = 'tsh-lib-info';

            const nameRow = document.createElement('div');
            nameRow.className = 'tsh-lib-name-row';

            const name = document.createElement('span');
            name.className = 'tsh-lib-name';
            name.textContent = entry.name;
            nameRow.appendChild(name);

            const versionLabel = (activeMatch && activeMatch.version) || (knownMatch && knownMatch.version);
            if (versionLabel) {
                const ver = document.createElement('span');
                ver.className = 'tsh-lib-version';
                ver.textContent = 'v' + versionLabel;
                nameRow.appendChild(ver);
            }

            const badge = document.createElement('span');
            badge.className = 'tsh-lib-badge' + (isActive ? ' tsh-lib-badge-active' : (knownMatch ? ' tsh-lib-badge-known' : ''));
            badge.textContent = isActive ? 'Active' : (knownMatch ? 'Installed' : 'Available');
            nameRow.appendChild(badge);

            info.appendChild(nameRow);

            const desc = document.createElement('div');
            desc.className = 'tsh-lib-desc';
            desc.textContent = entry.description;
            info.appendChild(desc);

            row.appendChild(info);

            const install = document.createElement('a');
            install.className = 'tsh-lib-install';
            install.href = `https://greasyfork.org/en/scripts/${entry.id}`;
            install.target = '_blank';
            install.rel = 'noopener';
            install.textContent = knownMatch ? 'View' : 'Install';
            row.appendChild(install);

            list.appendChild(row);
        });

        wrap.appendChild(list);

        // ---- Watching (third-party) section ----
        const watchTitle = document.createElement('div');
        watchTitle.className = 'tsh-lib-section-title';
        watchTitle.textContent = 'Watching';
        wrap.appendChild(watchTitle);

        const watchIntroRow = document.createElement('div');
        watchIntroRow.className = 'tsh-watch-intro-row';

        const watchIntro = document.createElement('p');
        watchIntro.className = 'tsh-lib-intro';
        watchIntro.style.margin = '0';
        watchIntro.textContent = 'Third-party scripts you\u2019re tracking. Checked periodically against GreasyFork.';
        watchIntroRow.appendChild(watchIntro);

        const watchList = getWatchList();

        if (watchList.length) {
            const checkAllBtn = document.createElement('button');
            checkAllBtn.type = 'button';
            checkAllBtn.className = 'tsh-lib-check-all';
            checkAllBtn.textContent = 'Check all';
            checkAllBtn.addEventListener('click', async () => {
                checkAllBtn.disabled = true;
                checkAllBtn.textContent = 'Checking…';
                await checkAllWatchEntries({ force: true });
                checkAllBtn.disabled = false;
                checkAllBtn.textContent = 'Check all';
                renderLibraryPanel(container);
                updateButtonState();
            });
            watchIntroRow.appendChild(checkAllBtn);
        }

        wrap.appendChild(watchIntroRow);

        // ---- add-by-ID form ----
        const addForm = document.createElement('div');
        addForm.className = 'tsh-watch-add';

        const addInput = document.createElement('input');
        addInput.type = 'text';
        addInput.className = 'tsh-watch-add-input';
        addInput.placeholder = 'GreasyFork script ID or link…';

        const addBtn = document.createElement('button');
        addBtn.type = 'button';
        addBtn.className = 'tsh-watch-add-btn';
        addBtn.textContent = 'Add';

        addForm.append(addInput, addBtn);
        wrap.appendChild(addForm);

        const addStatus = document.createElement('div');
        addStatus.className = 'tsh-watch-add-status';
        wrap.appendChild(addStatus);

        const submitAdd = () => {
            addWatchEntryFromInput(addInput.value, {
                statusEl: addStatus,
                inputEl: addInput,
                addBtn,
                onDone: () => {
                    renderLibraryPanel(container);
                    updateButtonState();
                }
            });
        };

        addBtn.addEventListener('click', submitAdd);
        addInput.addEventListener('keydown', e => {
            if (e.key === 'Enter') {
                e.preventDefault();
                submitAdd();
            }
        });

        // ---- watched entries ----
        if (watchList.length) {
            const listEl = document.createElement('div');
            listEl.className = 'tsh-lib-list';
            listEl.style.marginTop = '6px';

            const state = getWatchState();

            watchList.forEach(entry => {
                const s = state[entry.id] || {};
                const hasUpdate = !!(s.version && s.seenVersion && s.version !== s.seenVersion);

                const row = document.createElement('div');
                row.className = 'tsh-lib-row' + (hasUpdate ? ' tsh-watch-row-updated' : '');

                const info = document.createElement('div');
                info.className = 'tsh-lib-info';

                const nameRow = document.createElement('div');
                nameRow.className = 'tsh-lib-name-row';

                const name = document.createElement('span');
                name.className = 'tsh-lib-name';
                name.textContent = entry.name;
                nameRow.appendChild(name);

                if (s.version) {
                    const ver = document.createElement('span');
                    ver.className = 'tsh-lib-version';
                    ver.textContent = 'v' + s.version;
                    nameRow.appendChild(ver);
                }

                const badge = document.createElement('span');
                badge.className = 'tsh-lib-badge' + (hasUpdate ? ' tsh-watch-badge-update' : '');
                badge.textContent = hasUpdate ? 'Update' : (s.version ? 'Up to date' : 'Not checked yet');
                nameRow.appendChild(badge);

                info.appendChild(nameRow);

                if (entry.author) {
                    const desc = document.createElement('div');
                    desc.className = 'tsh-lib-desc';
                    desc.textContent = `by ${entry.author}`;
                    info.appendChild(desc);
                }

                row.appendChild(info);

                if (s.lastChecked) {
                    const checked = document.createElement('span');
                    checked.className = 'tsh-watch-checked';
                    checked.textContent = relativeTime(s.lastChecked);
                    row.appendChild(checked);
                }

                const view = document.createElement('a');
                view.className = 'tsh-lib-install';
                view.href = `https://greasyfork.org/en/scripts/${entry.id}`;
                view.target = '_blank';
                view.rel = 'noopener';
                view.textContent = 'View';
                view.addEventListener('click', () => {
                    if (hasUpdate) {
                        setWatchEntry(entry.id, { seenVersion: s.version });
                        updateButtonState();
                    }
                });
                row.appendChild(view);

                const remove = document.createElement('button');
                remove.type = 'button';
                remove.className = 'tsh-watch-remove';
                remove.title = `Remove ${entry.name} from watchlist`;
                remove.textContent = '×';
                remove.addEventListener('click', () => {
                    removeWatchEntry(entry.id);
                    renderLibraryPanel(container);
                    updateButtonState();
                });
                row.appendChild(remove);

                listEl.appendChild(row);
            });

            wrap.appendChild(listEl);

            // background throttled refresh whenever this tab renders
            checkAllWatchEntries().then(() => updateButtonState());
        } else {
            const empty = document.createElement('div');
            empty.className = 'tsh-dash-empty';
            empty.style.padding = '14px 0';
            empty.innerHTML = 'Nothing on your watchlist yet.<br><span>Paste a GreasyFork link or script ID above to start tracking one.</span>';
            wrap.appendChild(empty);
        }

        const contact = document.createElement('div');
        contact.className = 'tsh-lib-contact';
        contact.innerHTML = `Questions or bug reports? <a href="${CONTACT_URL}" target="_blank" rel="noopener">Message cowboyup on Torn</a>.`;
        wrap.appendChild(contact);

        container.appendChild(wrap);
    }

    // ----- Debug (lightweight) -----
    function safeScriptStatus(script) {
        try {
            if (typeof script.status === 'function') return String(script.status() || '');
        } catch (err) {
            return `(status error: ${err && err.message ? err.message : 'unknown'})`;
        }
        return '';
    }

    function buildScriptDiagnostic(script) {
        const prefKeys = script.prefs && Array.isArray(script.prefs.fields)
            ? script.prefs.fields.map(f => f.key)
            : [];
        // Never dump secret values — only whether a key looks set
        const valueFlags = {};
        if (script.prefs && script.prefs.values && typeof script.prefs.values === 'object') {
            Object.keys(script.prefs.values).forEach(key => {
                const val = script.prefs.values[key];
                if (val == null || val === '') valueFlags[key] = 'empty';
                else if (typeof val === 'boolean' || typeof val === 'number') valueFlags[key] = val;
                else if (/key|token|secret|password/i.test(key)) valueFlags[key] = 'set';
                else valueFlags[key] = 'set';
            });
        }
        return {
            id: script.id,
            name: script.name,
            version: script.version || '',
            active: !!script.active,
            order: script.order,
            hasOpen: typeof script.open === 'function',
            hasClose: typeof script.close === 'function',
            hasStatus: typeof script.status === 'function',
            status: safeScriptStatus(script),
            prefKeys,
            valueFlags,
            page: location.href,
            hubVersion: HUB_VERSION,
            checkedAt: new Date().toISOString()
        };
    }

    function buildDiagnosticsPayload() {
        const known = getKnownScripts();
        const live = Array.from(registry.values()).map(s => buildScriptDiagnostic(s));
        return {
            hubVersion: HUB_VERSION,
            page: location.href,
            uiEnabled: isUIEnabled(),
            registeredCount: live.length,
            knownCount: known.length,
            registered: live,
            known,
            watchList: getWatchList(),
            watchState: getWatchState(),
            recentEvents: debugLog.slice()
        };
    }

    async function copyText(text, button, okLabel) {
        try {
            await navigator.clipboard.writeText(text);
            if (button) {
                const prev = button.textContent;
                button.textContent = okLabel || 'Copied';
                setTimeout(() => { button.textContent = prev; }, 1500);
            }
        } catch (_) {
            window.prompt('Copy:', text);
        }
    }

    function renderDebugPanel(container) {
        if (!container) return;
        container.innerHTML = '';

        const wrap = document.createElement('div');
        wrap.className = 'tsh-debug-wrap';

        const intro = document.createElement('p');
        intro.className = 'tsh-debug-intro';
        intro.textContent = 'Lightweight diagnostics for Hub-aware scripts. Start here when something looks wrong.';
        wrap.appendChild(intro);

        const meta = document.createElement('div');
        meta.className = 'tsh-debug-meta';
        meta.textContent = `Hub v${HUB_VERSION} · ${registry.size} registered · ${getKnownScripts().length} known · ${getWatchList().length} watched · UI ${isUIEnabled() ? 'on' : 'off'}`;
        wrap.appendChild(meta);

        const actions = document.createElement('div');
        actions.className = 'tsh-debug-actions';

        const copyBtn = document.createElement('button');
        copyBtn.type = 'button';
        copyBtn.className = 'tsh-debug-btn';
        copyBtn.textContent = 'Copy diagnostics';
        copyBtn.addEventListener('click', () => {
            copyText(JSON.stringify(buildDiagnosticsPayload(), null, 2), copyBtn);
        });
        actions.appendChild(copyBtn);

        const pingBtn = document.createElement('button');
        pingBtn.type = 'button';
        pingBtn.className = 'tsh-debug-btn';
        pingBtn.textContent = 'Ping scripts';
        pingBtn.title = 'Re-dispatch hub ready so scripts can re-register';
        pingBtn.addEventListener('click', () => {
            pushDebugLog('ping: torn-script-hub:ready');
            document.dispatchEvent(new CustomEvent(EVENTS.READY));
            renderDebugPanel(container);
        });
        actions.appendChild(pingBtn);

        const clearBtn = document.createElement('button');
        clearBtn.type = 'button';
        clearBtn.className = 'tsh-debug-btn tsh-debug-btn-muted';
        clearBtn.textContent = 'Clear known list';
        clearBtn.title = 'Removes saved known-scripts cache (Library installed flags). Live registry is unchanged.';
        clearBtn.addEventListener('click', () => {
            if (!confirm('Clear Hub known-scripts cache? Library “Installed” flags reset until scripts register again.')) return;
            try { localStorage.removeItem(KNOWN_SCRIPTS_KEY); } catch (_) {}
            pushDebugLog('cleared known-scripts cache');
            renderDebugPanel(container);
            renderLibraryPanel(document.querySelector('[data-panel="library"]'));
        });
        actions.appendChild(clearBtn);

        wrap.appendChild(actions);

        const listTitle = document.createElement('div');
        listTitle.className = 'tsh-debug-section-title';
        listTitle.textContent = 'Registered on this page — click a script for actions';
        wrap.appendChild(listTitle);

        const list = document.createElement('div');
        list.className = 'tsh-debug-list';
        const scripts = Array.from(registry.values()).sort((a, b) => (a.order || 1000) - (b.order || 1000) || a.name.localeCompare(b.name));
        if (!scripts.length) {
            const empty = document.createElement('div');
            empty.className = 'tsh-debug-empty';
            empty.textContent = 'No scripts registered yet on this page.';
            list.appendChild(empty);
        } else {
            scripts.forEach(s => {
                const row = document.createElement('div');
                row.className = 'tsh-debug-row tsh-debug-row-clickable';
                row.tabIndex = 0;
                row.setAttribute('role', 'button');
                row.title = 'Show actions for this script';

                const statusText = safeScriptStatus(s);
                const head = document.createElement('div');
                head.className = 'tsh-debug-row-main';
                head.innerHTML =
                    `<strong>${escapeHtml(s.name)}</strong>` +
                    `<span class="tsh-debug-id">${escapeHtml(s.id)}</span>` +
                    (s.version ? `<span class="tsh-debug-ver">v${escapeHtml(s.version)}</span>` : '');
                row.appendChild(head);

                const sub = document.createElement('div');
                sub.className = 'tsh-debug-row-sub';
                sub.textContent = (s.prefs ? 'prefs' : 'no prefs') + (statusText ? ' · ' + statusText : '');
                row.appendChild(sub);

                const detail = document.createElement('div');
                detail.className = 'tsh-debug-detail';
                detail.hidden = true;

                const detailStatus = document.createElement('div');
                detailStatus.className = 'tsh-debug-detail-status';
                detailStatus.textContent = statusText ? `Status: ${statusText}` : 'Status: (none)';
                detail.appendChild(detailStatus);

                const detailActions = document.createElement('div');
                detailActions.className = 'tsh-debug-actions';

                const refreshBtn = document.createElement('button');
                refreshBtn.type = 'button';
                refreshBtn.className = 'tsh-debug-btn tsh-debug-btn-muted';
                refreshBtn.textContent = 'Refresh status';
                refreshBtn.addEventListener('click', (ev) => {
                    ev.stopPropagation();
                    const next = safeScriptStatus(s);
                    detailStatus.textContent = next ? `Status: ${next}` : 'Status: (none)';
                    sub.textContent = (s.prefs ? 'prefs' : 'no prefs') + (next ? ' · ' + next : '');
                    pushDebugLog(`status: ${s.id} → ${next || '(none)'}`);
                });
                detailActions.appendChild(refreshBtn);

                const openBtn = document.createElement('button');
                openBtn.type = 'button';
                openBtn.className = 'tsh-debug-btn';
                openBtn.textContent = 'Open';
                openBtn.disabled = typeof s.open !== 'function';
                openBtn.addEventListener('click', (ev) => {
                    ev.stopPropagation();
                    pushDebugLog(`open: ${s.id}`);
                    try { s.open(); } catch (err) {
                        pushDebugLog(`open error: ${s.id} ${err && err.message ? err.message : ''}`);
                    }
                });
                detailActions.appendChild(openBtn);

                if (s.prefs) {
                    const prefsBtn = document.createElement('button');
                    prefsBtn.type = 'button';
                    prefsBtn.className = 'tsh-debug-btn tsh-debug-btn-muted';
                    prefsBtn.textContent = 'Settings';
                    prefsBtn.addEventListener('click', (ev) => {
                        ev.stopPropagation();
                        currentPrefsScriptId = s.id;
                        const dash = document.getElementById(IDS.DASHBOARD);
                        if (!dash) return;
                        dash.querySelectorAll('.tsh-dash-tab').forEach(t => t.classList.remove('active'));
                        dash.querySelectorAll('.tsh-dash-panel-content').forEach(p => p.classList.remove('active'));
                        const tab = dash.querySelector('.tsh-dash-tab[data-tab="prefs"]');
                        const panel = dash.querySelector('[data-panel="prefs"]');
                        if (tab) tab.classList.add('active');
                        if (panel) {
                            panel.classList.add('active');
                            renderPrefsPanel(panel);
                        }
                    });
                    detailActions.appendChild(prefsBtn);
                }

                const copyOneBtn = document.createElement('button');
                copyOneBtn.type = 'button';
                copyOneBtn.className = 'tsh-debug-btn';
                copyOneBtn.textContent = 'Copy diagnostic';
                copyOneBtn.addEventListener('click', (ev) => {
                    ev.stopPropagation();
                    const payload = buildScriptDiagnostic(s);
                    pushDebugLog(`diagnostic: ${s.id}`);
                    copyText(JSON.stringify(payload, null, 2), copyOneBtn);
                });
                detailActions.appendChild(copyOneBtn);

                detail.appendChild(detailActions);
                row.appendChild(detail);

                const toggleDetail = () => {
                    const opening = detail.hidden;
                    list.querySelectorAll('.tsh-debug-detail').forEach(d => { d.hidden = true; });
                    list.querySelectorAll('.tsh-debug-row').forEach(r => r.classList.remove('tsh-debug-row-open'));
                    if (opening) {
                        detail.hidden = false;
                        row.classList.add('tsh-debug-row-open');
                        // live refresh when opened
                        const next = safeScriptStatus(s);
                        detailStatus.textContent = next ? `Status: ${next}` : 'Status: (none)';
                        sub.textContent = (s.prefs ? 'prefs' : 'no prefs') + (next ? ' · ' + next : '');
                    }
                };
                row.addEventListener('click', (ev) => {
                    if (ev.target.closest('button')) return;
                    toggleDetail();
                });
                row.addEventListener('keydown', (ev) => {
                    if (ev.key === 'Enter' || ev.key === ' ') {
                        ev.preventDefault();
                        toggleDetail();
                    }
                });

                list.appendChild(row);
            });
        }
        wrap.appendChild(list);

        const logTitle = document.createElement('div');
        logTitle.className = 'tsh-debug-section-title';
        logTitle.textContent = 'Recent Hub events';
        wrap.appendChild(logTitle);

        const log = document.createElement('pre');
        log.className = 'tsh-debug-log';
        log.textContent = debugLog.length ? debugLog.join('\n') : 'No events yet.';
        wrap.appendChild(log);

        container.appendChild(wrap);
    }

    // =========================================================================
    // ONBOARDING
    // =========================================================================
    function showOnboarding() {
        if (localStorage.getItem(STORAGE_KEY)) return;
        if (document.getElementById('tsh-onboarding')) return;

        const overlay = document.createElement('div');
        overlay.id = 'tsh-onboarding';
        overlay.className = 'tsh-onboarding';
        overlay.innerHTML = `
            <div class="tsh-onboard-card">
                <h3>Welcome to Script Hub</h3>
                <p>This is your control center for Torn userscripts.</p>
                <ul>
                    <li>Scripts register here automatically</li>
                    <li>One dashboard to launch everything</li>
                    <li>Inline preferences when scripts support them</li>
                </ul>
                <p class="tsh-onboard-hint">Click the <strong>Script Hub</strong> item in the sidebar to open the dashboard.</p>
                <button class="tsh-onboard-btn" id="tsh-onboard-dismiss">Got it</button>
            </div>
        `;
        document.body.appendChild(overlay);

        overlay.querySelector('#tsh-onboard-dismiss').addEventListener('click', dismissOnboarding);
        overlay.addEventListener('click', e => {
            if (e.target === overlay) dismissOnboarding();
        });
    }

    function dismissOnboarding() {
        const overlay = document.getElementById('tsh-onboarding');
        if (overlay) overlay.remove();
        try { localStorage.setItem(STORAGE_KEY, HUB_VERSION); } catch (e) {}
    }

    // =========================================================================
    // UTILS
    // =========================================================================
    function escapeHtml(str) {
        const div = document.createElement('div');
        div.textContent = str;
        return div.innerHTML;
    }

    function relativeTime(ts) {
        const diffMs = Date.now() - ts;
        const mins = Math.round(diffMs / 60000);
        if (mins < 1) return 'checked just now';
        if (mins < 60) return `checked ${mins}m ago`;
        const hrs = Math.round(mins / 60);
        if (hrs < 24) return `checked ${hrs}h ago`;
        const days = Math.round(hrs / 24);
        return `checked ${days}d ago`;
    }

    // =========================================================================
    // STYLES
    // =========================================================================
    function installStyles() {
        if (document.getElementById(IDS.STYLE)) return;

        const style = document.createElement('style');
        style.id = IDS.STYLE;
        style.textContent = `
            #tsh-sidebar-row .tsh-svg { width:18px !important; height:18px !important; display:block !important; }
            #tsh-sidebar-row { cursor:pointer; position:relative; }
            #tsh-sidebar-row.tsh-disabled { cursor:default; opacity:0.45; filter:grayscale(0.6); }
            #tsh-sidebar-row.tsh-disabled .tsh-svg { opacity:0.5; }
            .tsh-watch-dot {
                position:absolute; top:-2px; right:-2px;
                width:8px; height:8px; border-radius:50%;
                background:#e0554f; box-shadow:0 0 0 2px var(--tsh-bg,#f5f5f5);
                pointer-events:none;
            }

            .tsh-menu {
                position:fixed; z-index:2147483647;
                min-width:240px; max-width:320px; padding:6px;
                display:none;
                background:var(--tsh-bg,#f5f5f5); color:var(--tsh-text,#222);
                border:1px solid var(--tsh-border,#bbb); border-radius:6px;
                box-shadow:0 8px 24px rgba(0,0,0,.35);
                font-family:Arial,Helvetica,sans-serif; font-size:13px;
            }
            .tsh-menu.tsh-open { display:block; }
            .tsh-item {
                width:100%; min-height:36px; display:flex; align-items:center;
                padding:8px 10px; margin:0; border:0; border-radius:4px;
                background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer;
            }
            .tsh-item:hover, .tsh-item:focus { background:var(--tsh-hover,rgba(0,0,0,.08)); outline:none; }
            .tsh-item-manage { border-top:1px solid var(--tsh-border,#bbb); margin-top:4px; padding-top:10px; border-radius:0 0 4px 4px; font-weight:700; }
            .tsh-item-text { flex:1; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
            .tsh-item-version { margin-left:8px; opacity:.5; font-size:11px; }
            .tsh-empty { padding:12px; opacity:.55; font-size:12px; }
            .tsh-divider { height:1px; background:var(--tsh-border,#bbb); margin:4px 0; opacity:.6; }

            .tsh-dashboard {
                position:fixed; inset:0; z-index:2147483646;
                display:none; align-items:flex-start; justify-content:center;
                padding:12px; padding-top:max(12px, env(safe-area-inset-top, 0px));
                font-family:Arial,Helvetica,sans-serif;
            }
            .tsh-dashboard.tsh-dash-open { display:flex; }
            .tsh-dash-backdrop { position:absolute; inset:0; background:rgba(0,0,0,.55); }
            .tsh-dash-panel {
                position:relative; width:min(100%, 480px);
                max-height:calc(100vh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
                display:flex; flex-direction:column;
                background:var(--tsh-bg,#f5f5f5); color:var(--tsh-text,#222);
                border:1px solid var(--tsh-border,#bbb); border-radius:4px;
                box-shadow:0 10px 28px rgba(0,0,0,.4); overflow:hidden;
                font-size:12px;
            }
            .tsh-dash-header {
                display:flex; align-items:center; justify-content:space-between;
                padding:8px 10px; border-bottom:1px solid var(--tsh-border,#bbb); flex-shrink:0;
            }
            .tsh-dash-title { font-size:13px; font-weight:700; }
            .tsh-dash-ver { opacity:.5; font-size:11px; font-weight:400; margin-left:5px; }
            .tsh-dash-close {
                width:26px; height:26px; border:0; border-radius:3px;
                background:transparent; color:inherit; font-size:18px; line-height:1;
                cursor:pointer; display:flex; align-items:center; justify-content:center;
            }
            .tsh-dash-close:hover { background:var(--tsh-hover,rgba(0,0,0,.08)); }

            .tsh-dash-tabs {
                display:flex; padding:0 6px; border-bottom:1px solid var(--tsh-border,#bbb);
                background:var(--tsh-tab-bg,rgba(0,0,0,.03)); flex-shrink:0;
            }
            .tsh-dash-tab {
                flex:1; padding:8px 6px 7px; border:0; border-bottom:2px solid transparent;
                margin-bottom:-1px; background:transparent; color:inherit;
                font:inherit; font-size:12px; font-weight:600; cursor:pointer; opacity:.55; text-align:center;
            }
            .tsh-dash-tab.active { border-bottom-color:#4e8ac7; opacity:1; }
            .tsh-dash-tab:hover { opacity:.85; }

            .tsh-dash-body { flex:1; overflow-y:auto; padding:8px 10px; -webkit-overflow-scrolling:touch; }
            .tsh-dash-panel-content { display:none; }
            .tsh-dash-panel-content.active { display:block; }
            .tsh-dash-empty { text-align:center; padding:24px 12px; opacity:.6; font-size:12px; line-height:1.5; }
            .tsh-dash-empty span { font-size:11px; display:block; margin-top:4px; }

            .tsh-script-list { display:flex; flex-direction:column; gap:4px; }
            .tsh-script-row {
                min-height:34px; display:flex; align-items:center;
                border:1px solid var(--tsh-border,#bbb); border-radius:3px;
                background:var(--tsh-row-bg,rgba(0,0,0,.02)); overflow:hidden;
            }
            .tsh-script-row:hover { border-color:rgba(78,138,199,.55); background:var(--tsh-hover,rgba(0,0,0,.05)); }
            .tsh-script-row-off { opacity:.55; }
            .tsh-script-row-inactive { opacity:.45; }
            .tsh-script-launch {
                min-width:0; flex:1; display:flex; align-items:center;
                padding:6px 10px; border:0; background:transparent; color:inherit;
                font:inherit; font-size:12px; text-align:left; cursor:pointer; min-height:34px;
            }
            .tsh-script-launch:disabled { cursor:default; }
            .tsh-script-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:600; }
            .tsh-script-version { margin-left:auto; padding-left:8px; opacity:.5; font-size:10px; flex-shrink:0; }
            .tsh-script-toggle {
                margin-right:6px; min-width:40px; min-height:24px; padding:2px 8px;
                border:1px solid var(--tsh-border,#bbb); border-radius:3px;
                background:transparent; color:inherit; font-size:11px; font-weight:600; cursor:pointer; opacity:.75;
            }
            .tsh-script-toggle.active { color:#4e8ac7; border-color:rgba(78,138,199,.65); opacity:1; }
            .tsh-script-inactive-note { margin-right:10px; font-size:10px; opacity:.55; font-style:italic; white-space:nowrap; }

            .tsh-prefs-list { display:flex; flex-direction:column; gap:4px; }
            .tsh-prefs-row {
                min-height:36px; display:flex; align-items:center; justify-content:space-between;
                padding:0 10px; border:1px solid var(--tsh-border,#bbb); border-radius:3px;
                background:var(--tsh-row-bg,rgba(0,0,0,.02)); color:inherit;
                font:inherit; font-size:12px; font-weight:600; cursor:pointer; text-align:left;
            }
            .tsh-prefs-row:hover { border-color:rgba(78,138,199,.55); background:var(--tsh-hover,rgba(0,0,0,.05)); }
            .tsh-prefs-row-action { font-size:11px; font-weight:500; opacity:.6; }

            .tsh-prefs-detail-header { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
            .tsh-prefs-back {
                padding:4px 8px; border:1px solid var(--tsh-border,#bbb); border-radius:3px;
                background:transparent; color:inherit; font-size:12px; cursor:pointer;
            }
            .tsh-prefs-back:hover { background:var(--tsh-hover,rgba(0,0,0,.06)); }
            .tsh-prefs-detail-title { font-size:13px; font-weight:700; }
            .tsh-prefs-status {
                margin-bottom:8px; padding:6px 10px; border-radius:3px;
                background:var(--tsh-status-bg,rgba(78,138,199,.12));
                color:var(--tsh-status-text,#2a5a8a); font-size:12px; font-weight:600;
            }
            .tsh-prefs-detail-body { display:flex; flex-direction:column; gap:10px; }
            .tsh-pref-grid {
                display:grid; grid-template-columns:repeat(auto-fill, minmax(120px, 1fr)); gap:6px 8px;
            }
            .tsh-pref-toggles {
                display:grid; grid-template-columns:repeat(auto-fill, minmax(130px, 1fr)); gap:6px;
            }
            .tsh-pref-stack { display:flex; flex-direction:column; gap:6px; }
            .tsh-pref-btn-row { display:flex; flex-wrap:wrap; gap:6px; }
            .tsh-pref-field { display:flex; flex-direction:column; gap:2px; min-width:0; }
            .tsh-pref-label-row { display:flex; align-items:center; gap:3px; min-height:16px; }
            .tsh-pref-label { font-size:11px; opacity:.85; }
            .tsh-pref-label.tsh-has-hint { cursor:help; }
            .tsh-pref-hint { font-size:10px; color:#4e8ac7; cursor:help; line-height:1; }
            .tsh-pref-input, .tsh-pref-select {
                width:100%; box-sizing:border-box; padding:4px 7px;
                border:1px solid var(--tsh-border,#bbb); border-radius:3px;
                background:var(--tsh-input-bg,#fff); color:var(--tsh-text,#222);
                font-size:12px; min-height:26px;
            }
            .tsh-pref-toggle {
                align-self:flex-start; min-width:44px; min-height:22px; padding:2px 8px;
                border:1px solid var(--tsh-border,#bbb); border-radius:11px;
                background:var(--tsh-toggle-off,#ddd); color:inherit;
                font-size:11px; font-weight:700; cursor:pointer;
            }
            .tsh-pref-toggle.active { background:#4e8ac7; border-color:#4e8ac7; color:#fff; }
            .tsh-pref-btn {
                padding:5px 10px; border:1px solid #3d6eac; border-radius:3px;
                background:#3d6eac; color:#fff; font-size:12px; font-weight:600;
                cursor:pointer; min-height:28px;
            }
            .tsh-pref-btn:hover { background:#355e94; }
            .tsh-prefs-footer {
                margin-top:10px; padding-top:6px; border-top:1px solid var(--tsh-border,#bbb);
                font-size:10px; opacity:.5; text-align:center;
            }

            .tsh-lib-wrap { display:flex; flex-direction:column; gap:10px; }
            .tsh-lib-section-title {
                font-size:10px; font-weight:700; text-transform:uppercase;
                letter-spacing:.04em; opacity:.55; margin:2px 0 -4px;
            }
            .tsh-lib-section-title:first-child { margin-top:0; }
            .tsh-lib-intro { margin:0; font-size:12px; opacity:.7; line-height:1.5; }
            .tsh-lib-list { display:flex; flex-direction:column; gap:6px; }
            .tsh-lib-row {
                display:flex; align-items:center; justify-content:space-between; gap:10px;
                padding:8px 10px; border:1px solid var(--tsh-border,#bbb); border-radius:4px;
                background:var(--tsh-row-bg,rgba(0,0,0,.02));
            }
            .tsh-lib-row:hover { border-color:rgba(78,138,199,.55); background:var(--tsh-hover,rgba(0,0,0,.05)); }
            .tsh-lib-info { min-width:0; flex:1; }
            .tsh-lib-name-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
            .tsh-lib-name { font-size:12px; font-weight:700; }
            .tsh-lib-version { font-size:10px; opacity:.5; }
            .tsh-lib-badge {
                font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.03em;
                padding:2px 6px; border-radius:9px;
                background:var(--tsh-toggle-off,#ddd); color:inherit; opacity:.75;
            }
            .tsh-lib-badge-known { background:rgba(78,138,199,.18); color:#2a5a8a; opacity:1; }
            .tsh-lib-badge-active { background:#4e8ac7; color:#fff; opacity:1; }
            .tsh-lib-desc { margin-top:3px; font-size:11px; opacity:.65; line-height:1.4; }
            .tsh-lib-install {
                flex-shrink:0; padding:5px 12px; border:1px solid #3d6eac; border-radius:3px;
                background:#3d6eac; color:#fff; font-size:11px; font-weight:600;
                text-decoration:none; white-space:nowrap;
            }
            .tsh-lib-install:hover { background:#355e94; }
            .tsh-lib-contact {
                margin-top:4px; padding-top:8px; border-top:1px solid var(--tsh-border,#bbb);
                font-size:11px; opacity:.75; text-align:center;
            }
            .tsh-lib-contact a { color:#4e8ac7; text-decoration:none; font-weight:600; }
            .tsh-lib-contact a:hover { text-decoration:underline; }

            .tsh-watch-intro-row { display:flex; align-items:center; justify-content:space-between; gap:8px; }
            .tsh-lib-check-all {
                flex-shrink:0; padding:4px 10px; border:1px solid var(--tsh-border,#bbb);
                border-radius:3px; background:transparent; color:inherit; font-size:11px;
                font-weight:600; cursor:pointer;
            }
            .tsh-lib-check-all:hover { background:var(--tsh-hover,rgba(0,0,0,.06)); }
            .tsh-lib-check-all:disabled { opacity:.6; cursor:default; }

            .tsh-watch-add { display:flex; gap:6px; }
            .tsh-watch-add-input {
                flex:1; min-width:0; box-sizing:border-box; padding:6px 8px;
                border:1px solid var(--tsh-border,#bbb); border-radius:3px;
                background:var(--tsh-input-bg,#fff); color:var(--tsh-text,#222); font-size:12px;
            }
            .tsh-watch-add-btn {
                flex-shrink:0; padding:6px 14px; border:1px solid #3d6eac; border-radius:3px;
                background:#3d6eac; color:#fff; font-size:12px; font-weight:600; cursor:pointer;
            }
            .tsh-watch-add-btn:hover { background:#355e94; }
            .tsh-watch-add-btn:disabled { opacity:.6; cursor:default; }
            .tsh-watch-add-status { font-size:11px; opacity:.7; min-height:14px; }

            .tsh-watch-row-updated { border-color:rgba(224,85,79,.55) !important; }
            .tsh-watch-badge-update { background:#e0554f; color:#fff; opacity:1; }
            .tsh-watch-checked { font-size:10px; opacity:.5; white-space:nowrap; flex-shrink:0; }
            .tsh-watch-remove {
                flex-shrink:0; width:24px; height:24px; border:1px solid var(--tsh-border,#bbb);
                border-radius:3px; background:transparent; color:inherit; font-size:14px;
                line-height:1; cursor:pointer;
            }
            .tsh-watch-remove:hover { background:rgba(224,85,79,.15); border-color:rgba(224,85,79,.55); }

            .tsh-debug-wrap { display:flex; flex-direction:column; gap:10px; }
            .tsh-debug-intro { margin:0; font-size:12px; opacity:.7; line-height:1.5; }
            .tsh-debug-meta { font-size:11px; opacity:.65; }
            .tsh-debug-actions { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
            .tsh-debug-btn {
                flex-shrink:0; padding:5px 12px; border:1px solid #3d6eac; border-radius:3px;
                background:#3d6eac; color:#fff; font-size:11px; font-weight:600;
                cursor:pointer; line-height:1.2; white-space:nowrap;
            }
            .tsh-debug-btn:hover { background:#355e94; }
            .tsh-debug-btn-muted {
                background:transparent; color:inherit; border-color:var(--tsh-border,#bbb); font-weight:600;
            }
            .tsh-debug-btn-muted:hover { background:var(--tsh-hover,rgba(0,0,0,.05)); }
            .tsh-debug-section-title {
                font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; opacity:.55; margin-top:2px;
            }
            .tsh-debug-list { display:flex; flex-direction:column; gap:4px; }
            .tsh-debug-row {
                padding:6px 8px; border:1px solid var(--tsh-border,#bbb); border-radius:3px;
                background:var(--tsh-row-bg,rgba(0,0,0,.02)); font-size:11px;
            }
            .tsh-debug-row-clickable { cursor:pointer; }
            .tsh-debug-row-clickable:hover { border-color:rgba(78,138,199,.55); background:var(--tsh-hover,rgba(0,0,0,.05)); }
            .tsh-debug-row-open { border-color:rgba(78,138,199,.7); }
            .tsh-debug-row-main { display:flex; flex-wrap:wrap; align-items:baseline; gap:6px; }
            .tsh-debug-id, .tsh-debug-ver { font-size:10px; opacity:.5; font-weight:400; }
            .tsh-debug-row-sub { margin-top:2px; font-size:10px; opacity:.6; line-height:1.35; }
            .tsh-debug-detail {
                margin-top:8px; padding-top:8px; border-top:1px solid var(--tsh-border,#bbb);
                display:flex; flex-direction:column; gap:8px;
            }
            .tsh-debug-detail-status { font-size:11px; opacity:.8; line-height:1.4; word-break:break-word; }
            .tsh-debug-empty { font-size:12px; opacity:.55; padding:6px 0; }
            .tsh-debug-log {
                margin:0; padding:8px; max-height:160px; overflow:auto;
                border:1px solid var(--tsh-border,#bbb); border-radius:3px;
                background:var(--tsh-input-bg,#fff); font-size:10px; line-height:1.4;
                white-space:pre-wrap; word-break:break-word;
            }

            .tsh-onboarding {
                position:fixed; inset:0; z-index:2147483647;
                display:flex; align-items:center; justify-content:center;
                background:rgba(0,0,0,.55); font-family:Arial,Helvetica,sans-serif; padding:16px;
            }
            .tsh-onboard-card {
                width:100%; max-width:400px; padding:24px;
                background:var(--tsh-bg,#f5f5f5); color:var(--tsh-text,#222);
                border-radius:8px; box-shadow:0 16px 48px rgba(0,0,0,.4); text-align:center;
            }
            .tsh-onboard-card h3 { margin:0 0 10px; font-size:18px; }
            .tsh-onboard-card p { margin:0 0 12px; font-size:13px; opacity:.85; line-height:1.5; }
            .tsh-onboard-card ul { text-align:left; display:inline-block; margin:0 0 16px; padding-left:20px; font-size:13px; opacity:.85; }
            .tsh-onboard-hint {
                background:var(--tsh-row-bg,rgba(0,0,0,.05)); padding:10px 12px;
                border-radius:5px; font-size:12px; margin-bottom:16px !important;
            }
            .tsh-onboard-btn {
                padding:10px 28px; border:0; border-radius:5px;
                background:#3d6eac; color:#fff; font-size:14px; font-weight:700; cursor:pointer; min-height:40px;
            }

            :root {
                --tsh-bg:#f5f5f5; --tsh-text:#222; --tsh-border:#c5c5c5;
                --tsh-hover:rgba(0,0,0,.07); --tsh-row-bg:rgba(0,0,0,.025);
                --tsh-input-bg:#fff; --tsh-toggle-off:#d8d8d8; --tsh-tab-bg:rgba(0,0,0,.03);
                --tsh-status-bg:rgba(78,138,199,.12); --tsh-status-text:#2a5a8a;
            }
            body.dark-mode {
                --tsh-bg:#252525; --tsh-text:#e8e8e8; --tsh-border:#4a4a4a;
                --tsh-hover:rgba(255,255,255,.08); --tsh-row-bg:rgba(255,255,255,.03);
                --tsh-input-bg:#1a1a1a; --tsh-toggle-off:#3a3a3a; --tsh-tab-bg:rgba(255,255,255,.03);
                --tsh-status-bg:rgba(78,138,199,.18); --tsh-status-text:#8bb8e8;
            }

            @media (max-width: 480px) {
                .tsh-dash-panel { width:100%; }
                .tsh-pref-grid, .tsh-pref-toggles { grid-template-columns:1fr 1fr; }
                .tsh-lib-row { flex-direction:column; align-items:flex-start; }
                .tsh-lib-install { align-self:flex-end; }
            }
            @media (max-width: 360px) {
                .tsh-pref-grid, .tsh-pref-toggles { grid-template-columns:1fr; }
            }
        `;
        (document.head || document.documentElement).appendChild(style);
    }

    // =========================================================================
    // MOUNT
    // =========================================================================
    function mount() {
        if (!document.body) return false;

        document.querySelectorAll('[data-tsh-hub-slide]').forEach(el => el.remove());
        document.querySelectorAll('#' + IDS.ROW).forEach(el => el.remove());
        const oldMenu = document.getElementById(IDS.MENU);
        if (oldMenu) oldMenu.remove();

        installStyles();

        const calendar = document.getElementById('nav-calendar');
        const calendarSlide = calendar ? calendar.closest('.swiper-slide') : null;
        const swiperWrapper = calendarSlide ? calendarSlide.closest('.swiper-wrapper') : null;
        const canCloneDesktop = !!(calendar && calendar.parentNode && !calendarSlide);

        let row;
        if (canCloneDesktop) {
            row = createNativeRow(calendar);
            calendar.parentNode.insertBefore(row, calendar.nextSibling);
            mountMode = 'row';
        } else if (calendarSlide && swiperWrapper) {
            row = createSwiperSlide(calendar, calendarSlide);
            swiperWrapper.insertBefore(row, calendarSlide.nextSibling);
            mountMode = 'swiper';
            try {
                const swiperContainer = swiperWrapper.closest('.swiper') || swiperWrapper.closest('.swiper-container');
                if (swiperContainer && swiperContainer.swiper && typeof swiperContainer.swiper.update === 'function') {
                    swiperContainer.swiper.update();
                }
            } catch (e) {}
        } else {
            return false;
        }

        updateButtonState();

        const menu = createMenu();
        document.body.appendChild(menu);
        renderMenu();

        if (!readyFired) {
            readyFired = true;
            document.dispatchEvent(new CustomEvent(EVENTS.READY, { detail: { version: HUB_VERSION } }));
        }
        return true;
    }

    // =========================================================================
    // WATCHER
    // =========================================================================
    function startSidebarWatcher() {
        let rafPending = false;
        let mountSuccess = false;

        const desiredMode = () => {
            const calendar = document.getElementById('nav-calendar');
            const calendarSlide = calendar ? calendar.closest('.swiper-slide') : null;
            if (calendar && calendar.parentNode && !calendarSlide) return 'row';
            if (calendarSlide) return 'swiper';
            return null;
        };

        const tryMount = () => {
            if (rafPending) return;
            rafPending = true;
            requestAnimationFrame(() => {
                rafPending = false;
                const existing = document.getElementById(IDS.ROW);
                const missing = !existing || !document.body.contains(existing);
                const mode = desiredMode();
                if (!mode) return;
                if (missing || mountMode !== mode) {
                    if (mount()) {
                        mountSuccess = true;
                        setTimeout(showOnboarding, 600);
                    }
                }
            });
        };

        if (document.body) {
            if (mount()) {
                mountSuccess = true;
                setTimeout(showOnboarding, 600);
            }
        } else {
            const bodyWatcher = new MutationObserver(() => {
                if (document.body) {
                    bodyWatcher.disconnect();
                    if (mount()) {
                        mountSuccess = true;
                        setTimeout(showOnboarding, 600);
                    }
                }
            });
            bodyWatcher.observe(document.documentElement, { childList: true });
        }

        const observer = new MutationObserver(tryMount);
        observer.observe(document.documentElement, { childList: true, subtree: true });
        window.addEventListener('resize', tryMount, { passive: true });

        let attempts = 0;
        const timer = setInterval(() => {
            if (mountSuccess || attempts++ > 60) {
                clearInterval(timer);
                return;
            }
            tryMount();
        }, 400);
    }

    // =========================================================================
    // GLOBAL HANDLERS – primary click opens DASHBOARD
    // =========================================================================
    function onOutsideInteraction(e) {
        const row  = document.getElementById(IDS.ROW);
        const menu = document.getElementById(IDS.MENU);
        if (!row || !menu) return;
        if (e.target !== row && !row.contains(e.target) && !menu.contains(e.target)) {
            closeMenu();
        }
    }

    function onHubActivation(e) {
        if (!isUIEnabled()) return;
        const target = e.target instanceof Element ? e.target : null;
        const row = target?.closest('#' + IDS.ROW);
        if (!row || !row.dataset.tshHubButton) return;
        if (e.button !== 0) return;

        e.preventDefault();
        e.stopImmediatePropagation();
        e.stopPropagation();
        requestDashboardOpen();
    }

    function onHubContextMenu(e) {
        const target = e.target instanceof Element ? e.target : null;
        const row = target?.closest('#' + IDS.ROW);
        if (!row || !row.dataset.tshHubButton) return;
        if (isUIEnabled()) return;

        e.preventDefault();
        e.stopImmediatePropagation();
        e.stopPropagation();
        setUIEnabled(true);
        requestDashboardOpen();
    }

    document.addEventListener('click', onOutsideInteraction, true);
    document.addEventListener('touchstart', onOutsideInteraction, { passive: true, capture: true });
    document.addEventListener('click', onHubActivation, true);
    document.addEventListener('contextmenu', onHubContextMenu, true);

    document.addEventListener('keydown', e => {
        if (e.key === 'Escape') {
            if (currentPrefsScriptId) {
                currentPrefsScriptId = null;
                const dash = document.getElementById(IDS.DASHBOARD);
                if (dash) renderPrefsPanel(dash.querySelector('[data-panel="prefs"]'));
            } else {
                closeMenu();
                closeDashboard();
            }
        }
        if (e.shiftKey && (e.key === 'H' || e.key === 'h')) {
            e.preventDefault();
            setUIEnabled(!isUIEnabled());
        }
    });
    window.addEventListener('resize', () => {
        const menu = document.getElementById(IDS.MENU);
        if (menu && menu.classList.contains('tsh-open')) positionMenu();
    });
    window.addEventListener('scroll', () => {
        const menu = document.getElementById(IDS.MENU);
        if (menu && menu.classList.contains('tsh-open')) positionMenu();
    }, true);

    // =========================================================================
    // START
    // =========================================================================
    drainQueue();

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