The ultimate X experience: Seamless Audio, Liquid Glass, and Perfect Layouts.
// ==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);
}
})();