DevTools Emulator — Console & Elements

iPhone/iPad 用 DevTools エミュレータ。Console パネル(ログ表示)と Elements パネル(DOM ツリー表示・要素選択・CSS スタイル編集)を提供します。

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/577436/1822650/DevTools%20Emulator%20%E2%80%94%20Console%20%20Elements.js

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         DevTools Emulator — Console & Elements
// @namespace    http://tampermonkey.net/
// @version      3.1
// @description  iPhone/iPad 用 DevTools エミュレータ。Console パネル(ログ表示)と Elements パネル(DOM ツリー表示・要素選択・CSS スタイル編集)を提供します。
// @author       Professional Debugger
// @match        *://*/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    // ================================================================
    // 定数
    // ================================================================

    /** DevTools コンテナの ID */
    const TOOL_ID       = 'tm-devtools';
    /** 選択要素ハイライトオーバーレイの ID */
    const OVERLAY_ID    = 'tm-highlight-overlay';
    /** Console ログの最大保持件数(メモリ対策) */
    const MAX_LOGS      = 100;
    /** DOM ツリーの最大再帰深度 */
    const MAX_DOM_DEPTH = 50;
    /** タグラベルに表示する属性の最大数 */
    const MAX_ATTR_SHOW = 20;
    /** 文字列の最大表示長 */
    const TRUNCATE_LEN  = 100;

    // ================================================================
    // ユーティリティ関数
    // ================================================================

    /**
     * テキストと CSS クラスから <span> 要素を生成する
     * @param {string} text
     * @param {string} [className]
     * @returns {HTMLSpanElement}
     */
    function createSpan(text, className) {
        const el = document.createElement('span');
        if (className) el.className = className;
        el.textContent = text;
        return el;
    }

    /**
     * 値を文字列化して指定の最大長に切り詰める
     * @param {*} value
     * @param {number} [max]
     * @returns {string}
     */
    function truncate(value, max = TRUNCATE_LEN) {
        const s = String(value);
        return s.length > max ? `${s.slice(0, max)}…` : s;
    }

    /**
     * 要素の短い CSS セレクタ風識別子を返す
     * @param {HTMLElement} element
     * @returns {string}
     */
    function getElementSelector(element) {
        const tag = element.tagName.toLowerCase();
        const id  = element.id ? `#${element.id}` : '';
        const cls = typeof element.className === 'string' && element.className.trim()
            ? '.' + element.className.trim().split(/\s+/).slice(0, 2).join('.')
            : '';
        return `${tag}${id}${cls}`;
    }

    // ================================================================
    // SVG アイコンファクトリ
    // ================================================================

    /**
     * SVG 名前空間で要素を生成し、属性を一括設定する
     * @param {string} tag
     * @param {Object<string,string>} attrs
     * @returns {SVGElement}
     */
    function createSvgEl(tag, attrs = {}) {
        const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
        Object.entries(attrs).forEach(([k, v]) => el.setAttribute(k, v));
        return el;
    }

    /**
     * 共通の SVG ルート要素を生成する
     * @param {string} [size='12']
     * @returns {SVGSVGElement}
     */
    function makeSvgRoot(size = '12') {
        return createSvgEl('svg', {
            width: size, height: size,
            viewBox: '0 0 16 16',
            fill: 'none',
            stroke: 'currentColor',
            'stroke-width': '2',
            'stroke-linecap': 'round',
            'stroke-linejoin': 'round',
        });
    }

    /** 検索(虫眼鏡)アイコンを生成する */
    function makeIconSearch() {
        const svg = makeSvgRoot();
        svg.appendChild(createSvgEl('circle', { cx: '6.5', cy: '6.5', r: '4' }));
        svg.appendChild(createSvgEl('line',   { x1: '9.5', y1: '9.5', x2: '14', y2: '14' }));
        return svg;
    }

    /** 再描画(回転矢印)アイコンを生成する */
    function makeIconRefresh() {
        const svg = makeSvgRoot();
        // 円弧(3/4 周)
        svg.appendChild(createSvgEl('path',     { d: 'M14 8A6 6 0 1 1 10.2 3' }));
        // 矢じり
        svg.appendChild(createSvgEl('polyline', { points: '10,1 10,4 13,4' }));
        return svg;
    }

    /** 下向きシェブロン(最小化)アイコンを生成する */
    function makeIconChevronDown() {
        const svg = makeSvgRoot();
        svg.setAttribute('stroke-width', '2.5');
        svg.appendChild(createSvgEl('polyline', { points: '3,5 8,11 13,5' }));
        return svg;
    }

    /** 上向きシェブロン(最大化復帰)アイコンを生成する */
    function makeIconChevronUp() {
        const svg = makeSvgRoot();
        svg.setAttribute('stroke-width', '2.5');
        svg.appendChild(createSvgEl('polyline', { points: '3,11 8,5 13,11' }));
        return svg;
    }

    // ================================================================
    // CSS スタイル定義
    // ================================================================
    const CSS = `
        /* ===== コンテナ ===== */
        #${TOOL_ID} {
            position: fixed; bottom: 0; right: 0; width: 100%; max-height: 45vh;
            color: #ddd; font-size: 10px;
            font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
            z-index: 2147483647; border-top: 2px solid #555;
            display: flex; flex-direction: column;
            background: #1e1e1e; pointer-events: none;
            transition: max-height 0.2s;
        }
        #${TOOL_ID}.minimized { max-height: 32px; overflow: hidden; }
        #${TOOL_ID} * { pointer-events: auto; box-sizing: border-box; }
        /* SVG アイコンが行ボックスに浮かないようにする */
        #${TOOL_ID} .tm-ctrl-btn svg { display: block; }

        /* ===== ヘッダー ===== */
        .tm-header {
            background: #2d2d2d; padding: 0 8px;
            display: flex; align-items: center; flex-shrink: 0;
            border-bottom: 1px solid #444; min-height: 32px; gap: 8px;
        }
        .tm-tabs { display: flex; }
        .tm-tab-btn {
            background: transparent; color: #999; border: none;
            padding: 6px 12px; font-size: 11px; cursor: pointer;
            border-bottom: 2px solid transparent;
            -webkit-tap-highlight-color: transparent;
        }
        .tm-tab-btn.active { color: #fff; border-bottom-color: #4a9eff; }
        .tm-controls { display: flex; gap: 4px; margin-left: auto; }
        .tm-ctrl-btn {
            background: #3c3c3c; color: #ccc; border: none;
            padding: 4px 9px; border-radius: 3px; font-size: 10px;
            cursor: pointer; white-space: nowrap;
            display: flex; align-items: center; justify-content: center;
            -webkit-tap-highlight-color: transparent;
        }
        .tm-ctrl-btn:active { background: #505050; }
        /* アクティブ状態(Inspect ON / Styles ON など) */
        .tm-ctrl-btn.on { background: #0d62d1; color: #fff; }

        /* ===== パネル共通 ===== */
        .tm-panel { flex: 1; overflow: hidden; display: none; flex-direction: column; min-height: 0; }
        .tm-panel.active { display: flex; }

        /* ===== Console パネル ===== */
        .tm-log-area {
            flex: 1; overflow-y: auto; padding: 4px;
            -webkit-overflow-scrolling: touch;
        }
        .tm-log-line {
            border-bottom: 1px solid #2a2a2a; padding: 3px 0;
            white-space: pre-wrap; word-break: break-all; line-height: 1.5;
        }
        .tm-log-line.type-log   { color: #33cc33; }
        .tm-log-line.type-warn  { color: #ffaa00; background: rgba(255,170,0,0.2); }
        .tm-log-line.type-error { color: #ff5555; background: rgba(255,85,85,0.2); }
        .tm-log-line.type-info  { color: #55aaff; }
        .tm-log-time { color: #555; font-size: 8px; margin-right: 6px; }

        /* Console オブジェクト / DOM ツリー */
        .tm-ct-indent { margin-left: 14px; border-left: 1px dotted #383838; padding-left: 4px; }
        summary.tm-ct-summary {
            list-style: none; cursor: pointer; outline: none;
            display: flex; align-items: flex-start;
        }
        summary.tm-ct-summary::-webkit-details-marker { display: none; }
        summary.tm-ct-summary::before {
            content: '▶'; min-width: 12px; font-size: 8px; color: #777;
            transition: transform 0.1s; margin-right: 2px;
            padding-top: 2px; flex-shrink: 0; display: inline-block;
        }
        details[open] > summary.tm-ct-summary::before { transform: rotate(90deg); }
        .tm-ct-key     { color: #9cdcfe; }
        .tm-ct-val     { color: #ce9178; }
        .tm-ct-tag     { color: #569cd6; }
        .tm-ct-attr    { color: #9cdcfe; }
        .tm-ct-attrval { color: #ce9178; }

        /* ===== Elements パネル ===== */
        .tm-el-toolbar {
            background: #252526; padding: 4px 6px;
            display: flex; align-items: center; gap: 5px;
            flex-shrink: 0; border-bottom: 1px solid #333;
            overflow-x: auto; -webkit-overflow-scrolling: touch;
        }
        .tm-el-status {
            color: #777; font-size: 9px; flex: 1; overflow: hidden;
            text-overflow: ellipsis; white-space: nowrap; min-width: 0;
        }
        /* デフォルト: DOM ツリーとスタイルパネルを左右に並べる(iPad 等) */
        .tm-el-body { flex: 1; display: flex; flex-direction: row; overflow: hidden; min-height: 0; }

        /* 狭い画面(iPhone 等)では上下に積む */
        @media (max-width: 600px) {
            .tm-el-body { flex-direction: column; }
            .tm-styles-pane {
                width: 100% !important;
                max-width: none !important;
                border-left: none !important;
                border-top: 1px solid #383838;
                max-height: 40%;
            }
        }

        /* DOM ツリー */
        .tm-dom-tree {
            flex: 1; overflow: auto; padding: 4px 2px;
            -webkit-overflow-scrolling: touch; min-width: 0;
        }

        /* ノード行: [展開ボタン(20px)] [タグラベル] */
        .tm-dn-row { display: flex; align-items: center; line-height: 1.8; }

        /* タップしやすいよう 20×20px を確保した展開ボタン */
        .tm-dn-expand {
            flex-shrink: 0; width: 20px; height: 20px; cursor: pointer;
            display: flex; align-items: center; justify-content: center;
            font-size: 8px; color: #666; user-select: none;
            -webkit-tap-highlight-color: transparent;
        }
        .tm-dn-expand.has-ch::after { content: '▶'; display: inline-block; }
        .tm-dn-expand.open::after   { transform: rotate(90deg); }

        /* タグラベル(クリックで要素を選択する) */
        .tm-dn-label {
            display: inline-flex; align-items: baseline; flex-wrap: wrap;
            cursor: pointer; padding: 1px 4px; border-radius: 3px;
            -webkit-tap-highlight-color: transparent; user-select: none;
        }
        .tm-dn-label:active   { background: rgba(255,255,255,0.07); }
        .tm-dn-label.selected { background: #264f78; }

        /* 子コンテナ・閉じタグ・テキストノード・コメント */
        .tm-dn-children  { margin-left: 20px; border-left: 1px dotted #383838; padding-left: 2px; }
        .tm-dn-close     { color: #569cd6; padding-left: 22px; line-height: 1.8; display: block; white-space: nowrap; }
        .tm-dn-textnode  { color: #a8c4a2; font-style: italic; padding-left: 22px; display: block; white-space: pre-wrap; word-break: break-all; }
        .tm-dn-comment   { color: #6a9955; padding-left: 22px; display: block; }

        /* タグ構成要素の色分け */
        .tm-el-tagname  { color: #569cd6; }
        .tm-el-attrname { color: #9cdcfe; }
        .tm-el-attrval  { color: #ce9178; }
        .tm-el-punct    { color: #888; }
        .tm-el-more     { color: #6a9955; font-size: 9px; }

        /* ===== スタイルパネル ===== */
        .tm-styles-pane {
            flex-shrink: 0; width: 44%; max-width: 200px;
            border-left: 1px solid #383838;
            overflow-y: auto; padding: 0;
            -webkit-overflow-scrolling: touch;
            display: none;
        }
        .tm-styles-pane.visible { display: block; }
        .tm-sp-section-title {
            color: #666; font-size: 9px; padding: 3px 6px;
            border-bottom: 1px solid #333; background: #252526;
            position: sticky; top: 0;
        }
        .tm-sp-row   { display: flex; align-items: center; padding: 1px 4px; gap: 3px; }
        .tm-sp-prop  {
            color: #9cdcfe; font-size: 9px; white-space: nowrap;
            flex-shrink: 0; min-width: 70px; max-width: 90px;
            overflow: hidden; text-overflow: ellipsis;
        }
        /* 編集可能な値インプット */
        .tm-sp-input {
            background: #2a2a2a; color: #ce9178; border: 1px solid #3c3c3c;
            padding: 1px 3px; font-size: 9px; border-radius: 2px;
            flex: 1; min-width: 0;
            font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
        }
        .tm-sp-input:focus { border-color: #4a9eff; outline: none; }
        /* 読み取り専用の値ラベル */
        .tm-sp-readonly {
            color: #777; font-size: 9px; flex: 1;
            overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
        }
        /* 新規プロパティ追加フォーム */
        .tm-sp-add-row { display: flex; gap: 3px; padding: 3px 4px; }
        .tm-sp-add-row input {
            background: #2a2a2a; color: #ccc; border: 1px solid #3c3c3c;
            padding: 2px 4px; font-size: 9px; border-radius: 2px;
            min-width: 0; flex: 1;
            font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
        }
        .tm-sp-add-row input:focus { border-color: #4a9eff; outline: none; }
        .tm-sp-add-row input::placeholder { color: #555; }

        /* ===== 選択要素ハイライトオーバーレイ ===== */
        #${OVERLAY_ID} {
            position: fixed; pointer-events: none; z-index: 2147483646;
            border: 2px solid #4a9eff; background: rgba(74,158,255,0.1);
            display: none;
        }
        #tm-overlay-badge {
            position: absolute; top: -18px; left: 0;
            background: #4a9eff; color: #fff; font-size: 9px;
            padding: 1px 5px; border-radius: 2px 2px 0 0; white-space: nowrap;
            font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
        }
    `;

    // ================================================================
    // HighlightOverlay — 選択要素の上に重ねて表示するオーバーレイ
    // ================================================================
    class HighlightOverlay {
        constructor() {
            this._el    = null;
            this._badge = null;
            this._setup();
        }

        _setup() {
            this._el = document.createElement('div');
            this._el.id = OVERLAY_ID;

            this._badge = document.createElement('div');
            this._badge.id = 'tm-overlay-badge';
            this._el.appendChild(this._badge);

            document.documentElement.appendChild(this._el);
        }

        /**
         * 指定要素の位置にオーバーレイを表示する
         * @param {HTMLElement} element
         */
        show(element) {
            if (!(element instanceof HTMLElement)) { this.hide(); return; }
            const r = element.getBoundingClientRect();
            this._badge.textContent = getElementSelector(element);
            Object.assign(this._el.style, {
                display: 'block',
                top:     `${r.top}px`,
                left:    `${r.left}px`,
                width:   `${r.width}px`,
                height:  `${r.height}px`,
            });
        }

        /**
         * 選択要素が変形した際などにオーバーレイ位置を再計算する
         * @param {HTMLElement} element
         */
        update(element) {
            if (this._el.style.display !== 'none') this.show(element);
        }

        /** オーバーレイを非表示にする */
        hide() {
            this._el.style.display = 'none';
        }
    }

    // ================================================================
    // ConsolePanel — console.log 等をパネルに表示する
    // ================================================================
    class ConsolePanel {
        constructor() {
            this._logArea = null;
        }

        /**
         * パネルの <div> 要素を組み立てて返す
         * @returns {HTMLDivElement}
         */
        buildElement() {
            const panel = document.createElement('div');
            panel.className = 'tm-panel';
            panel.dataset.panel = 'console';

            this._logArea = document.createElement('div');
            this._logArea.className = 'tm-log-area';
            panel.appendChild(this._logArea);

            this._interceptConsole();
            this._interceptGlobalErrors();
            return panel;
        }

        /** ログエリアをクリアする */
        clear() {
            this._logArea?.replaceChildren();
        }

        /**
         * ログ行を追加する
         * @param {'log'|'warn'|'error'|'info'} type
         * @param {IArguments} args
         */
        print(type, args) {
            if (!this._logArea) return;

            const SNAP_THRESHOLD = 30;
            const wasAtBottom = (this._logArea.scrollHeight - this._logArea.scrollTop - this._logArea.clientHeight) <= SNAP_THRESHOLD;

            const line = document.createElement('div');
            line.className = `tm-log-line type-${type}`;

            const now = new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
            line.appendChild(createSpan(now, 'tm-log-time'));

            Array.from(args).forEach(arg => {
                const wrapper = document.createElement('span');
                wrapper.style.cssText = 'display:inline-block; vertical-align:top; margin-right:8px;';
                wrapper.appendChild(this._renderValue(arg));
                line.appendChild(wrapper);
            });

            this._logArea.appendChild(line);

            // 最大件数超過時は最古の行を削除する
            if (this._logArea.children.length > MAX_LOGS) {
                this._logArea.removeChild(this._logArea.firstChild);
            }
            // ユーザーが末尾を閲覧中なら自動スクロールする
            if (wasAtBottom) {
                this._logArea.scrollTop = this._logArea.scrollHeight;
            }
        }

        // ------ プライベートメソッド ------

        /**
         * 任意の値を再帰的に DOM 要素としてレンダリングする
         * @param {*} value
         * @returns {HTMLElement}
         */
        _renderValue(value) {
            if (value === null)            return createSpan('null',      'tm-ct-val');
            if (value === undefined)       return createSpan('undefined', 'tm-ct-val');
            if (typeof value !== 'object') return createSpan(String(value), 'tm-ct-val');
            if (value instanceof HTMLElement) return this._renderDomNode(value);
            return this._renderObject(value);
        }

        /**
         * プレーンオブジェクト / 配列を折り畳み可能なツリーとしてレンダリングする
         * @param {object|Array} obj
         * @returns {HTMLElement}
         */
        _renderObject(obj) {
            const isArray = Array.isArray(obj);
            const keys    = Object.keys(obj);
            if (keys.length === 0) return createSpan(isArray ? '[]' : '{}', 'tm-ct-val');

            const summary = document.createElement('summary');
            summary.className = 'tm-ct-summary';
            summary.textContent = isArray
                ? `Array(${obj.length})`
                : `Object { ${keys.slice(0, 2).join(', ')}${keys.length > 2 ? '…' : ''} }`;

            const content = document.createElement('div');
            content.className = 'tm-ct-indent';
            keys.forEach(key => {
                const row = document.createElement('div');
                row.appendChild(createSpan(`${key}: `, 'tm-ct-key'));
                row.appendChild(this._renderValue(obj[key]));
                content.appendChild(row);
            });

            const details = document.createElement('details');
            details.appendChild(summary);
            details.appendChild(content);
            return details;
        }

        /**
         * HTML 要素を折り畳み可能なタグツリーとしてレンダリングする
         * @param {HTMLElement} el
         * @returns {HTMLElement}
         */
        _renderDomNode(el) {
            const children = Array.from(el.childNodes).filter(n =>
                n.nodeType !== Node.TEXT_NODE || n.textContent.trim()
            );

            // 子なし要素はタグ表示のみ
            if (children.length === 0) return this._buildDomTagSpan(el);

            const summary = document.createElement('summary');
            summary.className = 'tm-ct-summary';
            summary.appendChild(this._buildDomTagSpan(el));

            const content = document.createElement('div');
            content.className = 'tm-ct-indent';
            children.forEach(child => {
                const row = document.createElement('div');
                if (child.nodeType === Node.TEXT_NODE) {
                    row.textContent = truncate(child.textContent.trim(), 60);
                } else {
                    row.appendChild(this._renderValue(child));
                }
                content.appendChild(row);
            });

            const details = document.createElement('details');
            details.appendChild(summary);
            details.appendChild(content);
            return details;
        }

        /**
         * 要素の開きタグを <span> として組み立てる
         * @param {HTMLElement} el
         * @returns {HTMLSpanElement}
         */
        _buildDomTagSpan(el) {
            const wrap = document.createElement('span');
            wrap.appendChild(createSpan('<', 'tm-ct-tag'));
            wrap.appendChild(createSpan(el.tagName.toLowerCase(), 'tm-ct-tag'));
            Array.from(el.attributes).forEach(a => {
                wrap.appendChild(createSpan(` ${a.name}`, 'tm-ct-attr'));
                if (a.value) {
                    wrap.appendChild(createSpan(`="${truncate(a.value, 20)}"`, 'tm-ct-attrval'));
                }
            });
            wrap.appendChild(createSpan('>', 'tm-ct-tag'));
            return wrap;
        }

        /** console.log / warn / error / info をフックして画面に表示する */
        _interceptConsole() {
            ['log', 'warn', 'error', 'info'].forEach(method => {
                const orig = console[method];
                console[method] = (...args) => {
                    this.print(method, args);
                    orig.apply(console, args);
                };
            });
        }

        /** window.onerror / onunhandledrejection をフックする */
        _interceptGlobalErrors() {
            window.onerror = (msg, url, line, col) => {
                this.print('error', [`Runtime Error: ${msg}\nAt: ${url}:${line}:${col}`]);
            };
            window.onunhandledrejection = (e) => {
                this.print('error', [`Unhandled Promise Rejection: ${e.reason}`]);
            };
        }
    }

    // ================================================================
    // ElementsPanel — DOM ツリー検査・要素選択・CSS スタイル編集
    // ================================================================
    class ElementsPanel {
        /**
         * @param {HighlightOverlay} highlight
         */
        constructor(highlight) {
            this._highlight      = highlight;
            this._selectedEl     = null;   // 現在選択中の DOM 要素
            this._inspectMode    = false;
            this._touchStartPos  = null;   // Inspect タップ判定用の開始座標

            // Inspect モードのイベントハンドラ参照(removeEventListener 用)
            this._inspectHandlers = { touchstart: null, touchend: null, click: null };

            // DOM 参照
            this._treeContainer = null;
            this._stylesPane    = null;
            this._statusEl      = null;
            this._inspectBtn    = null;
            this._stylesBtn     = null;
        }

        /**
         * パネルの <div> 要素を組み立てて返す
         * @returns {HTMLDivElement}
         */
        buildElement() {
            const panel = document.createElement('div');
            panel.className = 'tm-panel';
            panel.dataset.panel = 'elements';
            panel.appendChild(this._buildToolbar());

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

            this._treeContainer = document.createElement('div');
            this._treeContainer.className = 'tm-dom-tree';

            this._stylesPane = document.createElement('div');
            this._stylesPane.className = 'tm-styles-pane';

            body.appendChild(this._treeContainer);
            body.appendChild(this._stylesPane);
            panel.appendChild(body);
            return panel;
        }

        /** DOM ツリーを(再)描画する */
        renderDomTree() {
            this._treeContainer.replaceChildren();
            const rootNode = this._buildNode(document.documentElement, 0);
            if (rootNode) this._treeContainer.appendChild(rootNode);
            this._scrollToSelected();
        }

        // ---------- ツールバー ----------

        /**
         * ツールバーを組み立てて返す
         * @returns {HTMLDivElement}
         */
        _buildToolbar() {
            const bar = document.createElement('div');
            bar.className = 'tm-el-toolbar';

            this._inspectBtn = this._makeCtrlBtn(
                makeIconSearch(),
                'ページ上の要素をタップして選択',
                () => this._toggleInspect()
            );
            bar.appendChild(this._inspectBtn);

            bar.appendChild(this._makeCtrlBtn(
                makeIconRefresh(),
                'DOM ツリーを再描画',
                () => this.renderDomTree()
            ));

            this._stylesBtn = this._makeCtrlBtn(
                'Styles',
                'スタイルパネルの表示切替',
                () => this._toggleStylesPane()
            );
            bar.appendChild(this._stylesBtn);

            this._statusEl = document.createElement('span');
            this._statusEl.className = 'tm-el-status';
            this._statusEl.textContent = '要素を選択してください';
            bar.appendChild(this._statusEl);

            return bar;
        }

        /**
         * コントロールボタンを生成する
         * content には文字列または SVG 要素を渡す
         * @param {string|SVGElement} content
         * @param {string} title
         * @param {Function} handler
         * @returns {HTMLButtonElement}
         */
        _makeCtrlBtn(content, title, handler) {
            const btn = document.createElement('button');
            btn.className = 'tm-ctrl-btn';
            btn.title = title;
            btn.onclick = handler;
            if (typeof content === 'string') {
                btn.textContent = content;
            } else {
                btn.appendChild(content);
            }
            return btn;
        }

        // ---------- DOM ノード構築 ----------

        /**
         * Node を再帰的に UI 要素として構築する
         * @param {Node} node
         * @param {number} depth
         * @returns {HTMLElement|null}
         */
        _buildNode(node, depth) {
            if (depth > MAX_DOM_DEPTH) return null;
            switch (node.nodeType) {
                case Node.TEXT_NODE:    return this._buildTextNode(node);
                case Node.COMMENT_NODE: return this._buildCommentNode(node);
                case Node.ELEMENT_NODE: return this._buildElementNode(node, depth);
                default:                return null;
            }
        }

        /**
         * テキストノードの表示行を生成する
         * @param {Text} node
         * @returns {HTMLDivElement|null}
         */
        _buildTextNode(node) {
            const text = node.textContent.trim();
            if (!text) return null;
            const div = document.createElement('div');
            div.className = 'tm-dn-textnode';
            div.textContent = `"${truncate(text, 80)}"`;
            return div;
        }

        /**
         * コメントノードの表示行を生成する
         * @param {Comment} node
         * @returns {HTMLDivElement}
         */
        _buildCommentNode(node) {
            const div = document.createElement('div');
            div.className = 'tm-dn-comment';
            div.textContent = `<!-- ${truncate(node.textContent.trim(), 60)} -->`;
            return div;
        }

        /**
         * 要素ノードの表示ブロックを生成する(展開 / 折り畳み対応)
         * @param {HTMLElement} el
         * @param {number} depth
         * @returns {HTMLDivElement|null}
         */
        _buildElementNode(el, depth) {
            // このツール自体の要素はスキップして循環を防ぐ
            if (el.id === TOOL_ID || el.id === OVERLAY_ID) return null;

            const significantChildren = Array.from(el.childNodes).filter(n => {
                if (n.id === TOOL_ID || n.id === OVERLAY_ID) return false;
                if (n.nodeType === Node.TEXT_NODE)    return n.textContent.trim().length > 0;
                if (n.nodeType === Node.ELEMENT_NODE) return true;
                return n.nodeType === Node.COMMENT_NODE;
            });

            const hasChildren = significantChildren.length > 0;

            // ---- 行レイアウト: [展開ボタン(20px)] [タグラベル] ----
            const row = document.createElement('div');
            row.className = 'tm-dn-row';

            const expandBtn = document.createElement('div');
            expandBtn.className = `tm-dn-expand${hasChildren ? ' has-ch' : ''}`;

            const label = this._buildTagLabel(el);
            row.appendChild(expandBtn);
            row.appendChild(label);

            const wrapper = document.createElement('div');
            wrapper.appendChild(row);

            if (hasChildren) {
                const childContainer = document.createElement('div');
                childContainer.className = 'tm-dn-children';
                childContainer.style.display = 'none'; // 初期状態は折り畳み

                significantChildren.forEach(child => {
                    const childEl = this._buildNode(child, depth + 1);
                    if (childEl) childContainer.appendChild(childEl);
                });

                // 閉じタグ
                const closeTag = document.createElement('span');
                closeTag.className = 'tm-dn-close';
                closeTag.appendChild(createSpan('</', 'tm-el-tagname'));
                closeTag.appendChild(createSpan(el.tagName.toLowerCase(), 'tm-el-tagname'));
                closeTag.appendChild(createSpan('>', 'tm-el-punct'));
                childContainer.appendChild(closeTag);

                wrapper.appendChild(childContainer);

                // 展開ボタンは DOM の現在状態を読んで開閉を判断する(状態が外から変わっても正確に動く)
                expandBtn.onclick = (e) => {
                    e.stopPropagation();
                    const isOpen = childContainer.style.display !== 'none';
                    childContainer.style.display = isOpen ? 'none' : 'block';
                    expandBtn.classList.toggle('open', !isOpen);
                };
            }

            return wrapper;
        }

        /**
         * 開きタグのクリッカブルラベルを生成する
         * @param {HTMLElement} el
         * @returns {HTMLDivElement}
         */
        _buildTagLabel(el) {
            const label = document.createElement('div');
            label.className = 'tm-dn-label';

            // 選択中の要素なら選択スタイルを適用する
            if (el === this._selectedEl) label.classList.add('selected');

            label.appendChild(createSpan('<',                       'tm-el-punct'));
            label.appendChild(createSpan(el.tagName.toLowerCase(), 'tm-el-tagname'));

            const attrs = Array.from(el.attributes);
            attrs.slice(0, MAX_ATTR_SHOW).forEach(a => {
                label.appendChild(createSpan(` ${a.name}`,         'tm-el-attrname'));
                if (a.value) {
                    label.appendChild(createSpan('="',             'tm-el-punct'));
                    label.appendChild(createSpan(truncate(a.value, 25), 'tm-el-attrval'));
                    label.appendChild(createSpan('"',              'tm-el-punct'));
                }
            });
            if (attrs.length > MAX_ATTR_SHOW) {
                label.appendChild(createSpan(` …+${attrs.length - MAX_ATTR_SHOW}`, 'tm-el-more'));
            }
            label.appendChild(createSpan('>', 'tm-el-punct'));

            // クリックで要素を選択する
            label.addEventListener('click', (e) => {
                e.stopPropagation();
                this._selectFromTree(el, label);
            });
            return label;
        }

        // ---------- 要素選択 ----------

        /**
         * DOM ツリー上のラベルクリックで要素を選択する
         * @param {HTMLElement} el
         * @param {HTMLDivElement} labelEl
         */
        _selectFromTree(el, labelEl) {
            this._clearCurrentSelection();
            this._selectedEl = el;
            labelEl.classList.add('selected');
            this._highlight.show(el);
            this._updateStatus(el);
            if (this._stylesPane.classList.contains('visible')) {
                this._renderStyles(el);
            }
        }

        /**
         * Inspect モード(ページタップ)で要素を選択する。
         * 選択状態を反映するために DOM ツリーを再描画する。
         * @param {HTMLElement} el
         */
        _selectFromInspect(el) {
            this._selectedEl = el;
            this._highlight.show(el);
            this._updateStatus(el);
            this.renderDomTree(); // 選択ラベルを反映するために再描画
            if (this._stylesPane.classList.contains('visible')) {
                this._renderStyles(el);
            }
        }

        /** 現在の選択状態を解除する */
        _clearCurrentSelection() {
            const prev = this._treeContainer.querySelector('.tm-dn-label.selected');
            if (prev) prev.classList.remove('selected');
        }

        /** ステータスバーに要素の識別子を表示する */
        _updateStatus(el) {
            this._statusEl.textContent = getElementSelector(el);
        }

        // ---------- Inspect モード ----------

        _toggleInspect() {
            this._inspectMode ? this._exitInspect() : this._enterInspect();
        }

        /** Inspect モードを開始し、ページへのタップを待ち受ける */
        _enterInspect() {
            this._inspectMode = true;
            this._inspectBtn.classList.add('on');
            this._statusEl.textContent = '要素をタップ…';

            // タップ開始座標を記録する(スクロール操作と区別するため)
            this._inspectHandlers.touchstart = (e) => {
                const t = e.touches[0];
                this._touchStartPos = { x: t.clientX, y: t.clientY };
            };

            // タップ終了時に要素を選択する
            this._inspectHandlers.touchend = (e) => {
                const t   = e.changedTouches[0];
                const pos = this._touchStartPos;
                if (!pos) return;

                // 指が大きく動いた場合はスクロールとみなして無視する
                const hasMoved = Math.abs(t.clientX - pos.x) > 10 || Math.abs(t.clientY - pos.y) > 10;
                if (hasMoved) return;

                // タップ座標からページ上の要素を取得する(オーバーレイは pointer-events:none なので除外される)
                const target = document.elementFromPoint(t.clientX, t.clientY);
                if (!target || document.getElementById(TOOL_ID)?.contains(target)) return;

                e.preventDefault();
                e.stopPropagation();
                this._selectFromInspect(target);
                this._exitInspect();
            };

            // タッチ非対応デバイス(PC でのプレビューなど)向けフォールバック
            this._inspectHandlers.click = (e) => {
                if (document.getElementById(TOOL_ID)?.contains(e.target)) return;
                e.preventDefault();
                e.stopPropagation();
                this._selectFromInspect(e.target);
                this._exitInspect();
            };

            document.addEventListener('touchstart', this._inspectHandlers.touchstart, { capture: true, passive: true });
            document.addEventListener('touchend',   this._inspectHandlers.touchend,   { capture: true, passive: false });
            document.addEventListener('click',      this._inspectHandlers.click,       { capture: true });
        }

        /** Inspect モードを終了してイベントリスナーを解除する */
        _exitInspect() {
            this._inspectMode = false;
            this._inspectBtn.classList.remove('on');
            document.removeEventListener('touchstart', this._inspectHandlers.touchstart, { capture: true });
            document.removeEventListener('touchend',   this._inspectHandlers.touchend,   { capture: true });
            document.removeEventListener('click',      this._inspectHandlers.click,       { capture: true });
            this._inspectHandlers = { touchstart: null, touchend: null, click: null };
            this._touchStartPos = null;
        }

        // ---------- スタイルパネル ----------

        /** スタイルパネルの表示 / 非表示を切り替える */
        _toggleStylesPane() {
            const visible = this._stylesPane.classList.toggle('visible');
            this._stylesBtn.classList.toggle('on', visible);
            if (visible && this._selectedEl) this._renderStyles(this._selectedEl);
        }

        /**
         * 選択要素のスタイルをスタイルパネルに描画する
         * @param {HTMLElement} el
         */
        _renderStyles(el) {
            this._stylesPane.replaceChildren();
            this._appendInlineStyleSection(el);
            this._appendComputedStyleSection(el);
        }

        /**
         * 編集可能なインラインスタイルセクションを追加する
         * @param {HTMLElement} el
         */
        _appendInlineStyleSection(el) {
            this._stylesPane.appendChild(this._makeSectionTitle('element.style'));

            const entries = this._getInlineStyleEntries(el);
            if (entries.length === 0) {
                this._stylesPane.appendChild(this._makeEmptyLabel());
            } else {
                entries.forEach(([prop, val]) => {
                    this._stylesPane.appendChild(this._makeEditableRow(prop, val, el));
                });
            }

            // 新規プロパティ追加フォームを末尾に配置する
            this._stylesPane.appendChild(this._makeAddStyleRow(el));
        }

        /**
         * 読み取り専用の Computed スタイルセクションを追加する
         * @param {HTMLElement} el
         */
        _appendComputedStyleSection(el) {
            this._stylesPane.appendChild(this._makeSectionTitle('Computed'));

            const IMPORTANT_PROPS = [
                'display', 'position', 'width', 'height', 'margin', 'padding',
                'color', 'background-color', 'font-size', 'font-weight', 'line-height',
                'border', 'border-radius', 'opacity', 'overflow',
                'flex', 'z-index', 'top', 'left', 'right', 'bottom',
                'transform', 'box-shadow', 'text-align', 'cursor',
            ];
            const computed = window.getComputedStyle(el);
            const entries  = IMPORTANT_PROPS
                .map(p => [p, computed.getPropertyValue(p)])
                .filter(([, v]) => v !== '');

            if (entries.length === 0) {
                this._stylesPane.appendChild(this._makeEmptyLabel());
            } else {
                entries.forEach(([prop, val]) => {
                    this._stylesPane.appendChild(this._makeReadonlyRow(prop, val));
                });
            }
        }

        /**
         * 要素のインラインスタイルを [prop, val] のペア配列として返す
         * @param {HTMLElement} el
         * @returns {Array<[string, string]>}
         */
        _getInlineStyleEntries(el) {
            const entries = [];
            for (let i = 0; i < el.style.length; i++) {
                const p = el.style[i];
                entries.push([p, el.style.getPropertyValue(p)]);
            }
            return entries;
        }

        /**
         * セクションタイトル要素を生成する
         * @param {string} text
         * @returns {HTMLDivElement}
         */
        _makeSectionTitle(text) {
            const el = document.createElement('div');
            el.className = 'tm-sp-section-title';
            el.textContent = text;
            return el;
        }

        /** 「(なし)」ラベルを生成する */
        _makeEmptyLabel() {
            const el = document.createElement('div');
            el.style.cssText = 'color:#555; font-size:9px; padding:2px 6px;';
            el.textContent = '(なし)';
            return el;
        }

        /**
         * 編集可能なスタイル行を生成する(インラインスタイル用)
         * @param {string} prop
         * @param {string} val
         * @param {HTMLElement} targetEl
         * @returns {HTMLDivElement}
         */
        _makeEditableRow(prop, val, targetEl) {
            const row = document.createElement('div');
            row.className = 'tm-sp-row';

            const propEl = document.createElement('div');
            propEl.className = 'tm-sp-prop';
            propEl.textContent = prop;
            propEl.title = prop;
            row.appendChild(propEl);

            const input = document.createElement('input');
            input.type = 'text';
            input.className = 'tm-sp-input';
            input.value = val;
            input.addEventListener('change', () => {
                try {
                    targetEl.style.setProperty(prop, input.value);
                    this._highlight.update(targetEl);
                } catch {
                    // 無効な値は静かに無視する
                }
            });
            row.appendChild(input);
            return row;
        }

        /**
         * 読み取り専用のスタイル行を生成する(Computed スタイル用)
         * @param {string} prop
         * @param {string} val
         * @returns {HTMLDivElement}
         */
        _makeReadonlyRow(prop, val) {
            const row = document.createElement('div');
            row.className = 'tm-sp-row';

            const propEl = document.createElement('div');
            propEl.className = 'tm-sp-prop';
            propEl.textContent = prop;
            propEl.title = prop;

            const valEl = document.createElement('div');
            valEl.className = 'tm-sp-readonly';
            valEl.textContent = val;
            valEl.title = val;

            row.appendChild(propEl);
            row.appendChild(valEl);
            return row;
        }

        /**
         * 新規インラインスタイルを追加するフォーム行を生成する。
         * 追加したプロパティには自動で !important を付与する。
         * @param {HTMLElement} targetEl
         * @returns {HTMLDivElement}
         */
        _makeAddStyleRow(targetEl) {
            const row = document.createElement('div');
            row.className = 'tm-sp-add-row';

            const propInput = document.createElement('input');
            propInput.type = 'text';
            propInput.placeholder = 'property';

            const valInput = document.createElement('input');
            valInput.type = 'text';
            valInput.placeholder = 'value';

            const addBtn = document.createElement('button');
            addBtn.className = 'tm-ctrl-btn';
            addBtn.textContent = '+';
            addBtn.title = '追加(!important を自動付与)';
            addBtn.onclick = () => {
                const p = propInput.value.trim();
                const v = valInput.value.trim();
                if (!p) return;
                try {
                    // 第 3 引数 'important' が CSS !important に相当する
                    targetEl.style.setProperty(p, v, 'important');
                    this._highlight.update(targetEl);
                    this._renderStyles(targetEl); // 追加後に再描画して反映する
                } catch {
                    // 無効な値は静かに無視する
                }
            };

            row.appendChild(propInput);
            row.appendChild(valInput);
            row.appendChild(addBtn);
            return row;
        }

        // ---------- ユーティリティ ----------

        /**
         * 選択中のノードが見えるよう祖先を全て展開してスクロールする
         */
        _scrollToSelected() {
            const selected = this._treeContainer.querySelector('.tm-dn-label.selected');
            if (!selected) return;

            // 選択ノードの祖先にある .tm-dn-children を全て展開する
            let el = selected.parentElement;
            while (el && el !== this._treeContainer) {
                if (el.classList.contains('tm-dn-children')) {
                    el.style.display = 'block';
                    // 対応する展開ボタンを open 状態にする
                    const expandBtn = el.previousElementSibling?.querySelector('.tm-dn-expand');
                    if (expandBtn) expandBtn.classList.add('open');
                }
                el = el.parentElement;
            }

            // DOM 更新後にスクロールする
            requestAnimationFrame(() => {
                selected.scrollIntoView({ block: 'center', behavior: 'smooth' });
            });
        }
    }

    // ================================================================
    // DevToolsUI — 全体の UI オーケストレーション
    // ================================================================
    class DevToolsUI {
        constructor() {
            this._isMinimized = false;
            this._activePanel = 'elements';
            this._console     = null;
            this._elements    = null;
            this._highlight   = null;
            this._container   = null;
            this._minBtn      = null; // 最小化ボタンへの参照(アイコン更新用)
            this._init();
        }

        _init() {
            // 二重起動を防ぐ
            if (document.getElementById(TOOL_ID)) return;
            this._injectStyles();
            this._highlight = new HighlightOverlay();
            this._buildUI();
        }

        /** CSS をページに注入する */
        _injectStyles() {
            const style = document.createElement('style');
            style.textContent = CSS;
            document.head.appendChild(style);
        }

        /** ルートコンテナを組み立てて <html> 直下に追加する */
        _buildUI() {
            this._container = document.createElement('div');
            this._container.id = TOOL_ID;

            this._container.appendChild(this._buildHeader());

            this._console  = new ConsolePanel();
            this._elements = new ElementsPanel(this._highlight);

            this._container.appendChild(this._console.buildElement());
            this._container.appendChild(this._elements.buildElement());

            document.documentElement.appendChild(this._container);

            // 初期パネルは Elements
            this._activatePanel('elements');
        }

        /**
         * ヘッダー(タブボタン + コントロールボタン)を組み立てて返す
         * @returns {HTMLDivElement}
         */
        _buildHeader() {
            const header = document.createElement('div');
            header.className = 'tm-header';

            // タブボタン群
            const tabs = document.createElement('div');
            tabs.className = 'tm-tabs';
            [
                { label: 'Elements', panel: 'elements' },
                { label: 'Console',  panel: 'console'  },
            ].forEach(({ label, panel }) => {
                const btn = document.createElement('button');
                btn.className = 'tm-tab-btn';
                btn.dataset.tab = panel;
                btn.textContent = label;
                btn.onclick = () => this._activatePanel(panel);
                tabs.appendChild(btn);
            });
            header.appendChild(tabs);

            // コントロールボタン群
            const controls = document.createElement('div');
            controls.className = 'tm-controls';

            const clearBtn = document.createElement('button');
            clearBtn.className = 'tm-ctrl-btn';
            clearBtn.textContent = 'Clear';
            clearBtn.onclick = () => this._handleClear();
            controls.appendChild(clearBtn);

            // 最小化ボタン: 初期は下向きシェブロン(パネルが開いている状態)
            this._minBtn = document.createElement('button');
            this._minBtn.className = 'tm-ctrl-btn';
            this._minBtn.title = '最小化 / 最大化';
            this._minBtn.appendChild(makeIconChevronDown());
            this._minBtn.onclick = () => this._toggleMinimize();
            controls.appendChild(this._minBtn);

            header.appendChild(controls);
            return header;
        }

        /**
         * 指定パネルをアクティブにして表示を切り替える
         * @param {'console'|'elements'} panelName
         */
        _activatePanel(panelName) {
            this._activePanel = panelName;

            // タブボタンのアクティブ状態を更新する
            this._container.querySelectorAll('.tm-tab-btn').forEach(btn => {
                btn.classList.toggle('active', btn.dataset.tab === panelName);
            });
            // パネルの表示 / 非表示を切り替える
            this._container.querySelectorAll('.tm-panel').forEach(panel => {
                panel.classList.toggle('active', panel.dataset.panel === panelName);
            });

            // Elements パネルに切り替えた際に DOM ツリーを描画する
            if (panelName === 'elements') {
                this._elements.renderDomTree();
            }
        }

        /** アクティブなパネルに応じた Clear 処理を実行する */
        _handleClear() {
            if (this._activePanel === 'console') {
                this._console.clear();
            } else {
                this._elements.renderDomTree();
            }
        }

        /**
         * パネルを最小化 / 最大化し、ボタンアイコンを切り替える
         */
        _toggleMinimize() {
            this._isMinimized = !this._isMinimized;
            this._container.classList.toggle('minimized', this._isMinimized);

            // 状態に応じてアイコンを入れ替える
            this._minBtn.replaceChildren(
                this._isMinimized ? makeIconChevronUp() : makeIconChevronDown()
            );

            if (this._isMinimized) this._highlight.hide();
        }
    }

    // ================================================================
    // エントリーポイント
    // ================================================================
    function bootstrap() {
        new DevToolsUI();
    }

    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        bootstrap();
    } else {
        window.addEventListener('DOMContentLoaded', bootstrap);
    }

})();