SG Toolkit Pro

Advanced giveaway toolkit for SteamGifts - filters, inline enter, ratings, sorting & more

スクリプトをインストールするには、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         SG Toolkit Pro
// @name:ru      SG Toolkit Pro
// @namespace    https://github.com/128team/tm_scripts
// @version      3.2.2
// @description     Advanced giveaway toolkit for SteamGifts - filters, inline enter, ratings, sorting & more
// @description:ru  Продвинутый тулкит для SteamGifts — фильтры, inline enter, рейтинги Steam и сортировка
// @author       d08
// @supportURL   https://github.com/128team/tm_scripts/issues
// @match        https://www.steamgifts.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @connect      store.steampowered.com
// @icon         https://raw.githubusercontent.com/128team/assets/main/logo128b.jpeg
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const VER = '3.2.1';

    /* 0. PURE UTILITIES  (no dependencies, no drama - these ones I actually like) */
    const TIMINGS = {
        DEBOUNCE_MS:        300,  // 300ms - not too fast, not too slow, like me on monday mornings
        FAIL_RESET_MS:     2000,  // 2 seconds staring at "Fail" - enough time to feel it
        NO_PTS_FLASH_MS:   1500,  // button flashes when out of points - like my career prospects
        SCROLL_TRIGGER_PX:  600,  // 600px from bottom - load earlier? pointless. later? too late
        SCROLL_INIT_MS:     500,  // wait 500ms on start, the page needs a moment to wake up
        LOADER_REMOVE_MS:  3000,  // "all loaded" banner - did you read it? doesn't matter
        ERROR_REMOVE_MS:   4000,  // show errors a bit longer - let it really sink in
        RATING_TIMEOUT_MS: 8000,  // Steam might be napping. 8 seconds is optimistic on my part
    };
    const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
    const debounce = (fn, ms) => {
        let timer;
        return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
    };

    /* 1. CONFIG  (user settings live here. don't hardcode stuff. I'm watching you.) */
    const DEFAULTS = {
        f_enabled: false, f_minP: 0, f_maxP: 300, f_exactP: '',
        f_minLv: 0, f_maxLv: 10, f_maxEntries: 0, f_minChance: 0,
        f_hideEntered: false, f_hideGroup: false, f_hideWL: false,
        f_dimMode: false, f_sort: 'default',
        x_inlineEnter: false, x_showChance: false, x_showPtsBadge: false,
        x_wlGlow: false, x_steamRating: false, x_steamLink: false,
        x_highlightLvNeg: false, x_darkTheme: false, x_autoScroll: false,
        ui_scale: 100, ui_panelW: 330, ui_fabSize: 48,
        ui_panelOpacity: 95, ui_fabX: -1, ui_fabY: -1,
        ui_lang: 'auto',
    };
    const cfgLoad = () => {
        try {
            const merged = { ...DEFAULTS, ...JSON.parse(GM_getValue('sgfp', '{}')) };
            const n = (v, def, lo, hi) => clamp(Number.isNaN(+v) ? def : +v, lo, hi);
            // filter fields - clamp everything, users will type 9999 everywhere and wonder why it breaks
            merged.f_minP       = n(merged.f_minP,       0,   0, 300);
            merged.f_maxP       = n(merged.f_maxP,       300, 0, 300);
            merged.f_minLv      = n(merged.f_minLv,      0,   0, 10);
            merged.f_maxLv      = n(merged.f_maxLv,      10,  0, 10);
            merged.f_maxEntries = n(merged.f_maxEntries, 0,   0, 99999);
            merged.f_minChance  = n(merged.f_minChance,  0,   0, 100);
            // UI fields - same deal. trust no one. not even yourself from last week.
            merged.ui_scale        = n(merged.ui_scale,        100, 60,  150);
            merged.ui_panelW       = n(merged.ui_panelW,       330, 240, 500);
            merged.ui_fabSize      = n(merged.ui_fabSize,      48,  32,  72);
            merged.ui_panelOpacity = n(merged.ui_panelOpacity, 95,  40,  100);
            return merged;
        } catch { return { ...DEFAULTS }; }
    };
    const cfgSave = c => GM_setValue('sgfp', JSON.stringify(c));
    let C = cfgLoad();

    /* 1b. I18N  (two languages. english default, russian available. adding more? extend STRINGS.) */
    const STRINGS = {
        en: {
            tab_filter: '🔍 Filters', tab_feat: '⚙ Features', tab_settings: '🎛 Appearance', tab_about: 'ℹ️',
            stat_total: 'Total', stat_shown: 'Shown', stat_hidden: 'Hidden',
            f_enabled: 'Filters active',
            sh_points: '💰 Points', f_from: 'From', f_to: 'To',
            f_exact: 'Exact', f_exact_hint: '5,10,15 (empty=range)', f_exact_ph: 'empty',
            sh_level: '🎚 Level', sh_entries: '👥 Entries',
            f_max_entries: 'Max entries', f_min_chance: 'Min chance %',
            sh_hide: '👁 Hide',
            f_hide_entered: 'Hide entered', f_hide_group: 'Hide Group Only',
            f_hide_wl: 'Hide Whitelist Only', f_dim: 'Dim instead of hide',
            sh_sort: '🔃 Sort', f_sort_lbl: 'Order',
            sort_default: 'Default', sort_pts_asc: 'Price ↑', sort_pts_desc: 'Price ↓',
            sort_ent_asc: 'Entries ↑', sort_ent_desc: 'Entries ↓',
            sort_ch_desc: 'Chance ↓', sort_lv_desc: 'Level ↓', sort_end_asc: 'Ending soon',
            sh_buttons: '🖱 Buttons',
            x_enter: 'Inline Enter button', x_enter_hint: '«Enter» button in the list',
            sh_info: '📊 Info',
            x_chance: 'Win chance', x_chance_hint: '«Chance: X%» badge',
            x_pts: 'Game price', x_pts_hint: '«Price: XP» badge',
            x_rating: 'Steam rating', x_rating_hint: '«Rating: X%» badge',
            x_steam: 'Steam Store link',
            sh_highlight: '🎨 Highlights',
            x_wl: 'Wishlist highlight', x_lvneg: 'Level-locked', x_lvneg_hint: 'Red stripe',
            x_dark: 'Dark theme', x_dark_hint: 'Dark mode for the entire site',
            sh_scroll: '📜 Scroll', x_scroll: 'Auto-scroll', x_scroll_hint: 'Loads next pages while scrolling',
            btn_loadall: '⏬ Load all pages', btn_loadall_stop: '⏹ Stop loading',
            scroll_loadall: (cur, tot) => tot ? `⏳ Loading all pages... (${cur}/${tot})` : `⏳ Loading all pages... (page ${cur})`,
            sh_scale: '📐 Interface scale',
            ui_text: 'Text size', ui_panel_w: 'Panel width', ui_btn_sz: 'Button size', ui_opacity: 'Opacity',
            sh_pos: '📍 Position',
            ui_pos_hint: '💡 Drag the ⭐ button anywhere — panel adapts automatically',
            btn_resetpos: '↩ Reset position', sh_data: '🔧 Data', btn_resetall: '♻ Reset ALL settings',
            sh_lang: '🌐 Language', lang_lbl: 'Language',
            about_desc: 'Advanced toolkit for SteamGifts.<br>Filters, sorting, inline enter,<br>Steam ratings and more.',
            about_author: 'Author:',
            about_tip: 'Hotkey: <b style="color:#7ab8e0">Alt+F</b><br>Drag the ⭐ button anywhere<br>Panel opens toward free space automatically<br><br>Script does <b>NOT</b> violate SG rules —<br>no auto-entering giveaways',
            badge_chance: p => `Chance: ${p}`, badge_price: p => `Price: ${p}P`,
            badge_rating: p => `Rating: ${p}%`, badge_rating_t: (pos, tot) => `${pos} of ${tot} reviews`,
            enter_title: c => `Enter (${c}P)`, leave_title: 'Click to leave',
            scroll_load: p => `⏳ Loading page ${p}...`, scroll_done: '✅ All giveaways loaded',
            scroll_err: (n, m) => n >= 3 ? `❌ Error (attempt 3/3): ${m}` : `⚠️ Error (attempt ${n}/3): ${m}`,
            fab_title: 'SG Toolkit Pro — drag me!',
            confirm_reset: 'Reset ALL SG Toolkit Pro settings?',
        },
        ru: {
            tab_filter: '🔍 Фильтры', tab_feat: '⚙ Функции', tab_settings: '🎛 Вид', tab_about: 'ℹ️',
            stat_total: 'Всего', stat_shown: 'Видно', stat_hidden: 'Скрыто',
            f_enabled: 'Фильтры активны',
            sh_points: '💰 Поинты', f_from: 'От', f_to: 'До',
            f_exact: 'Точные', f_exact_hint: '5,10,15 (пусто=диапазон)', f_exact_ph: 'пусто',
            sh_level: '🎚 Уровень', sh_entries: '👥 Участники',
            f_max_entries: 'Макс. entries', f_min_chance: 'Мин. шанс %',
            sh_hide: '👁 Скрытие',
            f_hide_entered: 'Скрыть вошедшие', f_hide_group: 'Скрыть Group Only',
            f_hide_wl: 'Скрыть Whitelist Only', f_dim: 'Затемнять вместо скрытия',
            sh_sort: '🔃 Сортировка', f_sort_lbl: 'Порядок',
            sort_default: 'По умолчанию', sort_pts_asc: 'Цена ↑', sort_pts_desc: 'Цена ↓',
            sort_ent_asc: 'Entries ↑', sort_ent_desc: 'Entries ↓',
            sort_ch_desc: 'Шанс ↓', sort_lv_desc: 'Уровень ↓', sort_end_asc: 'Скоро заканчив.',
            sh_buttons: '🖱 Кнопки',
            x_enter: 'Inline Enter кнопка', x_enter_hint: 'Кнопка «Enter» прямо в списке',
            sh_info: '📊 Информация',
            x_chance: 'Шанс выигрыша', x_chance_hint: 'Бейдж «Шанс: X%»',
            x_pts: 'Цена игры', x_pts_hint: 'Бейдж «Цена: XP»',
            x_rating: 'Рейтинг Steam', x_rating_hint: 'Бейдж «Рейтинг: X%»',
            x_steam: 'Ссылка на Steam Store',
            sh_highlight: '🎨 Подсветка',
            x_wl: 'Wishlist подсветка', x_lvneg: 'Недоступный уровень', x_lvneg_hint: 'Красная полоска',
            x_dark: 'Тёмная тема', x_dark_hint: 'Тёмный режим для всего сайта',
            sh_scroll: '📜 Прокрутка', x_scroll: 'Авто-скролл', x_scroll_hint: 'Подгружает следующие страницы при прокрутке',
            btn_loadall: '⏬ Загрузить все страницы', btn_loadall_stop: '⏹ Остановить загрузку',
            scroll_loadall: (cur, tot) => tot ? `⏳ Загрузка всех страниц... (${cur}/${tot})` : `⏳ Загрузка всех страниц... (стр. ${cur})`,
            sh_scale: '📐 Масштаб интерфейса',
            ui_text: 'Размер текста', ui_panel_w: 'Ширина панели', ui_btn_sz: 'Размер кнопки', ui_opacity: 'Прозрачность',
            sh_pos: '📍 Позиция',
            ui_pos_hint: '💡 Перетащи кнопку ⭐ в любое место — панель адаптируется автоматически',
            btn_resetpos: '↩ Сбросить позицию', sh_data: '🔧 Данные', btn_resetall: '♻ Сбросить ВСЕ настройки',
            sh_lang: '🌐 Язык', lang_lbl: 'Язык',
            about_desc: 'Продвинутый тулкит для SteamGifts.<br>Фильтры, сортировка, inline enter,<br>рейтинги Steam и многое другое.',
            about_author: 'Автор:',
            about_tip: 'Горячая клавиша: <b style="color:#7ab8e0">Alt+F</b><br>Кнопку ⭐ можно перетаскивать<br>Панель автоматически открывается в нужном направлении<br><br>Скрипт <b>НЕ</b> нарушает правила SG —<br>никакого авто-входа в раздачи',
            badge_chance: p => `Шанс: ${p}`, badge_price: p => `Цена: ${p}P`,
            badge_rating: p => `Рейтинг: ${p}%`, badge_rating_t: (pos, tot) => `${pos} из ${tot} отзывов`,
            enter_title: c => `Войти (${c}P)`, leave_title: 'Нажмите чтобы выйти',
            scroll_load: p => `⏳ Загрузка страницы ${p}...`, scroll_done: '✅ Все раздачи загружены',
            scroll_err: (n, m) => n >= 3 ? `❌ Ошибка (попытка 3/3): ${m}` : `⚠️ Ошибка (попытка ${n}/3): ${m}`,
            fab_title: 'SG Toolkit Pro — перетащи!',
            confirm_reset: 'Сбросить ВСЕ настройки SG Toolkit Pro?',
        },
    };
    // T(key) — static string. T(key, arg1, ...) — calls the function value with args.
    const T = (key, ...a) => {
        const lang = C.ui_lang === 'auto' ? (navigator.language?.startsWith('ru') ? 'ru' : 'en') : (STRINGS[C.ui_lang] ? C.ui_lang : 'en');
        const val = STRINGS[lang]?.[key] ?? STRINGS.en[key] ?? key;
        return typeof val === 'function' ? val(...a) : val;
    };

    /* 2. HELPERS  (tiny utils that each do one thing. one. and only one.) */
    const $ = s => document.querySelector(s);
    const $$ = s => [...document.querySelectorAll(s)];
    const $id = id => document.getElementById(id);
    const mkEl = (tag, attrs = {}, children = []) => {
        const e = document.createElement(tag);
        for (const [k, v] of Object.entries(attrs)) {
            if (k === 'style' && typeof v === 'object') Object.assign(e.style, v);
            else if (k === 'className') e.className = v;
            else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2).toLowerCase(), v);
            else e.setAttribute(k, v);
        }
        for (const c of children) {
            if (typeof c === 'string') e.appendChild(document.createTextNode(c));
            else if (c) e.appendChild(c);
        }
        return e;
    };

    let _xsrfCache = null;
    function getXsrf() {
        // fast path - the normal hidden input. 99% of page loads. we're done in one line.
        const el = $('input[name="xsrf_token"]');
        if (el) return el.value;
        if (_xsrfCache) return _xsrfCache;
        // fallback: iterate forms the proper way. no DOM serialization. no drama.
        for (const form of document.forms) {
            const inp = form.elements.namedItem('xsrf_token');
            if (inp?.value) { _xsrfCache = inp.value; return _xsrfCache; }
        }
        return null;
    }
    function getPoints() {
        const e = $('.nav__points');
        return e ? parseInt(e.textContent.replace(/,/g, ''), 10) : 0;
    }
    function getAppId(row) {
        const img = row.querySelector('.giveaway_image_thumbnail, .giveaway_image_thumbnail_missing');
        if (img) { const m = (img.getAttribute('style') || '').match(/apps\/(\d+)/); if (m) return m[1]; }
        const sl = row.querySelector('a[href*="store.steampowered.com/app/"]');
        if (sl) { const m = sl.href.match(/app\/(\d+)/); if (m) return m[1]; }
        return null;
    }

    const ratingCache = {};
    const API = {
        async toggleEntry(code, action, xsrf) {
            const resp = await fetch('/ajax.php', {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: `xsrf_token=${xsrf}&do=${action}&code=${code}`,
            });
            return resp.json();
        },

        fetchRating(appId) {
            return new Promise(resolve => {
                if (ratingCache[appId] !== undefined) return resolve(ratingCache[appId]);
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: `https://store.steampowered.com/appreviews/${appId}?json=1&language=all&purchase_type=all&num_per_page=0`,
                    timeout: TIMINGS.RATING_TIMEOUT_MS,
                    onload(res) {
                        try {
                            const d = JSON.parse(res.responseText), s = d.query_summary;
                            if (!s) { ratingCache[appId] = null; return resolve(null); }
                            const total = s.total_reviews || 0, pos = s.total_positive || 0;
                            const pct = total > 0 ? Math.round((pos / total) * 100) : 0;
                            const r = { pct, total, pos }; ratingCache[appId] = r; resolve(r);
                        } catch { ratingCache[appId] = null; resolve(null); }
                    },
                    onerror:   () => { ratingCache[appId] = null; resolve(null); },
                    ontimeout: () => { ratingCache[appId] = null; resolve(null); },
                });
            });
        },

        async fetchPage(url) {
            const r = await fetch(url, { credentials: 'include' });
            if (!r.ok) throw new Error(r.status);
            return r.text();
        },
    };

    /* 3. GIVEAWAY MODEL  (our precious little data structure. it holds everything. be nice to it.) */
    class Giveaway {
        constructor(outer, inner) {
            this.dom    = { outer, inner, link: null };
            this.id     = { code: '', appId: null };
            this.data   = { name: '', cost: 0, copies: 1, entries: 0, level: 0, endTime: 0, originalIndex: 0 };
            this.status = { entered: false, lvNeg: false, isWl: false, isGroup: false, isWhitelist: false, isPinned: false };
            this.metrics = { chance: 100 };
        }

        get canEnter() { return !this.status.lvNeg && !this.status.entered && getPoints() >= this.data.cost; }

        markEntered(entered) {
            const wasEntered = this.status.entered;
            this.status.entered = entered;
            this.dom.inner.classList.toggle('is-faded', entered);
            // update entries + recalc chance - stale cache would lie to the filter. I learned this the hard way.
            if (entered && !wasEntered) this.data.entries++;
            else if (!entered && wasEntered && this.data.entries > 0) this.data.entries--;
            this.metrics.chance = this.data.entries > 0 ? (this.data.copies / this.data.entries) * 100 : 100;
        }
    }

    /* 4. PARSE  (reading the DOM so you don't have to. you're welcome.) */
    // WeakMap cache: one Giveaway per DOM node. GC handles cleanup when the element dies. lovely.
    const _gaCache = new WeakMap();
    let _parseCounter = 0;  // global index to remember original order - we need this to undo sorting

    function parseAll() {
        return $$('.giveaway__row-outer-wrap').map(outer => {
            if (_gaCache.has(outer)) return _gaCache.get(outer);

            const inner = outer.querySelector('.giveaway__row-inner-wrap');
            if (!inner) return null;

            const ga = new Giveaway(outer, inner);

            // id - code from URL slug, appId from image style or steam link. DOM detective work.
            const link = inner.querySelector('.giveaway__heading__name');
            ga.dom.link    = link;
            ga.id.code     = link?.href?.match(/\/giveaway\/([^/]+)\//)?.[1] || '';
            ga.id.appId    = getAppId(inner);

            // data - scraping text from heading spans like it's 2008. works though.
            ga.data.name = link?.textContent?.trim() || '';
            inner.querySelectorAll('.giveaway__heading__thin').forEach(t => {
                const pM = t.textContent.match(/(\d+)P/);            if (pM) ga.data.cost   = +pM[1];
                const cM = t.textContent.match(/\(([\d,]+)\s*Cop/i); if (cM) ga.data.copies = +cM[1].replace(/,/g, '');
            });
            const eL = inner.querySelector('.giveaway__links a[href*="/entries"]');
            if (eL) { const m = eL.textContent.match(/([\d,]+)/); if (m) ga.data.entries = +m[1].replace(/,/g, ''); }
            const lvEl = inner.querySelector('[class*="contributor-level"]');
            if (lvEl) { const m = lvEl.textContent.match(/(\d+)/); if (m) ga.data.level = +m[1]; }
            const tEl = inner.querySelector('[data-timestamp]');
            if (tEl) ga.data.endTime = +tEl.getAttribute('data-timestamp');

            // status - who can enter, who's wishlist, who's pinned, who's group-only. lots of flags.
            ga.status.lvNeg      = !!inner.querySelector('.giveaway__column--contributor-level--negative');
            ga.status.entered    = inner.classList.contains('is-faded');
            ga.status.isWl       = !!inner.querySelector('.giveaway__column--positive') || outer.classList.contains('giveaway__row-outer-wrap--wishlist');
            ga.status.isGroup    = !!inner.querySelector('.giveaway__column--group');
            ga.status.isWhitelist= !!inner.querySelector('.giveaway__column--whitelist');
            ga.status.isPinned   = !!outer.closest('.pinned-giveaways__inner-wrap');

            // metrics - one metric. chance of winning. it's always lower than you hope.
            ga.metrics.chance = ga.data.entries > 0 ? (ga.data.copies / ga.data.entries) * 100 : 100;

            ga.data.originalIndex = _parseCounter++;
            _gaCache.set(outer, ga);
            return ga;
        }).filter(Boolean);
    }

    /* 4b. INLINE ENTER / LEAVE  (click a button, win a game. statistically unlikely, but here we are.) */
    function addInlineEnterButtons(giveaways) {
        if (!C.x_inlineEnter) { $$('.sgfp-enter').forEach(e => e.remove()); return; }
        const xsrf = getXsrf();
        if (!xsrf) return;
        giveaways.forEach(ga => {
            if (ga.dom.inner.querySelector('.sgfp-enter')) return;
            if (ga.status.lvNeg) return;
            const linksEl = ga.dom.inner.querySelector('.giveaway__links');
            if (!linksEl) return;

            const btn = mkEl('a', { href: '#', className: 'sgfp-enter' });

            function setEnterState() {
                btn.className = 'sgfp-enter sgfp-enter-join';
                btn.innerHTML = '<i class="fa fa-plus-circle"></i> Enter';
                btn.title = T('enter_title', ga.data.cost);
            }
            function setEnteredState() {
                btn.className = 'sgfp-enter sgfp-enter-leave';
                btn.innerHTML = '<i class="fa fa-minus-circle"></i> Entered';
                btn.title = T('leave_title');
            }
            function setFailState() {
                btn.classList.remove('sgfp-loading');
                btn.innerHTML = '<i class="fa fa-times-circle"></i> Fail';
                setTimeout(() => { if (ga.status.entered) setEnteredState(); else setEnterState(); }, TIMINGS.FAIL_RESET_MS);
            }

            if (ga.status.entered) setEnteredState(); else setEnterState();

            btn.addEventListener('click', async (e) => {
                e.preventDefault();
                if (btn.classList.contains('sgfp-loading')) return;

                const isLeave = btn.classList.contains('sgfp-enter-leave');
                const action = isLeave ? 'entry_delete' : 'entry_insert';

                if (!isLeave) {
                    const pts = getPoints();
                    if (ga.data.cost > pts) {
                        btn.classList.add('sgfp-no-pts');
                        setTimeout(() => btn.classList.remove('sgfp-no-pts'), TIMINGS.NO_PTS_FLASH_MS);
                        return;
                    }
                }

                btn.classList.add('sgfp-loading');
                btn.innerHTML = '<i class="fa fa-refresh fa-spin"></i> ...';

                try {
                    const data = await API.toggleEntry(ga.id.code, action, xsrf);
                    if (data.type === 'success') {
                        btn.classList.remove('sgfp-loading');
                        ga.markEntered(!isLeave);
                        if (isLeave) setEnterState(); else setEnteredState();
                        if (data.points !== undefined) {
                            const p = $('.nav__points');
                            if (p) p.textContent = data.points;
                        }
                        updateStats();
                    } else {
                        setFailState();
                    }
                } catch {
                    setFailState();
                }
            });

            linksEl.insertBefore(btn, linksEl.firstChild);
        });
    }

    /* 5. STEAM RATING  (asking Steam nicely for numbers. they don't always respond nicely.) */
    let _ratingsEpoch = 0;
    async function addRatings(giveaways) {
        if (!C.x_steamRating) { $$('.sgfp-rating').forEach(e => e.remove()); return; }
        const epoch = ++_ratingsEpoch;
        const candidates = giveaways.filter(ga => ga.id.appId && !ga.dom.inner.querySelector('.sgfp-rating'));
        const ratings = await Promise.all(candidates.map(ga => API.fetchRating(ga.id.appId)));
        if (epoch !== _ratingsEpoch) return;  // stale batch - a newer run already started. abandon ship.
        candidates.forEach((ga, i) => {
            const r = ratings[i];
            if (!r) return;
            const cls = r.pct >= 80 ? 'sgfp-rt-good' : r.pct >= 50 ? 'sgfp-rt-mid' : 'sgfp-rt-bad';
            const badge = mkEl('span', { className: `sgfp-rating ${cls}`, title: T('badge_rating_t', r.pos, r.total) },
                [T('badge_rating', r.pct)]);
            ga.dom.inner.querySelector('.giveaway__heading')?.appendChild(badge);
        });
    }

    /* 6. DECORATIONS  (stickers on giveaways. I got carried away. no regrets.) */
    // DecorationRegistry: name > fn(ga, C) - new badge? one registerDecoration() call. I'm proud of this.
    const DecorationRegistry = new Map();
    function registerDecoration(name, fn) { DecorationRegistry.set(name, fn); }

    registerDecoration('chance', (ga, C) => {
        if (!C.x_showChance || ga.status.entered) return;
        const txt = ga.metrics.chance >= 100 ? '100%' : ga.metrics.chance.toFixed(2) + '%';
        ga.dom.inner.querySelector('.giveaway__links')?.appendChild(
            mkEl('span', { className: 'sgfp-chance' + (ga.metrics.chance < 1 ? ' low' : '') }, [T('badge_chance', txt)])
        );
    });

    registerDecoration('ptsBadge', (ga, C) => {
        if (!C.x_showPtsBadge) return;
        const cls = ga.data.cost <= 5 ? 'cheap' : ga.data.cost <= 25 ? 'mid' : 'exp';
        const linksArea = ga.dom.inner.querySelector('.giveaway__links');
        const enterBtn = linksArea?.querySelector('.sgfp-enter');
        const ptag = mkEl('span', { className: `sgfp-ptag ${cls}` }, [T('badge_price', ga.data.cost)]);
        if (enterBtn && enterBtn.nextSibling) linksArea.insertBefore(ptag, enterBtn.nextSibling);
        else if (linksArea) linksArea.appendChild(ptag);
    });

    registerDecoration('wlGlow', (ga, C) => {
        if (C.x_wlGlow && ga.status.isWl) ga.dom.outer.classList.add('sgfp-wl-glow');
    });

    registerDecoration('lvNegHighlight', (ga, C) => {
        if (C.x_highlightLvNeg && ga.status.lvNeg) ga.dom.outer.classList.add('sgfp-lvneg-hl');
    });

    registerDecoration('steamLink', (ga, C) => {
        if (!C.x_steamLink || !ga.id.appId || ga.dom.inner.querySelector('.sgfp-steam-link')) return;
        ga.dom.inner.querySelector('.giveaway__links')?.appendChild(
            mkEl('a', { className: 'sgfp-steam-link', href: `https://store.steampowered.com/app/${ga.id.appId}/`, target: '_blank', title: 'Steam Store' },
                [mkEl('i', { className: 'fa fa-steam' })])
        );
    });

    function decorate(giveaways) {
        giveaways.forEach(ga => {
            ga.dom.inner.querySelectorAll('.sgfp-chance, .sgfp-ptag, .sgfp-steam-link').forEach(e => e.remove());
            ga.dom.outer.classList.remove('sgfp-wl-glow', 'sgfp-dimmed', 'sgfp-hidden-ga', 'sgfp-lvneg-hl');
            DecorationRegistry.forEach(fn => fn(ga, C));
        });
    }

    /* 7. FILTER & SORT  (the main attraction. why we're all here. it just hides stuff.) */

    // FilterRegistry: name > factory(C) > predicate(ga) > bool (true = hide)
    // add a new filter? one line. I designed this. I'm not subtle about being pleased with myself.
    const FilterRegistry = new Map();
    function registerFilter(name, factory) { FilterRegistry.set(name, factory); }

    registerFilter('entered',   C => ga => C.f_hideEntered && ga.status.entered);
    registerFilter('group',     C => ga => C.f_hideGroup   && ga.status.isGroup);
    registerFilter('whitelist', C => ga => C.f_hideWL      && ga.status.isWhitelist);
    registerFilter('level',     C => ga => ga.data.level < C.f_minLv || ga.data.level > C.f_maxLv);
    registerFilter('entries',   C => ga => C.f_maxEntries > 0 && ga.data.entries > C.f_maxEntries);
    registerFilter('chance',    C => ga => C.f_minChance  > 0 && ga.metrics.chance < C.f_minChance);
    registerFilter('points',    C => {
        // exactSet is built once per applyFilters call - not per giveaway. I'm not a monster.
        const exactSet = C.f_exactP.trim()
            ? new Set(C.f_exactP.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n)))
            : null;
        return ga => exactSet
            ? !exactSet.has(ga.data.cost)
            : (ga.data.cost < C.f_minP || ga.data.cost > C.f_maxP);
    });

    // SortRegistry: key > comparator(a, b)
    // new sort mode? SortRegistry.set(key, fn). yes, both registries are this clean. no, I won't stop.
    const SortRegistry = new Map([
        ['pts-asc',  (a, b) => a.data.cost      - b.data.cost],
        ['pts-desc', (a, b) => b.data.cost      - a.data.cost],
        ['ent-asc',  (a, b) => a.data.entries   - b.data.entries],
        ['ent-desc', (a, b) => b.data.entries   - a.data.entries],
        ['ch-desc',  (a, b) => b.metrics.chance - a.metrics.chance],
        ['lv-desc',  (a, b) => b.data.level     - a.data.level],
        ['end-asc',  (a, b) => a.data.endTime   - b.data.endTime],
    ]);

    let lastStats = { total: 0, shown: 0, hidden: 0 };
    function applyFilters(giveaways) {
        let shown = 0, hidden = 0;

        if (C.f_enabled) {
            // factories run once per call - predicates close over C and exactSet. efficient on purpose.
            const predicates = [...FilterRegistry.values()].map(factory => factory(C));
            giveaways.forEach(ga => {
                ga.dom.outer.classList.remove('sgfp-dimmed', 'sgfp-hidden-ga');
                if (predicates.some(pred => pred(ga))) {
                    hidden++;
                    ga.dom.outer.classList.add(C.f_dimMode ? 'sgfp-dimmed' : 'sgfp-hidden-ga');
                } else { shown++; }
            });
        } else {
            giveaways.forEach(ga => {
                ga.dom.outer.classList.remove('sgfp-dimmed', 'sgfp-hidden-ga');
                shown++;
            });
        }

        // 'default' sort uses originalIndex to undo user-applied sorting. they want it back. always.
        const sortFn = C.f_sort === 'default'
            ? (a, b) => a.data.originalIndex - b.data.originalIndex
            : SortRegistry.get(C.f_sort);
        if (sortFn) {
            const sortable = giveaways.filter(g => !g.status.isPinned);
            sortable.sort(sortFn);
            sortable.forEach(g => g.dom.outer.parentNode.appendChild(g.dom.outer));
        }

        lastStats = { total: giveaways.length, shown, hidden };
        updateStats();
    }
    function updateStats() {
        const map = { 'sgfp-s-total': lastStats.total, 'sgfp-s-shown': lastStats.shown, 'sgfp-s-hidden': lastStats.hidden, 'sgfp-s-pts': getPoints() };
        for (const [id, v] of Object.entries(map)) { const e = $id(id); if (e) e.textContent = v; }
    }

    /* 7b. AUTO SCROLL  (loads more pages as you scroll. you still won't win the game.) */
    class AutoScroller {
        constructor() {
            this._active   = false;
            this._loading  = false;
            this._page     = 1;
            this._done     = false;
            this._retries  = 0;  // reset on each successful fetch - eternal optimism
            // bind once - removeEventListener needs the EXACT same function reference. fun discovery.
            this._handler = this._onScroll.bind(this);
        }

        start() {
            if (this._active) return;
            this._active  = true;
            this._done    = false;
            this._retries = 0;
            const cur = document.querySelector('.pagination__navigation a.is-selected');
            this._page = cur ? (parseInt(cur.textContent, 10) || 1) : 1;
            window.addEventListener('scroll', this._handler);
            setTimeout(this._handler, TIMINGS.SCROLL_INIT_MS);
        }

        stop() {
            this._active = false;
            window.removeEventListener('scroll', this._handler);
        }

        _buildNextUrl() {
            const nextPage = this._page + 1;  // don't mutate yet - only increment on success. matters a lot.
            const loc = window.location;
            if (loc.pathname === '/' || loc.pathname === '') return `/giveaways/search?page=${nextPage}`;
            const url = new URL(loc.href);
            url.searchParams.set('page', nextPage);
            return url.toString();
        }

        _onScroll() {
            if (!this._active || this._loading || this._done) return;
            if (window.innerHeight + window.scrollY < document.documentElement.scrollHeight - TIMINGS.SCROLL_TRIGGER_PX) return;
            this._fetchNext();
        }

        _fetchNext(onDone) {
            if (this._done) { if (onDone) onDone(true); return; }

            const nextPage = this._page + 1;
            const nextUrl = this._buildNextUrl();
            this._loading = true;

            const allRows = $$('.giveaway__row-outer-wrap');
            const lastRow = allRows[allRows.length - 1];
            if (!lastRow) { this._loading = false; if (onDone) onDone(true); return; }
            const parent = lastRow.parentNode;

            const loader = mkEl('div', { className: 'sgfp-scroll-loader' }, [T('scroll_load', nextPage)]);
            if (lastRow.nextSibling) parent.insertBefore(loader, lastRow.nextSibling);
            else parent.appendChild(loader);

            API.fetchPage(nextUrl)
                .then(html => {
                    const doc = new DOMParser().parseFromString(html, 'text/html');
                    const newRows = doc.querySelectorAll('.giveaway__row-outer-wrap');
                    if (newRows.length === 0) {
                        this._done = true;
                        loader.textContent = T('scroll_done');
                        loader.style.color = '#4ecb71';
                        setTimeout(() => loader.remove(), TIMINGS.LOADER_REMOVE_MS);
                        this._loading = false;
                        if (onDone) onDone(true);
                        return;
                    }
                    let insertPoint = loader;
                    newRows.forEach(row => {
                        if (row.closest('.pinned-giveaways__inner-wrap')) return;
                        const imported = document.importNode(row, true);
                        insertPoint.parentNode.insertBefore(imported, insertPoint.nextSibling);
                        insertPoint = imported;
                    });
                    loader.remove();
                    this._page++;     // only here. not before. not during. here.
                    this._retries = 0;  // we made it, reset the shame counter
                    this._loading = false;
                    runAll();
                    if (onDone) onDone(false);
                })
                .catch(err => {
                    this._retries++;
                    loader.textContent = T('scroll_err', this._retries, err.message);
                    if (this._retries >= 3) {
                        this._done = true;
                        this._loading = false;
                        loader.style.color = '#e05555';
                        setTimeout(() => loader.remove(), TIMINGS.ERROR_REMOVE_MS);
                        if (onDone) onDone(true);
                    } else {
                        loader.style.color = '#c8b43c';
                        setTimeout(() => loader.remove(), TIMINGS.ERROR_REMOVE_MS);
                        // exponential backoff - sounds fancy, it's just FAIL_RESET_MS × retryCount
                        const backoff = TIMINGS.FAIL_RESET_MS * this._retries;
                        setTimeout(() => { this._loading = false; if (onDone) onDone(false); }, backoff);
                    }
                });
        }

        /* Load ALL remaining pages in sequence - no scrolling required */
        loadAll() {
            if (this._loadingAll) { this.stopLoadAll(); return; }
            this._loadingAll = true;
            this._done = false;
            this._retries = 0;
            const cur = document.querySelector('.pagination__navigation a.is-selected');
            this._page = cur ? (parseInt(cur.textContent, 10) || 1) : 1;

            // detect total pages from pagination
            const allPageLinks = document.querySelectorAll('.pagination__navigation a[data-page-number]');
            this._totalPages = 0;
            allPageLinks.forEach(a => {
                const n = parseInt(a.getAttribute('data-page-number'), 10);
                if (n > this._totalPages) this._totalPages = n;
            });

            this._updateLoadAllBtn();
            this._loadNext();
        }

        _loadNext() {
            if (!this._loadingAll) return;
            // update button text with progress
            this._updateLoadAllBtn();

            this._fetchNext((done) => {
                if (done || !this._loadingAll) {
                    this._loadingAll = false;
                    this._updateLoadAllBtn();
                    return;
                }
                // small delay between fetches to not hammer the server
                setTimeout(() => this._loadNext(), 300);
            });
        }

        stopLoadAll() {
            this._loadingAll = false;
            this._updateLoadAllBtn();
        }

        _updateLoadAllBtn() {
            const btn = $id('sgfp-loadall');
            if (!btn) return;
            if (this._loadingAll) {
                btn.textContent = T('btn_loadall_stop');
                btn.title = T('scroll_loadall', this._page, this._totalPages);
                btn.classList.add('sgfp-btn-active');
            } else {
                btn.textContent = T('btn_loadall');
                btn.title = '';
                btn.classList.remove('sgfp-btn-active');
                if (this._done) { btn.disabled = true; btn.textContent = T('scroll_done'); }
            }
        }
    }
    const autoScroller = new AutoScroller();

    /* 7c. DARK THEME  (toggles the whole site dark. your eyes will thank you at 3am.) */
    function applyDarkTheme() {
        document.documentElement.classList.toggle('sgfp-dark', !!C.x_darkTheme);
    }

    /* 8. RUN ALL  (the function that calls everything. yes, every time. yes, even that.) */
    function runAll() {
        const ga = parseAll();
        decorate(ga);
        addInlineEnterButtons(ga);
        applyFilters(ga);
        applyDarkTheme();
        if (C.x_steamRating) addRatings(ga);
        if (C.x_autoScroll) autoScroller.start(); else autoScroller.stop();
    }

    /* 9. PANEL POSITIONING  (smart direction - opens toward free space. took embarrassingly long to get right.) */
    function positionPanel() {
        const panel = $id('sgfp-panel');
        const fab = $id('sgfp-fab');
        if (!panel || !fab) return;

        const fr = fab.getBoundingClientRect();
        const gap = 12;
        const vh = window.innerHeight, vw = window.innerWidth;

        // wipe all positioning first - old values fight new ones and everyone loses
        panel.style.top = ''; panel.style.bottom = '';
        panel.style.left = ''; panel.style.right = '';

        // vertical: top half > panel opens below. bottom half > panel opens above.
        const fabCenterY = fr.top + fr.height / 2;
        if (fabCenterY < vh / 2) {
            panel.style.top = `${fr.bottom + gap}px`;
        } else {
            panel.style.bottom = `${vh - fr.top + gap}px`;
        }

        // horizontal: right side > right-align. left side > left-align. straightforward. somehow.
        const fabCenterX = fr.left + fr.width / 2;
        if (fabCenterX > vw / 2) {
            panel.style.right = `${vw - fr.right}px`;
        } else {
            panel.style.left = `${fr.left}px`;
        }
    }

    function applyUISettings() {
        const panel = $id('sgfp-panel');
        const fab = $id('sgfp-fab');
        if (!panel || !fab) return;
        const scale = clamp(C.ui_scale, 60, 150);
        const w = clamp(C.ui_panelW, 240, 500);
        const fabSz = clamp(C.ui_fabSize, 32, 72);
        const opacity = clamp(C.ui_panelOpacity, 40, 100);
        panel.style.fontSize = `${12 * scale / 100}px`;
        panel.style.width = `${w}px`;
        panel.style.opacity = `${opacity / 100}`;
        fab.style.width = `${fabSz}px`;
        fab.style.height = `${fabSz}px`;
        const icon = fab.querySelector('.sgfp-fab-icon');
        if (icon) { icon.style.width = `${Math.round(fabSz * 0.65)}px`; icon.style.height = `${Math.round(fabSz * 0.65)}px`; }
        positionPanel();
    }

    /* 10. DRAGGABLE FAB  (drag the star anywhere. it saves position. it remembers. unlike me.) */
    function makeFabDraggable(fab) {
        let startX, startY, startLeft, startTop, dragging = false, moved = false;
        fab.addEventListener('mousedown', e => {
            if (e.button !== 0) return;
            dragging = true; moved = false;
            startX = e.clientX; startY = e.clientY;
            const r = fab.getBoundingClientRect();
            startLeft = r.left; startTop = r.top;
            fab.style.transition = 'none'; e.preventDefault();
        });
        document.addEventListener('mousemove', e => {
            if (!dragging) return;
            const dx = e.clientX - startX, dy = e.clientY - startY;
            if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
            fab.style.left = `${clamp(startLeft + dx, 0, window.innerWidth - fab.offsetWidth)}px`;
            fab.style.top = `${clamp(startTop + dy, 0, window.innerHeight - fab.offsetHeight)}px`;
            fab.style.right = 'auto'; fab.style.bottom = 'auto';
            positionPanel();
        });
        document.addEventListener('mouseup', () => {
            if (!dragging) return;
            dragging = false; fab.style.transition = '';
            const r = fab.getBoundingClientRect();
            C.ui_fabX = Math.round(r.left); C.ui_fabY = Math.round(r.top); cfgSave(C);
        });
        fab.addEventListener('click', e => {
            if (moved) { e.preventDefault(); e.stopPropagation(); return; }
            $id('sgfp-panel').classList.toggle('open');
            fab.classList.toggle('active');
            positionPanel();
        });
    }
    function restoreFabPosition(fab) {
        if (C.ui_fabX >= 0 && C.ui_fabY >= 0) {
            fab.style.left = `${clamp(C.ui_fabX, 0, window.innerWidth - fab.offsetWidth)}px`;
            fab.style.top = `${clamp(C.ui_fabY, 0, window.innerHeight - fab.offsetHeight)}px`;
            fab.style.right = 'auto'; fab.style.bottom = 'auto';
        }
    }

    /* 11. CSS  (dark theme. blue accents. a suspicious amount of border-radius. I have a type.) */
    function injectCSS() {
        const s = document.createElement('style'); s.id = 'sgfp-css';
        s.textContent = `
/* ── FAB ── */
#sgfp-fab {
    position: fixed; bottom: 24px; right: 24px; z-index: 999999;
    width: ${C.ui_fabSize}px; height: ${C.ui_fabSize}px; border-radius: 50%;
    background: linear-gradient(135deg, #4a7ab5, #3a5a8a);
    border: 2px solid #5a8ac0; color: #fff;
    display: flex; align-items: center; justify-content: center;
    cursor: grab; box-shadow: 0 4px 16px rgba(0,0,0,.4);
    transition: all .2s; user-select: none;
}
#sgfp-fab:hover { box-shadow: 0 6px 24px rgba(74,122,181,.5); }
#sgfp-fab:active { cursor: grabbing; }
#sgfp-fab.active { background: linear-gradient(135deg, #3a6a3a, #2a5a2a); border-color: #4a8a4a; }
#sgfp-fab .sgfp-fab-icon { pointer-events: none; }

/* ── Panel ── */
#sgfp-panel {
    display: none; position: fixed;
    width: ${C.ui_panelW}px; height: 540px;
    background: #1b2028; border: 1px solid #2e3d4d;
    border-radius: 12px; color: #d0d8e2; font-family: 'Open Sans', Arial, sans-serif;
    font-size: 12px; z-index: 999998; box-shadow: 0 12px 48px rgba(0,0,0,.6);
    overflow: hidden; opacity: ${C.ui_panelOpacity / 100};
}
#sgfp-panel.open { display: flex; flex-direction: column; }

/* ── Tabs ── */
.sgfp-tabs { display: flex; background: #151a22; border-bottom: 1px solid #2e3d4d; flex-shrink: 0; }
.sgfp-tab {
    flex: 1; padding: 10px 0; text-align: center; cursor: pointer;
    font-size: 0.92em; font-weight: 600; color: #7a8a9a;
    transition: all .15s; border-bottom: 2px solid transparent;
}
.sgfp-tab:hover { color: #b0c0d0; }
.sgfp-tab.active { color: #7ab8e0; border-bottom-color: #4a8ab5; }

/* ── Tab content - FIXED HEIGHT ── */
.sgfp-tc { display: none; padding: 12px 16px; overflow-y: auto; height: 484px; }
.sgfp-tc.active { display: block; }
.sgfp-tc::-webkit-scrollbar { width: 5px; }
.sgfp-tc::-webkit-scrollbar-thumb { background: #2e3d4d; border-radius: 3px; }

/* ── Section ── */
.sgfp-sh {
    font-size: 0.75em; text-transform: uppercase; letter-spacing: 1.2px;
    color: #5a7a8a; margin: 10px 0 6px; padding-top: 8px;
    border-top: 1px solid rgba(255,255,255,.06);
}
.sgfp-sh:first-child { border-top: none; margin-top: 2px; padding-top: 0; }

/* ── Row ── */
.sgfp-r { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; min-height: 28px; }
.sgfp-r label { flex: 1; color: #b0bcc8; font-size: 0.96em; }
.sgfp-r .sgfp-hint { font-size: 0.78em; color: #607080; display: block; margin-top: 1px; }

/* ── Inputs ── */
.sgfp-n {
    width: 58px; background: #111820; border: 1px solid #2e3d4d;
    color: #e0e6ec; border-radius: 5px; padding: 4px 6px; text-align: center; font-size: 1em;
}
.sgfp-n:focus { border-color: #4a8ab5; outline: none; }
.sgfp-t {
    width: 100px; background: #111820; border: 1px solid #2e3d4d;
    color: #e0e6ec; border-radius: 5px; padding: 4px 8px; font-size: 0.92em;
}
.sgfp-t:focus { border-color: #4a8ab5; outline: none; }
.sgfp-t::placeholder { color: #445; }
.sgfp-sel {
    background: #111820; border: 1px solid #2e3d4d; color: #e0e6ec;
    border-radius: 5px; padding: 4px 6px; font-size: 0.92em; cursor: pointer;
}

/* ── SWITCH - compact pill ── */
.sgfp-sw {
    position: relative; display: block;
    width: 36px; min-width: 36px; max-width: 36px; height: 20px;
    flex-shrink: 0; flex-grow: 0;
}
.sgfp-sw input { opacity: 0; width: 0; height: 0; position: absolute; }
.sgfp-sw .sl {
    position: absolute; cursor: pointer; top: 0; left: 0;
    width: 36px; height: 20px;
    background: #2a2a38; border-radius: 20px; transition: .25s;
}
.sgfp-sw .sl::before {
    content: ''; position: absolute; height: 14px; width: 14px;
    left: 3px; top: 3px; background: #556;
    border-radius: 50%; transition: .25s;
}
.sgfp-sw input:checked + .sl { background: #28603a; }
.sgfp-sw input:checked + .sl::before { transform: translateX(16px); background: #4ecb71; }

/* ── Buttons ── */
.sgfp-btn {
    width: 100%; padding: 8px; margin-top: 8px; border: none;
    border-radius: 6px; cursor: pointer; font-size: 0.96em; font-weight: 700;
    background: linear-gradient(135deg, #3a6b8a, #2a5070); color: #e0e8f0;
    transition: all .15s; letter-spacing: .5px;
}
.sgfp-btn:hover { background: linear-gradient(135deg, #4a7b9a, #3a6080); }
.sgfp-btn-danger { background: linear-gradient(135deg, #5a3030, #4a2020) !important; }
.sgfp-btn-danger:hover { background: linear-gradient(135deg, #6a3a3a, #5a2a2a) !important; }
.sgfp-btn-active { background: linear-gradient(135deg, #8a5a20, #6a4010) !important; animation: sgfp-pulse 1.5s ease-in-out infinite; }
.sgfp-btn-active:hover { background: linear-gradient(135deg, #9a6a30, #7a5020) !important; }
.sgfp-btn:disabled { opacity: .5; cursor: default; pointer-events: none; }
@keyframes sgfp-pulse { 0%,100% { opacity: 1; } 50% { opacity: .7; } }

/* ── Slider ── */
.sgfp-slider-val { display: inline-block; min-width: 36px; text-align: right; color: #7ab8e0; font-weight: 700; font-size: 0.92em; }
.sgfp-range {
    -webkit-appearance: none; width: 90px; height: 4px; background: #2e3d4d;
    border-radius: 2px; outline: none; cursor: pointer; margin: 0 6px;
}
.sgfp-range::-webkit-slider-thumb {
    -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%;
    background: #4a8ab5; cursor: pointer; border: 2px solid #1b2028;
}

/* ── Stats ── */
.sgfp-stats {
    display: flex; justify-content: space-around;
    padding: 6px 0; background: #111820; flex-shrink: 0;
    border-bottom: 1px solid #2e3d4d;
}
.sgfp-stats > div { text-align: center; color: #7a8a9a; font-size: 0.7em; text-transform: uppercase; }
.sgfp-stats .n { display: block; font-size: 1.2em; font-weight: 700; color: #b0bcc8; }
.sgfp-stats .n.g { color: #4ecb71; } .sgfp-stats .n.r { color: #e05555; } .sgfp-stats .n.b { color: #7ab8e0; }

/* ── About ── */
.sgfp-about { text-align: center; padding: 24px 12px; }
.sgfp-about h2 { margin: 0 0 4px; font-size: 1.5em; color: #e8eef4; }
.sgfp-about .ver { color: #5a7a8a; font-size: 0.92em; margin-bottom: 16px; display: block; }
.sgfp-about p { color: #8a9aaa; font-size: 0.92em; line-height: 1.7; margin: 8px 0; }
.sgfp-about a { color: #7ab8e0; text-decoration: none; }
.sgfp-about a:hover { text-decoration: underline; }
.sgfp-about .sgfp-logo { font-size: 3.3em; margin-bottom: 8px; }

/* ═══ GIVEAWAY DECORATIONS ═══ */

/* Enter button - green for join, red for leave */
.sgfp-enter {
    display: inline-flex; align-items: center; gap: 4px;
    padding: 2px 10px; margin-right: 10px;
    cursor: pointer; font-size: 12px; font-weight: 600;
    border: 1px solid transparent; border-radius: 4px;
    transition: all .15s; text-decoration: none !important;
    line-height: 22px; vertical-align: middle;
}
.sgfp-enter-join {
    color: #4ecb71; border-color: #2a5a3a;
}
.sgfp-enter-join:hover { background: #2a5a3a; color: #fff; }
.sgfp-enter-leave {
    color: #e06060; border-color: #5a2a2a;
}
.sgfp-enter-leave:hover { background: #5a2a2a; color: #fff; }
.sgfp-enter.sgfp-loading { color: #999; border-color: #444; cursor: wait; }
.sgfp-enter.sgfp-no-pts { color: #e05555 !important; border-color: #5a2a2a !important; animation: sgfp-shake .3s; }
@keyframes sgfp-shake { 25%{transform:translateX(-3px)} 75%{transform:translateX(3px)} }

/* Scroll loader */
.sgfp-scroll-loader { text-align: center; padding: 20px; color: #7ab8e0; font-size: 13px; }

/* Chance badge - with label */
.sgfp-chance {
    display: inline-block; margin-left: 8px; padding: 2px 8px;
    background: rgba(78,203,113,.12); color: #4ecb71; border-radius: 10px;
    font-size: 12.5px; font-weight: 600; vertical-align: middle;
}
.sgfp-chance.low { background: rgba(224,85,85,.12); color: #e05555; }

/* Points badge - with label */
.sgfp-ptag {
    display: inline-block; padding: 2px 8px; margin-right: 6px;
    border-radius: 4px; font-size: 12.5px; font-weight: 600; vertical-align: middle;
}
.sgfp-ptag.cheap { background: rgba(78,203,113,.12); color: #4ecb71; }
.sgfp-ptag.mid   { background: rgba(200,180,60,.12); color: #c8b43c; }
.sgfp-ptag.exp   { background: rgba(224,85,85,.12); color: #e05555; }

/* Rating badge - with label */
.sgfp-rating {
    display: inline-block; padding: 2px 8px; margin-left: 6px;
    border-radius: 4px; font-size: 12.5px; font-weight: 600;
}
.sgfp-rt-good { background: rgba(78,203,113,.12); color: #4ecb71; }
.sgfp-rt-mid  { background: rgba(200,180,60,.12); color: #c8b43c; }
.sgfp-rt-bad  { background: rgba(224,85,85,.12); color: #e05555; }

/* Steam link */
.sgfp-steam-link {
    display: inline-block; margin-left: 8px; color: #6a9aba;
    font-size: 14px; vertical-align: middle; text-decoration: none !important;
}
.sgfp-steam-link:hover { color: #7ab8e0; }

/* Highlights */
.sgfp-wl-glow .giveaway__row-inner-wrap { box-shadow: inset 3px 0 0 #4ecb71 !important; }
.sgfp-dimmed { opacity: .2; transition: opacity .15s; }
.sgfp-dimmed:hover { opacity: .65; }
.sgfp-hidden-ga { display: none !important; }
.sgfp-lvneg-hl .giveaway__row-inner-wrap { box-shadow: inset 3px 0 0 #e05555 !important; }

/* ═══ DARK THEME ═══ */
html.sgfp-dark { color-scheme: dark; }
html.sgfp-dark body,
html.sgfp-dark .page__outer-wrap { background: #1a1d23 !important; color: #c8cdd4 !important; }

/* Header & Nav */
html.sgfp-dark .header { background: #14171c !important; }
html.sgfp-dark .nav__button-container .nav__button,
html.sgfp-dark .nav__button-container--notification .nav__button { background: #1e222a !important; }
html.sgfp-dark .nav__button-container .nav__button:hover,
html.sgfp-dark .nav__button-container--notification .nav__button:hover { background: #282d38 !important; }
html.sgfp-dark .nav__sits, html.sgfp-dark .nav__heading,
html.sgfp-dark .nav__heading__button, html.sgfp-dark .nav__heading__secondary-link,
html.sgfp-dark .header__button--is-dropdown { color: #a0a8b4 !important; }
html.sgfp-dark .nav__absolute-dropdown { background: #1e222a !important; border-color: #2e3440 !important; }
html.sgfp-dark .nav__absolute-dropdown a { color: #b0b8c4 !important; }
html.sgfp-dark .nav__absolute-dropdown a:hover { background: #282d38 !important; }
html.sgfp-dark .nav__row { border-color: #2e3440 !important; }
html.sgfp-dark .nav__row:hover { background: #282d38 !important; }

/* Search */
html.sgfp-dark .sidebar__search-container { background: #1e222a !important; border-color: #2e3440 !important; }
html.sgfp-dark .sidebar__search-input { background: transparent !important; color: #c8cdd4 !important; }

/* Featured giveaway */
html.sgfp-dark .featured__container { background: linear-gradient(135deg, #1e2530 0%, #1a1d23 100%) !important; }
html.sgfp-dark .featured__heading, html.sgfp-dark .featured__heading__medium,
html.sgfp-dark .featured__heading__small { color: #e0e4ea !important; }
html.sgfp-dark .featured__column { border-color: rgba(255,255,255,.08) !important; }
html.sgfp-dark .global__image-outer-wrap--missing-image { background: #252830 !important; }

/* Giveaway rows */
html.sgfp-dark .giveaway__row-inner-wrap { background: #1e222a !important; }
html.sgfp-dark .giveaway__row-outer-wrap { border-bottom-color: #2a2e38 !important; }
html.sgfp-dark .giveaway__heading__name { color: #d8dde4 !important; }
html.sgfp-dark .giveaway__heading__thin { color: #6a7080 !important; }
html.sgfp-dark .giveaway__columns span,
html.sgfp-dark .giveaway__column--width-fill span { color: #7a8290 !important; }
html.sgfp-dark .giveaway__links a { color: #6a7a8a !important; }
html.sgfp-dark .giveaway__links a:hover { color: #8aa0b8 !important; }
html.sgfp-dark .giveaway__row-inner-wrap.is-faded { opacity: .45; }

/* Pinned giveaways */
html.sgfp-dark .pinned-giveaways__outer-wrap { background: #161920 !important; border-color: #2a2e38 !important; }
html.sgfp-dark .pinned-giveaways__inner-wrap { background: transparent !important; }

/* Sidebar */
html.sgfp-dark .sidebar { background: transparent !important; }
html.sgfp-dark .sidebar__heading { color: #8a9aaa !important; border-bottom-color: #2a2e38 !important; }
html.sgfp-dark .sidebar__navigation__item { border-color: #2a2e38 !important; }
html.sgfp-dark .sidebar__navigation__item__link { color: #a0a8b4 !important; }
html.sgfp-dark .sidebar__navigation__item__link:hover { background: #1e222a !important; }
html.sgfp-dark .sidebar__navigation__item__link--is-selected { background: #252a34 !important; }
html.sgfp-dark .sidebar__navigation__item__count { color: #6a7a8a !important; }

/* Pagination */
html.sgfp-dark .pagination { background: #1e222a !important; border-color: #2a2e38 !important; }
html.sgfp-dark .pagination__navigation a { color: #7a8a9a !important; border-color: #2a2e38 !important; }
html.sgfp-dark .pagination__navigation a:hover { background: #282d38 !important; color: #c8cdd4 !important; }
html.sgfp-dark .pagination__navigation a.is-selected { background: #2a3a50 !important; color: #7ab8e0 !important; }

/* Giveaway page (single) */
html.sgfp-dark .page__heading { border-bottom-color: #2a2e38 !important; }
html.sgfp-dark .page__heading__breadcrumbs a { color: #6a7a8a !important; }
html.sgfp-dark .sidebar__entry-insert, html.sgfp-dark .sidebar__entry-delete,
html.sgfp-dark .sidebar__entry-loading { background: #252a34 !important; border-color: #2a2e38 !important; }
html.sgfp-dark .sidebar__entry-insert { color: #4ecb71 !important; }
html.sgfp-dark .sidebar__entry-delete { color: #e05555 !important; }

/* Comments */
html.sgfp-dark .comment__parent { border-color: #2a2e38 !important; }
html.sgfp-dark .comment__child { border-color: #2a2e38 !important; }
html.sgfp-dark .comment_inner { background: transparent !important; }
html.sgfp-dark .comment__summary { border-color: #2a2e38 !important; }
html.sgfp-dark .comment__username a { color: #7ab8e0 !important; }
html.sgfp-dark .comment__description, html.sgfp-dark .markdown { color: #b0b8c4 !important; }
html.sgfp-dark .comment__actions a { color: #6a7a8a !important; }
html.sgfp-dark .comment__toggle { color: #6a7a8a !important; }

/* Tables & discussions */
html.sgfp-dark .table__heading { background: #14171c !important; color: #7a8a9a !important; }
html.sgfp-dark .table__row-inner-wrap { background: #1e222a !important; border-bottom-color: #2a2e38 !important; }
html.sgfp-dark .table__row-outer-wrap { border-bottom-color: #2a2e38 !important; }
html.sgfp-dark .table__column--width-fill a { color: #d0d4da !important; }
html.sgfp-dark .table__column__secondary-link { color: #6a7a8a !important; }
html.sgfp-dark .table__column--width-small { color: #7a8a9a !important; }

/* Forms & inputs */
html.sgfp-dark .form__input-small, html.sgfp-dark .form__input-large,
html.sgfp-dark textarea, html.sgfp-dark input[type="text"],
html.sgfp-dark input[type="number"], html.sgfp-dark select { background: #14171c !important; border-color: #2e3440 !important; color: #c8cdd4 !important; }

/* Footer */
html.sgfp-dark .footer__outer-wrap { background: #14171c !important; }
html.sgfp-dark .footer__outer-wrap a, html.sgfp-dark .footer__outer-wrap span { color: #5a6a7a !important; }

/* Generic links & text */
html.sgfp-dark a { color: #7ab8e0; }
html.sgfp-dark .page__heading__button, html.sgfp-dark .page__heading__button a { color: #7a8a9a !important; }
html.sgfp-dark .page__heading__button:hover { color: #a0b0c0 !important; }

/* Level badges */
html.sgfp-dark .giveaway__column--contributor-level { color: #7a8a9a !important; }
html.sgfp-dark .giveaway__column--contributor-level--positive { color: #4ecb71 !important; }
html.sgfp-dark .giveaway__column--contributor-level--negative { color: #e05555 !important; }

/* Misc */
html.sgfp-dark .global__image-outer-wrap { border-color: #2a2e38 !important; }
html.sgfp-dark .widget-container { border-color: #2a2e38 !important; }
html.sgfp-dark .page_heading_btn { background: #252a34 !important; }
html.sgfp-dark ::-webkit-scrollbar { width: 8px; }
html.sgfp-dark ::-webkit-scrollbar-track { background: #1a1d23; }
html.sgfp-dark ::-webkit-scrollbar-thumb { background: #2e3440; border-radius: 4px; }
html.sgfp-dark ::-webkit-scrollbar-thumb:hover { background: #3e4450; }
        `;
        document.head.appendChild(s);
    }

    /* 12. BUILD PANEL  (UI factory. generates HTML strings like it's 2012. it works, don't touch it.) */
    function sw(id, lbl, chk, hint = '') {
        const h = hint ? `<span class="sgfp-hint">${hint}</span>` : '';
        return `<div class="sgfp-r"><label>${lbl}${h}</label><label class="sgfp-sw"><input type="checkbox" id="${id}" ${chk?'checked':''}><span class="sl"></span></label></div>`;
    }
    function num(id, lbl, val, min = 0, max = 9999) {
        return `<div class="sgfp-r"><label>${lbl}</label><input type="number" class="sgfp-n" id="${id}" value="${val}" min="${min}" max="${max}"></div>`;
    }
    function slider(id, lbl, val, min, max, step, unit = '') {
        return `<div class="sgfp-r"><label>${lbl}</label>
            <input type="range" class="sgfp-range" id="${id}" min="${min}" max="${max}" step="${step}" value="${val}">
            <span class="sgfp-slider-val" id="${id}-v">${val}${unit}</span></div>`;
    }

    function buildFilterTab() {
        return `
        <div class="sgfp-tc active" id="sgfp-t-filter">
            ${sw('sgfp-f-on', T('f_enabled'), C.f_enabled)}
            <div class="sgfp-sh">${T('sh_points')}</div>
            ${num('sgfp-f-minP', T('f_from'), C.f_minP, 0, 300)}
            ${num('sgfp-f-maxP', T('f_to'), C.f_maxP, 0, 300)}
            <div class="sgfp-r"><label>${T('f_exact')}<span class="sgfp-hint">${T('f_exact_hint')}</span></label>
                <input type="text" class="sgfp-t" id="sgfp-f-exactP" value="${C.f_exactP}" placeholder="${T('f_exact_ph')}"></div>
            <div class="sgfp-sh">${T('sh_level')}</div>
            ${num('sgfp-f-minLv', T('f_from'), C.f_minLv, 0, 10)}
            ${num('sgfp-f-maxLv', T('f_to'), C.f_maxLv, 0, 10)}
            <div class="sgfp-sh">${T('sh_entries')}</div>
            ${num('sgfp-f-maxE', T('f_max_entries'), C.f_maxEntries, 0, 99999)}
            ${num('sgfp-f-minC', T('f_min_chance'), C.f_minChance, 0, 100)}
            <div class="sgfp-sh">${T('sh_hide')}</div>
            ${sw('sgfp-f-hideE', T('f_hide_entered'), C.f_hideEntered)}
            ${sw('sgfp-f-hideG', T('f_hide_group'), C.f_hideGroup)}
            ${sw('sgfp-f-hideWL', T('f_hide_wl'), C.f_hideWL)}
            ${sw('sgfp-f-dim', T('f_dim'), C.f_dimMode)}
            <div class="sgfp-sh">${T('sh_sort')}</div>
            <div class="sgfp-r"><label>${T('f_sort_lbl')}</label>
                <select class="sgfp-sel" id="sgfp-f-sort">
                    <option value="default" ${C.f_sort==='default'?'selected':''}>${T('sort_default')}</option>
                    <option value="pts-asc" ${C.f_sort==='pts-asc'?'selected':''}>${T('sort_pts_asc')}</option>
                    <option value="pts-desc" ${C.f_sort==='pts-desc'?'selected':''}>${T('sort_pts_desc')}</option>
                    <option value="ent-asc" ${C.f_sort==='ent-asc'?'selected':''}>${T('sort_ent_asc')}</option>
                    <option value="ent-desc" ${C.f_sort==='ent-desc'?'selected':''}>${T('sort_ent_desc')}</option>
                    <option value="ch-desc" ${C.f_sort==='ch-desc'?'selected':''}>${T('sort_ch_desc')}</option>
                    <option value="lv-desc" ${C.f_sort==='lv-desc'?'selected':''}>${T('sort_lv_desc')}</option>
                    <option value="end-asc" ${C.f_sort==='end-asc'?'selected':''}>${T('sort_end_asc')}</option>
                </select></div>
        </div>`;
    }

    function buildFeatTab() {
        return `
        <div class="sgfp-tc" id="sgfp-t-feat">
            <div class="sgfp-sh">${T('sh_buttons')}</div>
            ${sw('sgfp-x-enter', T('x_enter'), C.x_inlineEnter, T('x_enter_hint'))}
            <div class="sgfp-sh">${T('sh_info')}</div>
            ${sw('sgfp-x-chance', T('x_chance'), C.x_showChance, T('x_chance_hint'))}
            ${sw('sgfp-x-pts', T('x_pts'), C.x_showPtsBadge, T('x_pts_hint'))}
            ${sw('sgfp-x-rating', T('x_rating'), C.x_steamRating, T('x_rating_hint'))}
            ${sw('sgfp-x-steam', T('x_steam'), C.x_steamLink)}
            <div class="sgfp-sh">${T('sh_highlight')}</div>
            ${sw('sgfp-x-wl', T('x_wl'), C.x_wlGlow)}
            ${sw('sgfp-x-lvneg', T('x_lvneg'), C.x_highlightLvNeg, T('x_lvneg_hint'))}
            ${sw('sgfp-x-dark', T('x_dark'), C.x_darkTheme, T('x_dark_hint'))}
            <div class="sgfp-sh">${T('sh_scroll')}</div>
            ${sw('sgfp-x-scroll', T('x_scroll'), C.x_autoScroll, T('x_scroll_hint'))}
            <button class="sgfp-btn" id="sgfp-loadall" style="margin-top:4px">${T('btn_loadall')}</button>
        </div>`;
    }

    function buildSettingsTab() {
        return `
        <div class="sgfp-tc" id="sgfp-t-settings">
            <div class="sgfp-sh">${T('sh_scale')}</div>
            ${slider('sgfp-ui-scale', T('ui_text'), C.ui_scale, 60, 150, 5, '%')}
            ${slider('sgfp-ui-panelW', T('ui_panel_w'), C.ui_panelW, 240, 500, 10, 'px')}
            ${slider('sgfp-ui-fabSize', T('ui_btn_sz'), C.ui_fabSize, 32, 72, 2, 'px')}
            ${slider('sgfp-ui-opacity', T('ui_opacity'), C.ui_panelOpacity, 40, 100, 5, '%')}
            <div class="sgfp-sh">${T('sh_pos')}</div>
            <div class="sgfp-r"><label style="color:#607080;font-size:0.83em">${T('ui_pos_hint')}</label></div>
            <button class="sgfp-btn sgfp-btn-danger" id="sgfp-ui-resetpos" style="margin-top:6px">${T('btn_resetpos')}</button>
            <div class="sgfp-sh">${T('sh_lang')}</div>
            <div class="sgfp-r"><label>${T('lang_lbl')}</label>
                <select class="sgfp-sel" id="sgfp-ui-lang">
                    <option value="auto" ${C.ui_lang==='auto'?'selected':''}>Auto</option>
                    <option value="en" ${C.ui_lang==='en'?'selected':''}>English</option>
                    <option value="ru" ${C.ui_lang==='ru'?'selected':''}>Русский</option>
                </select></div>
            <div class="sgfp-sh" style="margin-top:12px">${T('sh_data')}</div>
            <button class="sgfp-btn sgfp-btn-danger" id="sgfp-reset">${T('btn_resetall')}</button>
        </div>`;
    }

    function buildAboutTab() {
        return `
        <div class="sgfp-tc" id="sgfp-t-about">
            <div class="sgfp-about">
                <div class="sgfp-logo">⚡</div>
                <h2>SG Toolkit Pro</h2>
                <span class="ver">v${VER}</span>
                <p>${T('about_desc')}</p>
                <p style="margin-top:16px"><strong>${T('about_author')}</strong> d08 (pain)<br>
                    <a href="https://github.com/128team/tm_scripts/" target="_blank">🔗 github.com/128team/tm_scripts</a></p>
                <p style="color:#4a5a6a;font-size:0.83em;margin-top:24px">${T('about_tip')}</p>
            </div>
        </div>`;
    }

    function createPanelDOM() {
        const fab = mkEl('div', { id: 'sgfp-fab', title: T('fab_title') });
        fab.innerHTML = `<img class="sgfp-fab-icon" src="https://raw.githubusercontent.com/128team/assets/main/logo128b.jpeg" style="width:${Math.round(C.ui_fabSize * 0.65)}px;height:${Math.round(C.ui_fabSize * 0.65)}px;border-radius:4px;pointer-events:none">`;
        document.body.appendChild(fab);
        restoreFabPosition(fab);
        makeFabDraggable(fab);

        const panel = mkEl('div', { id: 'sgfp-panel' });
        panel.innerHTML = `
        <div class="sgfp-tabs">
            <div class="sgfp-tab active" data-tab="filter">${T('tab_filter')}</div>
            <div class="sgfp-tab" data-tab="feat">${T('tab_feat')}</div>
            <div class="sgfp-tab" data-tab="settings">${T('tab_settings')}</div>
            <div class="sgfp-tab" data-tab="about">${T('tab_about')}</div>
        </div>
        <div class="sgfp-stats">
            <div>${T('stat_total')}<span class="n" id="sgfp-s-total">0</span></div>
            <div>${T('stat_shown')}<span class="n g" id="sgfp-s-shown">0</span></div>
            <div>${T('stat_hidden')}<span class="n r" id="sgfp-s-hidden">0</span></div>
            <div>Points<span class="n b" id="sgfp-s-pts">0</span></div>
        </div>
        ${buildFilterTab()}
        ${buildFeatTab()}
        ${buildSettingsTab()}
        ${buildAboutTab()}`;
        document.body.appendChild(panel);
        return { fab, panel };
    }

    /* 13. REBUILD PANEL  (called when language changes - simplest way to re-translate everything.) */
    function rebuildPanel() {
        const wasOpen = $id('sgfp-panel')?.classList.contains('open');
        $id('sgfp-panel')?.remove();
        $id('sgfp-fab')?.remove();
        const { fab, panel } = createPanelDOM();
        bindPanelEvents(fab, panel);
        if (wasOpen) { panel.classList.add('open'); fab.classList.add('active'); positionPanel(); }
        runAll();
    }

    function bindPanelEvents(fab, panel) {
        // tab switching - remove active from all, add to clicked one. revolutionary UX pattern.
        panel.querySelectorAll('.sgfp-tab').forEach(tab => {
            tab.addEventListener('click', () => {
                panel.querySelectorAll('.sgfp-tab').forEach(t => t.classList.remove('active'));
                panel.querySelectorAll('.sgfp-tc').forEach(t => t.classList.remove('active'));
                tab.classList.add('active');
                $id(`sgfp-t-${tab.dataset.tab}`)?.classList.add('active');
            });
        });

        // all inputs feed the same debounced function. change anything > 300ms > runAll. clean.
        const debouncedApply = debounce(() => { readUI(); runAll(); }, TIMINGS.DEBOUNCE_MS);
        panel.querySelectorAll('.sgfp-sw input').forEach(inp => inp.addEventListener('change', debouncedApply));
        $id('sgfp-f-sort').addEventListener('change', debouncedApply);
        panel.querySelectorAll('.sgfp-n, .sgfp-t').forEach(inp => {
            inp.addEventListener('keydown', e => { if (e.key === 'Enter') debouncedApply(); });
            inp.addEventListener('change', debouncedApply);
        });
        panel.querySelectorAll('.sgfp-sel').forEach(sel => sel.addEventListener('change', debouncedApply));

        // sliders update their value label in real time. small detail. satisfying.
        panel.querySelectorAll('.sgfp-range').forEach(range => {
            const valEl = $id(range.id + '-v');
            if (!valEl) return;
            const unit = valEl.textContent.replace(/[\d]/g, '');
            range.addEventListener('input', () => { valEl.textContent = range.value + unit; });
            range.addEventListener('change', () => { readUISettings(); applyUISettings(); });
        });
        $id('sgfp-ui-resetpos').addEventListener('click', () => {
            C.ui_fabX = -1; C.ui_fabY = -1; cfgSave(C);
            fab.style.left = ''; fab.style.top = ''; fab.style.right = '24px'; fab.style.bottom = '24px';
            positionPanel();
        });
        // language selector - needs full panel rebuild to re-translate all strings
        $id('sgfp-ui-lang').addEventListener('change', () => {
            C.ui_lang = $id('sgfp-ui-lang').value;
            cfgSave(C);
            rebuildPanel();
        });
        $id('sgfp-reset').addEventListener('click', () => {
            if (confirm(T('confirm_reset'))) { cfgSave(DEFAULTS); location.reload(); }
        });
        $id('sgfp-loadall').addEventListener('click', () => autoScroller.loadAll());

        document.addEventListener('keydown', e => {
            if (e.altKey && e.key.toLowerCase() === 'f') {
                panel.classList.toggle('open'); fab.classList.toggle('active'); positionPanel(); e.preventDefault();
            }
        });
        window.addEventListener('resize', () => positionPanel());

        applyUISettings();
    }

    function buildPanel() {
        const { fab, panel } = createPanelDOM();
        bindPanelEvents(fab, panel);
    }

    /* 14. READ UI > CONFIG  (DOM values > config object. boring. necessary. the glue.) */
    function readUI() {
        const n = (id, def = 0) => { const x = +$id(id).value; return Number.isNaN(x) ? def : x; };
        C.f_enabled     = $id('sgfp-f-on').checked;
        C.f_minP        = n('sgfp-f-minP');
        C.f_maxP        = n('sgfp-f-maxP', 300);
        C.f_exactP      = $id('sgfp-f-exactP').value.trim();
        C.f_minLv       = n('sgfp-f-minLv');
        C.f_maxLv       = n('sgfp-f-maxLv', 10);
        C.f_maxEntries  = n('sgfp-f-maxE');
        C.f_minChance   = n('sgfp-f-minC');
        C.f_hideEntered = $id('sgfp-f-hideE').checked;
        C.f_hideGroup   = $id('sgfp-f-hideG').checked;
        C.f_hideWL      = $id('sgfp-f-hideWL').checked;
        C.f_dimMode     = $id('sgfp-f-dim').checked;
        C.f_sort        = $id('sgfp-f-sort').value;
        C.x_inlineEnter = $id('sgfp-x-enter').checked;
        C.x_showChance  = $id('sgfp-x-chance').checked;
        C.x_showPtsBadge= $id('sgfp-x-pts').checked;
        C.x_steamRating = $id('sgfp-x-rating').checked;
        C.x_steamLink   = $id('sgfp-x-steam').checked;
        C.x_wlGlow      = $id('sgfp-x-wl').checked;
        C.x_highlightLvNeg = $id('sgfp-x-lvneg').checked;
        C.x_darkTheme   = $id('sgfp-x-dark').checked;
        C.x_autoScroll  = $id('sgfp-x-scroll').checked;
        cfgSave(C);
    }
    function readUISettings() {
        const n = (id, def) => { const x = +$id(id).value; return Number.isNaN(x) ? def : x; };
        C.ui_scale       = n('sgfp-ui-scale', 100);
        C.ui_panelW      = n('sgfp-ui-panelW', 330);
        C.ui_fabSize     = n('sgfp-ui-fabSize', 48);
        C.ui_panelOpacity= n('sgfp-ui-opacity', 95);
        cfgSave(C);
    }

    /* 15. TAMPERMONKEY MENU  (one menu item. it opens the panel. that's the whole section.) */
    GM_registerMenuCommand('Toggle SG Toolkit Pro (Alt+F)', () => {
        $id('sgfp-panel')?.classList.toggle('open');
        $id('sgfp-fab')?.classList.toggle('active');
        positionPanel();
    });

    /* 16. INIT  (the main(). if there are no giveaways on the page we just... leave. quietly.) */
    function init() {
        if (!$$('.giveaway__row-outer-wrap').length) return;
        injectCSS();
        buildPanel();
        runAll();
    }

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

})();