Devtool Mobile Pro

Enables you to open developer tools on mobile

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Devtool Mobile Pro
// @namespace    https://greasyfork.org/users/1426529
// @version      2.1.0
// @description  Enables you to open developer tools on mobile
// @match        *://*/*
// @grant        none
// @license      MIT
// @run-at       document-start
// ==/UserScript==

(function () {
    "use strict";

    /* =========================================================
     * 0. Trusted Types support
     * ========================================================= */

    let ttPolicy = null;
    try {
        if (window.trustedTypes && trustedTypes.createPolicy) {
            ttPolicy = trustedTypes.createPolicy("devtool-mobile-" + Date.now() + "-" + Math.random().toString(36).slice(2), {
                createHTML: (s) => s,
                createScript: (s) => s,
                createScriptURL: (s) => s,
            });
        }
    } catch (_) {}
    function tt(html) {
        return ttPolicy ? ttPolicy.createHTML(html) : html;
    }
    function ttScript(s) {
        return ttPolicy ? ttPolicy.createScript(s) : s;
    }
    function ttScriptURL(s) {
        return ttPolicy ? ttPolicy.createScriptURL(s) : s;
    }
    // Best-effort only helps if the page hasn't already claimed a policy named "default".
    try {
        if (window.trustedTypes && trustedTypes.createPolicy) {
            trustedTypes.createPolicy("default", { createHTML: (s) => s, createScript: (s) => s, createScriptURL: (s) => s });
        }
    } catch (_) {}

    /* =========================================================
     * 1. Gesture settings
     * ========================================================= */

    const SETTINGS_KEY = "devtoolMobileGestureCfg_v1";
    const DEFAULT_GESTURES = {
        eruda: { fingers: 2, taps: 3 },
        inspector: { fingers: 2, taps: 4 },
    };
    function loadGestures() {
        try {
            const raw = localStorage.getItem(SETTINGS_KEY);
            if (!raw) return { ...DEFAULT_GESTURES };
            const parsed = JSON.parse(raw);
            return {
                eruda: { fingers: parsed?.eruda?.fingers || 2, taps: parsed?.eruda?.taps || 3 },
                inspector: { fingers: parsed?.inspector?.fingers || 2, taps: parsed?.inspector?.taps || 4 },
            };
        } catch (_) {
            return { ...DEFAULT_GESTURES };
        }
    }
    function saveGestures(cfg) {
        try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(cfg)); } catch (_) {}
    }
    let gestureCfg = loadGestures();
    function gesturesCollide(cfg) {
        return cfg.eruda.fingers === cfg.inspector.fingers && cfg.eruda.taps === cfg.inspector.taps;
    }

    /* =========================================================
     * 2. Gesture dispatcher
     * ========================================================= */

    const TAP_WINDOW_MS = 550;
    const tapGroups = {};

    function actionsForFingerCount(n) {
        const list = [];
        if (gestureCfg.eruda.fingers === n) list.push({ type: "eruda", taps: gestureCfg.eruda.taps });
        if (gestureCfg.inspector.fingers === n) list.push({ type: "inspector", taps: gestureCfg.inspector.taps });
        return list;
    }
    function fireGestureAction(type) {
        if (type === "eruda") toggleEruda();
        else if (type === "inspector") toggleInspector();
    }

    document.addEventListener(
        "touchstart",
        (e) => {
            const n = e.touches.length;
            if (n < 2) return;
            const actions = actionsForFingerCount(n);
            if (!actions.length) return;

            if (!tapGroups[n]) tapGroups[n] = { count: 0, timer: null };
            const g = tapGroups[n];
            g.count++;
            if (g.timer) clearTimeout(g.timer);
            g.timer = setTimeout(() => {
                const taps = g.count;
                g.count = 0;
                const match = actions.find((a) => a.taps === taps);
                if (match) fireGestureAction(match.type);
            }, TAP_WINDOW_MS);
        },
        { capture: true, passive: true }
    );

    const KEYBOARD_SHORTCUT_KEY = "devtoolMobileKeyboardEnabled_v1";
    function loadKeyboardEnabled() {
        try {
            const v = localStorage.getItem(KEYBOARD_SHORTCUT_KEY);
            return v === null ? true : v === "1";
        } catch (_) {
            return true;
        }
    }
    function saveKeyboardEnabled(v) {
        try { localStorage.setItem(KEYBOARD_SHORTCUT_KEY, v ? "1" : "0"); } catch (_) {}
    }
    let keyboardShortcutsEnabled = loadKeyboardEnabled();

    // Ctrl+Shift+I (devtools) or Ctrl+Shift+R (hard reload) — browsers already reserve those 
    // Ctrl+Alt+E / Ctrl+Alt+I are not commonly bound by browsers/OS.
    document.addEventListener("keydown", (e) => {
        if (!keyboardShortcutsEnabled) return;
        if (e.ctrlKey && e.altKey && !e.shiftKey && e.key.toLowerCase() === "e") { e.preventDefault(); toggleEruda(); }
        if (e.ctrlKey && e.altKey && !e.shiftKey && e.key.toLowerCase() === "i") { e.preventDefault(); toggleInspector(); }
    });


    /* =========================================================
     * 3. Eruda — reopens automatically after a reload if it was open before.
     * ========================================================= */

    const ERUDA_STATE_KEY = "devtoolMobileErudaOpen_v1";
    let erudaLoaded = false;
    let erudaScriptEl = null;

    function toggleEruda() {
        erudaLoaded ? unloadEruda() : loadEruda();
    }
    function loadEruda() {
        if (erudaLoaded) return;
        erudaScriptEl = document.createElement("script");
        erudaScriptEl.src = ttScriptURL("//cdn.jsdelivr.net/npm/eruda");
        erudaScriptEl.onload = () => {
            try {
                window.eruda.init();
                window.eruda.show();
                erudaLoaded = true;
                try { localStorage.setItem(ERUDA_STATE_KEY, "1"); } catch (_) {}
            } catch (_) {}
        };
        erudaScriptEl.onerror = () => {
            console.warn("[devtool-mobile] eruda failed to load (blocked CDN or CSP). Falling back to built-in inspector.");
            erudaScriptEl = null;
            showInspector();
        };
        (document.body || document.documentElement).appendChild(erudaScriptEl);
    }
    function unloadEruda() {
        if (!erudaLoaded) return;
        try { window.eruda && window.eruda.destroy(); } catch (_) {}
        if (erudaScriptEl?.parentNode) erudaScriptEl.parentNode.removeChild(erudaScriptEl);
        erudaScriptEl = null;
        erudaLoaded = false;
        try { localStorage.setItem(ERUDA_STATE_KEY, "0"); } catch (_) {}
        try {
            document.getElementById("eruda")?.remove();
            delete window.eruda;
        } catch (_) {}
    }

    /* =========================================================
     * 4. Console capture + REPL
     * ========================================================= */

    const MAX_LOG_ENTRIES = 500;
    const logBuffer = [];
    const groupState = { indent: 0 };
    const timeLabels = new Map();
    const countLabels = new Map();

    function argsToText(args) {
        return args
            .map((a) => {
                if (typeof a === "string") return a;
                try { return JSON.stringify(a); } catch (_) { return String(a); }
            })
            .join(" ");
    }
    function withIndent(text) {
        return groupState.indent > 0 ? "  ".repeat(groupState.indent) + text : text;
    }
    function pushLog(tag, args) {
        const text = withIndent(argsToText(args));
        logBuffer.push({ time: new Date().toLocaleTimeString(), tag, text });
        if (logBuffer.length > MAX_LOG_ENTRIES) logBuffer.shift();
        if (panelVisible && activeTab === "console") renderConsole();
    }
    function pushTableLog(data) {
        const tableData = consoleTableData(data);
        logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "table", text: "[table]", tableData });
        if (logBuffer.length > MAX_LOG_ENTRIES) logBuffer.shift();
        if (panelVisible && activeTab === "console") renderConsole();
    }
    function consoleTableData(data) {
        if (data == null || typeof data !== "object") return { headers: ["Value"], rows: [[String(data)]] };
        const rows = Array.isArray(data) ? data.map((v, i) => [String(i), v]) : Object.entries(data);
        const colSet = new Set();
        let hasPrimitiveValues = false;
        rows.forEach(([, v]) => {
            if (v && typeof v === "object") Object.keys(v).forEach((k) => colSet.add(k));
            else hasPrimitiveValues = true;
        });
        const cols = Array.from(colSet);
        const headers = ["(index)", ...cols, ...(hasPrimitiveValues ? ["Values"] : [])];
        const dataRows = rows.map(([idx, v]) => {
            const line = [idx];
            cols.forEach((c) => {
                const cell = v && typeof v === "object" && c in v ? v[c] : "";
                line.push(cell === undefined ? "" : typeof cell === "object" ? JSON.stringify(cell) : String(cell));
            });
            if (hasPrimitiveValues) line.push(v && typeof v === "object" ? "" : String(v));
            return line;
        });
        return { headers, rows: dataRows };
    }
    function logEntryToText(l) {
        if (l.tag === "table" && l.tableData) {
            return [l.tableData.headers.join("\t"), ...l.tableData.rows.map((r) => r.join("\t"))].join("\n");
        }
        return l.text;
    }

    ["log", "warn", "error", "info", "debug"].forEach((method) => {
        const orig = console[method];
        console[method] = function (...args) {
            pushLog(method, args);
            return orig ? orig.apply(console, args) : undefined;
        };
    });
    const origClear = console.clear;
    console.clear = function (...args) {
        logBuffer.length = 0;
        if (panelVisible && activeTab === "console") renderConsole();
        return origClear ? origClear.apply(console, args) : undefined;
    };
    const origDir = console.dir;
    console.dir = function (...args) {
        pushLog("log", args);
        return origDir ? origDir.apply(console, args) : undefined;
    };
    const origTable = console.table;
    console.table = function (data, ...rest) {
        pushTableLog(data);
        return origTable ? origTable.apply(console, [data, ...rest]) : undefined;
    };
    console.time = function (label = "default") {
        timeLabels.set(label, performance.now());
    };
    console.timeEnd = function (label = "default") {
        if (!timeLabels.has(label)) { pushLog("warn", [`Timer '${label}' does not exist`]); return; }
        const elapsed = performance.now() - timeLabels.get(label);
        timeLabels.delete(label);
        pushLog("log", [`${label}: ${elapsed.toFixed(2)}ms`]);
    };
    console.timeLog = function (label = "default", ...args) {
        if (!timeLabels.has(label)) { pushLog("warn", [`Timer '${label}' does not exist`]); return; }
        const elapsed = performance.now() - timeLabels.get(label);
        pushLog("log", [`${label}: ${elapsed.toFixed(2)}ms`, ...args]);
    };
    console.group = console.groupCollapsed = function (...args) {
        pushLog("log", args.length ? args : ["console.group"]);
        groupState.indent++;
    };
    console.groupEnd = function () {
        groupState.indent = Math.max(0, groupState.indent - 1);
    };
    console.count = function (label = "default") {
        const n = (countLabels.get(label) || 0) + 1;
        countLabels.set(label, n);
        pushLog("log", [`${label}: ${n}`]);
    };
    console.countReset = function (label = "default") {
        countLabels.set(label, 0);
    };
    console.assert = function (condition, ...args) {
        if (condition) return;
        pushLog("error", ["Assertion failed:", ...args]);
    };
    console.trace = function (...args) {
        const stack = new Error().stack || "";
        pushLog("log", [[["Trace:", ...args].join(" "), stack].join("\n")]);
    };

    window.addEventListener("error", (e) => {
        pushLog("error", [`${e.message} @ ${e.filename}:${e.lineno}:${e.colno}`]);
    });
    window.addEventListener("unhandledrejection", (e) => {
        pushLog("error", [`Unhandled promise rejection: ${e.reason}`]);
    });

    function formatEvalResult(v) {
        if (v === undefined) return "undefined";
        if (v === null) return "null";
        if (typeof v === "function") return v.toString();
        if (typeof v === "string") return v;
        if (v instanceof Node) return `<${v.nodeName?.toLowerCase() || "node"}> ${v.outerHTML ? v.outerHTML.slice(0, 300) : ""}`;
        try { return JSON.stringify(v, null, 2); } catch (_) { try { return String(v); } catch (__) { return "(unserializable value)"; } }
    }

    function attemptNonceEval(code) {
        return new Promise((resolve, reject) => {
            try {
                const existing = document.querySelector("script[nonce]");
                const nonce = existing ? existing.nonce || existing.getAttribute("nonce") : null;
                if (!nonce) { reject(new Error("No reusable nonce found on this page.")); return; }
                const resultKey = "__di_eval_" + Math.random().toString(36).slice(2);
                const s = document.createElement("script");
                s.setAttribute("nonce", nonce);
                s.textContent = ttScript(
                    `try { window['${resultKey}'] = { ok: true, value: (function(){ return (${code}); })() }; } catch (e) { window['${resultKey}'] = { ok: false, error: String(e) }; }`
                );
                document.documentElement.appendChild(s);
                s.remove();
                const res = window[resultKey];
                delete window[resultKey];
                if (!res) { reject(new Error("The nonce script did not run — CSP blocked it too.")); return; }
                if (res.ok) resolve(res.value);
                else reject(new Error(res.error));
            } catch (err) {
                reject(err);
            }
        });
    }

    async function runCommand(code) {
        logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "log", text: "> " + code });
        try {
            const result = (0, eval)(code);
            logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "result", text: formatEvalResult(result) });
        } catch (err) {
            const msg = String((err && err.message) || err);
            const isCspEval = err instanceof EvalError || /unsafe-eval|content security policy|trusted-types-eval/i.test(msg);
            if (isCspEval) {
                logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "warn", text: "eval() is blocked by this site's CSP (no 'unsafe-eval'). Trying a nonce-reuse fallback…" });
                try {
                    const result = await attemptNonceEval(code);
                    logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "result", text: formatEvalResult(result) + "\n(via nonce-reuse fallback — this trick only works on some sites)" });
                } catch (err2) {
                    logBuffer.push({
                        time: new Date().toLocaleTimeString(),
                        tag: "error",
                        text:
                            "Fallback also failed: " + ((err2 && err2.message) || err2) +
                            "\nThis site's CSP fully blocks dynamic code execution — no way around that from a userscript.",
                    });
                }
            } else {
                logBuffer.push({ time: new Date().toLocaleTimeString(), tag: "error", text: (err && err.stack) || String(err) });
            }
        }
        if (logBuffer.length > MAX_LOG_ENTRIES) logBuffer.splice(0, logBuffer.length - MAX_LOG_ENTRIES);
        renderConsole();
        const input = body.querySelector(".di-cmd-input");
        if (input) input.focus();
    }

    /* =========================================================
     * 5. Network capture
     * ========================================================= */

    const NETWORK_LOG_KEY = "devtoolMobileNetworkLog_v1";
    const MAX_NETWORK_ENTRIES = 200;
    let networkIdCounter = 0;

    function isTextLikeContentType(ct) {
        const c = (ct || "").toLowerCase();
        if (!c) return true; // unknown — assume text, safer to show than to hide
        return /^text\/|application\/(json|javascript|xml|x-www-form-urlencoded)|\+json|\+xml/.test(c);
    }

    // Live per-entry state, kept separate from `networkLog` so the log remains JSON-safe for sessionStorage. 
    // Stores Response clones and image object URLs.
    const networkLiveState = new Map(); // id -> { liveResponse?, blobUrl? }

    function loadNetworkLog() {
        try {
            const raw = sessionStorage.getItem(NETWORK_LOG_KEY);
            const arr = raw ? JSON.parse(raw) : [];
            return Array.isArray(arr) ? arr : [];
        } catch (_) { return []; }
    }
    const networkLog = loadNetworkLog();
    networkIdCounter = networkLog.reduce((max, n) => Math.max(max, n.id || 0), 0);
    // Restored entries cannot retain a live Response clone, so they are marked for on-demand fetching.
    networkLog.forEach((n) => { if (!n.bodyLoaded) n.bodyLoaded = false; });

    let networkSaveTimer = null;
    function saveNetworkLog() {
        if (networkSaveTimer) return;
        networkSaveTimer = setTimeout(() => {
            networkSaveTimer = null;
            try { sessionStorage.setItem(NETWORK_LOG_KEY, JSON.stringify(networkLog.slice(-MAX_NETWORK_ENTRIES))); } catch (_) {}
        }, 250);
    }
    const NETWORK_RECORDING_KEY = "devtoolMobileNetworkRecording_v1";
    function loadNetworkRecording() {
        try {
            const v = sessionStorage.getItem(NETWORK_RECORDING_KEY);
            return v === null ? true : v === "1";
        } catch (_) { return true; }
    }
    function saveNetworkRecording(v) {
        try { sessionStorage.setItem(NETWORK_RECORDING_KEY, v ? "1" : "0"); } catch (_) {}
    }
    let networkRecording = loadNetworkRecording();

    // Called when the inspector is explicitly closed (not just left open across a reload) — frees everything accumulated this session.
    function clearNetworkSession() {
        networkLiveState.forEach((state) => {
            if (state.blobUrl) { try { URL.revokeObjectURL(state.blobUrl); } catch (_) {} }
        });
        networkLiveState.clear();
        networkLog.length = 0;
        try { sessionStorage.removeItem(NETWORK_LOG_KEY); } catch (_) {}
    }

    function pushNetwork(entry) {
        if (!networkRecording) return;
        entry.id = ++networkIdCounter;
        networkLog.push(entry);
        if (networkLog.length > MAX_NETWORK_ENTRIES) {
            const dropped = networkLog.shift();
            const state = networkLiveState.get(dropped.id);
            if (state?.blobUrl) { try { URL.revokeObjectURL(state.blobUrl); } catch (_) {} }
            networkLiveState.delete(dropped.id);
        }
        saveNetworkLog();
        if (panelVisible && activeTab === "network") renderNetwork();
    }

    (function patchFetch() {
        if (!window.fetch) return;
        const origFetch = window.fetch;
        window.fetch = function (...args) {
            const start = performance.now();
            const req = args[0];
            const init = args[1] || {};
            const url = typeof req === "string" ? req : req?.url || String(req);
            const method = init.method || (req && req.method) || "GET";
            let reqHeaders = {};
            try {
                const h = init.headers || (req && req.headers);
                if (h instanceof Headers) h.forEach((v, k) => (reqHeaders[k] = v));
                else if (h && typeof h === "object") reqHeaders = { ...h };
            } catch (_) {}
            const reqBody = typeof init.body === "string" ? init.body : init.body ? "(non-text body)" : "";
            return origFetch.apply(this, args).then(
                (res) => {
                    if (!networkRecording) return res;
                    let resHeaders = {};
                    try { res.headers.forEach((v, k) => (resHeaders[k] = v)); } catch (_) {}
                    const textLike = isTextLikeContentType(resHeaders["content-type"]);
                    const entry = {
                        time: new Date().toLocaleTimeString(), method, url, status: res.status,
                        duration: Math.round(performance.now() - start), type: "fetch",
                        body: "", reqHeaders, reqBody, resHeaders, bodyLoaded: textLike,
                    };
                    if (textLike) {
                        try {
                            res.clone().text().then((t) => { entry.body = t; saveNetworkLog(); }).catch(() => {});
                        } catch (_) {}
                        pushNetwork(entry);
                    } else {
                        pushNetwork(entry);
                        // Keep a live Response clone so the body can be read without another request.
                        // The clone is released after reading, when the entry is removed, or on close.
                        try { networkLiveState.set(entry.id, { liveResponse: res.clone() }); } catch (_) {}
                    }
                    return res;
                },
                (err) => {
                    pushNetwork({ time: new Date().toLocaleTimeString(), method, url, status: "ERR", duration: Math.round(performance.now() - start), type: "fetch", body: String(err), reqHeaders, reqBody, resHeaders: {}, bodyLoaded: true });
                    throw err;
                }
            );
        };
    })();

    (function patchXHR() {
        const origOpen = XMLHttpRequest.prototype.open;
        const origSend = XMLHttpRequest.prototype.send;
        const origSetHeader = XMLHttpRequest.prototype.setRequestHeader;
        XMLHttpRequest.prototype.open = function (method, url, ...rest) {
            this.__di = { method, url, reqHeaders: {} };
            return origOpen.call(this, method, url, ...rest);
        };
        XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
            if (this.__di) this.__di.reqHeaders[name] = value;
            return origSetHeader.call(this, name, value);
        };
        XMLHttpRequest.prototype.send = function (bodyArg, ...rest) {
            if (this.__di) {
                this.__di.start = performance.now();
                this.__di.reqBody = typeof bodyArg === "string" ? bodyArg : bodyArg ? "(non-text body)" : "";
                this.addEventListener("loadend", () => {
                    if (!networkRecording) return;
                    let resHeaders = {};
                    try {
                        this.getAllResponseHeaders()
                            .split("\r\n")
                            .filter(Boolean)
                            .forEach((line) => {
                                const idx = line.indexOf(":");
                                if (idx > 0) resHeaders[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
                            });
                    } catch (_) {}
                    // XHR buffers the full response when using a text response type, so the complete response can be read directly without additional buffering.
                    let bodyText = "";
                    let bodyLoaded = true;
                    try {
                        const textLike = this.responseType === "" || this.responseType === "text";
                        bodyText = textLike ? String(this.responseText) : "(binary response — XHR with responseType other than \"text\" can't be read here; try the Resources tab, or switch this request to responseType \"text\"/\"json\")";
                    } catch (_) {
                        bodyText = "(couldn't read body)";
                    }
                    pushNetwork({
                        time: new Date().toLocaleTimeString(),
                        method: this.__di.method,
                        url: this.__di.url,
                        status: this.status,
                        duration: Math.round(performance.now() - this.__di.start),
                        type: "xhr",
                        body: bodyText,
                        bodyLoaded,
                        reqHeaders: this.__di.reqHeaders,
                        reqBody: this.__di.reqBody,
                        resHeaders,
                    });
                });
            }
            return origSend.apply(this, [bodyArg, ...rest]);
        };
    })();

    /* =========================================================
     * 6. Selector generation heuristics (ID/attribute/class priority, uniqueness checks, dynamic-token filtering)
     * ========================================================= */

    const DYNAMIC_TOKEN = /^[a-f0-9]{6,}$/i;
    const NOISY_CLASS = /active|selected|focus|hover|disabled|checked|open|loading|enter|leave|--is-|js-|css-/i;
    const RELIABLE_ATTRS = ["data-testid", "data-cy", "data-test", "name", "aria-label", "alt", "title", "placeholder", "role", "type", "for"];

    function cssEscape(v) {
        return typeof CSS !== "undefined" && CSS.escape ? CSS.escape(v) : v.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
    }
    function isStableToken(v) {
        if (!v) return false;
        if (v.length > 60) return false;
        if (DYNAMIC_TOKEN.test(v)) return false;
        if (/\d{4,}/.test(v)) return false;
        return true;
    }
    function uniqueCount(sel, root2 = document) {
        try { return root2.querySelectorAll(sel).length; } catch (_) { return -1; }
    }
    function stableClasses(el) {
        return Array.from(el.classList || []).filter((c) => isStableToken(c) && !NOISY_CLASS.test(c)).slice(0, 2);
    }
    function buildSingleLevelSelector(el) {
        const tag = el.tagName.toLowerCase();
        if (el.id && isStableToken(el.id)) {
            const sel = `#${cssEscape(el.id)}`;
            if (uniqueCount(sel) === 1) return sel;
        }
        for (const attr of RELIABLE_ATTRS) {
            const val = el.getAttribute(attr);
            if (val && isStableToken(val)) {
                const sel = `${tag}[${attr}="${val.replace(/"/g, '\\"')}"]`;
                if (uniqueCount(sel) === 1) return sel;
            }
        }
        const classes = stableClasses(el);
        if (classes.length) {
            const sel = `${tag}.${classes.map(cssEscape).join(".")}`;
            if (uniqueCount(sel) <= 3) return sel;
        }
        const parent = el.parentElement;
        if (parent) {
            const siblings = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
            if (siblings.length > 1) return `${tag}:nth-of-type(${siblings.indexOf(el) + 1})`;
        }
        return tag;
    }
    function buildSelector(el, maxDepth = 8) {
        if (!el || el.nodeType !== 1) return "";
        const parts = [];
        let node = el;
        for (let i = 0; i < maxDepth && node && node.tagName; i++) {
            const tag = node.tagName.toLowerCase();
            if (tag === "body" || tag === "html") break;
            parts.unshift(buildSingleLevelSelector(node));
            const path = parts.join(" > ");
            if (uniqueCount(path) === 1) return path;
            node = node.parentElement;
        }
        return parts.join(" > ");
    }
    function summarizeElement(el, depth = 0, maxDepth = 3) {
        const indent = "  ".repeat(depth);
        const tag = el.tagName.toLowerCase();
        const id = el.id ? `#${el.id}` : "";
        const cls = el.className && typeof el.className === "string" ? `.${el.className.trim().replace(/\s+/g, ".")}` : "";
        const text = el.childElementCount === 0 ? (el.textContent || "").trim().slice(0, 60) : "";
        let line = `${indent}<${tag}${id}${cls}>${text ? '  "' + text + '"' : ""}\n`;
        if (depth < maxDepth) {
            for (const child of el.children) line += summarizeElement(child, depth + 1, maxDepth);
        }
        return line;
    }

    /* =========================================================
     * 7. Element hide/show (session-only)
     * ========================================================= */

    const hiddenStore = new WeakMap();
    function isHidden(el) { return hiddenStore.has(el); }
    function toggleHide(el) {
        if (isHidden(el)) {
            el.style.display = hiddenStore.get(el);
            hiddenStore.delete(el);
        } else {
            hiddenStore.set(el, el.style.display || "");
            el.style.setProperty("display", "none", "important");
        }
    }

    /* =========================================================
     * 8. Cookies / localStorage / sessionStorage
     * ========================================================= */

    function getCookies() {
        return document.cookie
            .split(";")
            .map((s) => s.trim())
            .filter(Boolean)
            .map((s) => {
                const idx = s.indexOf("=");
                const name = idx >= 0 ? s.slice(0, idx) : s;
                let value = "";
                try { value = idx >= 0 ? decodeURIComponent(s.slice(idx + 1)) : ""; } catch (_) { value = idx >= 0 ? s.slice(idx + 1) : ""; }
                return { key: name, value };
            });
    }
    function setCookie(name, value) {
        // document.cookie does not expose a cookie's original path or domain.
        // Write the replacement at the root path as a best-effort fallback.
        // A cookie with different path/domain attributes may remain.
        document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=31536000; SameSite=Lax`;
    }
    function candidateCookiePaths() {
        const parts = location.pathname.split("/").filter(Boolean);
        const paths = ["/"];
        let acc = "";
        parts.slice(0, -1).forEach((p) => { acc += "/" + p; paths.push(acc); });
        paths.push(location.pathname);
        return Array.from(new Set(paths));
    }
    function candidateCookieDomains() {
        const host = location.hostname;
        const domains = [null, host];
        const parts = host.split(".");
        for (let i = 1; i < parts.length - 1; i++) domains.push(parts.slice(i).join("."));
        return Array.from(new Set(domains));
    }
    function deleteCookie(name) {
        // Tries every common path/domain combination, since the exact originals aren't knowable from JS
        // this maximizes the chance of actually removing it but still isn't 100% guaranteed on every site.
        const expire = "expires=Thu, 01 Jan 1970 00:00:00 GMT; max-age=0";
        candidateCookiePaths().forEach((path) => {
            candidateCookieDomains().forEach((domain) => {
                const domainPart = domain ? `; domain=${domain}` : "";
                document.cookie = `${name}=; path=${path}${domainPart}; ${expire}`;
            });
        });
    }
    function getWebStorageEntries(storage) {
        try { return Object.keys(storage).map((k) => ({ key: k, value: storage.getItem(k) })); } catch (_) { return []; }
    }

    /* =========================================================
     * 9. UI
     * ========================================================= */

    let panelVisible = false;
    let activeTab = "console";
    let pickerActive = false;
    let currentTarget = null;
    let highlightBox = null;
    let panelMinimized = false;
    let lastPanelHeight = "62vh";
    let fullSourceCache = null;
    const expandedSource = new Set(); // subset of "html","css","js"
    const expandedResources = new Set();
    const expandedNetwork = new Set();

    let root, style, panel, dragHandle, tabsBar, body, statusBar, pickerIndicator;

    const TAB_NAMES = ["console", "elements", "network", "resources", "storage", "source", "settings"];

    function buildUI() {
        root = document.createElement("div");
        root.style.cssText = "all:initial;position:fixed;inset:0;z-index:2147483647;pointer-events:none;";
        document.documentElement.appendChild(root);

        style = document.createElement("style");
        style.textContent = `
            .di-panel { position:fixed; left:0; right:0; bottom:0; height:62vh; background:#1e1e1e; color:#d4d4d4;
                font:12px/1.4 -apple-system,Menlo,monospace; display:none; flex-direction:column;
                border-top:1px solid #444; pointer-events:auto; }
            .di-panel.visible { display:flex; }
            .di-drag-handle { height:16px; flex:0 0 auto; display:flex; align-items:center; justify-content:center;
                background:#2a2a2a; cursor:ns-resize; touch-action:none; }
            .di-drag-grip { width:36px; height:4px; border-radius:2px; background:#5a5a5a; }
            .di-tabs { display:flex; background:#252526; border-bottom:1px solid #333; flex:0 0 auto; }
            .di-tabs-scroll { display:flex; overflow-x:auto; -webkit-overflow-scrolling:touch; flex:1; min-width:0; }
            .di-tabs-fixed { display:flex; flex:0 0 auto; border-left:1px solid #333; }
            .di-tab { flex:0 0 auto; padding:9px 13px; text-align:center; cursor:pointer; color:#9d9d9d;
                font-size:12px; font-weight:600; white-space:nowrap; }
            .di-tab.active { color:#4fc3f7; background:#1e1e1e; border-bottom:2px solid #4fc3f7; }
            .di-body { flex:1; overflow-y:auto; padding:8px; min-height:0; }
            .di-row { padding:4px 6px; margin-bottom:3px; border-radius:4px; word-break:break-word; white-space:pre-wrap; }
            .di-row.error { background:rgba(255,80,80,0.12); color:#ff8080; }
            .di-row.warn { background:rgba(255,200,80,0.10); color:#ffcf70; }
            .di-row.log, .di-row.info, .di-row.debug { background:rgba(255,255,255,0.03); }
            .di-row.result { background:rgba(79,195,247,0.08); color:#4fc3f7; }
            .di-row.table { background:rgba(255,255,255,0.03); }
            .di-time { color:#777; margin-right:6px; }
            .di-toolbar { display:flex; gap:6px; padding:6px 8px; background:#252526; flex-wrap:wrap; align-items:center; border-bottom:1px solid #333; }
            .di-btn { background:#333; color:#ddd; border:1px solid #444; border-radius:5px; padding:5px 9px; font-size:11px; cursor:pointer; }
            .di-btn.primary { background:#0e639c; border-color:#0e639c; color:#fff; }
            .di-btn.danger { background:#5a1d1d; border-color:#7a2a2a; color:#ffb3b3; }
            .di-btn:active { transform:scale(0.96); }
            .di-selector-box { background:#111; border:1px solid #333; border-radius:6px; padding:8px; margin-bottom:8px; word-break:break-all; }
            .di-label { color:#888; font-size:10px; text-transform:uppercase; margin-bottom:3px; }
            .di-match { color:#4fc3f7; font-weight:600; }
            .di-rr-row { display:flex; align-items:center; gap:6px; margin-bottom:8px; }
            .di-section-title { color:#4fc3f7; font-weight:700; margin:10px 0 4px; font-size:11px; text-transform:uppercase;
                display:flex; align-items:center; justify-content:space-between; }
            .di-attr { color:#9cdcfe; }
            .di-attrval { color:#ce9178; }
            .di-status { padding:4px 8px; font-size:11px; color:#4fc3f7; min-height:16px; flex:0 0 auto; }
            .di-highlight { position:fixed; z-index:2147483646; background:rgba(79,195,247,0.22); border:1px solid #4fc3f7;
                pointer-events:none; box-sizing:border-box; transition:opacity 0.3s; }
            .di-picker-indicator { position:fixed; left:50%; bottom:18px; transform:translateX(-50%);
                background:#0e639c; color:#fff; font:12px/1.4 -apple-system,Menlo,monospace; font-weight:600;
                padding:9px 16px; border-radius:999px; box-shadow:0 4px 14px rgba(0,0,0,0.35);
                pointer-events:auto; z-index:2147483647; display:none; white-space:nowrap; }
            .di-settings-row { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:9px 4px; border-bottom:1px solid #2c2c2c; }
            .di-settings-group { display:flex; gap:6px; align-items:center; }
            .di-settings-group select { background:#2a2a2a; color:#ddd; border:1px solid #444; border-radius:5px; padding:4px 6px; font-size:12px; }
            .di-settings-title { color:#4fc3f7; font-weight:700; font-size:11px; text-transform:uppercase; margin:12px 4px 2px; }
            .di-kv-row { padding:6px 0; border-bottom:1px solid #2c2c2c; }
            .di-kv-text { font-size:11px; word-break:break-all; margin-bottom:4px; }
            .di-kv-actions { display:flex; gap:6px; }
            .di-preview-box { margin:4px 0 10px; padding:8px; background:#111; border:1px solid #333; border-radius:6px; font-size:11px; }
            .di-preview-box img { max-width:100%; max-height:200px; display:block; }
            .di-detail-title { color:#4fc3f7; font-weight:700; margin-bottom:2px; }
            .di-detail-pre { white-space:pre-wrap; word-break:break-word; font-family:Menlo,Consolas,monospace; max-height:150px; overflow-y:auto; }
        `;
        root.appendChild(style);

        panel = document.createElement("div");
        panel.className = "di-panel";
        panel.style.height = lastPanelHeight;
        root.appendChild(panel);

        dragHandle = document.createElement("div");
        dragHandle.className = "di-drag-handle";
        const grip = document.createElement("div");
        grip.className = "di-drag-grip";
        dragHandle.appendChild(grip);
        panel.appendChild(dragHandle);
        makeResizable(dragHandle);

        tabsBar = document.createElement("div");
        tabsBar.className = "di-tabs";

        const tabsScroll = document.createElement("div");
        tabsScroll.className = "di-tabs-scroll";
        TAB_NAMES.forEach((tabName) => {
            const el = document.createElement("div");
            el.className = "di-tab" + (tabName === activeTab ? " active" : "");
            el.textContent = tabName[0].toUpperCase() + tabName.slice(1);
            el.dataset.tab = tabName;
            el.addEventListener("click", () => switchTab(tabName));
            tabsScroll.appendChild(el);
        });
        tabsBar.appendChild(tabsScroll);

        const tabsFixed = document.createElement("div");
        tabsFixed.className = "di-tabs-fixed";
        const minTab = document.createElement("div");
        minTab.className = "di-tab";
        minTab.textContent = "▁";
        minTab.addEventListener("click", toggleMinimize);
        tabsFixed.appendChild(minTab);
        const closeTab = document.createElement("div");
        closeTab.className = "di-tab";
        closeTab.textContent = "Close";
        closeTab.addEventListener("click", hideInspector);
        tabsFixed.appendChild(closeTab);
        tabsBar.appendChild(tabsFixed);

        panel.appendChild(tabsBar);

        statusBar = document.createElement("div");
        statusBar.className = "di-status";
        panel.appendChild(statusBar);

        body = document.createElement("div");
        body.className = "di-body";
        panel.appendChild(body);

        highlightBox = document.createElement("div");
        highlightBox.className = "di-highlight";
        highlightBox.style.display = "none";
        root.appendChild(highlightBox);

        pickerIndicator = document.createElement("div");
        pickerIndicator.className = "di-picker-indicator";
        pickerIndicator.textContent = "Selecting… tap, or drag across elements and release (tap here to cancel)";
        pickerIndicator.addEventListener("click", () => stopPicker(true));
        root.appendChild(pickerIndicator);
    }

    function makeResizable(handle) {
        let dragging = false, startY = 0, startHeight = 0;
        function pos(e) { return e.touches ? e.touches[0] : e; }
        function start(e) {
            if (panelMinimized) return;
            dragging = true;
            startY = pos(e).clientY;
            startHeight = panel.getBoundingClientRect().height;
        }
        function move(e) {
            if (!dragging) return;
            const delta = startY - pos(e).clientY;
            const vh = window.innerHeight;
            let newHeight = Math.max(vh * 0.2, Math.min(vh * 0.92, startHeight + delta));
            panel.style.height = newHeight + "px";
            lastPanelHeight = panel.style.height;
            e.preventDefault();
        }
        function end() { dragging = false; }
        handle.addEventListener("touchstart", start, { passive: true });
        handle.addEventListener("touchmove", move, { passive: false });
        handle.addEventListener("touchend", end, { passive: true });
        handle.addEventListener("mousedown", start);
        window.addEventListener("mousemove", move);
        window.addEventListener("mouseup", end);
    }

    function toggleMinimize() {
        panelMinimized = !panelMinimized;
        if (panelMinimized) {
            lastPanelHeight = panel.style.height || lastPanelHeight;
            panel.style.height = "48px";
            body.style.display = "none";
            statusBar.style.display = "none";
        } else {
            panel.style.height = lastPanelHeight;
            body.style.display = "";
            statusBar.style.display = "";
        }
    }

    function setStatus(msg, ms = 1800) {
        statusBar.textContent = msg;
        if (ms) setTimeout(() => { if (statusBar.textContent === msg) statusBar.textContent = ""; }, ms);
    }

    function switchTab(tabName) {
        activeTab = tabName;
        tabsBar.querySelectorAll(".di-tab[data-tab]").forEach((el) => {
            el.classList.toggle("active", el.dataset.tab === tabName);
        });
        if (tabName === "console") renderConsole();
        else if (tabName === "elements") renderElements();
        else if (tabName === "network") renderNetwork();
        else if (tabName === "resources") renderResources();
        else if (tabName === "storage") renderStorage();
        else if (tabName === "source") renderSource();
        else if (tabName === "settings") renderSettings();
    }

    const INSPECTOR_STATE_KEY = "devtoolMobileInspectorOpen_v1";

    function toggleInspector() {
        panelVisible ? hideInspector() : showInspector();
    }
    function showInspector() {
        if (!root) buildUI();
        panelVisible = true;
        panel.classList.add("visible");
        try { localStorage.setItem(INSPECTOR_STATE_KEY, "1"); } catch (_) {}
        switchTab(activeTab);
    }
    // Release accumulated session data when the inspector is explicitly closed.
    function hideInspector() {
        panelVisible = false;
        panel.classList.remove("visible");
        stopPicker();
        try { localStorage.setItem(INSPECTOR_STATE_KEY, "0"); } catch (_) {}
        clearNetworkSession();
    }

    /* ---------- helpers ---------- */

    function copyText(text) {
        try {
            const ta = document.createElement("textarea");
            ta.value = text;
            ta.style.position = "fixed";
            ta.style.opacity = "0";
            document.body.appendChild(ta);
            ta.select();
            document.execCommand("copy");
            document.body.removeChild(ta);
            setStatus("Copied");
        } catch (_) {
            if (navigator.clipboard) { navigator.clipboard.writeText(text); setStatus("Copied (clipboard API)"); }
            else setStatus("Copy failed");
        }
    }
    function escapeHtml(s) {
        return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
    }
    function mkSmallBtn(label, onClick) {
        const b = document.createElement("button");
        b.className = "di-btn";
        b.textContent = label;
        b.addEventListener("click", onClick);
        return b;
    }
    function appendSection(title, contentText, container = body) {
        const t = document.createElement("div");
        t.className = "di-section-title";
        t.textContent = title;
        container.appendChild(t);
        const pre = document.createElement("div");
        pre.style.cssText = "background:#111;border:1px solid #333;border-radius:6px;padding:8px;white-space:pre-wrap;word-break:break-word;max-height:220px;overflow-y:auto;font-family:Menlo,Consolas,monospace;font-size:11px;";
        pre.textContent = contentText;
        container.appendChild(pre);
        return pre;
    }
    function buildDetailBlock(title, text) {
        const wrap = document.createElement("div");
        wrap.style.marginBottom = "8px";
        const t = document.createElement("div");
        t.className = "di-detail-title";
        t.textContent = title;
        wrap.appendChild(t);
        const pre = document.createElement("div");
        pre.className = "di-detail-pre";
        pre.textContent = text;
        wrap.appendChild(pre);
        return wrap;
    }
    function buildTableElement({ headers, rows }) {
        const wrap = document.createElement("div");
        wrap.style.cssText = "overflow-x:auto; margin-top:4px;";
        const table = document.createElement("table");
        table.style.cssText = "border-collapse:collapse; font-size:11px; font-family:Menlo,Consolas,monospace; min-width:100%;";
        const thead = document.createElement("thead");
        const hr = document.createElement("tr");
        headers.forEach((h) => {
            const th = document.createElement("th");
            th.textContent = h;
            th.style.cssText = "border:1px solid #444; padding:4px 8px; background:#252526; color:#4fc3f7; text-align:left; white-space:nowrap;";
            hr.appendChild(th);
        });
        thead.appendChild(hr);
        table.appendChild(thead);
        const tbody = document.createElement("tbody");
        rows.forEach((r) => {
            const tr = document.createElement("tr");
            r.forEach((cell) => {
                const td = document.createElement("td");
                td.textContent = cell;
                td.style.cssText = "border:1px solid #333; padding:4px 8px; white-space:nowrap;";
                tr.appendChild(td);
            });
            tbody.appendChild(tr);
        });
        table.appendChild(tbody);
        wrap.appendChild(table);
        return wrap;
    }

    /* ---------- Console tab ---------- */

    function renderConsole() {
        body.replaceChildren();

        const toolbar = document.createElement("div");
        toolbar.className = "di-toolbar";
        toolbar.appendChild(mkSmallBtn("Clear", () => { logBuffer.length = 0; renderConsole(); }));
        const copyBtn = mkSmallBtn("Copy all", () => copyText(logBuffer.map((l) => `[${l.time}] [${l.tag}] ${logEntryToText(l)}`).join("\n")));
        copyBtn.classList.add("primary");
        toolbar.appendChild(copyBtn);
        body.appendChild(toolbar);

        const note = document.createElement("div");
        note.style.cssText = "color:#777; font-size:10px; padding:4px 8px;";
        note.textContent = "Supported: log/warn/error/info/debug, table, time/timeEnd/timeLog, group/groupEnd, count, assert, trace, dir, clear.";
        body.appendChild(note);

        const cmdRow = document.createElement("div");
        cmdRow.style.cssText = "display:flex; gap:6px; padding:6px 8px; background:#1a1a1a; border-bottom:1px solid #333; align-items:flex-end;";
        const cmdInput = document.createElement("textarea");
        cmdInput.rows = 3;
        cmdInput.placeholder = "Tap Run (or Ctrl/Cmd+Enter) to execute.";
        cmdInput.className = "di-cmd-input";
        cmdInput.autocomplete = "off";
        cmdInput.autocapitalize = "off";
        cmdInput.spellcheck = false;
        cmdInput.style.cssText = "flex:1; background:#111; color:#d4d4d4; border:1px solid #444; border-radius:5px; padding:7px 9px; font:12px/1.3 Menlo,monospace; resize:vertical; min-height:48px;";
        const runBtn = document.createElement("button");
        runBtn.className = "di-btn primary";
        runBtn.textContent = "Run";
        const run = () => {
            const value = cmdInput.value.trim();
            if (!value) return;
            runCommand(value);
        };
        runBtn.addEventListener("click", run);
        cmdInput.addEventListener("keydown", (e) => {
            if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); run(); }
        });
        cmdRow.append(cmdInput, runBtn);
        body.appendChild(cmdRow);

        const list = document.createElement("div");
        list.style.padding = "6px";
        if (!logBuffer.length) {
            const hint = document.createElement("div");
            hint.style.cssText = "color:#777;padding:10px;";
            hint.textContent = "No console output yet. Try typing an expression above, e.g. document.title";
            list.appendChild(hint);
        } else {
            logBuffer.slice().reverse().forEach((l) => {
                const row = document.createElement("div");
                row.className = `di-row ${l.tag}`;
                if (l.tag === "table" && l.tableData) {
                    const timeSpan = document.createElement("span");
                    timeSpan.className = "di-time";
                    timeSpan.textContent = l.time;
                    row.appendChild(timeSpan);
                    row.appendChild(buildTableElement(l.tableData));
                } else {
                    row.innerHTML = tt(`<span class="di-time">${l.time}</span>${escapeHtml(l.text)}`);
                }
                list.appendChild(row);
            });
        }
        body.appendChild(list);
    }

    /* ---------- Elements tab ---------- */

    function outerHtmlOf(el, maxLen = 4000) { return el.cloneNode(true).outerHTML.slice(0, maxLen); }
    function computedStyleSummary(el) {
        const props = ["display", "position", "width", "height", "margin", "padding", "color", "background-color", "font-size", "z-index", "opacity", "overflow", "visibility"];
        const cs = window.getComputedStyle(el);
        return props.map((p) => `${p}: ${cs.getPropertyValue(p)};`).join("\n");
    }
    function elementHierarchy(el) {
        const chain = [];
        let node = el;
        while (node && node.tagName && node.tagName.toLowerCase() !== "body") { chain.unshift(node); node = node.parentElement; }
        return chain;
    }
    function elLabel(el) {
        const id = el.id ? `#${el.id}` : "";
        const cls = el.className && typeof el.className === "string" ? "." + el.className.trim().split(/\s+/).join(".") : "";
        return `<${el.tagName.toLowerCase()}${id}${cls}>`;
    }

    function renderElements() {
        body.replaceChildren();

        const toolbar = document.createElement("div");
        toolbar.className = "di-toolbar";
        const pickBtn = document.createElement("button");
        pickBtn.className = "di-btn primary";
        pickBtn.textContent = pickerActive ? "✕ Cancel selection" : "⌖ Select element";
        pickBtn.addEventListener("click", () => (pickerActive ? stopPicker(true) : startPicker()));
        toolbar.appendChild(pickBtn);
        body.appendChild(toolbar);

        body.appendChild(renderSelectorScanBox());

        if (currentTarget) {
            renderElementDetail(currentTarget);
        } else {
            const hint = document.createElement("div");
            hint.style.cssText = "color:#777;padding:10px 4px;";
            hint.textContent = 'Tap "⌖ Select element", then tap (or drag then release over) anything on the page. Or use the selector scan above.';
            body.appendChild(hint);
        }
    }

    function renderSelectorScanBox() {
        const box = document.createElement("div");
        box.style.marginBottom = "10px";

        const title = document.createElement("div");
        title.className = "di-section-title";
        title.textContent = "Selector scan (manual)";
        box.appendChild(title);

        const row = document.createElement("div");
        row.style.cssText = "display:flex;gap:6px;margin-bottom:6px;";
        const input = document.createElement("input");
        input.type = "text";
        input.placeholder = "e.g. a   img   button   h1   [href]";
        input.style.cssText = "flex:1;background:#111;color:#d4d4d4;border:1px solid #444;border-radius:5px;padding:6px 8px;font:12px Menlo,monospace;";
        const scanBtn = document.createElement("button");
        scanBtn.className = "di-btn primary";
        scanBtn.textContent = "Scan";
        row.append(input, scanBtn);
        box.appendChild(row);

        const resultBox = document.createElement("div");
        box.appendChild(resultBox);

        const PAGE_SIZE = 10;
        let scanMatches = [];
        let scanPage = 0;

        function renderResultsPage() {
            resultBox.replaceChildren();
            if (!scanMatches.length) return;

            const totalPages = Math.max(1, Math.ceil(scanMatches.length / PAGE_SIZE));
            if (scanPage >= totalPages) scanPage = totalPages - 1;
            if (scanPage < 0) scanPage = 0;
            const start = scanPage * PAGE_SIZE;
            const pageItems = scanMatches.slice(start, start + PAGE_SIZE);

            const summary = document.createElement("div");
            summary.style.cssText = "color:#4fc3f7;font-size:11px;margin-bottom:6px;";
            summary.textContent = `${scanMatches.length} match(es) — showing ${start + 1}-${start + pageItems.length}`;
            resultBox.appendChild(summary);

            pageItems.forEach((m, i) => {
                const item = document.createElement("div");
                item.style.cssText = "display:flex;justify-content:space-between;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid #2c2c2c;";
                const label = document.createElement("span");
                label.style.cssText = "font-size:11px;color:#ce9178;word-break:break-all;flex:1;";
                label.textContent = `[${start + i}] ${elLabel(m)}`;
                const showBtn = mkSmallBtn("Highlight", () => flashHighlight(m));
                const selectBtn = mkSmallBtn("Inspect", () => selectElement(m));
                selectBtn.classList.add("primary");
                item.append(label, showBtn, selectBtn);
                resultBox.appendChild(item);
            });

            if (totalPages > 1) {
                const navRow = document.createElement("div");
                navRow.style.cssText = "display:flex;align-items:center;justify-content:center;gap:10px;margin-top:8px;";
                const prevBtn = mkSmallBtn("‹ Prev", () => { scanPage--; renderResultsPage(); });
                prevBtn.disabled = scanPage === 0;
                const pageLabel = document.createElement("span");
                pageLabel.style.cssText = "font-size:11px;color:#888;min-width:70px;text-align:center;";
                pageLabel.textContent = `Page ${scanPage + 1}/${totalPages}`;
                const nextBtn = mkSmallBtn("Next ›", () => { scanPage++; renderResultsPage(); });
                nextBtn.disabled = scanPage >= totalPages - 1;
                navRow.append(prevBtn, pageLabel, nextBtn);
                resultBox.appendChild(navRow);
            }
        }

        function doScan() {
            const sel = input.value.trim();
            resultBox.replaceChildren();
            scanMatches = [];
            scanPage = 0;
            if (!sel) return;
            try {
                scanMatches = Array.from(document.querySelectorAll(sel)).filter((el) => !(root && root.contains(el)));
            } catch (err) {
                const errDiv = document.createElement("div");
                errDiv.style.color = "#ff8080";
                errDiv.style.fontSize = "11px";
                errDiv.textContent = "Invalid selector: " + err.message;
                resultBox.appendChild(errDiv);
                return;
            }
            renderResultsPage();
        }
        scanBtn.addEventListener("click", doScan);
        input.addEventListener("keydown", (e) => { if (e.key === "Enter") doScan(); });

        const howto = document.createElement("div");
        howto.style.cssText = "color:#777;font-size:10px;margin-top:4px;line-height:1.4;";
        howto.textContent = 'Type a CSS selector and tap Scan.';
        box.appendChild(howto);

        return box;
    }

    function renderElementDetail(el) {
        const selector = buildSelector(el);
        const matches = selector ? uniqueCount(selector) : 0;

        const quickText = [`Selector: ${selector || "(could not generate)"}`, `Matches: ${matches}`, "", "Structure (3 levels):", summarizeElement(el).trimEnd()].join("\n");
        appendSection("Quick summary", quickText);

        const box = document.createElement("div");
        box.className = "di-selector-box";
        box.innerHTML = tt(`
            <div class="di-label">CSS selector <span class="di-match">(${matches} match${matches === 1 ? "" : "es"})</span></div>
            <div>${escapeHtml(selector || "(could not generate)")}</div>
        `);
        body.appendChild(box);

        const chain = elementHierarchy(el);
        const idx = chain.indexOf(el);

        const actions = document.createElement("div");
        actions.className = "di-toolbar";
        actions.style.padding = "0 0 8px 0";
        actions.appendChild(mkSmallBtn("Copy selector", () => copyText(selector)));
        actions.appendChild(mkSmallBtn("Copy HTML", () => copyText(outerHtmlOf(el))));
        actions.appendChild(mkSmallBtn("Highlight on page", () => flashHighlight(el)));
        const hideBtn = mkSmallBtn(isHidden(el) ? "Restore element" : "Hide element", () => { toggleHide(el); renderElementDetail(el); });
        hideBtn.className = isHidden(el) ? "di-btn primary" : "di-btn danger";
        actions.appendChild(hideBtn);
        body.appendChild(actions);

        const attrPre = appendSection("Attributes", "");
        attrPre.innerHTML = tt(Array.from(el.attributes).map((a) => `<span class="di-attr">${escapeHtml(a.name)}</span>=<span class="di-attrval">"${escapeHtml(a.value)}"</span>`).join("\n") || "(none)");

        appendSection("Computed style", computedStyleSummary(el));
        appendSection("Outer HTML", outerHtmlOf(el));
    }

    function selectElement(el) {
        currentTarget = el;
        flashHighlight(el);
        if (activeTab === "elements") renderElements();
    }
    function flashHighlight(el) {
        if (!el || !el.getBoundingClientRect || !highlightBox) return;
        try { el.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" }); } catch (_) {}
        highlightEl(el);
        setTimeout(() => highlightEl(el), 380);
        clearTimeout(flashHighlight._t);
        flashHighlight._t = setTimeout(() => { if (highlightBox) highlightBox.style.display = "none"; }, 2200);
    }

    /* ---------- Element picking ---------- */
    const PICK_TOUCH_OPTS = { capture: true, passive: false };

    function startPicker() {
        pickerActive = true;
        panel.classList.remove("visible");
        pickerIndicator.style.display = "block";
        document.addEventListener("touchstart", onPickTouchStart, PICK_TOUCH_OPTS);
        document.addEventListener("touchmove", onPickTouchMove, PICK_TOUCH_OPTS);
        document.addEventListener("touchend", onPick, PICK_TOUCH_OPTS);
        document.addEventListener("touchcancel", onPickCancel, PICK_TOUCH_OPTS);
        document.addEventListener("mousemove", onHoverMove, true);
        document.addEventListener("click", onPickClick, true);
    }
    function stopPicker(cancelled = false) {
        pickerActive = false;
        pickerIndicator.style.display = "none";
        document.removeEventListener("touchstart", onPickTouchStart, PICK_TOUCH_OPTS);
        document.removeEventListener("touchmove", onPickTouchMove, PICK_TOUCH_OPTS);
        document.removeEventListener("touchend", onPick, PICK_TOUCH_OPTS);
        document.removeEventListener("touchcancel", onPickCancel, PICK_TOUCH_OPTS);
        document.removeEventListener("mousemove", onHoverMove, true);
        document.removeEventListener("click", onPickClick, true);
        if (highlightBox) highlightBox.style.display = "none";
        if (panelVisible) panel.classList.add("visible");
        if (activeTab === "elements") renderElements();
        if (cancelled) setStatus("Selection cancelled");
    }
    function elementFromEvent(e) {
        const p = e.changedTouches?.[0] || e.touches?.[0] || e;
        return document.elementFromPoint(p.clientX, p.clientY);
    }
    function isPickerChrome(el) { return !el || el === pickerIndicator || (panel && panel.contains(el)); }
    function highlightEl(el) {
        const r = el.getBoundingClientRect();
        highlightBox.style.display = "block";
        highlightBox.style.left = r.left + "px";
        highlightBox.style.top = r.top + "px";
        highlightBox.style.width = r.width + "px";
        highlightBox.style.height = r.height + "px";
    }
    function onPickTouchStart(e) { const el = elementFromEvent(e); if (isPickerChrome(el)) return; e.preventDefault(); e.stopPropagation(); highlightEl(el); }
    function onPickTouchMove(e) { const el = elementFromEvent(e); if (isPickerChrome(el)) return; e.preventDefault(); e.stopPropagation(); highlightEl(el); }
    function onPickCancel() { stopPicker(true); }
    function onHoverMove(e) { const el = elementFromEvent(e); if (isPickerChrome(el)) return; highlightEl(el); }
    function onPick(e) { const el = elementFromEvent(e); if (isPickerChrome(el)) return; e.preventDefault(); e.stopPropagation(); stopPicker(); selectElement(el); setStatus("Element selected"); }
    function onPickClick(e) { const el = e.target; if (isPickerChrome(el)) return; e.preventDefault(); e.stopPropagation(); stopPicker(); selectElement(el); setStatus("Element selected"); }

    /* ---------- Network tab ---------- */

    function renderNetwork() {
        body.replaceChildren();
        const toolbar = document.createElement("div");
        toolbar.className = "di-toolbar";
        const recBtn = mkSmallBtn(networkRecording ? "⏺ Recording" : "⏸ Paused", () => {
            networkRecording = !networkRecording;
            saveNetworkRecording(networkRecording);
            renderNetwork();
        });
        recBtn.className = networkRecording ? "di-btn danger" : "di-btn";
        toolbar.appendChild(recBtn);
        toolbar.appendChild(mkSmallBtn("Clear", () => { clearNetworkSession(); expandedNetwork.clear(); renderNetwork(); }));
        body.appendChild(toolbar);

        const note = document.createElement("div");
        note.style.cssText = "color:#777;font-size:11px;padding:6px 4px;";
        note.textContent = "Text/JSON responses are captured in full right away. Images/video/binary responses are only fetched in full when you open them below.";
        body.appendChild(note);

        if (!networkLog.length) {
            const empty = document.createElement("div");
            empty.style.cssText = "color:#777;padding:10px;";
            empty.textContent = "No requests captured yet.";
            body.appendChild(empty);
            return;
        }
        networkLog.slice().reverse().forEach((n) => {
            const row = document.createElement("div");
            row.className = "di-row log";
            row.style.cursor = "pointer";
            row.innerHTML = tt(`<span class="di-time">${n.time}</span><span class="di-attr">${escapeHtml(String(n.method))}</span> ${escapeHtml(String(n.status))} · ${n.duration}ms<br>${escapeHtml(n.url)}`);
            row.addEventListener("click", () => {
                if (expandedNetwork.has(n.id)) {
                    expandedNetwork.delete(n.id);
                    renderNetwork();
                } else {
                    expandedNetwork.add(n.id);
                    renderNetwork();
                    if (!n.bodyLoaded) ensureNetworkBodyLoaded(n).then(renderNetwork);
                }
            });
            body.appendChild(row);

            if (expandedNetwork.has(n.id)) {
                const detail = document.createElement("div");
                detail.className = "di-preview-box";
                const reqH = n.reqHeaders && Object.keys(n.reqHeaders).length ? Object.entries(n.reqHeaders).map(([k, v]) => `${k}: ${v}`).join("\n") : "(none captured)";
                const resH = n.resHeaders && Object.keys(n.resHeaders).length ? Object.entries(n.resHeaders).map(([k, v]) => `${k}: ${v}`).join("\n") : "(none captured)";
                detail.appendChild(buildDetailBlock("Request headers", reqH));
                if (n.reqBody) detail.appendChild(buildDetailBlock("Request body", n.reqBody));
                detail.appendChild(buildDetailBlock("Response headers", resH));
                const liveState = networkLiveState.get(n.id);
                if (liveState?.blobUrl) {
                    const img = document.createElement("img");
                    img.src = liveState.blobUrl;
                    img.style.cssText = "max-width:100%; max-height:200px; display:block; margin-bottom:8px;";
                    detail.appendChild(img);
                }
                detail.appendChild(buildDetailBlock("Response body", n.bodyLoaded ? n.body || "(empty body)" : "Loading full response…"));
                const copyBtn = mkSmallBtn("Copy full entry", () => copyText(JSON.stringify(n, null, 2)));
                copyBtn.classList.add("primary");
                detail.appendChild(copyBtn);
                body.appendChild(detail);
            }
        });
    }

    // deferred response body using the retained Response clone when available.
    // Otherwise, fetch the resource again.
    function ensureNetworkBodyLoaded(n) {
        if (n.bodyLoaded) return Promise.resolve();
        const ct = (n.resHeaders && n.resHeaders["content-type"]) || "";
        const state = networkLiveState.get(n.id) || {};
        const resPromise = state.liveResponse ? Promise.resolve(state.liveResponse) : fetch(n.url).then((r) => r);
        return resPromise
            .then((res) => {
                if (/^image\//i.test(ct)) {
                    return res.blob().then((blob) => {
                        if (state.blobUrl) { try { URL.revokeObjectURL(state.blobUrl); } catch (_) {} }
                        const blobUrl = URL.createObjectURL(blob);
                        networkLiveState.set(n.id, { blobUrl });
                        n.body = `(image, ${blob.size.toLocaleString()} bytes, ${ct})`;
                    });
                }
                return res.text().then((t) => { n.body = t; networkLiveState.delete(n.id); });
            })
            .catch((err) => {
                n.body = "Couldn't load: " + ((err && err.message) || err);
            })
            .then(() => {
                n.bodyLoaded = true;
                saveNetworkLog();
            });
    }

    /* ---------- Resources tab (Performance API + content preview) ---------- */

    function dedupeResourceEntries(entries) {
        const map = new Map();
        entries.forEach((r) => {
            const existing = map.get(r.name);
            if (!existing || r.startTime > existing.startTime) map.set(r.name, r);
        });
        return Array.from(map.values());
    }

    function collectPageResources() {
        const map = new Map();

        const add = (url, type, element = null, timing = null) => {
            if (!url) return;

            const key = url;

            if (!map.has(key)) {
                map.set(key, {
                    name: url,
                    type,
                    element,
                    timing
                });
            } else {
                const existing = map.get(key);

                // DOM 요소 정보가 새로 발견되면 보완
                if (!existing.element && element) {
                    existing.element = element;
                }

                // Performance 정보가 있으면 보완
                if (!existing.timing && timing) {
                    existing.timing = timing;
                }

                // image 정보가 발견되면 image로 유지
                if (type === "image") {
                    existing.type = "image";
                }
            }
        };

        // 1. <img> — 실제 페이지의 모든 img 요소
        document.querySelectorAll("img").forEach((el) => {
            const url = el.currentSrc || el.src;
            add(url, "image", el);
        });

        // 2. <picture>/<source>의 srcset
        document.querySelectorAll("source[src], source[srcset]").forEach((el) => {
            const url = el.src || el.srcset;
            if (url) add(url, "image", el);
        });

        // 3. Scripts
        document.querySelectorAll("script[src]").forEach((el) => {
            add(el.src, "script", el);
        });

        // 4. Stylesheets
        document.querySelectorAll('link[rel~="stylesheet"][href]').forEach((el) => {
            add(el.href, "stylesheet", el);
        });

        // 5. Performance Resource Timing
        performance.getEntriesByType("resource")
            .sort((a, b) => a.startTime - b.startTime)
            .forEach((entry) => {
                let type = "other";

                if (entry.initiatorType === "img" || looksLikeImageUrl(entry.name)) {
                    type = "image";
                } else if (entry.initiatorType === "script") {
                    type = "script";
                } else if (entry.initiatorType === "link") {
                    type = "stylesheet";
                }

                add(entry.name, type, null, entry);
            });

        return Array.from(map.values()).sort((a, b) => {
            const at = a.timing ? a.timing.startTime : Infinity;
            const bt = b.timing ? b.timing.startTime : Infinity;
            return at - bt;
        });
    }

    function looksLikeImageUrl(url) {
        return /\.(jpe?g|png|gif|webp|avif|bmp|ico|svg)(\?|#|$)/i.test(url);
    }

    let activeGalleryImage = null;
    let resourceSnapshot = null;

    function renderResources() {
        body.replaceChildren();

        const toolbar = document.createElement("div");
        toolbar.className = "di-toolbar";

        toolbar.appendChild(
            mkSmallBtn("Refresh", () => {
                resourceSnapshot = null;
                activeGalleryImage = null;
                expandedResources.clear();
                renderResources();
            })
        );

        body.appendChild(toolbar);

        const note = document.createElement("div");
        note.style.cssText = "color:#777;font-size:11px;padding:6px 4px;";
        note.textContent =
            "Resources currently available on the page. Refresh to capture newly loaded resources.";
        body.appendChild(note);

        // Resource Snapshot
        if (!resourceSnapshot) {
            resourceSnapshot = collectPageResources();
        }

        const allEntries = resourceSnapshot;

        if (!allEntries.length) {
            const empty = document.createElement("div");
            empty.style.cssText = "color:#777;padding:10px;";
            empty.textContent = "No resources found.";
            body.appendChild(empty);
            return;
        }

        // Images
        const imageEntries = allEntries.filter((r) => r.type === "image");

        // Other resources
        const otherEntries = allEntries.filter((r) => r.type !== "image");

        // ---------- Images ----------
        if (imageEntries.length) {
            const title = document.createElement("div");
            title.className = "di-section-title";
            title.textContent = `Images (${imageEntries.length})`;
            body.appendChild(title);

            const grid = document.createElement("div");
            grid.style.cssText =
                "display:grid;grid-template-columns:repeat(auto-fill,minmax(64px,1fr));gap:6px;margin-bottom:8px;";

            imageEntries.forEach((r) => {
                const cell = document.createElement("div");

                cell.title = r.name;
                cell.style.cssText =
                    "aspect-ratio:1;background:#111;border:1px solid #333;border-radius:6px;display:flex;align-items:center;justify-content:center;overflow:hidden;cursor:pointer;";

                const img = document.createElement("img");

                img.src = r.element?.currentSrc || r.name;
                img.alt = "";
                img.loading = "lazy";
                img.style.cssText =
                    "width:100%;height:100%;object-fit:contain;display:block;";

                img.onerror = () => {
                    img.remove();

                    const fallback = document.createElement("span");
                    fallback.textContent = "🖼";
                    fallback.style.cssText =
                        "font-size:20px;color:#555;";
                    cell.appendChild(fallback);
                };

                cell.appendChild(img);

                cell.addEventListener("click", () => {
                    activeGalleryImage = r;
                    renderResources();
                });

                grid.appendChild(cell);
            });

            body.appendChild(grid);

            // Selected image details
            if (activeGalleryImage) {
                const box = document.createElement("div");
                box.className = "di-preview-box";
                body.appendChild(box);

                showImageResourceDetails(activeGalleryImage, box);
            }
        }

        // ---------- Other resources ----------
        if (otherEntries.length) {
            const title = document.createElement("div");
            title.className = "di-section-title";
            title.textContent = `Other resources (${otherEntries.length})`;
            body.appendChild(title);

            otherEntries.forEach((r) => {
                const row = document.createElement("div");
                row.className = "di-row log";
                row.style.cursor = "pointer";

                const timing = r.timing;

                row.textContent =
                    `[${timing?.initiatorType || r.type || "?"}] ` +
                    `${timing ? Math.round(timing.duration) : 0}ms  ` +
                    r.name;

                row.addEventListener("click", () => {
                    expandedResources.has(r.name)
                        ? expandedResources.delete(r.name)
                        : expandedResources.add(r.name);

                    renderResources();
                });

                body.appendChild(row);

                if (expandedResources.has(r.name)) {
                    const box = document.createElement("div");
                    box.className = "di-preview-box";
                    box.textContent = "Loading preview…";
                    body.appendChild(box);

                    loadResourcePreview(r.name, box);
                }
            });
        }
    }
    
    function showImageResourceDetails(resource, container) {
        container.replaceChildren();

        const url = resource.name;
        const timing = resource.timing;
        const element = resource.element;

        const title = document.createElement("div");
        title.className = "di-section-title";
        title.textContent = "Image Resource";
        container.appendChild(title);

        const info = document.createElement("div");
        info.className = "di-detail-pre";

        const lines = [
            `URL: ${url}`,
            `Type: image`,
            element ? `Element: <${element.tagName.toLowerCase()}>` : null,
            timing ? `Initiator: ${timing.initiatorType || "-"}` : null,
            timing ? `Start time: ${Math.round(timing.startTime)} ms` : null,
            timing ? `Duration: ${Math.round(timing.duration)} ms` : null,
            timing ? `Transfer size: ${timing.transferSize.toLocaleString()} bytes` : null,
            timing ? `Encoded body size: ${timing.encodedBodySize.toLocaleString()} bytes` : null,
            timing ? `Decoded body size: ${timing.decodedBodySize.toLocaleString()} bytes` : null
        ].filter(Boolean);

        info.textContent = lines.join("\n");
        container.appendChild(info);

        // HTML 형태의 코드
        const codeTitle = document.createElement("div");
        codeTitle.className = "di-section-title";
        codeTitle.textContent = "HTML";
        container.appendChild(codeTitle);

        const code = document.createElement("div");
        code.className = "di-detail-pre";

        if (element) {
            code.textContent = element.outerHTML;
        } else {
            code.textContent = `<img src="${url}">`;
        }

        container.appendChild(code);
    }

    function loadResourcePreview(url, container) {
        fetch(url)
            .then((res) => {
                const ct = res.headers.get("content-type") || "";
                const cl = parseInt(res.headers.get("content-length") || "0", 10);
                if (/^image\//.test(ct)) {
                    return res.blob().then((blob) => {
                        container.replaceChildren();
                        const img = document.createElement("img");
                        img.src = URL.createObjectURL(blob);
                        container.appendChild(img);
                        const info = document.createElement("div");
                        info.style.cssText = "color:#777;font-size:10px;margin-top:4px;";
                        info.textContent = `${ct} · ${blob.size.toLocaleString()} bytes`;
                        container.appendChild(info);
                    });
                }
                if (cl && cl > 2000000) {
                    container.replaceChildren();
                    const tooBig = document.createElement("div");
                    tooBig.style.cssText = "color:#777;font-size:11px;";
                    tooBig.textContent = `${Math.round(cl / 1024)} KB — too large to render inline here without risking the page freezing.`;
                    container.appendChild(tooBig);
                    container.appendChild(mkSmallBtn("Open full content in new tab", () => window.open(url, "_blank")));
                    return null;
                }
                return res.text().then((text) => {
                    container.replaceChildren();
                    const pre = document.createElement("div");
                    pre.className = "di-detail-pre";
                    pre.style.maxHeight = "220px";
                    pre.textContent = text;
                    container.appendChild(pre);
                });
            })
            .catch((err) => {
                container.replaceChildren();
                const errDiv = document.createElement("div");
                errDiv.style.cssText = "color:#ff8080;font-size:11px;margin-bottom:6px;";
                errDiv.textContent = "Couldn't load preview: " + ((err && err.message) || err) + ". Often caused by CORS, a CSP restriction, or an expired/signed URL — the browser itself can't tell us more than that.";
                container.appendChild(errDiv);
                container.appendChild(mkSmallBtn("Open in new tab instead", () => window.open(url, "_blank")));
            });
    }
    
    async function loadResourceCode(url, container) {
        container.replaceChildren();

        const info = document.createElement("div");
        info.className = "di-detail-pre";
        info.textContent = `URL: ${url}\n\nHTML:\n<img src="${url}">`;

        container.appendChild(info);
    }

    /* ---------- Storage tab ---------- */

    function renderStorage() {
        body.replaceChildren();
        const toolbar = document.createElement("div");
        toolbar.className = "di-toolbar";
        toolbar.appendChild(mkSmallBtn("Refresh", renderStorage));
        body.appendChild(toolbar);

        const cookieNote = document.createElement("div");
        cookieNote.style.cssText = "color:#777;font-size:11px;padding:6px 4px;";
        cookieNote.textContent = "Browsers never expose a cookie's original path/domain to JS, so edits/deletes may fail — on some sites, a new cookie may remain instead of replacing the original.";
        body.appendChild(cookieNote);

        renderStorageSection("Cookies", getCookies(), {
            onAdd: () => { const n = prompt("Cookie name?"); if (!n) return; const v = prompt(`Value for "${n}"?`, "") || ""; setCookie(n, v); renderStorage(); },
            onEdit: (k, v) => { const nv = prompt(`New value for cookie "${k}"`, v); if (nv === null) return; setCookie(k, nv); renderStorage(); },
            onDelete: (k) => { deleteCookie(k); renderStorage(); },
        });
        renderStorageSection("localStorage", getWebStorageEntries(window.localStorage), {
            onAdd: () => { const k = prompt("Key?"); if (!k) return; const v = prompt("Value?", "") || ""; try { localStorage.setItem(k, v); } catch (_) {} renderStorage(); },
            onEdit: (k, v) => { const nv = prompt(`New value for localStorage["${k}"]`, v); if (nv === null) return; try { localStorage.setItem(k, nv); } catch (_) {} renderStorage(); },
            onDelete: (k) => { try { localStorage.removeItem(k); } catch (_) {} renderStorage(); },
        });
        renderStorageSection("sessionStorage", getWebStorageEntries(window.sessionStorage), {
            onAdd: () => { const k = prompt("Key?"); if (!k) return; const v = prompt("Value?", "") || ""; try { sessionStorage.setItem(k, v); } catch (_) {} renderStorage(); },
            onEdit: (k, v) => { const nv = prompt(`New value for sessionStorage["${k}"]`, v); if (nv === null) return; try { sessionStorage.setItem(k, nv); } catch (_) {} renderStorage(); },
            onDelete: (k) => { try { sessionStorage.removeItem(k); } catch (_) {} renderStorage(); },
        });
    }

    function renderStorageSection(title, entries, handlers) {
        const titleRow = document.createElement("div");
        titleRow.className = "di-section-title";
        const titleText = document.createElement("span");
        titleText.textContent = `${title} (${entries.length})`;
        titleRow.appendChild(titleText);
        titleRow.appendChild(mkSmallBtn("+ Add", handlers.onAdd));
        body.appendChild(titleRow);

        if (!entries.length) {
            const empty = document.createElement("div");
            empty.style.cssText = "color:#777;font-size:11px;padding:4px 4px 10px;";
            empty.textContent = "(empty)";
            body.appendChild(empty);
            return;
        }
        entries.forEach(({ key, value }) => {
            const row = document.createElement("div");
            row.className = "di-kv-row";
            const kv = document.createElement("div");
            kv.className = "di-kv-text";
            kv.innerHTML = tt(`<span class="di-attr">${escapeHtml(key)}</span> = <span class="di-attrval">${escapeHtml(String(value))}</span>`);
            row.appendChild(kv);
            const actions = document.createElement("div");
            actions.className = "di-kv-actions";
            actions.appendChild(mkSmallBtn("Copy", () => copyText(String(value))));
            actions.appendChild(mkSmallBtn("Edit", () => handlers.onEdit(key, value)));
            const delBtn = mkSmallBtn("Delete", () => handlers.onDelete(key));
            delBtn.className = "di-btn danger";
            actions.appendChild(delBtn);
            row.appendChild(actions);
            body.appendChild(row);
        });
    }

    /* ---------- Source tab ---------- */

    function renderSource() {
        body.replaceChildren();

        const toolbar = document.createElement("div");
        toolbar.className = "di-toolbar";
        toolbar.appendChild(mkSmallBtn(sourceButtonLabel("html", "HTML"), () => toggleSourceView("html")));
        toolbar.appendChild(mkSmallBtn(sourceButtonLabel("css", "CSS"), () => toggleSourceView("css")));
        toolbar.appendChild(mkSmallBtn(sourceButtonLabel("js", "JS"), () => toggleSourceView("js")));
        toolbar.appendChild(mkSmallBtn(fullSourceCache ? "Hide original source" : "Original source (fetch)", toggleFullSource));
        body.appendChild(toolbar);

        const note = document.createElement("div");
        note.style.cssText = "color:#777;font-size:11px;padding:4px 4px 8px;line-height:1.5;";
        note.textContent = 'HTML/CSS/JS show the live, already-rendered page. "Original source" fetches what the server actually sent before any JS ran. Tap a button again to collapse it.';
        body.appendChild(note);

        if (!expandedSource.size && !fullSourceCache) {
            const hint = document.createElement("div");
            hint.style.cssText = "color:#777;padding:10px 4px;";
            hint.textContent = "Pick HTML / CSS / JS above, or fetch the original source.";
            body.appendChild(hint);
        }
        ["html", "css", "js"].forEach((mode) => { if (expandedSource.has(mode)) renderSourceContent(mode); });
        if (fullSourceCache) appendSection(`Original source — ${fullSourceCache.label}`, fullSourceCache.html.slice(0, 20000));

        appendSection("Environment", diagnosticsText());
    }
    function sourceButtonLabel(mode, name) {
        return (expandedSource.has(mode) ? "Hide " : "") + name;
    }
    function toggleSourceView(mode) {
        expandedSource.has(mode) ? expandedSource.delete(mode) : expandedSource.add(mode);
        renderSource();
    }
    function renderSourceContent(mode) {
        if (mode === "html") {
            appendSection("Live DOM (document.documentElement.outerHTML)", document.documentElement.outerHTML.slice(0, 20000));
        } else if (mode === "css") {
            let text = "";
            Array.from(document.styleSheets).forEach((sheet) => {
                text += `/* ${sheet.href || "inline <style>"} */\n`;
                try { Array.from(sheet.cssRules || []).forEach((r) => { text += r.cssText + "\n"; }); }
                catch (_) { text += "/* not accessible (cross-origin stylesheet) */\n"; }
                text += "\n";
            });
            appendSection("CSS (accessible stylesheets only)", text.slice(0, 20000) || "(none accessible)");
        } else if (mode === "js") {
            const text = Array.from(document.scripts)
                .map((s, i) => (s.src ? `${i + 1}. <script src="${s.src}">` : `${i + 1}. inline script (${(s.textContent || "").length} chars)\n${(s.textContent || "").slice(0, 300)}${(s.textContent || "").length > 300 ? "…" : ""}`))
                .join("\n\n");
            appendSection("Scripts on page", text || "(none)");
        }
    }
    function toggleFullSource() {
        if (fullSourceCache) { fullSourceCache = null; renderSource(); return; }
        setStatus("Fetching page source…", 0);
        fetch(location.href)
            .then((r) => r.text())
            .then((html) => { fullSourceCache = { html, label: "fetched from server" }; setStatus(""); renderSource(); })
            .catch(() => { fullSourceCache = { html: document.documentElement.outerHTML, label: "fetch blocked — showing live DOM instead" }; setStatus(""); renderSource(); });
    }
    function diagnosticsText() {
        let webgl = "unavailable";
        try {
            const c = document.createElement("canvas");
            const gl = c.getContext("webgl") || c.getContext("experimental-webgl");
            if (gl) {
                const dbg = gl.getExtension("WEBGL_debug_renderer_info");
                webgl = dbg ? `${gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL)} / ${gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)}` : "available (renderer info hidden)";
            }
        } catch (_) {}
        return [
            `User agent: ${navigator.userAgent}`,
            `Language: ${navigator.language}`,
            `Timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`,
            `Screen: ${screen.width}x${screen.height} @ DPR ${window.devicePixelRatio}`,
            `Viewport: ${window.innerWidth}x${window.innerHeight}`,
            `CPU threads: ${navigator.hardwareConcurrency || "n/a"}`,
            `Memory: ${navigator.deviceMemory || "n/a"} GB`,
            `WebGL: ${webgl}`,
        ].join("\n");
    }

    /* ---------- Settings tab ---------- */

    function renderSettings() {
        body.replaceChildren();
        const title = document.createElement("div");
        title.className = "di-settings-title";
        title.textContent = "Trigger gestures";
        body.appendChild(title);

        body.appendChild(buildGestureRow("Open eruda", "eruda"));
        body.appendChild(buildGestureRow("Open built-in inspector", "inspector"));

        const resetBtn = document.createElement("button");
        resetBtn.className = "di-btn";
        resetBtn.textContent = "Reset to default";
        resetBtn.style.margin = "12px 4px";
        resetBtn.addEventListener("click", () => {
            gestureCfg = { ...DEFAULT_GESTURES };
            saveGestures(gestureCfg);
            renderSettings();
            setStatus("Reset to default gestures");
        });
        body.appendChild(resetBtn);

        const note = document.createElement("div");
        note.style.cssText = "color:#777;font-size:11px;padding:0 4px;line-height:1.6;";
        note.textContent = "Hold down one finger and tap with another the configured number of times. Drag the handle bar at the top of the panel to resize it, or tap ▁ to minimize.";
        body.appendChild(note);

        const kbTitle = document.createElement("div");
        kbTitle.className = "di-settings-title";
        kbTitle.textContent = "Keyboard shortcuts (desktop)";
        body.appendChild(kbTitle);

        const kbRow = document.createElement("div");
        kbRow.className = "di-settings-row";
        const kbLabel = document.createElement("div");
        kbLabel.style.cssText = "font-size:12px;color:#ddd;line-height:1.6;";
        kbLabel.textContent = "Ctrl+Alt+E → eruda   /   Ctrl+Alt+I → inspector";
        const kbToggle = document.createElement("input");
        kbToggle.type = "checkbox";
        kbToggle.checked = keyboardShortcutsEnabled;
        kbToggle.style.cssText = "width:18px;height:18px;flex:0 0 auto;";
        kbToggle.addEventListener("change", () => {
            keyboardShortcutsEnabled = kbToggle.checked;
            saveKeyboardEnabled(keyboardShortcutsEnabled);
            setStatus(keyboardShortcutsEnabled ? "Keyboard shortcuts on" : "Keyboard shortcuts off");
        });
        kbRow.append(kbLabel, kbToggle);
        body.appendChild(kbRow);
    }

    function buildGestureRow(label, key) {
        const row = document.createElement("div");
        row.className = "di-settings-row";
        const labelEl = document.createElement("div");
        labelEl.style.cssText = "font-size:12px;color:#ddd;";
        labelEl.textContent = label;
        const group = document.createElement("div");
        group.className = "di-settings-group";
        const fingerSelect = document.createElement("select");
        [2, 3].forEach((n) => {
            const opt = document.createElement("option");
            opt.value = n;
            opt.textContent = `${n} fingers`;
            if (gestureCfg[key].fingers === n) opt.selected = true;
            fingerSelect.appendChild(opt);
        });
        const tapSelect = document.createElement("select");
        [2, 3, 4, 5].forEach((n) => {
            const opt = document.createElement("option");
            opt.value = n;
            opt.textContent = `${n} taps`;
            if (gestureCfg[key].taps === n) opt.selected = true;
            tapSelect.appendChild(opt);
        });
        function applyChange() {
            const next = { ...gestureCfg, [key]: { fingers: parseInt(fingerSelect.value, 10), taps: parseInt(tapSelect.value, 10) } };
            if (gesturesCollide(next)) {
                setStatus("Can't use the same gesture for both triggers", 2500);
                fingerSelect.value = gestureCfg[key].fingers;
                tapSelect.value = gestureCfg[key].taps;
                return;
            }
            gestureCfg = next;
            saveGestures(gestureCfg);
            setStatus("Saved");
        }
        fingerSelect.addEventListener("change", applyChange);
        tapSelect.addEventListener("change", applyChange);
        group.append(fingerSelect, tapSelect);
        row.append(labelEl, group);
        return row;
    }

    document.addEventListener("DOMContentLoaded", () => {
        try { if (localStorage.getItem(ERUDA_STATE_KEY) === "1") loadEruda(); } catch (_) {}
        try { if (localStorage.getItem(INSPECTOR_STATE_KEY) === "1") showInspector(); } catch (_) {}
    });
})();