Devtool Mobile Pro

Enables you to open developer tools on mobile

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

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

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu betiği yüklemek için bir betik yöneticisi eklentisi yüklemeniz gerekecektir.

(Zaten bir betik yöneticim var, hadi yükleyelim!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==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 (_) {}
    });
})();