Vanilla

Lightweight console JS/CSS, inspect, snippets, commands

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Vanilla
// @namespace    http://tampermonkey.net/
// @version      15.1
// @description  Lightweight console JS/CSS, inspect, snippets, commands
// @author       placeholdernamexd
// @match        *://*/*
// @grant        GM_addStyle
// @run-at       document-end
// @license      MIT
// @icon         https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExd2ppNmY0YWg3OTBnaXMyaDl0bnNwZnFsMjl0dXZ3NTNtbWxqYzh4bCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/fHyqSLg41HNYF68PRi/giphy.gif
// ==/UserScript==

(function(){
    const isMac = /Mac/i.test(navigator.platform);
    let settings = {
        theme: localStorage.t || 'd',
        opacity: +(localStorage.o) || 95,
        fontSize: +(localStorage.f) || 13,
        dock: localStorage.d || 'br',
        pin: localStorage.p === 't',
        mode: localStorage.m || 'j',
        snippets: JSON.parse(localStorage.sn || '{}'),
        history: JSON.parse(localStorage.h || '[]'),
        filter: 'all',
        keyMod: localStorage.km || (isMac ? 'Meta' : 'Alt'),
        keyCode: localStorage.kc || 'KeyX'
    };
    let histIndex = -1;
    let panel, input, logs, resizeHandle, pinBtn, modeBtn, styleTag;

    // helpers
    function save() {
        localStorage.t = settings.theme;
        localStorage.o = settings.opacity;
        localStorage.f = settings.fontSize;
        localStorage.d = settings.dock;
        localStorage.p = settings.pin ? 't' : 'f';
        localStorage.m = settings.mode;
        localStorage.sn = JSON.stringify(settings.snippets);
        localStorage.h = JSON.stringify(settings.history);
        localStorage.km = settings.keyMod;
        localStorage.kc = settings.keyCode;
    }

    function esc(s) { return s.replace(/[&<>]/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[m])); }

    function fmt(v, d=0) {
        if (v === null) return '<span style="color:#569cd6;">null</span>';
        if (v === undefined) return '<span style="color:#569cd6;">undefined</span>';
        if (typeof v === 'string') return `<span style="color:#ce9178;">"${esc(v)}"</span>`;
        if (typeof v === 'number') return `<span style="color:#b5cea8;">${v}</span>`;
        if (typeof v === 'boolean') return `<span style="color:#569cd6;">${v}</span>`;
        if (typeof v === 'function') return `<span style="color:#dcdcaa;">ƒ ${v.name || 'anon'}()</span>`;
        if (Array.isArray(v)) {
            if (d > 2) return '<span style="color:#9cdcfe;">Array(...)</span>';
            let items = v.slice(0,10).map(x => fmt(x, d+1)).join(',');
            return `<span class="exp" data-type="array" data-val='${esc(JSON.stringify(v))}'>▶ Array(${v.length})</span>`;
        }
        if (typeof v === 'object') {
            if (d > 2) return '<span style="color:#9cdcfe;">{...}</span>';
            let keys = Object.keys(v).slice(0,5);
            let preview = keys.map(k => `${k}: ${fmt(v[k], d+1)}`).join(',');
            return `<span class="exp" data-type="object" data-val='${esc(JSON.stringify(v))}'>▶ {${preview}}</span>`;
        }
        return String(v);
    }

    function addLog(cmd, res, type='log', isCmd=true) {
        if (settings.filter !== 'all' && type !== settings.filter) return;
        let entry = document.createElement('div');
        entry.className = `log-entry ${type}`;
        let time = new Date().toLocaleTimeString();
        let resHtml = '';
        if (res !== undefined && res !== null) resHtml = `<div class="log-result">${fmt(res)}</div>`;
        else if (type === 'info') { resHtml = `<div class="log-result" style="color:#888;">${esc(cmd)}</div>`; cmd = ''; }
        else if (type === 'error') { resHtml = `<div class="log-result" style="color:#ff6b6b;">${esc(res || cmd)}</div>`; cmd = ''; }
        entry.innerHTML = `${cmd && isCmd ? `<div class="log-cmd"><span style="color:#888;">${time}</span> &gt; ${esc(cmd)}</div>` : ''}${resHtml}`;
        logs.appendChild(entry);
        entry.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
        entry.querySelectorAll('.exp').forEach(el => {
            el.onclick = e => {
                e.stopPropagation();
                if (el.classList.contains('expanded')) {
                    el.innerHTML = el.getAttribute('data-orig');
                    el.classList.remove('expanded');
                } else {
                    let data = JSON.parse(el.getAttribute('data-val'));
                    let expanded = fmt(data, 0).replace('▶', '▼');
                    el.setAttribute('data-orig', el.innerHTML);
                    el.innerHTML = expanded;
                    el.classList.add('expanded');
                }
            };
        });
    }

    function runCommand(cmd) {
        if (!cmd.trim()) return;
        if (cmd[0] === '/') { handleSlash(cmd.slice(1)); return; }
        settings.history.unshift(cmd);
        if (settings.history.length > 100) settings.history.pop();
        histIndex = -1;
        save();
        if (settings.mode === 'c') {
            if (!styleTag) { styleTag = document.createElement('style'); styleTag.id = 'vanilla-css'; document.head.appendChild(styleTag); }
            styleTag.textContent = cmd;
            addLog(cmd, 'CSS applied', 'info');
            return;
        }
        let oldLog = console.log, oldErr = console.error, oldWarn = console.warn, out = null;
        console.log = (...a) => { out = a.length === 1 ? a[0] : a; oldLog(...a); };
        console.error = (...a) => { out = a[0]; oldErr(...a); };
        console.warn = (...a) => { out = a[0]; oldWarn(...a); };
        try {
            let result = eval(cmd);
            if (result !== undefined) out = result;
            addLog(cmd, out !== undefined ? out : 'undefined');
        } catch(e) { addLog(cmd, e.message, 'error'); }
        finally { console.log = oldLog; console.error = oldErr; console.warn = oldWarn; }
    }

    function handleSlash(arg) {
        let parts = arg.trim().split(/\s+/), m = parts[0].toLowerCase();
        if (m === 'help') addLog('/help /inspect /clear /theme /pin /reload /js /css /filter [all|log|warn|error]', null, 'info');
        else if (m === 'inspect') startInspect();
        else if (m === 'clear') { logs.innerHTML = ''; addLog('Logs cleared', null, 'info'); }
        else if (m === 'theme') { settings.theme = settings.theme === 'd' ? 'l' : 'd'; save(); applyTheme(); }
        else if (m === 'pin') { settings.pin = !settings.pin; save(); pinBtn.textContent = settings.pin ? '📌' : '📍'; }
        else if (m === 'reload') location.reload();
        else if (m === 'js') { settings.mode = 'j'; modeBtn.textContent = 'JS'; addLog('JS mode', null, 'info'); }
        else if (m === 'css') { settings.mode = 'c'; modeBtn.textContent = 'CSS'; addLog('CSS mode', null, 'info'); }
        else if (m === 'filter' && parts[1] && ['all','log','warn','error'].includes(parts[1])) { settings.filter = parts[1]; save(); addLog(`Filter: ${settings.filter}`, null, 'info'); document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active')); document.querySelector(`.filter-btn[data-f="${settings.filter}"]`)?.classList.add('active'); }
        else addLog(`Unknown: ${m}. /help`, null, 'error');
    }

    // inspect mode
    let inspectActive = false, inspectHighlight, inspectTooltip;
    function startInspect() {
        if (inspectActive) return;
        inspectActive = true;
        inspectHighlight = document.createElement('div');
        inspectHighlight.style.cssText = 'position:fixed;pointer-events:none;border:2px solid #1e88e5;background:rgba(30,136,229,0.1);z-index:2147483646;display:none;';
        inspectTooltip = document.createElement('div');
        inspectTooltip.style.cssText = 'position:fixed;background:#1e1e1e;color:#fff;padding:4px 8px;border-radius:6px;font-family:monospace;font-size:11px;z-index:2147483647;pointer-events:none;display:none;';
        document.body.appendChild(inspectHighlight);
        document.body.appendChild(inspectTooltip);
        document.addEventListener('mousemove', onInspectMove);
        document.addEventListener('click', onInspectClick, true);
        addLog('Inspect mode – click any element', null, 'info');
    }
    function onInspectMove(e) {
        if (!inspectActive) return;
        let el = e.target, rect = el.getBoundingClientRect();
        inspectHighlight.style.display = 'block';
        inspectHighlight.style.top = rect.top + 'px';
        inspectHighlight.style.left = rect.left + 'px';
        inspectHighlight.style.width = rect.width + 'px';
        inspectHighlight.style.height = rect.height + 'px';
        let tag = el.tagName.toLowerCase(), id = el.id ? `#${el.id}` : '', cls = el.className ? `.${el.className.split(' ')[0]}` : '';
        inspectTooltip.style.display = 'block';
        inspectTooltip.style.top = (rect.top - 28) + 'px';
        inspectTooltip.style.left = rect.left + 'px';
        inspectTooltip.innerHTML = `${tag}${id}${cls}<br>${Math.round(rect.width)}×${Math.round(rect.height)}`;
    }
    function onInspectClick(e) {
        if (!inspectActive) return;
        e.preventDefault(); e.stopPropagation();
        let el = e.target;
        showElementActions(el);
        document.removeEventListener('mousemove', onInspectMove);
        document.removeEventListener('click', onInspectClick, true);
        inspectHighlight.remove(); inspectTooltip.remove();
        inspectActive = false;
    }
    function showElementActions(el) {
        let div = document.createElement('div');
        div.style.cssText = 'position:fixed;background:#2d2d2d;border:1px solid #555;border-radius:6px;padding:4px;z-index:2147483648;display:flex;gap:4px;';
        let rect = el.getBoundingClientRect();
        div.style.top = (rect.bottom + 5) + 'px';
        div.style.left = rect.left + 'px';
        let copy = document.createElement('button'); copy.textContent = 'Copy';
        let hide = document.createElement('button'); hide.textContent = 'Hide';
        let edit = document.createElement('button'); edit.textContent = 'Edit';
        let del = document.createElement('button'); del.textContent = 'Delete';
        [copy, hide, edit, del].forEach(b => { b.style.cssText = 'background:#3c3c3c;border:none;color:#fff;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:11px;'; });
        copy.onclick = () => { navigator.clipboard.writeText(getSelector(el)); div.remove(); };
        hide.onclick = () => { el.style.display = 'none'; div.remove(); };
        edit.onclick = () => { let t = prompt('Edit text:', el.innerText); if (t !== null) el.innerText = t; div.remove(); };
        del.onclick = () => { el.remove(); div.remove(); };
        div.appendChild(copy); div.appendChild(hide); div.appendChild(edit); div.appendChild(del);
        document.body.appendChild(div);
        setTimeout(() => div.remove(), 10000);
    }
    function getSelector(el) {
        if (el.id) return `#${el.id}`;
        let path = [];
        while (el && el.tagName) {
            let idx = 1, sib = el.previousElementSibling;
            while (sib) { if (sib.tagName === el.tagName) idx++; sib = sib.previousElementSibling; }
            path.unshift(el.tagName.toLowerCase() + (idx > 1 ? `:nth-of-type(${idx})` : ''));
            el = el.parentElement;
            if (path.length > 5) break;
        }
        return path.join(' > ');
    }

    // UI theme
    function applyTheme() {
        let dark = settings.theme === 'd';
        let bg = dark ? '#1e1e1e' : '#f3f3f3', fg = dark ? '#ccc' : '#333', ibg = dark ? '#2d2d2d' : '#fff', bd = dark ? '#3c3c3c' : '#ccc';
        panel.style.background = bg; panel.style.color = fg; panel.style.borderColor = bd;
        input.style.background = ibg; input.style.color = fg; input.style.borderColor = bd;
        let style = document.getElementById('vanilla-style');
        if (!style) { style = document.createElement('style'); style.id = 'vanilla-style'; document.head.appendChild(style); }
        style.textContent = `
            #vanilla-panel {
                position: fixed; width: 520px; max-width: 90vw; background: ${bg}; border: 1px solid ${bd};
                border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.2); font-family: system-ui, monospace;
                font-size: ${settings.fontSize}px; display: none; flex-direction: column; z-index: 2147483647;
                backdrop-filter: blur(2px); opacity: ${settings.opacity/100};
            }
            .vanilla-header { padding: 8px 12px; background: ${dark ? '#2d2d2d' : '#e8e8e8'}; border-radius: 8px 8px 0 0; cursor: move; display: flex; justify-content: space-between; user-select: none; }
            .vanilla-actions button { background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 4px; font-size: 14px; color: ${fg}; opacity: 0.7; }
            .vanilla-actions button:hover { background: ${dark ? '#3c3c3c' : '#d0d0d0'}; opacity: 1; }
            .filter-bar { display: flex; gap: 6px; padding: 4px 12px; border-bottom: 1px solid ${bd}; }
            .filter-btn { background: none; border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer; color: ${fg}; font-size: 11px; }
            .filter-btn.active { background: ${dark ? '#0e6392' : '#007acc'}; color: white; }
            .snippets-bar { display: flex; gap: 6px; padding: 6px 12px; border-bottom: 1px solid ${bd}; }
            .snippets-bar select, .snippets-bar button { background: ${ibg}; color: ${fg}; border: 1px solid ${bd}; border-radius: 4px; padding: 4px 8px; font-size: 11px; }
            .input-line { display: flex; align-items: center; padding: 8px 12px; gap: 8px; border-bottom: 1px solid ${bd}; }
            .prompt { font-weight: bold; color: ${dark ? '#9cdcfe' : '#06c'}; }
            #vanilla-input { flex: 1; background: ${ibg}; border: 1px solid ${bd}; border-radius: 4px; padding: 6px 8px; color: ${fg}; font-family: monospace; font-size: ${settings.fontSize}px; outline: none; }
            .logs-area { height: 260px; overflow-y: auto; padding: 8px; font-family: monospace; font-size: ${settings.fontSize-1}px; }
            .log-entry { margin-bottom: 8px; border-bottom: 1px solid ${bd}; padding-bottom: 4px; }
            .log-cmd { color: ${dark ? '#9cdcfe' : '#06c'}; word-break: break-all; }
            .log-result { padding-left: 16px; color: ${fg}; white-space: pre-wrap; }
            .exp { cursor: pointer; color: ${dark ? '#ce9178' : '#a31515'}; }
            .resize-handle { position: absolute; bottom: 0; right: 0; width: 15px; height: 15px; cursor: nw-resize; z-index: 10; }
        `;
        panel.style.opacity = settings.opacity/100;
    }

    function applyDock() {
        if (settings.dock === 'custom') return;
        let pos = settings.dock;
        panel.style.left = 'auto'; panel.style.right = 'auto'; panel.style.top = 'auto'; panel.style.bottom = 'auto';
        if (pos === 'br') { panel.style.bottom = '20px'; panel.style.right = '20px'; }
        else if (pos === 'bl') { panel.style.bottom = '20px'; panel.style.left = '20px'; }
        else if (pos === 'tr') { panel.style.top = '20px'; panel.style.right = '20px'; }
        else if (pos === 'tl') { panel.style.top = '20px'; panel.style.left = '20px'; }
    }

    function buildUI() {
        panel = document.createElement('div'); panel.id = 'vanilla-panel'; document.body.appendChild(panel);
        applyTheme(); applyDock();
        panel.innerHTML = `
            <div class="vanilla-header">
                <span>Vanilla</span>
                <div class="vanilla-actions">
                    <button data-act="inspect">⌖</button>
                    <button data-act="clear">🗑</button>
                    <button data-act="save">💾</button>
                    <button data-act="theme">🌓</button>
                    <button data-act="settings">⚙</button>
                    <button data-act="pin">${settings.pin ? '📌' : '📍'}</button>
                    <button data-act="hide">−</button>
                </div>
            </div>
            <div class="filter-bar">
                <button class="filter-btn" data-f="all">ALL</button>
                <button class="filter-btn" data-f="log">LOG</button>
                <button class="filter-btn" data-f="warn">WARN</button>
                <button class="filter-btn" data-f="error">ERROR</button>
            </div>
            <div class="snippets-bar">
                <select id="snippet-select"></select>
                <button id="snippet-load">Load</button>
                <button id="snippet-save">Save</button>
                <button id="snippet-del">Del</button>
                <button id="mode-switch">${settings.mode === 'j' ? 'JS' : 'CSS'}</button>
            </div>
            <div class="input-line">
                <span class="prompt">&gt;</span>
                <input type="text" id="vanilla-input" autocomplete="off">
            </div>
            <div class="logs-area"></div>
            <div class="resize-handle"></div>
        `;
        input = document.getElementById('vanilla-input');
        logs = document.querySelector('.logs-area');
        pinBtn = document.querySelector('[data-act="pin"]');
        modeBtn = document.getElementById('mode-switch');
        resizeHandle = document.querySelector('.resize-handle');

        // Event listeners
        document.querySelectorAll('[data-act]').forEach(btn => {
            btn.onclick = () => {
                let act = btn.getAttribute('data-act');
                if (act === 'inspect') startInspect();
                if (act === 'clear') { logs.innerHTML = ''; addLog('Logs cleared', null, 'info'); }
                if (act === 'save') saveSnippet();
                if (act === 'theme') { settings.theme = settings.theme === 'd' ? 'l' : 'd'; save(); applyTheme(); }
                if (act === 'settings') openSettings();
                if (act === 'pin') { settings.pin = !settings.pin; save(); pinBtn.textContent = settings.pin ? '📌' : '📍'; }
                if (act === 'hide') panel.style.display = 'none';
            };
        });

        // Filter buttons
        document.querySelectorAll('.filter-btn').forEach(btn => {
            btn.onclick = () => {
                settings.filter = btn.getAttribute('data-f');
                save();
                document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
                btn.classList.add('active');
                addLog(`Filter: ${settings.filter}`, null, 'info');
            };
            if (btn.getAttribute('data-f') === settings.filter) btn.classList.add('active');
        });

        // Snippets
        function refreshSnippets() {
            let sel = document.getElementById('snippet-select');
            sel.innerHTML = '<option value="">-- snippets --</option>';
            for (let name in settings.snippets) {
                let opt = document.createElement('option');
                opt.value = name; opt.textContent = name;
                sel.appendChild(opt);
            }
        }
        document.getElementById('snippet-load').onclick = () => {
            let name = document.getElementById('snippet-select').value;
            if (name && settings.snippets[name]) input.value = settings.snippets[name];
        };
        document.getElementById('snippet-save').onclick = saveSnippet;
        document.getElementById('snippet-del').onclick = () => {
            let name = document.getElementById('snippet-select').value;
            if (name && confirm(`Delete "${name}"?`)) {
                delete settings.snippets[name];
                save(); refreshSnippets();
                addLog(`Snippet "${name}" deleted`, null, 'info');
            }
        };
        function saveSnippet() {
            let name = prompt('Snippet name:', 'snippet_' + Date.now());
            if (name) { settings.snippets[name] = input.value; save(); refreshSnippets(); addLog(`Snippet "${name}" saved`, null, 'info'); }
        }
        refreshSnippets();

        modeBtn.onclick = () => {
            settings.mode = settings.mode === 'j' ? 'c' : 'j';
            save();
            modeBtn.textContent = settings.mode === 'j' ? 'JS' : 'CSS';
            addLog(`${settings.mode === 'j' ? 'JS' : 'CSS'} mode`, null, 'info');
        };

        // Input handling
        input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') {
                let cmd = input.value;
                if (cmd.trim()) runCommand(cmd);
                input.value = '';
                e.preventDefault();
            } else if (e.key === 'ArrowUp') {
                if (histIndex + 1 < settings.history.length) {
                    histIndex++;
                    input.value = settings.history[histIndex];
                }
                e.preventDefault();
            } else if (e.key === 'ArrowDown') {
                if (histIndex > 0) {
                    histIndex--;
                    input.value = settings.history[histIndex];
                } else if (histIndex === 0) {
                    histIndex = -1;
                    input.value = '';
                }
                e.preventDefault();
            }
        });

        // Drag and resize
        let header = document.querySelector('.vanilla-header');
        let dragActive = false, dragX, dragY, startLeft, startTop;
        header.addEventListener('mousedown', (e) => {
            if (e.target.closest('.vanilla-actions')) return;
            dragActive = true;
            dragX = e.clientX; dragY = e.clientY;
            let rect = panel.getBoundingClientRect();
            startLeft = rect.left; startTop = rect.top;
            panel.style.cursor = 'grabbing';
            e.preventDefault();
        });
        document.addEventListener('mousemove', (e) => {
            if (!dragActive) return;
            let left = startLeft + (e.clientX - dragX);
            let top = startTop + (e.clientY - dragY);
            left = Math.min(Math.max(0, left), window.innerWidth - panel.offsetWidth);
            top = Math.min(Math.max(0, top), window.innerHeight - panel.offsetHeight);
            panel.style.left = left + 'px';
            panel.style.top = top + 'px';
            panel.style.right = 'auto';
            panel.style.bottom = 'auto';
            settings.dock = 'custom';
            save();
        });
        document.addEventListener('mouseup', () => { dragActive = false; panel.style.cursor = ''; });

        let resizeActive = false, resizeStartX, resizeStartY, startW, startH;
        resizeHandle.addEventListener('mousedown', (e) => {
            resizeActive = true;
            resizeStartX = e.clientX; resizeStartY = e.clientY;
            startW = panel.offsetWidth; startH = panel.offsetHeight;
            e.preventDefault(); e.stopPropagation();
        });
        document.addEventListener('mousemove', (e) => {
            if (!resizeActive) return;
            let dw = e.clientX - resizeStartX;
            let dh = e.clientY - resizeStartY;
            let newW = Math.min(Math.max(400, startW + dw), 900);
            let newH = Math.min(Math.max(260, startH + dh), 700);
            panel.style.width = newW + 'px';
            settings.dock = 'custom';
            save();
        });
        document.addEventListener('mouseup', () => { resizeActive = false; });

        addLog('Vanilla Console ready', null, 'info');
        addLog('Type /help for commands', null, 'info');
    }

    function openSettings() {
        let modal = document.createElement('div');
        modal.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d2d;padding:20px;border-radius:12px;z-index:2147483648;min-width:280px;box-shadow:0 8px 28px rgba(0,0,0,0.4);';
        modal.innerHTML = `
            <h3 style="margin:0 0 12px;">Settings</h3>
            <label>Opacity: <span id="opacityVal">${settings.opacity}</span>%</label>
            <input type="range" id="opacitySlider" min="50" max="100" value="${settings.opacity}" style="width:100%;margin:6px 0 12px;">
            <label>Font size: <span id="fontSizeVal">${settings.fontSize}</span>px</label>
            <input type="range" id="fontSizeSlider" min="10" max="18" value="${settings.fontSize}" style="width:100%;margin:6px 0 12px;">
            <label>Dock position:</label>
            <select id="dockSelect" style="width:100%;margin:6px 0 12px;">
                <option value="br" ${settings.dock==='br'?'selected':''}>Bottom Right</option>
                <option value="bl" ${settings.dock==='bl'?'selected':''}>Bottom Left</option>
                <option value="tr" ${settings.dock==='tr'?'selected':''}>Top Right</option>
                <option value="tl" ${settings.dock==='tl'?'selected':''}>Top Left</option>
                <option value="custom" ${settings.dock==='custom'?'selected':''}>Custom (draggable)</option>
            </select>
            <label>Key modifier:</label>
            <select id="keyModSelect" style="width:100%;margin:6px 0 12px;">
                <option value="Alt" ${settings.keyMod==='Alt'?'selected':''}>Alt</option>
                <option value="Ctrl" ${settings.keyMod==='Ctrl'?'selected':''}>Ctrl</option>
                <option value="Shift" ${settings.keyMod==='Shift'?'selected':''}>Shift</option>
                <option value="Meta" ${settings.keyMod==='Meta'?'selected':''}>${isMac?'Cmd':'Win'}</option>
            </select>
            <label>Key letter:</label>
            <input type="text" id="keyCodeInput" placeholder="e.g., KeyX" value="${settings.keyCode}" style="width:100%;margin:6px 0 12px;padding:6px;border-radius:4px;">
            <div style="margin-top:12px;">
                <label><input type="checkbox" id="pinCheck" ${settings.pin?'checked':''}> Pin mode</label>
            </div>
            <div style="display:flex;justify-content:flex-end;margin-top:16px;">
                <button id="closeModal" style="padding:6px 12px;">Close</button>
            </div>
        `;
        document.body.appendChild(modal);
        let opSlider = modal.querySelector('#opacitySlider'), fsSlider = modal.querySelector('#fontSizeSlider'), dockSel = modal.querySelector('#dockSelect'), modSel = modal.querySelector('#keyModSelect'), keyInp = modal.querySelector('#keyCodeInput'), pinChk = modal.querySelector('#pinCheck');
        opSlider.oninput = () => { settings.opacity = +opSlider.value; document.getElementById('opacityVal').innerText = settings.opacity; panel.style.opacity = settings.opacity/100; save(); };
        fsSlider.oninput = () => { settings.fontSize = +fsSlider.value; document.getElementById('fontSizeVal').innerText = settings.fontSize; applyTheme(); save(); };
        dockSel.onchange = () => { settings.dock = dockSel.value; applyDock(); save(); };
        modSel.onchange = () => { settings.keyMod = modSel.value; save(); updateKeybind(); };
        keyInp.onchange = () => { let val = keyInp.value.trim(); if (val) { settings.keyCode = val; save(); updateKeybind(); } };
        pinChk.onchange = () => { settings.pin = pinChk.checked; save(); pinBtn.textContent = settings.pin ? '📌' : '📍'; };
        modal.querySelector('#closeModal').onclick = () => modal.remove();
    }

    function updateKeybind() {
        // remove old listener and add new one
        document.removeEventListener('keydown', globalKeyHandler);
        document.addEventListener('keydown', globalKeyHandler);
    }

    function globalKeyHandler(e) {
        let mod = false;
        if (settings.keyMod === 'Alt') mod = e.altKey;
        else if (settings.keyMod === 'Ctrl') mod = e.ctrlKey;
        else if (settings.keyMod === 'Shift') mod = e.shiftKey;
        else if (settings.keyMod === 'Meta') mod = e.metaKey;
        if (mod && e.code === settings.keyCode) {
            e.preventDefault();
            if (panel.style.display === 'none') panel.style.display = 'flex';
            else panel.style.display = 'none';
        }
        // Command palette Cmd/Ctrl+K
        if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
            e.preventDefault();
            showCommandPalette();
        }
    }

    function showCommandPalette() {
        let cp = document.createElement('div');
        cp.style.cssText = 'position:fixed;top:30%;left:50%;transform:translate(-50%,-50%);background:#2d2d2d;border-radius:12px;width:400px;z-index:2147483649;box-shadow:0 10px 40px rgba(0,0,0,0.5);';
        cp.innerHTML = `<input type="text" id="cp-input" placeholder="Run command... (/inspect, /clear, etc.)" style="width:calc(100% - 24px);margin:12px;padding:8px;background:#1e1e1e;border:1px solid #555;border-radius:6px;color:#fff;font-family:monospace;">
                        <div id="cp-list" style="max-height:300px;overflow-y:auto;padding:0 12px 12px 12px;"></div>`;
        document.body.appendChild(cp);
        let inp = cp.querySelector('#cp-input');
        let listDiv = cp.querySelector('#cp-list');
        let cmds = ['/help', '/inspect', '/clear', '/theme', '/pin', '/reload', '/js', '/css', '/filter all', '/filter log', '/filter warn', '/filter error'];
        function render(filter='') {
            listDiv.innerHTML = '';
            cmds.filter(c => c.includes(filter)).forEach(cmd => {
                let div = document.createElement('div');
                div.textContent = cmd;
                div.style.padding = '6px'; div.style.cursor = 'pointer'; div.style.borderRadius = '4px';
                div.onmouseenter = () => div.style.background = '#3c3c3c';
                div.onmouseleave = () => div.style.background = '';
                div.onclick = () => { runCommand(cmd); cp.remove(); };
                listDiv.appendChild(div);
            });
        }
        render();
        inp.oninput = () => render(inp.value);
        inp.onkeydown = (e) => {
            if (e.key === 'Enter' && inp.value) { runCommand(inp.value); cp.remove(); }
            if (e.key === 'Escape') cp.remove();
        };
        inp.focus();
    }

    function init() {
        buildUI();
        document.addEventListener('keydown', globalKeyHandler);
        // start hidden
        panel.style.display = 'none';
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
})();