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();
})();