FlowX

The ultimate X experience: Seamless Audio, Liquid Glass, and Perfect Layouts.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         FlowX
// @namespace    https://greasyfork.org/users/gh0styFPS
// @version      2.0
// @description  The ultimate X experience: Seamless Audio, Liquid Glass, and Perfect Layouts.
// @author       gh0styFPS
// @match        https://x.com/*
// @match        https://www.x.com/*
// @grant        none
// @run-at       document-idle
// @license      MIT
// @icon         https://abs.twimg.com/icons/apple-touch-icon-192x192.png
// ==/UserScript==

(function() {
    'use strict';

    // --- Config & State ---
    const CONFIG = {
        storageKeyVol: 'flowx_volume',
        storageKeyTheme: 'flowx_theme',
        storageKeyPos: 'flowx_position',
        // Toggles
        storageKeyWide: 'flowx_widescreen',
        storageKeyAi: 'flowx_aishield',
        storageKeyGhost: 'flowx_ghost',
        storageKeySparks: 'flowx_sparks',

        adKeywords: /^(Ad|Promoted|Sponsored|Sponsrad|Gesponsert|Publicité)$/i,
        upsellKeywords: /(Subscribe to Premium|Get Verified|Upgrade to Premium|Verified Organizations)/i,
        aiKeywords: /\b(ChatGPT|Gemini|LLM|Midjourney|DALL-E|Stable Diffusion|AI Art|Generated by AI|Llama|Claude|Mistral|Sora)\b/i
    };

    let state = {
        vol: parseFloat(localStorage.getItem(CONFIG.storageKeyVol)) || 0.5,
        theme: localStorage.getItem(CONFIG.storageKeyTheme) || 'liquid',
        wide: localStorage.getItem(CONFIG.storageKeyWide) === 'true',
        aiShield: localStorage.getItem(CONFIG.storageKeyAi) === 'true',
        ghost: localStorage.getItem(CONFIG.storageKeyGhost) === 'true',
        sparks: localStorage.getItem(CONFIG.storageKeySparks) === 'true',
        pos: JSON.parse(localStorage.getItem(CONFIG.storageKeyPos)) || { x: 20, y: window.innerHeight - 70 }
    };

    // --- THEME ENGINE & CSS ---
    const STYLES = `
        :root {
            --spring-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
            --smooth-flow: cubic-bezier(0.25, 0.8, 0.25, 1);
            --glide-ease: cubic-bezier(0.22, 1, 0.36, 1);
            --fx-glow: rgba(255,255,255,0.2);
            --fx-accent: #fff;
            --fx-tooltip-bg: rgba(0,0,0,0.9);
            --fx-tooltip-txt: #fff;
            --fx-tooltip-border: rgba(255,255,255,0.15);
        }

        /* --- GLOBAL ANIMATIONS --- */
        [data-testid="sidebarColumn"], nav a { transition: all 0.3s ease !important; }

        /* --- TOOLTIPS --- */
        .fx-info {
            display: inline-flex; justify-content: center; align-items: center;
            width: 14px; height: 14px; border-radius: 50%;
            background: rgba(255,255,255,0.15); color: var(--fx-accent);
            font-size: 10px; cursor: help; margin-left: 6px; font-weight: bold;
            position: relative; border: 1px solid rgba(255,255,255,0.1);
            transition: background 0.2s;
        }
        .fx-info:hover { background: var(--fx-accent); color: var(--fx-tooltip-bg); }

        .fx-info:hover::after {
            content: attr(data-desc);
            position: absolute; top: 50%; left: 22px;
            transform: translateY(-50%) scale(0.95);
            width: 140px; padding: 8px 10px; font-size: 11px; line-height: 1.25;
            border-radius: 10px; text-align: left; font-weight: 500;
            pointer-events: none; z-index: 99999; white-space: normal;
            background: var(--fx-tooltip-bg); color: var(--fx-tooltip-txt);
            border: 1px solid var(--fx-tooltip-border);
            backdrop-filter: blur(15px);
            box-shadow: 0 4px 15px rgba(0,0,0,0.4);
            animation: tooltipSlide 0.2s var(--spring-bounce) forwards;
        }

        @keyframes tooltipSlide {
            0% { opacity: 0; transform: translateY(-50%) translateX(-5px) scale(0.9); }
            100% { opacity: 1; transform: translateY(-50%) translateX(0) scale(1); }
        }

        /* --- MODE: WIDESCREEN --- */
        body.fx-mode-wide [data-testid="sidebarColumn"], body.fx-mode-wide header[role="banner"] { display: none !important; }
        body.fx-mode-wide main[role="main"] { display: flex !important; flex-direction: column !important; align-items: center !important; margin-left: 0 !important; width: 100vw !important; }
        body.fx-mode-wide [data-testid="primaryColumn"] { margin: 0 auto !important; max-width: 650px !important; width: 100% !important; border: none !important; }

        /* --- PARTICLES --- */
        .fx-spark {
            position: fixed; pointer-events: none; border-radius: 50%;
            width: 3px; height: 3px; z-index: 99999;
            animation: sparkFly 0.5s ease-out forwards;
        }
        @keyframes sparkFly {
            0% { transform: translate(0, 0) scale(1); opacity: 1; }
            100% { transform: translate(var(--tx), var(--ty)) scale(0); opacity: 0; }
        }

        /* --- UI ANIMATIONS --- */
        @keyframes panelOpen { 0% { opacity: 0; transform: scale(0.6) translateY(20px); filter: blur(12px); } 100% { opacity: 1; transform: scale(1) translateY(0); filter: blur(0px); } }
        @keyframes panelMin { 0% { opacity: 1; transform: scale(1) translateY(0); filter: blur(0px); } 100% { opacity: 0; transform: scale(0.4) translateY(40px); filter: blur(15px); } }
        @keyframes rgbCycle {
            0% { border-color: #ff0000; color: #ff0000; --fx-accent: #ff0000; }
            50% { border-color: #00ff00; color: #00ff00; --fx-accent: #00ff00; }
            100% { border-color: #ff00ff; color: #ff00ff; --fx-accent: #ff00ff; }
        }
        @keyframes rgbBg { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } }

        .fx-anim-open { animation: panelOpen 0.35s var(--spring-bounce) forwards; }
        .fx-anim-min { animation: panelMin 0.25s var(--smooth-flow) forwards; pointer-events: none; }
        .fx-anim-die { animation: dissolve 0.3s ease-out forwards; pointer-events: none; }
        @keyframes dissolve { 0% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(0.8); filter: blur(10px); } }

        /* BUTTON & PANEL */
        #flowx-btn {
            position: fixed; width: 45px; height: 45px; border-radius: 16px;
            display: flex; align-items: center; justify-content: center;
            font-weight: 900; font-family: "SF Pro Display", sans-serif; font-size: 15px;
            cursor: grab; z-index: 10001; user-select: none;
            transition: transform 0.1s, opacity 0.5s ease;
        }
        #flowx-btn:active { cursor: grabbing; transform: scale(0.95); }
        #flowx-btn.fx-glide { transition: left 0.5s var(--glide-ease), top 0.5s var(--glide-ease), opacity 0.5s ease; }
        #flowx-btn.fx-ghost-hidden { opacity: 0.05 !important; filter: grayscale(100%); transform: scale(0.8); pointer-events: none; }

        #flowx-panel {
            position: fixed;
            width: 380px;
            border-radius: 24px; padding: 25px;
            z-index: 10000; font-family: "SF Pro Text", system-ui, sans-serif; display: none;
            max-height: 85vh; overflow-y: auto; overflow-x: visible;
        }

        /* CONTROLS */
        .fx-controls { display: flex; gap: 8px; margin-right: 15px; }
        .fx-ctrl-btn { cursor: pointer; display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%; background:rgba(255,255,255,0.1); font-size: 9px; }
        .fx-ctrl-btn:hover { background: rgba(255,50,50,0.5); }
        .fx-label { font-size: 12px; opacity: 0.8; font-weight: 600; }
        .fx-vol-val { font-size: 12px; font-weight: 700; float: right; }

        .fx-slider-wrap { position: relative; width: 100%; height: 20px; display: flex; align-items: center; margin: 8px 0 20px 0; }
        .fx-slider-bg { position: absolute; left: 0; right: 0; height: 4px; border-radius: 99px; background: rgba(255,255,255,0.15); z-index: 1; }
        .fx-slider-fill { position: absolute; left: 0; height: 4px; border-radius: 99px; z-index: 2; pointer-events: none; }
        .fx-slider { -webkit-appearance: none; width: 100%; height: 20px; background: transparent; position: absolute; z-index: 3; margin: 0; outline: none; }
        .fx-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #fff; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.3); transition: transform 0.15s; }
        .fx-slider::-webkit-slider-thumb:hover { transform: scale(1.15); }

        .fx-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
        .fx-txt-grp { display:flex; align-items:center; }
        .fx-txt { font-size: 14px; font-weight: 500; }
        .fx-toggle { position: relative; width: 40px; height: 22px; border-radius: 99px; background: rgba(120,120,120,0.3); cursor: pointer; transition: background 0.3s; }
        .fx-toggle::after { content: ''; position: absolute; left: 2px; top: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform 0.3s var(--spring-bounce); box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
        .fx-toggle.active { background: #34C759; } .fx-toggle.active::after { transform: translateX(18px); }
        .fx-select { width: 100%; padding: 12px; border-radius: 12px; font-size: 13px; outline: none; border:none; margin-bottom: 20px; cursor: pointer; }

        /* --- THEMES --- */

        /* 1. LIQUID GLASS (REMASTERED - ULTRA GLOSSY & CLEAR) */
        .fx-theme-liquid {
            --fx-glow: rgba(150, 180, 255, 0.6);
            /* Clearer, watery dark tint for high contrast but glass feel */
            background: rgba(15, 25, 40, 0.45);
            /* Significantly reduced blur for the "less blurry" request */
            backdrop-filter: blur(6px) saturate(180%);
            -webkit-backdrop-filter: blur(6px) saturate(180%);
            /* Glassy borders with direction */
            border: 1px solid rgba(255, 255, 255, 0.2);
            border-top: 1px solid rgba(255, 255, 255, 0.5);
            border-left: 1px solid rgba(255, 255, 255, 0.4);
            /* The Gloss: Top shine + inner depth + drop shadow */
            box-shadow:
                0 25px 50px -12px rgba(0, 0, 0, 0.6),
                inset 0 1px 0 rgba(255, 255, 255, 0.6), /* Specular top highlight */
                inset 0 20px 40px rgba(255, 255, 255, 0.05); /* Liquid depth */
            color: #fff;
            --fx-tooltip-bg: rgba(10, 15, 25, 0.9);
            --fx-tooltip-txt: #fff;
            --fx-tooltip-border: rgba(255, 255, 255, 0.3);
        }
        .fx-theme-liquid .fx-slider-fill { background: linear-gradient(90deg, #a18cd1 0%, #fbc2eb 100%); }

        /* 2. RGB */
        .fx-theme-rgb {
            --fx-glow: rgba(255, 0, 0, 0.5);
            background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(20px);
            border: 2px solid transparent; box-shadow: 0 0 20px rgba(255,0,0,0.2);
            color: #fff; animation: rgbCycle 6s infinite linear;
            --fx-tooltip-bg: rgba(0, 0, 0, 0.95);
            --fx-tooltip-txt: #fff;
            --fx-tooltip-border: var(--fx-accent);
        }
        .fx-theme-rgb .fx-slider-fill { background: linear-gradient(90deg, red, orange, yellow, green, blue, indigo, violet); background-size: 200% 100%; animation: rgbBg 3s linear infinite; }
        .fx-theme-rgb .fx-slider::-webkit-slider-thumb { background: #000; border: 2px solid #fff; animation: rgbCycle 6s linear infinite; }

        /* 3. CYBER */
        .fx-theme-cyber {
            --fx-glow: rgba(0, 255, 255, 0.5);
            background: rgba(5, 5, 10, 0.95); border: 1px solid #0ff;
            box-shadow: 0 0 20px rgba(0, 255, 255, 0.2); color: #0ff; backdrop-filter: blur(10px);
            --fx-tooltip-bg: #000;
            --fx-tooltip-txt: #0ff;
            --fx-tooltip-border: #0ff;
        }
        .fx-theme-cyber .fx-slider-fill { background: #0ff; box-shadow: 0 0 10px #0ff; }
        .fx-theme-cyber .fx-toggle.active { background: #0ff; box-shadow: 0 0 10px #0ff; }

        /* 4. FROST */
        .fx-theme-frost {
            --fx-glow: rgba(0, 122, 255, 0.3);
            background: rgba(255, 255, 255, 0.85); backdrop-filter: blur(40px);
            border: 1px solid #ccc; color: #111; box-shadow: 0 20px 40px rgba(0,0,0,0.1);
            --fx-tooltip-bg: rgba(255, 255, 255, 0.95);
            --fx-tooltip-txt: #111;
            --fx-tooltip-border: #ccc;
        }
        .fx-theme-frost .fx-slider-fill { background: #007aff; } .fx-theme-frost .fx-toggle.active { background: #007aff; }

        /* 5. SUNSET */
        .fx-theme-sunset {
            --fx-glow: rgba(255, 107, 107, 0.4);
            background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
            color: #b54055; border: 1px solid #fff;
            --fx-tooltip-bg: rgba(255, 240, 240, 0.9);
            --fx-tooltip-txt: #b54055;
            --fx-tooltip-border: #ff6b6b;
        }
        .fx-theme-sunset .fx-slider-fill { background: #ff6b6b; } .fx-theme-sunset .fx-toggle.active { background: #ff6b6b; }
    `;

    const styleSheet = document.createElement("style");
    styleSheet.innerText = STYLES;
    document.head.appendChild(styleSheet);


    // --- AUDIO ENGINE ---
    // Seamless Audio scrolling
    setInterval(() => {
        const videos = document.querySelectorAll('video');
        videos.forEach(v => {
            // Only affect videos inside the timeline/tweets
            if (v.closest('[data-testid="videoComponent"]') || v.closest('[data-testid="tweet"]')) {
                // Force Unmute
                if (v.muted) v.muted = false;

                // Sync Volume
                if (Math.abs(v.volume - state.vol) > 0.01) v.volume = state.vol;
            }
        });
    }, 250);


    // --- LOGIC ---
    function applyModes() {
        const b = document.body.classList;
        state.wide ? b.add('fx-mode-wide') : b.remove('fx-mode-wide');
    }

    function spawnSpark(e) {
        if (!state.sparks) return;
        const target = e.target;
        if (!target.getAttribute('data-testid') || !target.getAttribute('data-testid').includes('tweetTextarea')) return;

        const rect = window.getSelection().getRangeAt(0).getBoundingClientRect();
        const colors = state.theme === 'rgb' ? ['#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff'] :
            state.theme === 'cyber' ? ['#0ff', '#fff'] : state.theme === 'sunset' ? ['#ff9a9e', '#fecfef'] : ['#ffffff', '#a18cd1'];

        for(let i=0; i<4; i++) {
            const spark = document.createElement('div');
            spark.className = 'fx-spark';
            spark.style.left = (rect.left) + 'px'; spark.style.top = (rect.top + 10) + 'px';
            spark.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
            const angle = Math.random() * Math.PI * 2;
            const velocity = 30 + Math.random() * 30;
            spark.style.setProperty('--tx', `${Math.cos(angle) * velocity}px`); spark.style.setProperty('--ty', `${Math.sin(angle) * velocity}px`);
            document.body.appendChild(spark);
            setTimeout(() => spark.remove(), 500);
        }
    }
    document.addEventListener('input', spawnSpark);

    const processedNodes = new WeakSet();
    function processArticle(article) {
        if (processedNodes.has(article)) return;
        processedNodes.add(article);
        const spans = article.querySelectorAll('span');
        for (const span of spans) {
            if (CONFIG.adKeywords.test(span.textContent.trim())) {
                const header = article.querySelector('[data-testid="User-Name"]');
                if (header && header.contains(span)) { article.style.display = 'none'; return; }
            }
        }
        if (state.aiShield && CONFIG.aiKeywords.test(article.innerText)) { article.style.opacity = '0.1'; article.style.pointerEvents = 'none'; }
    }

    const observer = new MutationObserver((mutations) => {
        mutations.forEach((m) => {
            m.addedNodes.forEach((node) => {
                if (node.nodeType !== 1) return;
                if (node.tagName === 'ARTICLE') processArticle(node);
                else node.querySelectorAll('article[data-testid="tweet"]').forEach(processArticle);
            });
        });
    });
    observer.observe(document.body, { childList: true, subtree: true });


    // --- UI Construction ---
    createUI();

    function createUI() {
        const btn = document.createElement('div');
        btn.id = 'flowx-btn'; btn.textContent = 'FX';
        btn.className = `fx-theme-${state.theme} fx-glide`;
        btn.style.left = state.pos.x + 'px'; btn.style.top = state.pos.y + 'px';

        const panel = document.createElement('div');
        panel.id = 'flowx-panel'; panel.className = `fx-theme-${state.theme}`;

        let isAnimating = false;
        let ghostTimer;

        // --- Helper to create rows with tooltips ---
        const createRow = (label, id, key, tooltip) => `
            <div class="fx-row">
                <div class="fx-txt-grp">
                    <span class="fx-txt">${label}</span>
                    <span class="fx-info" data-desc="${tooltip}">?</span>
                </div>
                <div class="fx-toggle ${state[key] ? 'active' : ''}" id="${id}"></div>
            </div>
        `;

        panel.innerHTML = `
            <div style="display:flex; align-items:center; margin-bottom:20px;">
                <div class="fx-controls">
                    <div class="fx-ctrl-btn fx-close" title="Destroy">X</div>
                </div>
                <div style="flex-grow:1; text-align:right;">
                    <span style="font-weight:700; font-size:16px; margin-right:6px;">FlowX</span>
                    <span style="opacity:0.6; font-size:11px;">v2.0</span>
                </div>
            </div>

            <select class="fx-select">
                <option value="liquid">💧 Liquid Glass</option>
                <option value="rgb">🌈 RGB Chroma</option>
                <option value="frost">❄️ Arctic Frost</option>
                <option value="cyber">⚡ Cyberpunk</option>
                <option value="sunset">🌅 Sunset Bliss</option>
            </select>

            <div style="margin-bottom:25px;">
                <div class="fx-row" style="margin-bottom:4px;">
                    <span class="fx-label" style="margin:0;">Volume</span>
                    <span class="fx-vol-val">${Math.round(state.vol * 100)}%</span>
                </div>
                <div class="fx-slider-wrap">
                    <div class="fx-slider-bg"></div>
                    <div class="fx-slider-fill" style="width:${state.vol * 100}%"></div>
                    <input class="fx-slider" type="range" min="0" max="100" value="${state.vol * 100}">
                </div>
            </div>

            <div style="max-height: 320px; overflow-y:auto; padding-right:5px; padding-left:2px; padding-bottom:10px;">
                <div class="fx-label" style="margin:10px 0 5px 0; color:var(--fx-accent);">VISUALS</div>
                ${createRow('Widescreen Mode', 'toggle-wide', 'wide', 'Hides sidebars and centers the timeline.')}
                ${createRow('Typing Particles', 'toggle-sparks', 'sparks', 'Typing releases particle effects from your cursor.')}

                <div class="fx-label" style="margin:15px 0 5px 0; color:var(--fx-accent);">UTILITY</div>
                ${createRow('Hide AI Content', 'toggle-ai', 'aiShield', 'Dims and disables tweets containing known AI keywords.')}
                ${createRow('Auto-Hide Menu Button', 'toggle-ghost', 'ghost', 'The FX button turns almost invisible when not in use.')}
            </div>
        `;

        // Logic Bindings
        const slider = panel.querySelector('.fx-slider');
        const sliderFill = panel.querySelector('.fx-slider-fill');
        const volVal = panel.querySelector('.fx-vol-val');
        slider.addEventListener('input', (e) => {
            state.vol = e.target.value / 100;
            localStorage.setItem(CONFIG.storageKeyVol, state.vol);
            sliderFill.style.width = `${state.vol * 100}%`;
            volVal.innerText = `${Math.round(state.vol * 100)}%`;
        });

        const themeSelect = panel.querySelector('.fx-select');
        themeSelect.value = state.theme;
        themeSelect.addEventListener('change', (e) => {
            state.theme = e.target.value;
            localStorage.setItem(CONFIG.storageKeyTheme, state.theme);
            panel.className = `fx-theme-${state.theme}`;
            btn.className = `fx-theme-${state.theme} fx-glide`;
        });

        const setupToggle = (id, keyName, stateKey) => {
            const el = panel.querySelector(`#${id}`);
            el.addEventListener('click', () => {
                state[stateKey] = !state[stateKey];
                el.classList.toggle('active', state[stateKey]);
                localStorage.setItem(CONFIG[keyName], state[stateKey]);
                applyModes();
                if (stateKey === 'ghost') resetGhostTimer();
            });
        };
        setupToggle('toggle-wide', 'storageKeyWide', 'wide');
        setupToggle('toggle-ai', 'storageKeyAi', 'aiShield');
        setupToggle('toggle-ghost', 'storageKeyGhost', 'ghost');
        setupToggle('toggle-sparks', 'storageKeySparks', 'sparks');

        function resetGhostTimer() {
            btn.classList.remove('fx-ghost-hidden');
            if (ghostTimer) clearTimeout(ghostTimer);
            if (!state.ghost || panel.classList.contains('fx-open')) return;
            ghostTimer = setTimeout(() => { if (!panel.classList.contains('fx-open')) btn.classList.add('fx-ghost-hidden'); }, 3000);
        }
        document.addEventListener('mousemove', resetGhostTimer);
        document.addEventListener('scroll', resetGhostTimer);
        resetGhostTimer();
        applyModes();

        const closeBtn = panel.querySelector('.fx-close');

        // --- FIXED MINIMIZE LOGIC ---
        function togglePanel() {
            if (isAnimating) return;
            const isOpen = panel.classList.contains('fx-open');

            if (isOpen) {
                // Close
                isAnimating = true;
                panel.classList.remove('fx-open');
                panel.classList.remove('fx-anim-open');
                panel.classList.add('fx-anim-min');

                panel.addEventListener('animationend', () => {
                    panel.style.display = 'none';
                    panel.classList.remove('fx-anim-min');
                    isAnimating = false;
                    resetGhostTimer();
                }, { once: true });
            } else {
                // Open
                isAnimating = true;
                panel.style.display = 'block';
                panel.classList.add('fx-open');
                btn.classList.remove('fx-ghost-hidden');
                if (ghostTimer) clearTimeout(ghostTimer);

                const btnRect = btn.getBoundingClientRect();
                const panelRect = panel.getBoundingClientRect();
                const winW = window.innerWidth, winH = window.innerHeight;
                let pLeft, pTop, originX, originY;

                if (btnRect.left > winW / 2) { pLeft = btnRect.left - panelRect.width - 15; originX = "right"; } else { pLeft = btnRect.left + btnRect.width + 15; originX = "left"; }
                if (btnRect.top > winH / 2) { pTop = btnRect.bottom - panelRect.height; originY = "bottom"; } else { pTop = btnRect.top; originY = "top"; }
                if (pTop < 20) pTop = 20; if (pTop + panelRect.height > winH) pTop = winH - panelRect.height - 20;

                panel.style.transformOrigin = `${originY} ${originX}`;
                panel.style.left = `${pLeft}px`; panel.style.top = `${pTop}px`;

                panel.classList.remove('fx-anim-min');
                void panel.offsetWidth;
                panel.classList.add('fx-anim-open');

                panel.addEventListener('animationend', () => { isAnimating = false; }, { once: true });
            }
        }

        closeBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            panel.classList.add('fx-anim-die');
            btn.classList.add('fx-anim-die');
            panel.addEventListener('animationend', () => { panel.remove(); btn.remove(); }, { once: true });
        });

        let isDragging = false, startX, startY, initialLeft, initialTop, hasMoved = false;
        btn.addEventListener('mousedown', (e) => { isDragging = true; hasMoved = false; btn.classList.remove('fx-glide'); startX = e.clientX; startY = e.clientY; initialLeft = btn.offsetLeft; initialTop = btn.offsetTop; });
        document.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { hasMoved = true; } btn.style.left = `${initialLeft + dx}px`; btn.style.top = `${initialTop + dy}px`; });

        document.addEventListener('mouseup', () => {
            if (!isDragging) return;
            isDragging = false;
            if (hasMoved) snapToCorner();
            else btn.classList.add('fx-glide');
        });

        btn.addEventListener('click', () => {
            if (hasMoved) return;
            togglePanel();
        });

        function snapToCorner() {
            btn.classList.add('fx-glide');
            const winW = window.innerWidth, winH = window.innerHeight;
            const btnRect = btn.getBoundingClientRect();
            const btnCX = btnRect.left + btnRect.width / 2; const btnCY = btnRect.top + btnRect.height / 2;
            const margin = 20;
            const finalX = (btnCX < winW / 2) ? margin : winW - btnRect.width - margin;
            const finalY = (btnCY < winH / 2) ? margin : winH - btnRect.height - margin;
            btn.style.left = `${finalX}px`; btn.style.top = `${finalY}px`;
            state.pos = { x: finalX, y: finalY };
            localStorage.setItem(CONFIG.storageKeyPos, JSON.stringify(state.pos));
        }

        window.addEventListener('resize', () => { btn.classList.add('fx-glide'); snapToCorner(); });
        document.body.appendChild(btn); document.body.appendChild(panel);
    }
})();