☰

Save Your Searches (custom)

Save your favourite search queries for easy retrieval, tuned for rule34.xxx and aibooru.online: Media/Sort buttons work silently (nothing is written into the search bar), tri-state on/off/negate filters, aspect-ratio line and badges, ready presets, NEW badges for tracked authors. Fork of "Save Your Searches" by php.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램을 먼저 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Save Your Searches (custom)
// @name:ru      Save Your Searches (custom) — кнопки фильтров для rule34.xxx и aibooru.online
// @namespace    local.rdss.save-your-searches
// @version      3.19.1
// @description  Save your favourite search queries for easy retrieval, tuned for rule34.xxx and aibooru.online: Media/Sort buttons work silently (nothing is written into the search bar), tri-state on/off/negate filters, aspect-ratio line and badges, ready presets, NEW badges for tracked authors. Fork of "Save Your Searches" by php.
// @description:ru  Сохранённые поисковые запросы под rule34.xxx и aibooru.online: кнопки Media/Sort работают скрытно (в строку поиска ничего не пишут), фильтры вкл/выкл/исключить, строка и бейджи пропорций, готовые пресеты, бейджи NEW у отслеживаемых авторов. Форк «Save Your Searches» от php.
// @author       php (original script) / local custom build
// @homepageURL  https://greasyfork.org/ru/scripts/577306-save-your-searches
// @supportURL   https://greasyfork.org/ru/scripts/577306-save-your-searches
// @license      MIT
// @match        *://rule34.xxx/*
// @match        *://aibooru.online/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        unsafeWindow
// @run-at       document-start
// @require      https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js
// NOTE: @downloadURL/@updateURL апстрима убраны, чтобы обновление с Greasyfork не затирало локальный форк.
// ==/UserScript==

(function() {
    'use strict';

    const Sortable = window.Sortable;

    // ==========================================
    // [Module 1] EventBus
    // ==========================================
    const EventBus = {
        events: {},
        on(event, listener) {
            if (!this.events[event]) this.events[event] = [];
            this.events[event].push(listener);
        },
        emit(event, data) {
            if (this.events[event]) {
                // [FIX] один упавший подписчик не должен ломать остальные
                this.events[event].forEach(listener => {
                    try { listener(data); } catch (err) { console.warn('[SYS] listener error on "' + event + '":', err); }
                });
            }
        }
    };

    // ==========================================
    // [Module 2] Helper (The Single Source of Truth)
    // ==========================================
    const Helper = {

        // Helper function for YYYY-MM-DD_HH-mm-ss
        getFormattedDate() {
            const d = new Date();
            const pad = (n) => n.toString().padStart(2, '0');
            return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
        },

        // [NEW] Порядок индексов кнопок для отображения.
        // group.alphabetize — по алфавиту (регистр не важен), при этом избранные всегда
        // в начале списка; внутри избранного алфавитный порядок тоже соблюдается.
        // Возвращает индексы реального массива group.queries.
        orderedIndexes(group) {
            const queries = Array.isArray(group.queries) ? group.queries : [];
            const indexes = queries.map((q, i) => i);

            if (!group.alphabetize) return indexes;

            const labels = group.labels || {};
            const favorites = Array.isArray(group.favorite) ? group.favorite : [];
            // сортируем по тому, что видно на кнопке (у "user:ник" — только ник)
            const captionOf = (i) => labels[queries[i]] || queries[i];
            const byName = (i1, i2) => String(captionOf(i1)).localeCompare(String(captionOf(i2)), undefined, { sensitivity: 'base', numeric: true });

            indexes.sort(byName);

            const favoriteIdx = indexes.filter(i => favorites.indexOf(queries[i]) !== -1);
            const restIdx = indexes.filter(i => favorites.indexOf(queries[i]) === -1);
            return favoriteIdx.concat(restIdx);
        },

        // [NEW] Косметические метки кнопок (показываются перед звёздочкой избранного,
        // на сортировку НЕ влияют — только отображение). Хранятся в group.marks[key].
        // Символы юникода: ♂ (male), ⚧ (trans). При желании можно заменить на <img> с data:URI.
        MARKERS: [
            { key: 'male', symbol: '♂', color: '#4a90d9', title: 'Метка ♂' },
            { key: 'trans', symbol: '⚧', color: '#d16ba5', title: 'Метка ⚧' },
        ],

        // [NEW] Задержка автоскрытия панели настроек после ухода указателя (мс)
        HOVER_CLOSE_MS: 600,

        // --- 2.1 Configuration Data ---
        Config: {
            // [NEW] Поддерживаемые сайты: на них есть профиль по умолчанию, чужие профили вычищаются
            SUPPORTED_HOSTS: ["rule34.xxx", "aibooru.online"],
            DEFAULT_DATA: {
                _meta: {
                    scriptVersion: typeof GM_info !== "undefined" ? GM_info.script.version : "Unknown",
                    exportDate: "" // This gets populated dynamically on save/export
                },
                profiles: {
                    "rule34.xxx": {
                        name: "Rule34",
                        selectors: { searchBar: "input[name='tags']", insertPoint: "div.tag-search" },
                        cheatsheetUrl: "https://rule34.xxx/index.php?page=help&topic=cheatsheet",
                        showCheatsheet: false,
                        wikiUrl: "https://rule34.xxx/index.php?page=wiki&s=list",
                        showWiki: false,
                        autoHideQueryBox: true, // [NEW] автоскрытие панели тегов при уходе указателя (по умолчанию вкл)
                        showSettingsBtn: true,
                        showRatioBadges: false, // [NEW] соотношения на миниатюрах (по умолчанию выключены)
                        delimiter: " ",
                        splitRegex: "\\s+",
                        // [NEW] Селекторы элементов сайта, которые скрипт прячет (кнопка "Filter AI posts")
                        hideSelectors: ["#displayOptions"],
                        groups: [
                            {
                                // [NEW] Отдельная группа сверху: video + ai, обе трёхсостоянийные
                                id: "g0",
                                label: "Media",
                                managed: true, // [NEW] правится только из данных/кода, в настройках не показывается
                                silent: true, // [NEW] работает «под капотом»: в строку поиска ничего не пишет
                                queries: ["( video ~ animated )", "( ai_generated ~ ai_assisted )"],
                                lock: [],
                                labels: { "( ai_generated ~ ai_assisted )": "ai" },
                                tristate: {
                                    "( video ~ animated )": "-video -animated",
                                    "( ai_generated ~ ai_assisted )": "-ai_generated -ai_assisted"
                                }
                            },
                            {
                                id: "g1",
                                label: "Sort",
                                managed: true, // [NEW] в настройках не показывается
                                silent: true, // [NEW] работает «под капотом»
                                queries: ["sort:score:desc", "sort:id:desc"],
                                // Заблокированные кнопки: нельзя переименовать / удалить / перетащить
                                lock: ["sort:score:desc", "sort:id:desc"],
                                // [NEW] Взаимоисключающие: активен только один, либо ни одного
                                radio: ["sort:score:desc", "sort:id:desc"],
                                // Подписи на кнопках (сам запрос не меняется)
                                labels: { "sort:score:desc": "score", "sort:id:desc": "id" }
                            },
                            {
                                // [NEW] Авторы: пустой блок сразу под Sort, заполняется в настройках.
                                // alphabetize — алфавитный порядок, favorite — избранные в начале списка
                                id: "g_authors",
                                label: "Authors",
                                queries: [],
                                favorite: [],
                                alphabetize: true
                            },
                            {
                                // [NEW] Блоки Meta: подписи кнопок — just girl и futa / femboy
                                id: "g2",
                                label: "Meta",
                                queries: [
                                    "-oral -1boy -penis* -sex -blowjob -sperm -cum -cum_* -testicles -large_penis -deep_penetration -penises_touching -yaoi -big_dildo -rape* -futanari* -futa_* -femboy* -feminine_male -dildo -big_penis",
                                    "( futanari* ~ otoko_no_ko ~ girly_boy ~ feminine_male ~ trap ~ dickgirl ~ femboy ~ male_on_futa ~ futa_* ) -futa_on_male -gay*"
                                ],
                                labels: {
                                    "-oral -1boy -penis* -sex -blowjob -sperm -cum -cum_* -testicles -large_penis -deep_penetration -penises_touching -yaoi -big_dildo -rape* -futanari* -futa_* -femboy* -feminine_male -dildo -big_penis": "just girl",
                                    "( futanari* ~ otoko_no_ko ~ girly_boy ~ feminine_male ~ trap ~ dickgirl ~ femboy ~ male_on_futa ~ futa_* ) -futa_on_male -gay*": "futa / femboy"
                                }
                            },
                            {
                                // [NEW] Блоки пропорций: подписи кнопок задаёт Storage.ASPECT_LABELS
                                id: "g_aspect",
                                label: "Aspect Ratio",
                                queries: [
                                    "( aspectratio:21:9 ~ aspectratio:20:9 ~ aspectratio:19.5:9 ~ aspectratio:43:18 ~ aspectratio:64:27 ~ aspectratio:12:5 ~ aspectratio:2.39:1 ~ aspectratio:2.35:1 ~ aspectratio:32:9 )",
                                    "( aspectratio:1:1 ~ aspectratio:5:4 ~ aspectratio:4:3 ~ aspectratio:11:9 )",
                                    "( aspectratio:16:9 ~ aspectratio:16:10 ~ aspectratio:1.85:1 ~ aspectratio:2:1 ~ aspectratio:17:9 ~ aspectratio:14:9 ~ aspectratio:1.78:1 )",
                                    "( aspectratio:9:19.5 ~ aspectratio:9:20 ~ aspectratio:9:21 ~ aspectratio:9:22 ~ aspectratio:1:2 ~ aspectratio:1:3 )",
                                    "( aspectratio:9:16 ~ aspectratio:10:16 ~ aspectratio:3:4 ~ aspectratio:4:5 ~ aspectratio:9:18 ~ aspectratio:13:19 ~ aspectratio:2:3 ~ aspectratio:10:15 )"
                                ]
                            }
                        ]
                    },
                    // [NEW] AIBooru (aibooru.online) — Danbooru-подобный AI-борд. Ключевые отличия:
                    //   поиск — GET-форма /posts (input[name=tags]), вставка — после section#search-box;
                    //   превью — <article id="post_N" data-id data-tags> (не span.thumb);
                    //   инфо-лист — section#post-information (не #stats); тег ai не нужен — весь сайт про AI.
                    "aibooru.online": {
                        name: "AIBooru",
                        selectors: { searchBar: "input[name='tags']", insertPoint: "section#search-box" },
                        cheatsheetUrl: "https://aibooru.online/wiki_pages/help%3Acheatsheet",
                        showCheatsheet: false, // [NEW] кнопку НЕ включаем — только ссылка
                        wikiUrl: "https://aibooru.online/wiki_pages/help:home",
                        showWiki: false,
                        autoHideQueryBox: true,
                        showSettingsBtn: true,
                        showRatioBadges: false,
                        delimiter: " ",
                        splitRegex: "\\s+",
                        hideSelectors: [],
                        aibVer: 2, // версия штатного состава профиля (см. migrateAibooruProfile)
                        groups: [
                            {
                                // [NEW] Media без "ai" (весь сайт — ai-арт). video — дочерний тег
                                // от animated (где есть video, там всегда есть animated), поэтому
                                // остаётся ОДИН тег: экономит 2 из 4 тегов лимита поиска.
                                id: "g0",
                                label: "Media",
                                managed: true,
                                silent: true,
                                queries: ["animated"],
                                lock: [],
                                tristate: { "animated": "-animated" }
                            },
                            {
                                // [NEW] Сортировка aibooru: только подтверждённые метатеги
                                // (order:rank/ views — из ссылок сайта, score/ favcount — из справки)
                                id: "g1",
                                label: "Sort",
                                managed: true,
                                silent: true,
                                queries: ["order:rank", "order:views", "order:score", "order:favcount"],
                                lock: ["order:rank", "order:views", "order:score", "order:favcount"],
                                radio: ["order:rank", "order:views", "order:score", "order:favcount"],
                                labels: {
                                    "order:rank": "rank",
                                    "order:views": "views",
                                    "order:score": "score",
                                    "order:favcount": "fav"
                                }
                            },
                            {
                                // [NEW] Авторы: сайт поддерживает user:<ник> (ссылка » у Uploader)
                                id: "g_authors",
                                label: "Authors",
                                queries: [],
                                favorite: [],
                                alphabetize: true
                            },
                            {
                                // [NEW] Meta под авторами: те же плашки, что на rule34, но под теги
                                // aibooru (без ~ и *). Порядок и подписи — как у rule34.
                                id: "g2",
                                label: "Meta",
                                queries: ["-sex -penis", "otoko_no_ko or futanari"],
                                labels: {
                                    "-sex -penis": "just girl",
                                    "otoko_no_ko or futanari": "futa / femboy"
                                }
                            },
                            {
                                // [NEW] Пропорции на aibooru: те же 5 категорий, что на rule34
                                // (Ultrawide / Square / Landscape / Tall Portrait / Portrait), но
                                // вместо тегов aspectratio: — ЧИСЛОВЫЕ диапазоны ratio: (метатег
                                // бесплатный). Границы — min..max значений категорий rule34,
                                // подписи берутся из Storage.ASPECT_LABELS_RANGE.
                                id: "g_aspect",
                                label: "Aspect Ratio",
                                silent: true,
                                queries: [
                                    "ratio:2.15..",
                                    "ratio:0.88..1.39",
                                    "ratio:1.4..2.1",
                                    "ratio:..0.5",
                                    "ratio:0.51..0.87"
                                ],
                                labels: {
                                    "ratio:2.15..": "Ultrawide",
                                    "ratio:0.88..1.39": "Square",
                                    "ratio:1.4..2.1": "Landscape",
                                    "ratio:..0.5": "Tall Portrait",
                                    "ratio:0.51..0.87": "Portrait"
                                }
                            },
                            {
                                // [NEW] Статусы: status: тоже бесплатный метатег, одинаковый
                                // метатег между собой не комбинируется → взаимоисключающая группа
                                id: "g_status",
                                label: "Status",
                                silent: true,
                                queries: ["status:active", "status:pending", "status:flagged", "status:deleted"],
                                radio: ["status:active", "status:pending", "status:flagged", "status:deleted"],
                                labels: {
                                    "status:active": "active",
                                    "status:pending": "pending",
                                    "status:flagged": "flagged",
                                    "status:deleted": "deleted"
                                }
                            },
                            {
                                // [NEW] Рейтинг в самом низу списка: SFW/NSFW. is: входит в список
                                // БЕСПЛАТНЫХ метатегов (не идёт в счётчик тегов), а is:sfw = g+s,
                                // is:nsfw = q+e — две взаимоисключающие кнопки вместо четырёх.
                                id: "g_rating",
                                label: "Rating",
                                silent: true,
                                queries: ["is:sfw", "is:nsfw"],
                                radio: ["is:sfw", "is:nsfw"],
                                labels: { "is:sfw": "SFW", "is:nsfw": "NSFW" }
                            }
                        ]
                    },
                }
            }
        },

        // --- 2.2 Styles Definition ---
        Styles: {
            PANEL_CSS: `
                .panel {
                    position: fixed;
                    top: 50%;
                    left: 50%;
                    transform: translate(-50%, -50%);
                    width: 95%;
                    max-width: 400px; /* Caps the size for desktop */
                    max-height: 90vh;
                    background: rgba(255,255,255,0.90);
                    backdrop-filter: blur(15px);
                    border-radius: 12px;
                    box-shadow: 0 10px 40px rgba(0,0,0,0.2);
                    z-index: 20000;
                    display: none;
                    flex-direction: column;
                    font-family: system-ui, sans-serif;
                    border: 1px solid rgba(0,0,0,0.1);
                }
                .header { padding: 15px; background: rgba(0,0,0,0.05); cursor: move; display: flex; justify-content: center; align-items: center; border-bottom: 1px solid rgba(0,0,0,0.1); font-weight: bold; }
                .content { padding: 15px; overflow-y: auto; flex: 1; }

                .footer { padding: 12px; display: flex; flex-direction: column; gap: 8px; background: #f9f9f9; border-top: 1px solid #eee; border-radius: 0 0 12px 12px; }
                .footer-row { display: flex; gap: 8px; width: 100%; }

                .section-title { font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase; margin: 15px 0 8px 0; }
                .config-field { margin-bottom: 10px; }
                .config-field label { display: block; font-size: 11px; margin-bottom: 4px; color: #666; font-weight: 600; }
                .config-field input[type="text"], .config-field select { width: 100%; box-sizing: border-box; padding: 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 13px; outline: none; }
                .checkbox-group { display: flex; flex-direction: column; gap: 8px; margin-top: 5px; }
                .checkbox-field { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; user-select: none; color: #444; }

                .card { background: white; padding: 12px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #eee; position: relative; }
                .sub-card { background: #f8f9fa; padding: 12px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #eaeced; position: relative; }
                .sub-card:last-child { margin-bottom: 0; } /* Removes extra space at the bottom of the container */
                .card-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }

                /* 幽靈輸入框樣式:平時像文字,互動時顯現 */
                .group-label-input {
                    background: transparent;
                    border: none;
                    /* Show a very faint line by default for mobile users */
                    border-bottom: 1px dashed rgba(0,0,0,0.1);
                    font-weight: bold;
                    font-size: 13px;
                    color: #444;
                    width: 100%;
                    outline: none;
                    transition: all 0.2s;
                    cursor: text;
                    padding: 2px 0;
                }

                .group-label-input:focus {
                    border-bottom: 1px solid #2196f3;
                    background: rgba(33, 150, 243, 0.05);
                }
                /* 排序模式下讓輸入框看起來像標籤,且不可點擊 */
                .group-label-input:disabled {
                    cursor: default;
                    border: none;
                }

                .tag-chip { display: inline-flex; align-items: center; background: #f0f0f0; padding: 4px 10px; border-radius: 6px; margin: 3px; font-size: 12px; border: 1px solid #e0e0e0; color: #000;}
                .tag-del { margin-left: 6px; color: #f44336; cursor: pointer; font-weight: bold; }
                /* [NEW] Кнопка избранного (стоит перед удалением) */
                .tag-fav { margin-left: 6px; color: #ffb300; cursor: pointer; font-size: 13px; line-height: 1; }
                .tag-fav:hover { color: #ff8f00; }
                .tag-fav.on { color: #ffc107; text-shadow: 0 0 4px rgba(255,193,7,0.6); }
                /* [NEW] Косметические метки (♂ / ⚧) — перед звёздочкой, на сортировку не влияют */
                .tag-mark { margin-left: 6px; cursor: pointer; font-size: 13px; line-height: 1; color: #bdbdbd; transition: color 0.15s; }
                .tag-mark:hover { opacity: 0.7; }
                .tag-mark.on { text-shadow: 0 0 3px rgba(0,0,0,0.15); }
                .tag-text { cursor: pointer; transition: color 0.15s; }
                .tag-text:hover { color: #2196f3; }

                /* Заблокированная кнопка: выглядит как тег, но не редактируется и не удаляется */
                .tag-fixed { background: #eceff1; border-style: dashed; }
                .tag-fixed-text { cursor: default; color: #546e7a; font-weight: 600; }
                .tag-fixed-text:hover { color: #546e7a; }

                .sortable-ghost { opacity: 0.4; border: 2px dashed #2196f3 !important; background: #e3f2fd !important; }

                .drag-handle-group {
                    cursor: grab;
                    color: #bbb;
                    font-size: 22px;
                    padding: 15px;
                    margin: -15px 0 -15px -15px; /* Negative margin offsets the padding */
                    display: inline-flex;
                    align-items: center;
                    justify-content: center;
                }

                .drag-handle-tag {
                    cursor: grab;
                    color: #ccc;
                    font-size: 18px;
                    padding: 10px;
                    margin: -10px 0 -10px -10px; /* Negative margin offsets the padding */
                    display: inline-flex;
                    align-items: center;
                    justify-content: center;
                }

                /* Change color when dragging to show it's "active" */
                .drag-handle-group:active, .drag-handle-tag:active {
                    color: #2196f3;
                }

                .btn { padding: 8px 16px; border-radius: 6px; cursor: pointer; border: none; font-size: 13px; font-weight: 500; transition: all 0.2s; display: inline-flex; align-items: center; justify-content: center; }
                .btn-add { background: #4caf50; color: white; margin-top: 5px; width: 100%; }
                .btn-current {
                    background: #ff9800;
                    color: white;
                }
                .btn:hover,
                .btn:active {
                    filter: brightness(0.9);
                }
                .btn-save { background: #2196f3; color: white; }

                /* [Modified] Rearrange button style adjusted for inline title placement */
                .btn-rearrange { background: #90a4ae; color: white; padding: 4px 10px; font-size: 11px; border-radius: 4px; }
                .btn-rearrange.active { background: #e91e63; }

                .btn-reset { background: #f44336; color: white; margin-right: auto; }
                .btn-data { background: #607d8b; color: white; flex: 1; font-size: 12px; padding: 6px; }

                .modal-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 20001; border-radius: 12px; }
                .modal-box { background: white; padding: 20px; border-radius: 10px; width: 280px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); text-align: center; }
                .modal-title { font-size: 14px; font-weight: bold; margin-bottom: 15px; color: #333; }
                .modal-buttons { display: flex; flex-direction: column; gap: 10px; }
                .btn-modal { padding: 10px; border-radius: 6px; cursor: pointer; border: none; font-size: 13px; font-weight: bold; text-align: center; transition: opacity 0.2s; }
                .btn-modal:hover { opacity: 0.8; }
                .btn-modal-current { background: #2196f3; color: white; }
                .btn-modal-all { background: #4caf50; color: white; }
                .btn-modal-cancel { background: #f44336; color: white; }
                .warning-box { background: #fff3cd; color: #856404; padding: 10px; border-radius: 8px; border: 1px solid #ffeeba; margin-bottom: 15px; font-size: 12px; line-height: 1.4; }
            `,
            INJECTOR_WRAPPER: "margin: 10px 0; font-family: sans-serif; display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;",
            TRIGGER_BTN: "padding: 6px; background: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500;",
            SETTINGS_BTN: "padding: 6px; background: #3f51b5; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500;",
            CHEATSHEET_BTN: "padding: 6px; background: #009688; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; text-decoration: none; font-weight: 500;",
            WIKI_BTN: "padding: 6px; background: #ff5722; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; text-decoration: none; font-weight: 500;",
            QUERY_BOX: "width: 100%; margin-top: 5px; display: flex; flex-direction: column; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05);",
            GROUP_CARD: (isLast) => `padding: 12px; border-bottom: ${isLast ? 'none' : '2px solid #eee'}`,
            GROUP_TITLE: "font-size: 11px; font-weight: bold; color: #888; text-transform: uppercase; margin-bottom: 8px;",

            TAG_ITEM: "background: white; color: #000; border: 1px solid #ced4da; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.15s ease;",
            TAG_ITEM_ACTIVE: "background: #e3f2fd; border: 1px solid #2196f3; color: #0d47a1; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: all 0.15s ease;",
            // [NEW] Состояния трёхсостоянийной кнопки: зелёный = запрос, красный = -запрос
            TAG_ITEM_ACTIVE_POS: "background: #c8e6c9; border: 1px solid #4caf50; color: #1b5e20; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.15s ease;",
            TAG_ITEM_ACTIVE_NEG: "background: #ffcdd2; border: 1px solid #f44336; color: #b71c1c; padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.15s ease;",

            // [NEW] Строка соотношения сторон на странице поста (под "Size:")
            RATIO_LINE: "display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 4px 0; font-size: 13px; font-family: sans-serif;",
            RATIO_NAME: (known) => `font-weight: 600; padding: 2px 6px; border-radius: 4px; ${known
                ? "background: #c8e6c9; color: #1b5e20; border: 1px solid #4caf50;"
                : "background: #ffcdd2; color: #b71c1c; border: 1px solid #f44336;"}`,
            RATIO_CHIP: "background: white; color: #000; border: 1px solid #ced4da; padding: 2px 6px; border-radius: 4px; cursor: pointer; font-size: 12px;",
            RATIO_CHIP_ACTIVE: "background: #c8e6c9; color: #1b5e20; border: 1px solid #4caf50; padding: 2px 6px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600;",

            // [NEW] Бейдж на превьюшке в списке: зелёный — значение есть в базе, красный — новое
            THUMB_BADGE: (known) => `position: absolute; left: 8px; top: 8px; z-index: 5; pointer-events: none; font: 600 11px/1.5 sans-serif; padding: 1px 5px; border-radius: 4px; color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.35); ${known ? "background: #4caf50;" : "background: #f44336;"}`,
            // [NEW] Бейдж NEW у новых постов отслеживаемых авторов (правый верхний угол)
            NEW_BADGE: "position: absolute; right: 8px; top: 8px; z-index: 6; pointer-events: none; font: 700 11px/1.5 sans-serif; padding: 1px 6px; border-radius: 4px; color: #fff; background: #d32f2f; box-shadow: 0 1px 3px rgba(0,0,0,0.4);",
            // [NEW] Красная лампочка «данных нет» у автора, чью страницу ещё ни разу не открывали
            NO_DATA_DOT: "display: inline-block; margin-left: 5px; font-size: 12px; line-height: 1; color: #e53935;",
        },

        // --- 2.3 HTML UI Components ---
        Templates: {
            PANEL_SHELL: (contentHTML) => `
                <style>${Helper.Styles.PANEL_CSS}</style>
                <div class="panel" id="main-panel">
                    <div class="header" id="drag-handle">Save Your Searches</div>
                    <div class="content" id="settings-content">${contentHTML}</div>

                    <div class="footer">
                        <div class="footer-row">
                            <button class="btn btn-data" id="btn-export-trigger">📤 Export</button>
                            <button class="btn btn-data" id="btn-import-trigger">📥 Import</button>
                            <input type="file" id="sys-import-file" style="display:none" accept=".json">
                        </div>
                        <div class="footer-row">
                            <button class="btn btn-reset" id="btn-reset">Reset</button>
                            <button class="btn" id="btn-cancel">Cancel</button>
                            <button class="btn btn-save" id="btn-save">Save & Apply</button>
                        </div>
                    </div>

                    <div class="modal-overlay" id="action-modal">
                        <div class="modal-box">
                            <div class="modal-title" id="modal-title">Select Scope</div>
                            <div class="modal-buttons">
                                <button class="btn-modal btn-modal-current" id="modal-btn-current">Only Current Website</button>
                                <button class="btn-modal btn-modal-all" id="modal-btn-all">All Websites</button>
                                <button class="btn-modal btn-modal-cancel" id="modal-btn-cancel">Cancel</button>
                            </div>
                        </div>
                    </div>
                </div>
            `,
            SITE_SELECTORS_SECTION: (profile, isRearranging) => `
                <div class="section-title">Site Selectors & Tag Delimiter</div>
                <div class="card">
                    <div class="config-field"><label>Search Bar Selector</label><input type="text" value="${profile.selectors.searchBar}" id="input-sel-search" ${isRearranging?'disabled':''}></div>
                    <div class="config-field"><label>Insert Point Selector</label><input type="text" value="${profile.selectors.insertPoint}" id="input-sel-insert" ${isRearranging?'disabled':''}></div>

                    <hr style="border: 0; border-top: 1px solid #eee; margin: 12px 0;">

                    <div style="display:flex; gap:10px;">
                        <div class="config-field" style="flex:1;"><label>Tag Delimiter Character</label><input type="text" value="${profile.delimiter}" id="input-delimiter" ${isRearranging?'disabled':''}></div>
                        <div class="config-field" style="flex:1;"><label>Tag Delimiter Regex</label><input type="text" value="${profile.splitRegex}" id="input-split-regex" ${isRearranging?'disabled':''}></div>
                    </div>
                </div>
            `,
            SITE_RESOURCES_SECTION: (profile, isRearranging) => `
                <div class="section-title">Site Resources & Buttons</div>
                <div class="card">
                    <div class="config-field"><label>Cheatsheet URL</label><input type="text" value="${profile.cheatsheetUrl || ''}" id="input-cheatsheet-url" ${isRearranging?'disabled':''}></div>
                    <div class="config-field"><label>Wiki URL</label><input type="text" value="${profile.wikiUrl || ''}" id="input-wiki-url" ${isRearranging?'disabled':''}></div>

                    <hr style="border: 0; border-top: 1px solid #eee; margin: 12px 0;">

                    <div class="checkbox-group">
                        <label class="checkbox-field"><input type="checkbox" id="check-show-ratios" ${profile.showRatioBadges ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Ratio Badges on Thumbnails</label>
                        <label class="checkbox-field"><input type="checkbox" id="check-show-cheatsheet" ${profile.showCheatsheet ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Cheatsheet</label>
                        <label class="checkbox-field"><input type="checkbox" id="check-show-wiki" ${profile.showWiki ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Wiki</label>
                        <label class="checkbox-field"><input type="checkbox" id="check-show-settings" ${profile.showSettingsBtn !== false ? 'checked' : ''} ${isRearranging?'disabled':''}> Show Settings Button</label>
                        <label class="checkbox-field"><input type="checkbox" id="check-auto-hide" ${profile.autoHideQueryBox !== false ? 'checked' : ''} ${isRearranging?'disabled':''}> Auto-hide Saved Searches Box (on mouse leave)</label>
                    </div>
                </div>
            `,
            // [MODIFIED] Заблокированные кнопки (group.lock) не удаляются, не переименовываются и не перетаскиваются.
            // group.labels даёт подпись на кнопке, group.tristate включает трёхсостоянийный режим (query <-> alt).
            TAG_GROUP_HTML: (group, gIdx, isRearranging) => {
                const lockList = Array.isArray(group.lock) ? group.lock : [];
                const favoriteList = Array.isArray(group.favorite) ? group.favorite : [];
                const marks = group.marks || {};
                const labels = group.labels || {};
                const tristate = group.tristate || {};
                // [NEW] значки гендеров и звёздочка — только у авторов и Meta
                const marksOK = Storage.allowsMarks(group);

                return `
                <div class="sub-card sortable-group-item" data-gidx="${gIdx}">
                    <div class="card-title">
                        <div style="display:flex; align-items:center; flex:1;">
                            ${isRearranging ? `<span class="drag-handle-group">☰</span>` : ''}


                            <input type="text"
                                   value="${group.label}"
                                   class="group-label-input"
                                   style="margin-left: 10px;"
                                   data-gidx="${gIdx}"
                                   ${isRearranging ? 'disabled' : ''}
                                   placeholder="Group Title">

                        </div>
                        ${!isRearranging ? `<span class="tag-del delete-group" data-gidx="${gIdx}">✖</span>` : ''}
                    </div>
                    <div style="display:flex; flex-wrap:wrap; min-height: 20px;" class="tags-sortable-container" data-gidx="${gIdx}">
                        ${Helper.orderedIndexes(group).map((qIdx) => {
                            const q = group.queries[qIdx];
                            const isLocked = lockList.indexOf(q) !== -1;
                            const isFavorite = favoriteList.indexOf(q) !== -1;
                            const tristateAlt = tristate[q] || '';
                            const caption = labels[q] || q;
                            const hint = isLocked
                                ? `Locked button: ${q}`
                                : (tristateAlt ? `${q} → ${tristateAlt} → off`
                                    : (caption === q ? (isRearranging ? '' : 'Click to edit')
                                        : `${q}${isRearranging ? '' : ' — Click to edit'}`));

                            return `
                            <span class="tag-chip ${isLocked ? 'tag-fixed' : 'sortable-tag-item'}" data-qidx="${qIdx}">
                                ${isRearranging && !isLocked ? `<span class="drag-handle-tag">⋮</span>` : ''}
                                <span class="tag-text ${isLocked ? 'tag-fixed-text' : 'edit-tag'}" data-gidx="${gIdx}" data-qidx="${qIdx}" title="${hint}">${caption}</span>
                                ${!isRearranging && !isLocked ? `
                                    ${marksOK ? Helper.MARKERS.map(m => {
                                        const on = (marks[m.key] || []).indexOf(q) !== -1;
                                        return `<span class="tag-mark toggle-mark ${on ? 'on' : ''}" data-gidx="${gIdx}" data-qidx="${qIdx}" data-mark="${m.key}" style="${on ? 'color:' + m.color : ''}" title="${on ? 'Убрать метку ' + m.symbol : m.title}">${m.symbol}</span>`;
                                    }).join('') : ''}
                                    ${marksOK ? `<span class="tag-fav toggle-fav ${isFavorite ? 'on' : ''}" data-gidx="${gIdx}" data-qidx="${qIdx}" title="${isFavorite ? 'Убрать из избранного' : 'В избранное (в начало списка)'}">${isFavorite ? '★' : '☆'}</span>` : ''}
                                    <span class="tag-del delete-tag" data-gidx="${gIdx}" data-qidx="${qIdx}">×</span>
                                ` : ''}
                            </span>
                        `;
                        }).join('')}
                    </div>
                    ${!isRearranging ? `
                        <div style="display:flex; gap:5px; margin-top:10px;">
                            <input type="text" placeholder="Add tag..." class="new-tag-input" data-gidx="${gIdx}" style="flex:1; padding:4px;">
                            <button class="btn btn-save add-tag-btn" data-gidx="${gIdx}" style="padding:4px 12px;">+</button>
                            <button class="btn btn-current add-current-query-btn" data-gidx="${gIdx}" title="Add current search bar content" style="padding:4px 8px;">+ Current</button>
                        </div>
                    ` : ''}
                </div>
            `;
            },
            NEW_PROFILE_WARNING: `
                <div class="warning-box">
                    <strong>⚠️ New Website Detected!</strong><br>
                    This website doesn't have a profile yet. Please click <b>"Save & Apply"</b> to save settings for this site.
                </div>
            `,
            SAVED_SEARCHES_HEADER: (isRearranging) => `
                <div class="section-title" style="display:flex; justify-content:space-between; align-items:center;">
                    <span>Saved Searches ${isRearranging ? '(Rearrange Mode)' : ''}</span>
                    <button class="btn btn-rearrange" id="btn-rearrange">⇅ Rearrange</button>
                </div>
                <div id="cards-container" class="card"></div>
            `
        }
    };

    // ==========================================
    // [Module 3] Storage
    // ==========================================
    const Storage = {
        STORAGE_KEY: "SaveYourSearches_Data",

        // [NEW] Встроенная группа "Media" (rule34): кнопки video и ai, обе трёхсостоянийные
        MEDIA_GROUP: {
            id: "g0",
            label: "Media",
            silent: true, // «под капотом»: кнопки не пишут в строку поиска
            video: { query: "( video ~ animated )", alt: "-video -animated", caption: "video" },
            ai: { query: "( ai_generated ~ ai_assisted )", alt: "-ai_generated -ai_assisted", caption: "ai" }
        },

        // [NEW] Блоки группы Meta: запрос + подпись на кнопке
        META_BLOCKS: [
            { query: "-oral -1boy -penis* -sex -blowjob -sperm -cum -cum_* -testicles -large_penis -deep_penetration -penises_touching -yaoi -big_dildo -rape* -futanari* -futa_* -femboy* -feminine_male -dildo -big_penis", caption: "just girl" },
            { query: "( futanari* ~ otoko_no_ko ~ girly_boy ~ feminine_male ~ trap ~ dickgirl ~ femboy ~ male_on_futa ~ futa_* ) -futa_on_male -gay*", caption: "futa / femboy" }
        ],
        META_PRESET_VERSION: 5,

        // [NEW] Разовая замена набора Meta: ставим блоки из META_BLOCKS
        applyMetaPreset(profile) {
            if (!profile || !Array.isArray(profile.groups)) return null;
            if (profile.metaPreset === this.META_PRESET_VERSION) return null;
            const group = profile.groups.find(g => String(g.label || '').trim().toLowerCase() === 'meta')
                || profile.groups.find(g => g.id === 'g2');
            if (!group) return null;
            const labels = {};
            this.META_BLOCKS.forEach(b => { labels[b.query] = b.caption; });
            group.queries = this.META_BLOCKS.map(b => b.query);
            group.labels = labels;
            group.active = [];
            group.activeNeg = [];
            group.favorite = [];
            group.tristate = {};
            profile.metaPreset = this.META_PRESET_VERSION;
            console.log('[SYS] meta: блоков загружено — ' + group.queries.length);
            return group;
        },

        // [NEW] Понятные подписи для группы Aspect Ratio: длинные OR-блоки
        // на кнопках заменяются коротким названием (сам запрос не меняется).
        // RATIO_MATCH_TOLERANCE — допуск 2% при сравнении соотношений по величине
        // (8:5 и 16:10 = одно значение). Размеры превьюшек округлены до целых
        // пикселей, но ошибка там ≤0.5%, а минимальный зазор между разными
        // значениями базы — 2.3%, так что 2% ничего лишнего не склеивает.
        RATIO_MATCH_TOLERANCE: 0.02,

        ASPECT_LABELS: {
            "( aspectratio:21:9 ~ aspectratio:20:9 ~ aspectratio:19.5:9 ~ aspectratio:43:18 ~ aspectratio:64:27 ~ aspectratio:12:5 ~ aspectratio:2.39:1 ~ aspectratio:2.35:1 ~ aspectratio:32:9 ~ aspectratio:18:7 )": "Ultrawide",
            "( aspectratio:1:1 ~ aspectratio:5:4 ~ aspectratio:4:3 ~ aspectratio:11:9 ~ aspectratio:9:7 ~ aspectratio:21:20 ~ aspectratio:11:8 ~ aspectratio:26:29 ~ aspectratio:21:22 ~ aspectratio:20:17 ~ aspectratio:10:9 ~ aspectratio:31:27 ~ aspectratio:29:27 ~ aspectratio:25:27 )": "Square",
            "( aspectratio:16:9 ~ aspectratio:16:10 ~ aspectratio:1.85:1 ~ aspectratio:2:1 ~ aspectratio:17:9 ~ aspectratio:14:9 ~ aspectratio:1.78:1 ~ aspectratio:17:12 ~ aspectratio:39:23 ~ aspectratio:40:23 ~ aspectratio:53:32 ~ aspectratio:47:24 ~ aspectratio:3:2 ~ aspectratio:19:13 )": "Landscape",
            "( aspectratio:9:19.5 ~ aspectratio:9:20 ~ aspectratio:9:21 ~ aspectratio:9:22 ~ aspectratio:1:2 ~ aspectratio:1:3 ~ aspectratio:9:25 ~ aspectratio:15:31 ~ aspectratio:3:22 ~ aspectratio:3:20 ~ aspectratio:2:29 ~ aspectratio:10:31 ~ aspectratio:3:16 ~ aspectratio:3:47 ~ aspectratio:1:10 ~ aspectratio:1:5 ~ aspectratio:3:10 ~ aspectratio:2:5 )": "Tall Portrait",
            "( aspectratio:9:16 ~ aspectratio:10:16 ~ aspectratio:3:4 ~ aspectratio:4:5 ~ aspectratio:9:18 ~ aspectratio:13:19 ~ aspectratio:2:3 ~ aspectratio:10:15 ~ aspectratio:17:20 ~ aspectratio:7:9 ~ aspectratio:17:28 ~ aspectratio:19:26 ~ aspectratio:12:17 ~ aspectratio:15:23 ~ aspectratio:20:23 ~ aspectratio:19:23 ~ aspectratio:17:31 ~ aspectratio:13:25 ~ aspectratio:10:19 ~ aspectratio:10:17 )": "Portrait"
        },

        // [NEW] Группа с соотношениями сторон: подписи вместо длинных запросов.
        // Подпись подбирается по СОДЕРЖИМОМУ (набору значений aspectratio:), а не по точной
        // строке — иначе после дописывания нового соотношения в OR-блок подпись бы терялась.
        ensureAspectLabels(profile) {
            const group = this.aspectGroup(profile);
            if (!group) return null;

            group.labels = Object.assign({}, group.labels || {});
            // [NEW] штатные подписи навешиваем разово (как Meta): переименование кнопки
            // пользователем больше не откатывается при каждой загрузке
            const applyDefaults = group.labelsVersion !== this.ASPECT_LABELS_VERSION;
            const known = Object.keys(this.ASPECT_LABELS).map(query => ({
                caption: this.ASPECT_LABELS[query],
                values: (query.match(/aspectratio:[0-9.]+:[0-9.]+/gi) || []).map(v => v.toLowerCase())
            }));

            group.queries.forEach(query => {
                const exact = this.ASPECT_LABELS[query] || this.ASPECT_LABELS_RANGE[query];
                if (exact) { if (applyDefaults || !group.labels[query]) group.labels[query] = exact; return; }

                const values = (String(query).match(/aspectratio:[0-9.]+:[0-9.]+/gi) || []).map(v => v.toLowerCase());
                if (!values.length) return;
                let best = null;
                known.forEach(k => {
                    if (!k.values.length) return;
                    const all = k.values.every(v => values.indexOf(v) !== -1);
                    if (all && (!best || k.values.length > best.values.length)) best = k;
                });
                if (!best) {
                    // запрос может быть «урезанным» вариантом штатного блока (или наоборот
                    // штатный блок дополнен): тогда берём категорию с наибольшим перекрытием
                    let hits = 0;
                    known.forEach(k => {
                        if (!k.values.length) return;
                        const h = k.values.filter(v => values.indexOf(v) !== -1).length;
                        if (h > hits) { hits = h; best = k; }
                    });
                    if (best && hits / values.length < 0.6) best = null;
                }
                if (best && (applyDefaults || !group.labels[query])) group.labels[query] = best.caption;
            });
            if (applyDefaults) group.labelsVersion = this.ASPECT_LABELS_VERSION;
            return group;
        },

        ASPECT_LABELS_VERSION: 1,

        // [NEW] Те же 5 категорий, но для сайтов с метатегом ratio (aibooru.online):
        // запрос — ЧИСЛОВЫЙ диапазон как у id (`ratio:1.4..2.1`, `ratio:..0.5`, `ratio:2.15..`).
        // Границы подобраны по значениям ASPECT_LABELS (min..max каждой категории rule34),
        // зазоры между категориями сохранены, чтобы диапазоны не накладывались.
        ASPECT_LABELS_RANGE: {
            "ratio:2.15..": "Ultrawide",          // 19.5:9 (2.17) … 32:9 (3.56)
            "ratio:0.88..1.39": "Square",         // 26:29 (0.90) … 11:8 (1.375)
            "ratio:1.4..2.1": "Landscape",        // 17:12 (1.42) … 2:1 (2.00)
            "ratio:..0.5": "Tall Portrait",       // 3:47 (0.064) … 1:2 (0.50)
            "ratio:0.51..0.87": "Portrait"        // 13:25 (0.52) … 20:23 (0.87)
        },

        // [NEW] Группа с соотношениями сторон. Ищем по названию/ид, а если не нашли —
        // по содержимому (любой запрос с aspectratio:), чтобы блок находился даже после
        // переименования или сброса данных.
        aspectGroup(profile) {
            if (!profile || !Array.isArray(profile.groups)) return null;
            return profile.groups.find(g => /aspect/i.test(String(g.label || '')) || g.id === "g_aspect")
                || profile.groups.find(g => (Array.isArray(g.queries) ? g.queries : []).some(q => /aspectratio:/i.test(String(q))))
                || null;
        },

        // [NEW] Где живёт группа соотношений: { host, group } в актуальных данных
        findAspectGroup(data, host) {
            const profiles = (data && data.profiles) || {};
            const own = profiles[host] ? this.aspectGroup(profiles[host]) : null;
            if (own) return { host, group: own };
            const other = Object.keys(profiles).find(h => this.aspectGroup(profiles[h]));
            return other ? { host: other, group: this.aspectGroup(profiles[other]) } : null;
        },

        // [NEW] Постоянный набор блоков пропорций для rule34 (на случай сброса данных)
        ASPECT_GROUP: {
            id: "g_aspect",
            label: "Aspect Ratio",
            silent: true, // «под капотом»: кнопки не пишут в строку поиска
            queries: [
                "( aspectratio:21:9 ~ aspectratio:20:9 ~ aspectratio:19.5:9 ~ aspectratio:43:18 ~ aspectratio:64:27 ~ aspectratio:12:5 ~ aspectratio:2.39:1 ~ aspectratio:2.35:1 ~ aspectratio:32:9 ~ aspectratio:18:7 )",
                "( aspectratio:1:1 ~ aspectratio:5:4 ~ aspectratio:4:3 ~ aspectratio:11:9 ~ aspectratio:9:7 ~ aspectratio:21:20 ~ aspectratio:11:8 ~ aspectratio:26:29 ~ aspectratio:21:22 ~ aspectratio:20:17 ~ aspectratio:10:9 ~ aspectratio:31:27 ~ aspectratio:29:27 ~ aspectratio:25:27 )",
                "( aspectratio:16:9 ~ aspectratio:16:10 ~ aspectratio:1.85:1 ~ aspectratio:2:1 ~ aspectratio:17:9 ~ aspectratio:14:9 ~ aspectratio:1.78:1 ~ aspectratio:17:12 ~ aspectratio:39:23 ~ aspectratio:40:23 ~ aspectratio:53:32 ~ aspectratio:47:24 ~ aspectratio:3:2 ~ aspectratio:19:13 )",
                "( aspectratio:9:19.5 ~ aspectratio:9:20 ~ aspectratio:9:21 ~ aspectratio:9:22 ~ aspectratio:1:2 ~ aspectratio:1:3 ~ aspectratio:9:25 ~ aspectratio:15:31 ~ aspectratio:3:22 ~ aspectratio:3:20 ~ aspectratio:2:29 ~ aspectratio:10:31 ~ aspectratio:3:16 ~ aspectratio:3:47 ~ aspectratio:1:10 ~ aspectratio:1:5 ~ aspectratio:3:10 ~ aspectratio:2:5 )",
                "( aspectratio:9:16 ~ aspectratio:10:16 ~ aspectratio:3:4 ~ aspectratio:4:5 ~ aspectratio:9:18 ~ aspectratio:13:19 ~ aspectratio:2:3 ~ aspectratio:10:15 ~ aspectratio:17:20 ~ aspectratio:7:9 ~ aspectratio:17:28 ~ aspectratio:19:26 ~ aspectratio:12:17 ~ aspectratio:15:23 ~ aspectratio:20:23 ~ aspectratio:19:23 ~ aspectratio:17:31 ~ aspectratio:13:25 ~ aspectratio:10:19 ~ aspectratio:10:17 )"
            ]
        },

        // [NEW] Группа соотношений существует всегда: если её нет (сброс/новый профиль),
        // добавляем в конец со штатными блоками; если есть, но пустая — заполняем.
        ensureAspectGroup(profile) {
            let group = this.aspectGroup(profile);
            if (!group) {
                group = { id: this.ASPECT_GROUP.id, label: this.ASPECT_GROUP.label, silent: true, queries: this.ASPECT_GROUP.queries.slice() };
                profile.groups.push(group);
                return group;
            }
            if (!Array.isArray(group.queries)) group.queries = [];
            if (!group.queries.length) group.queries = this.ASPECT_GROUP.queries.slice();
            return group;
        },

        // [NEW] Разовая чистка группы пропорций: теги, дописанные кликами по постам,
        // переезжают в group.hidden, а сами запросы возвращаются к 5 штатным OR-блокам.
        // Так строка поиска перестаёт пухнуть, а прежние отметки не теряются.
        sanitizeAspectGroup(profile) {
            const group = this.aspectGroup(profile);
            if (!group || group.sanitized === this.ASPECT_SANITIZE_VERSION) return group;
            if (!Array.isArray(group.queries) || !group.queries.length) return group;

            const defaults = this.ASPECT_GROUP.queries;
            const labels = Object.assign({}, group.labels || {});
            const hidden = Object.assign({}, group.hidden || {});
            const tagsOf = q => (String(q || '').match(/aspectratio:\s*[0-9.]+\s*:\s*[0-9.]+/gi) || [])
                .map(t => t.toLowerCase().replace(/\s+/g, ''));
            const allowed = {};
            defaults.forEach(q => tagsOf(q).forEach(t => { allowed[t] = true; }));

            const custom = [];
            const active = Array.isArray(group.active) ? group.active : [];
            const activeNext = [];
            // узнать штатный блок по содержимому: у него больше всего совпадающих тегов
            const bestByTags = (tags) => {
                let best = null, bestScore = 0;
                defaults.forEach(q => {
                    const set = tagsOf(q);
                    const hits = set.filter(t => tags.indexOf(t) !== -1).length;
                    const score = tags.length ? hits / tags.length : 0; // доля тегов запроса, найденных в штатном блоке
                    if (score > bestScore || (score === bestScore && best && set.length < tagsOf(best).length)) {
                        bestScore = score; best = q;
                    }
                });
                return bestScore >= 0.6 ? best : null;
            };

            group.queries.forEach(query => {
                const caption = labels[query];
                const tags = tagsOf(query);
                const def = defaults.find(q => this.ASPECT_LABELS[q] === caption)
                    || (defaults.indexOf(query) !== -1 ? query : null)
                    || bestByTags(tags);
                // всё, чего нет в штатных блоках — это старые отметки кликов: переносим в hidden
                const target = def ? (this.ASPECT_LABELS[def] || def) : (caption || query);
                tags.forEach(t => { if (!allowed[t]) hidden[t] = target; });
                if (def) { if (caption) labels[def] = caption; }
                else if (custom.indexOf(query) === -1) custom.push(query);
                // включённые фильтры переводим на штатный текст блока
                if (active.indexOf(query) !== -1) {
                    const on = def || query;
                    if (activeNext.indexOf(on) === -1) activeNext.push(on);
                }
            });

            // [NEW v2] теги, которые теперь входят в штатные блоки, больше не считаются
            // переопределением категории (иначе старые отметки уводят бейдж не туда)
            Object.keys(hidden).forEach(t => { if (allowed[t]) delete hidden[t]; });

            const queries = defaults.slice();
            custom.forEach(query => {
                let cleaned = query;
                tagsOf(query).forEach(t => { if (!allowed[t]) cleaned = this.removeRatioFromQuery(cleaned, t); });
                if (cleaned && queries.indexOf(cleaned) === -1) queries.push(cleaned);
            });

            group.queries = queries;
            group.labels = labels;
            group.hidden = hidden;
            // включённые фильтры должны существовать в списке категорий
            group.active = activeNext.filter(q => queries.indexOf(q) !== -1);
            group.sanitized = this.ASPECT_SANITIZE_VERSION;
            const moved = Object.keys(hidden).length;
            console.log('[SYS] aspect: запросы очищены — лишние теги убраны из строки поиска, под капотом записей:', moved);
            return group;
        },

        ASPECT_SANITIZE_VERSION: 2,

        // [NEW] Тег соотношения: { w, h } -> "aspectratio:12:5"
        ratioTag(ratio) {
            return `aspectratio:${ratio.w}:${ratio.h}`;
        },

        // [NEW] Числовое значение тега: "aspectratio:9:19.5" -> 0.4615
        ratioValue(text) {
            const m = String(text || '').match(/aspectratio:\s*([0-9.]+)\s*:\s*([0-9.]+)/i);
            if (!m) return null;
            const w = parseFloat(m[1]), h = parseFloat(m[2]);
            if (!w || !h) return null;
            return w / h;
        },

        // [NEW] Попадает ли значение в запрос вида `ratio:a..b` / `ratio:..b` / `ratio:a..`
        // (метатег ratio на aibooru использует ту же нотацию диапазонов, что id:)
        ratioRangeCovers(query, target) {
            const s = String(query || '');
            const rangeRe = /\bratio:\s*([0-9.]+)?\s*\.\.\s*([0-9.]+)?/gi;
            let m;
            while ((m = rangeRe.exec(s))) {
                const lo = m[1] !== undefined ? parseFloat(m[1]) : -Infinity;
                const hi = m[2] !== undefined ? parseFloat(m[2]) : Infinity;
                if (isFinite(lo) || isFinite(hi)) {
                    if (target >= lo && target <= hi) return true;
                }
            }
            // одиночное значение: ratio:4:3 или ratio:4/3 (та же нотация, что у id, но со скобкой)
            const singleRe = /\bratio:\s*([0-9.]+)\s*(?::|\/)\s*([0-9.]+)/gi;
            while ((m = singleRe.exec(s))) {
                const w = parseFloat(m[1]), h = parseFloat(m[2]);
                if (w && h) {
                    const v = w / h;
                    if (Math.abs(v - target) / target <= this.RATIO_MATCH_TOLERANCE) return true;
                }
            }
            // простое число (ratio:1.33) — только если в запросе нет диапазонов
            if (!/\bratio:\s*[0-9.]+\s*\.\./i.test(s)) {
                const dec = /\bratio:\s*([0-9.]+)(?![.:\/])/i.exec(s);
                if (dec) {
                    const v = parseFloat(dec[1]);
                    if (v && Math.abs(v - target) / target <= this.RATIO_MATCH_TOLERANCE) return true;
                }
            }
            return false;
        },

        // [NEW] Значение запроса равно искомому соотношению (по величине, допуск 2%).
        // Понимает обе нотации: теги aspectratio:W:H (rule34) и диапазоны ratio: (aibooru).
        queryCoversRatio(query, ratio) {
            const target = ratio.value !== undefined ? ratio.value : (ratio.w / ratio.h);
            const tokens = String(query || '').match(/aspectratio:\s*[0-9.]+\s*:\s*[0-9.]+/gi) || [];
            const byTag = tokens.some(t => {
                const v = this.ratioValue(t);
                return v !== null && Math.abs(v - target) / target <= this.RATIO_MATCH_TOLERANCE;
            });
            if (byTag) return true;
            return this.ratioRangeCovers(query, target);
        },

        // [NEW] Есть ли такое соотношение в запросе буквально (с границами чисел)
        hasRatioTag(query, ratioTag) {
            const esc = String(ratioTag).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            return new RegExp(`(^|[^0-9.])${esc}(?![0-9.])`, 'i').test(String(query || ''));
        },

        // [NEW] Где это соотношение уже встречается — во всех профилях всех сайтов
        findRatio(data, ratio) {
            const hits = [];
            const profiles = (data && data.profiles) || {};
            Object.keys(profiles).forEach(host => {
                const prof = profiles[host];
                ((prof && prof.groups) || []).forEach((group, gIdx) => {
                    (group.queries || []).forEach((query, qIdx) => {
                        if (this.queryCoversRatio(query, ratio)) hits.push({ host, groupIdx: gIdx, queryIdx: qIdx, group, query });
                    });
                });
            });
            return hits;
        },

        // [NEW] «Под капотом»: категория, к которой пользователь отнёс это соотношение.
        // Строка запроса при этом НЕ меняется — выбор живёт во внутреннем поле group.hidden.
        choiceForRatio(group, ratio) {
            if (!group || !group.hidden) return '';
            const tag = this.ratioTag(ratio);
            return group.hidden[tag] || '';
        },

        // Категория «под капотом» для этого сайта (по данным хранилища)
        hiddenChoice(data, host, ratio) {
            const found = this.findAspectGroup(data, host);
            return found ? this.choiceForRatio(found.group, ratio) : '';
        },

        // Ключ категории (подпись, если есть, иначе сам запрос)
        categoryKey(group, query) {
            const labels = (group && group.labels) || {};
            return labels[query] !== undefined ? labels[query] : query;
        },

        // ============================================================
        // [NEW] Группы «под капотом» (Media, Sort, Aspect Ratio).
        // Их кнопки ничего не пишут в строку поиска: состояние живёт в
        // group.active (включено) и group.activeNeg (отрицание для трёхсостоянийных),
        // а фильтр подставляется в запрос только в момент отправки поиска.
        // ============================================================
        SILENT_GROUPS: [
            { id: "g0", label: "media" },
            { id: "g1", label: "sort" },
            { id: "g_aspect", label: "aspect ratio" }
        ],

        isSilent(group) {
            return !!(group && group.silent === true);
        },

        // [NEW] Это группа авторов?
        isAuthorsGroup(group) {
            if (!group) return false;
            return group.id === "g_authors" || String(group.label || '').trim().toLowerCase() === "authors";
        },

        // [NEW] Метки ♂/⚧ и избранное (звёздочка / золотая рамка) — только у авторов и Meta.
        // Для остальных групп (Aspect Ratio, Status, Rating, Media, Sort) эти значки не нужны,
        // поэтому и не показываются — ни в виджете, ни в настройках.
        allowsMarks(group) {
            if (!group) return false;
            return this.isAuthorsGroup(group) || group.id === "g2"
                || String(group.label || '').trim().toLowerCase() === "meta";
        },

        silentGroups(profile) {
            return ((profile && profile.groups) || []).filter(g => this.isSilent(g));
        },

        // Состояние кнопки этой группы: '' | 'on' | 'neg'
        silentStateOf(group, query) {
            if (!group) return '';
            const neg = Array.isArray(group.activeNeg) ? group.activeNeg : [];
            if (neg.indexOf(query) !== -1 && ((group.tristate || {})[query] || '')) return 'neg';
            const on = Array.isArray(group.active) ? group.active : [];
            return on.indexOf(query) !== -1 ? 'on' : '';
        },

        // Текст, который уйдёт в поиск вместо кнопки
        silentText(group, query) {
            if (!group) return query;
            if (this.silentStateOf(group, query) === 'neg') return (group.tristate || {})[query] || query;
            return query;
        },

        // Включённые кнопки группы пропорций (для бейджей и подписей)
        aspectActive(profile) {
            const group = this.aspectGroup(profile);
            return (group && Array.isArray(group.active)) ? group.active.slice() : [];
        },

        // Готовый фрагмент запроса: всё, что включено «под капотом»
        silentSearchBlock(profile) {
            const parts = [];
            this.silentGroups(profile).forEach(group => {
                const queries = Array.isArray(group.queries) ? group.queries : [];
                const list = (Array.isArray(group.active) ? group.active : [])
                    .concat(Array.isArray(group.activeNeg) ? group.activeNeg : []);
                list.forEach(q => {
                    if (queries.indexOf(q) === -1) return;
                    const text = this.silentText(group, q);
                    if (parts.indexOf(text) === -1) parts.push(text);
                });
            });
            return parts.join(' ');
        },

        // Клик по кнопке «под капотом»: строку поиска не трогаем, гоним только состояние.
        // Трёхсостоянийные: off -> on -> neg -> off. Радио: либо один, либо никто.
        toggleSilent(host, groupId, query) {
            const data = this.load();
            const profile = (data.profiles || {})[host];
            if (!profile || !Array.isArray(profile.groups)) return null;
            const group = profile.groups.find(g => g.id === groupId || g.label === groupId);
            if (!group || !this.isSilent(group)) return null;
            if (!Array.isArray(group.queries) || group.queries.indexOf(query) === -1) return null;

            const active = Array.isArray(group.active) ? group.active.slice() : [];
            const neg = (Array.isArray(group.activeNeg) ? group.activeNeg : []).filter(q => q !== query);
            const state = this.silentStateOf(group, query);
            const alt = (group.tristate || {})[query] || '';
            const radio = Array.isArray(group.radio) ? group.radio : [];
            let nextActive;

            if (radio.indexOf(query) !== -1) {
                // взаимоисключающие: снимаем весь набор, включаем выбранную (повторный клик — только снять)
                const kept = active.filter(q => radio.indexOf(q) === -1);
                nextActive = state === 'on' ? kept : kept.concat([query]);
            } else if (state === 'on' && alt) {
                nextActive = active.filter(q => q !== query);
                neg.push(query);
            } else if (state === '') {
                nextActive = active.concat([query]);
            } else {
                nextActive = active.filter(q => q !== query);
            }

            group.active = nextActive.filter((q, i) => nextActive.indexOf(q) === i);
            group.activeNeg = (alt ? neg : []).filter((q, i) => neg.indexOf(q) === i);

            this.save(data);
            return { active: group.active, neg: group.activeNeg };
        },

        // Новый поиск (клик по тегу из списка, переход по ссылке) — прежние
        // скрытые фильтры больше не действуют, кнопки гаснут.
        resetSilentState(host) {
            const data = this.load();
            const profile = (data.profiles || {})[host];
            if (!profile) return false;
            let changed = false;
            (profile.groups || []).forEach(group => {
                if (!this.isSilent(group)) return;
                const had = (Array.isArray(group.active) && group.active.length)
                    || (Array.isArray(group.activeNeg) && group.activeNeg.length);
                if (!had) return;
                group.active = [];
                group.activeNeg = [];
                changed = true;
            });
            if (changed) {
                this.save(data);
                console.log('[SYS] скрытые фильтры сброшены: начался новый поиск');
            }
            return changed;
        },

        // [NEW] Подпись категории для этого соотношения (для подсказок)
        ratioCaption(data, host, ratio) {
            const found = this.findAspectGroup(data, host);
            if (!found) return '';
            const queries = found.group.queries || [];
            const labels = found.group.labels || {};
            const tag = this.ratioTag(ratio);
            // «под капотом» — категория, выбранная пользователем вручную
            const choice = this.choiceForRatio(found.group, ratio);
            if (choice) return choice;
            // сперва категория, где лежит ровно этот тег, потом — «похожее» значение
            let query = queries.find(q => this.hasRatioTag(q, tag));
            if (!query) query = queries.find(q => this.queryCoversRatio(q, ratio));
            if (!query) return '';
            if (labels[query]) return labels[query];
            // подпись могла остаться от исходной строки без этого тега:
            // берём категорию с наибольшим совпадением по набору значений пропорций
            const ratioSet = q => (String(q).match(/aspectratio:\s*[0-9.]+\s*:\s*[0-9.]+/gi) || [])
                .map(t => t.toLowerCase().replace(/\s+/g, ''));
            const current = ratioSet(query);
            let bestKey = '', bestScore = 0;
            Object.keys(labels).forEach(k => {
                const other = ratioSet(k);
                if (!other.length) return;
                const score = other.filter(t => current.indexOf(t) !== -1).length / Math.max(other.length, current.length);
                if (score > bestScore) { bestScore = score; bestKey = k; }
            });
            return (bestKey && bestScore > 0.5) ? labels[bestKey] : '';
        },

        // [NEW] Дописать тег в OR-блок: "( a ~ b )" -> "( a ~ b ~ aspectratio:12:5 )"
        addRatioToQuery(query, ratioTag) {
            const q = String(query);
            const close = q.lastIndexOf(")");
            if (close === -1) return `${q} ${ratioTag}`;
            return `${q.slice(0, close).trimEnd()} ~ ${ratioTag} )`;
        },

        // [NEW] Убрать тег из OR-блока (для перевыбора категории)
        removeRatioFromQuery(query, ratioTag) {
            const esc = String(ratioTag).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            return String(query)
                .replace(new RegExp(`\\s*~\\s*${esc}(?![0-9.])`, 'gi'), '')   // "... ~ tag"
                .replace(new RegExp(`(^|\\()\\s*${esc}(?![0-9.])\\s*(~\\s*)?`, 'gi'), '$1') // "tag ~ ..." / "( tag"
                .replace(/\(\s*~\s*/g, '( ')                                       // если тег был первым
                .replace(/\s+\)/g, ' )')
                .replace(/\(\s+\)/g, '')
                .trim();
        },

        // [NEW] Группа Authors: пустой блок сразу под Sort. Авторы в алфавитном порядке,
        // "user:ник" в запросе остаётся как есть, но на кнопке показывается только ник.
        ensureAuthorsGroup(profile) {
            const isSort = (g) => g.id === "g1" || String(g.label || '').trim().toLowerCase() === "sort";
            const isAuthors = (g) => g.id === "g_authors" || String(g.label || '').trim().toLowerCase() === "authors";

            let group = profile.groups.find(isAuthors);
            if (!group) {
                group = { id: "g_authors", label: "Authors", queries: [], favorite: [] };
                profile.groups.push(group);
            }

            // авторы всегда идут сразу под Sort
            profile.groups.splice(profile.groups.indexOf(group), 1);
            const sortIdx = profile.groups.findIndex(isSort);
            profile.groups.splice(sortIdx === -1 ? 0 : sortIdx + 1, 0, group);

            if (!Array.isArray(group.queries)) group.queries = [];
            if (!Array.isArray(group.favorite)) group.favorite = [];
            group.alphabetize = true;
            group.labels = Object.assign({}, group.labels || {});
            group.marks = group.marks || {};
            Helper.MARKERS.forEach(m => { if (!Array.isArray(group.marks[m.key])) group.marks[m.key] = []; });

            // чистка: пустые записи и точные дубликаты
            const cleaned = [];
            group.queries.forEach(q => {
                const query = String(q).trim();
                if (query && cleaned.indexOf(query) === -1) cleaned.push(query);
            });
            group.queries = cleaned;

            // подпись на кнопке: если пользователь её не задал, для "user:ник" показываем ник,
            // остальные — сам запрос. Заданные (в т.ч. вручную) подписи не трогаем.
            group.queries.forEach(query => {
                if (group.labels[query]) return;
                const m = /^user:\s*(.+)$/i.exec(query);
                if (m && m[1].trim()) group.labels[query] = m[1].trim();
            });

            // из избранного убираем то, чего больше нет в списке
            group.favorite = group.favorite.filter(f => cleaned.indexOf(f) !== -1);
            Helper.MARKERS.forEach(m => {
                group.marks[m.key] = group.marks[m.key].filter(f => cleaned.indexOf(f) !== -1);
            });

            // [NEW] состояние бейджа NEW на автора: { m — «знак», s — показанные id > m }.
            // Храним только для авторов из списка; старые форматы (число/массив) приводим к объекту.
            if (!profile.authorSeen || typeof profile.authorSeen !== 'object' || Array.isArray(profile.authorSeen)) {
                profile.authorSeen = {};
            }
            Object.keys(profile.authorSeen).forEach(k => {
                if (cleaned.indexOf(k) === -1) { delete profile.authorSeen[k]; return; }
                const v = profile.authorSeen[k];
                if (Array.isArray(v)) {
                    profile.authorSeen[k] = { m: v.reduce((x, y) => Math.max(x, Number(y) || 0), 0), s: [] };
                } else if (typeof v === 'number') {
                    profile.authorSeen[k] = { m: v > 0 ? v : 0, s: [] };
                } else if (!v || typeof v !== 'object') {
                    delete profile.authorSeen[k];
                } else {
                    if (typeof v.m !== 'number' || !(v.m >= 0)) v.m = 0;
                    if (!Array.isArray(v.s)) v.s = [];
                }
            });

            return group;
        },

        // [NEW] Приводит профиль к актуальному виду. Идемпотентно.
        // [NEW] Скрипт живёт только на поддерживаемых сайтах (Helper.Config.SUPPORTED_HOSTS):
        // чужие профили (danbooru и пр.) вычищаем. keep — хост или список хостов.
        pruneProfiles(data, keep) {
            if (!data || !data.profiles) return data;
            const list = Array.isArray(keep) ? keep : [keep];
            Object.keys(data.profiles).forEach(host => {
                if (list.indexOf(host) === -1) delete data.profiles[host];
            });
            return data;
        },

        // [NEW] Разовая миграция профиля aibooru.online (помечается profile.aibVer):
        //   v1: Media → animated; Sort → 4 порядка; Rating → is:sfw/is:nsfw; порядок групп;
        //       cheatsheet URL.
        //   v2: Meta — плашки как на rule34, но с тегами aibooru; cheatsheet-кнопка НЕ включается.
        // Запросы авторов, Meta, метки и избранное пользователя НЕ трогаем (Meta только дополняем).
        // ВАЖНО: меняешь дефолты Media/Sort/Rating/Meta — поднимай aibVer, иначе уже установленные
        // профили не пересоберутся (новые значения всё равно уедут в DEFAULT_DATA).
        migrateAibooruProfile(profile) {
            if (!profile || profile.aibVer >= 2) return profile;
            const groups = Array.isArray(profile.groups) ? profile.groups : [];
            const byLabel = l => groups.find(g => String(g.label || '').trim().toLowerCase() === l);
            const byId = id => groups.find(g => g.id === id);

            // 1) Media: оставляем единственный тег animated
            const media = byId("g0") || byLabel("media");
            if (media) {
                media.id = "g0"; media.label = "Media";
                media.silent = true; media.managed = true; media.lock = [];
                media.queries = ["animated"];
                media.tristate = { "animated": "-animated" };
                media.labels = {};
            }

            // 2) Sort: четыре подтверждённых порядка
            const SORT = ["order:rank", "order:views", "order:score", "order:favcount"];
            const sort = byId("g1") || byLabel("sort");
            if (sort) {
                sort.id = "g1"; sort.label = "Sort";
                sort.silent = true; sort.managed = true;
                sort.queries = SORT.slice(); sort.radio = SORT.slice(); sort.lock = SORT.slice();
                sort.labels = { "order:rank": "rank", "order:views": "views", "order:score": "score", "order:favcount": "fav" };
            }

            // 3) Rating → SFW / NSFW
            const QS = ["is:sfw", "is:nsfw"];
            let rating = byId("g_rating") || byLabel("rating");
            if (!rating) {
                rating = { id: "g_rating", label: "Rating" };
                groups.push(rating);
            }
            rating.id = "g_rating"; rating.label = "Rating";
            rating.silent = true; rating.managed = false; rating.lock = [];
            rating.queries = QS.slice(); rating.radio = QS.slice();
            rating.labels = { "is:sfw": "SFW", "is:nsfw": "NSFW" };
            if (Array.isArray(rating.favorite)) rating.favorite = rating.favorite.filter(f => QS.indexOf(f) !== -1);
            if (rating.marks) Object.keys(rating.marks).forEach(k => {
                rating.marks[k] = (rating.marks[k] || []).filter(x => QS.indexOf(x) !== -1);
            });

            // 4) порядок групп
            const order = ["g0", "g1", "g_authors", "g2", "g_aspect", "g_status", "g_rating"];
            const known = order.map(id => groups.find(g => g.id === id)).filter(Boolean);
            const rest = groups.filter(g => order.indexOf(g.id) === -1);
            profile.groups = known.concat(rest);

            // 5) Meta: те же плашки, что на rule34, но с тегами aibooru (без ~ и *).
            // Дополняем, не затирая то, что пользователь добавил сам.
            const meta = byId("g2") || byLabel("meta");
            if (meta) {
                if (!Array.isArray(meta.queries)) meta.queries = [];
                meta.labels = Object.assign({}, meta.labels || {});
                [
                    { query: "-sex -penis", caption: "just girl" },
                    { query: "otoko_no_ko or futanari", caption: "futa / femboy" }
                ].forEach(p => {
                    if (meta.queries.indexOf(p.query) === -1) meta.queries.push(p.query);
                    if (!meta.labels[p.query]) meta.labels[p.query] = p.caption;
                });
            }

            // 6) ресурсы: ссылка обновляется, но кнопка Cheatsheet НЕ включается
            profile.cheatsheetUrl = "https://aibooru.online/wiki_pages/help%3Acheatsheet";
            profile.showCheatsheet = false;
            profile.aibVer = 2;
            return profile;
        },

        normalizeProfile(profile, host) {
            if (!profile || !Array.isArray(profile.groups)) return profile;

            const LOCKED_QUERIES = ["sort:score:desc", "sort:id:desc"];
            const LOCKED_LABELS = { "sort:score:desc": "score", "sort:id:desc": "id" };

            profile.groups.forEach(group => { if (!Array.isArray(group.queries)) group.queries = []; });

            profile.groups.forEach(group => {
                const isSortGroup = group.id === "g1" || String(group.label || '').trim().toLowerCase() === "sort";
                if (!isSortGroup) return;

                // "video" больше не живёт внутри Sort — он переехал в отдельную группу Media
                const videoIdx = group.queries.indexOf("video");
                if (videoIdx !== -1) group.queries.splice(videoIdx, 1);

                const presentLocked = LOCKED_QUERIES.filter(q => group.queries.indexOf(q) !== -1);
                if (presentLocked.length) {
                    group.lock = presentLocked;
                    // [NEW] взаимоисключающие кнопки (активна только одна или ни одной)
                    group.radio = presentLocked;
                    group.labels = Object.assign({}, LOCKED_LABELS, group.labels || {});
                }
            });

            // [NEW] сайт-специфичные миграции: у каждого сайта свой набор групп и свой синтаксис
            const isRule34 = host === "rule34.xxx";

            // [NEW] aibooru: до ensure* — порядок групп и состав Rating должны быть верными
            if (host === "aibooru.online") this.migrateAibooruProfile(profile);

            if (isRule34) {
                // на случай импорта одиночного профиля (merge полей тогда не срабатывает)
                if (profile.hideSelectors === undefined) profile.hideSelectors = ["#displayOptions"];
            } else if (profile.hideSelectors === undefined) {
                profile.hideSelectors = []; // на других сайтах своих скрытий по умолчанию нет
            }
            // [NEW] бейджи соотношений по умолчанию выключены: разовая установка для старых данных
            if (profile.showRatioBadges === undefined || profile.ratioBadgesOff !== 1) {
                profile.showRatioBadges = false;
                profile.ratioBadgesOff = 1;
            }
            // порядок важен: Media всегда на верху (unshift), затем Authors, затем Aspect
            if (isRule34) this.ensureMediaGroup(profile);
            this.ensureAuthorsGroup(profile); // [NEW] группа авторов нужна на любом сайте
            if (isRule34) this.ensureAspectGroup(profile); // блоки пропорций: если данных нет — вернём штатные
            this.ensureAspectLabels(profile); // подписи для группы Aspect Ratio — на любом сайте
            if (isRule34) this.sanitizeAspectGroup(profile); // чистка запросов от старых кликов
            if (isRule34) this.applyMetaPreset(profile); // разовая замена набора Meta

            // Media и Sort правятся только из данных: в настройках их не показываем
            profile.groups.forEach(group => {
                const label = String(group.label || '').trim().toLowerCase();
                if (group.id === "g0" || group.id === "g1" || label === "media" || label === "sort") {
                    group.managed = true;
                }
            });

            // [NEW] Media / Sort / Aspect Ratio работают «под капотом»: в строку поиска не пишут
            profile.groups.forEach(group => {
                const label = String(group.label || '').trim().toLowerCase();
                if (this.SILENT_GROUPS.some(s => s.id === group.id || s.label === label)) group.silent = true;
                if (!Array.isArray(group.active)) group.active = [];
                if (!Array.isArray(group.activeNeg)) group.activeNeg = [];
            });

            // подписи/состояния/избранное для запросов, которых уже нет, выкидываем
            profile.groups.forEach(group => {
                const has = (q) => group.queries.indexOf(q) !== -1;
                if (group.labels) Object.keys(group.labels).forEach(k => { if (!has(k)) delete group.labels[k]; });
                if (group.tristate) Object.keys(group.tristate).forEach(k => { if (!has(k)) delete group.tristate[k]; });
                if (Array.isArray(group.favorite)) group.favorite = group.favorite.filter(f => has(f));
                if (group.marks) Object.keys(group.marks).forEach(k => {
                    if (Array.isArray(group.marks[k])) group.marks[k] = group.marks[k].filter(has);
                });
                if (Array.isArray(group.active)) group.active = group.active.filter(has);
                if (Array.isArray(group.activeNeg)) {
                    group.activeNeg = group.activeNeg.filter(q => has(q) && (group.tristate || {})[q] !== undefined);
                }
            });

            return profile;
        },

        // [NEW] Группа Media (video + ai) всегда существует и стоит первой.
        ensureMediaGroup(profile) {
            const M = this.MEDIA_GROUP;
            let group = profile.groups.find(g => g.id === M.id);
            if (!group) {
                group = { id: M.id, label: M.label, managed: true, silent: true, queries: [], lock: [], labels: {}, tristate: {} };
                profile.groups.unshift(group);
            } else if (profile.groups.indexOf(group) !== 0) {
                profile.groups.splice(profile.groups.indexOf(group), 1);
                profile.groups.unshift(group);
            }

            group.tristate = Object.assign({}, group.tristate || {});
            group.labels = Object.assign({}, group.labels || {});

            [M.video, M.ai].forEach(def => {
                if (group.queries.indexOf(def.query) === -1) group.queries.push(def.query);
                group.tristate[def.query] = def.alt;
                group.labels[def.query] = def.caption;
            });

            // порядок внутри группы: video, затем ai, затем всё остальное
            group.queries = [M.video.query, M.ai.query].concat(
                group.queries.filter(q => q !== M.video.query && q !== M.ai.query)
            );
            return group;
        },

        load() {
            const rawData = GM_getValue(this.STORAGE_KEY, null);
            // [FIX] на пустом хранилище пишем дефолт напрямую, без emit: иначе
            // refreshSettings() падает на tempData === null и виджет не появляется.
            if (!rawData) {
                const defaults = JSON.parse(JSON.stringify(Helper.Config.DEFAULT_DATA));
                Object.keys(defaults.profiles).forEach(k => this.normalizeProfile(defaults.profiles[k], k));
                GM_setValue(this.STORAGE_KEY, JSON.stringify(defaults));
                return defaults;
            }
            try {
                const data = JSON.parse(rawData);
                Object.keys(Helper.Config.DEFAULT_DATA.profiles).forEach(key => {
                    if (!data.profiles[key]) {
                        data.profiles[key] = Helper.Config.DEFAULT_DATA.profiles[key];
                    } else {
                        const defaultProf = Helper.Config.DEFAULT_DATA.profiles[key];
                        ["cheatsheetUrl", "showCheatsheet", "wikiUrl", "showWiki", "autoHideQueryBox", "delimiter", "splitRegex", "showSettingsBtn", "hideSelectors"].forEach(field => {
                            if (data.profiles[key][field] === undefined) {
                                // глубокая копия, чтобы массивы не шарили ссылку с дефолтом
                                data.profiles[key][field] = JSON.parse(JSON.stringify(defaultProf[field]));
                            }
                        });
                    }
                });
                // [NEW] нормализация всех профилей (в т.ч. добавленных вручную сайтов)
                this.pruneProfiles(data, Helper.Config.SUPPORTED_HOSTS);
                Object.keys(data.profiles).forEach(key => this.normalizeProfile(data.profiles[key], key));
                // [NEW] тихо фиксируем результат нормализации (миграции/чистки) без события,
                // иначе они повторялись бы при каждой загрузке страницы
                const json = JSON.stringify(data);
                if (json !== rawData) GM_setValue(this.STORAGE_KEY, json);
                return data;
            } catch (e) { return Helper.Config.DEFAULT_DATA; }
        },
        save(data) {
            // Apply dynamic version and date to _meta, and clean up the old 'settings' key
            if (!data._meta) data._meta = {};
            data._meta.scriptVersion = typeof GM_info !== "undefined" ? GM_info.script.version : "Unknown";
            data._meta.exportDate = Helper.getFormattedDate();
            if (data.settings) delete data.settings;

            // [NEW] нормализация перед записью (импорт, ручные правки)
            if (data.profiles) {
                this.pruneProfiles(data, Helper.Config.SUPPORTED_HOSTS);
                Object.keys(data.profiles).forEach(key => this.normalizeProfile(data.profiles[key], key));
            }

            GM_setValue(this.STORAGE_KEY, JSON.stringify(data));
            EventBus.emit('storage:updated', data);
        }
    };

    // ==========================================
    // [Module 4] UIEngine
    // ==========================================
    const UIEngine = {
        container: null, shadowRoot: null, panel: null, tempData: null, editingHost: null,
        isRearranging: false, sortableInstances: [],

        init() {
            this.container = document.createElement('div');
            this.container.id = 'sys-ui-root';
            document.body.appendChild(this.container);
            this.shadowRoot = this.container.attachShadow({ mode: 'open' });
            this.shadowRoot.innerHTML = Helper.Templates.PANEL_SHELL("");
            this.panel = this.shadowRoot.getElementById('main-panel');
            this.setupPanelDragging();
            this.bindBaseButtons(); // Binds static footer buttons
            EventBus.on('storage:updated', () => this.refreshSettings());
        },

        isNewProfile: false,

        toggle() {
            if (getComputedStyle(this.panel).display === 'none') {
                this.tempData = JSON.parse(JSON.stringify(Storage.load()));
                // [NEW] редактируем профиль текущего сайта; для неподдерживаемого — первый поддерживаемый
                const host = window.location.host;
                this.editingHost = (this.tempData.profiles && this.tempData.profiles[host])
                    ? host
                    : Helper.Config.SUPPORTED_HOSTS[0];
                this.isRearranging = false;

                if (!this.tempData.profiles[this.editingHost]) {
                    this.tempData.profiles[this.editingHost] = {
                        name: this.editingHost,
                        selectors: { searchBar: "", insertPoint: "" },
                        cheatsheetUrl: "", showCheatsheet: false,
                        wikiUrl: "", showWiki: false,
                        autoHideQueryBox: true,
                        delimiter: " ",
                        splitRegex: "\\s+",
                        groups: []
                    };
                }

                this.refreshSettings();
                this.panel.style.display = 'flex';

            } else {
                this.panel.style.display = 'none';
            }
        },

        refreshSettings() {
            // [FIX] панель ещё ни разу не открывали — обновлять нечего
            if (!this.tempData || !this.editingHost) return;

            this.sortableInstances.forEach(i => i.destroy());
            this.sortableInstances = [];

            const storedData = Storage.load();
            this.isNewProfile = !storedData.profiles[this.editingHost];

            const content = this.shadowRoot.getElementById('settings-content');
            const profile = this.tempData.profiles[this.editingHost];
            if (!profile) return;

            let html = this.isNewProfile ? Helper.Templates.NEW_PROFILE_WARNING : "";
            html += Helper.Templates.SITE_SELECTORS_SECTION(profile, this.isRearranging);
            html += Helper.Templates.SITE_RESOURCES_SECTION(profile, this.isRearranging);
            html += Helper.Templates.SAVED_SEARCHES_HEADER(this.isRearranging);

            if (!this.isRearranging) html += `<button class="btn btn-add" id="btn-add-group">+ Add New Group</button>`;

            content.innerHTML = html;

            const container = this.shadowRoot.getElementById('cards-container');
            profile.groups.forEach((group, gIdx) => {
                // [NEW] Media и Sort правятся только из данных — в настройках их не показываем
                if (group.managed) return;

                const cardDiv = document.createElement('div');
                cardDiv.innerHTML = Helper.Templates.TAG_GROUP_HTML(group, gIdx, this.isRearranging);
                container.appendChild(cardDiv.firstElementChild);
            });

            this.bindDynamicEvents();
            if (this.isRearranging) this.setupSortable();
        },

        setupSortable() {
            const profile = this.tempData.profiles[this.editingHost];
            const groupContainer = this.shadowRoot.getElementById('cards-container');
            this.sortableInstances.push(new Sortable(groupContainer, {
                animation: 180, handle: '.drag-handle-group', ghostClass: 'sortable-ghost',
                onEnd: (evt) => {
                    const movedGroup = profile.groups.splice(evt.oldIndex, 1)[0];
                    profile.groups.splice(evt.newIndex, 0, movedGroup);
                }
            }));

            this.shadowRoot.querySelectorAll('.tags-sortable-container').forEach(el => {
                this.sortableInstances.push(new Sortable(el, {
                    animation: 180,
                    handle: '.drag-handle-tag',
                    draggable: '.sortable-tag-item', // [NEW] заблокированные кнопки не таскаются
                    filter: '.tag-fixed',            // [NEW] страховка на случай drag за сам чип
                    ghostClass: 'sortable-ghost',
                    group: 'shared-tags', // [Changed] Common group name allows cross-list dragging
                    onEnd: (evt) => {
                        // [Changed] Identify source and destination group indices
                        const fromGidx = evt.from.dataset.gidx;
                        const toGidx = evt.to.dataset.gidx;

                        // [Changed] Target the specific arrays
                        const fromQueries = profile.groups[fromGidx].queries;
                        const toQueries = profile.groups[toGidx].queries;

                        // [Changed] Move the item from the old array to the new array
                        const movedQuery = fromQueries.splice(evt.oldIndex, 1)[0];
                        toQueries.splice(evt.newIndex, 0, movedQuery);

                        // [Changed] Refresh UI if moved to a new group to update HTML indices
                        if (fromGidx !== toGidx) {
                            this.refreshSettings();
                        }
                    }
                }));
            });
        },

        showActionModal(titleText, onCurrent, onAll) {
            const modal = this.shadowRoot.getElementById('action-modal');
            const title = this.shadowRoot.getElementById('modal-title');
            const btnCurrent = this.shadowRoot.getElementById('modal-btn-current');
            const btnAll = this.shadowRoot.getElementById('modal-btn-all');
            const btnCancel = this.shadowRoot.getElementById('modal-btn-cancel');

            title.innerText = titleText;
            modal.style.display = 'flex';

            const closeModal = () => {
                modal.style.display = 'none';
                btnCurrent.onclick = null;
                btnAll.onclick = null;
                btnCancel.onclick = null;
            };

            btnCurrent.onclick = () => { onCurrent(); closeModal(); };
            btnAll.onclick = () => { onAll(); closeModal(); };
            btnCancel.onclick = closeModal;
        },

        handleExport() {
            this.showActionModal(
                "Select Export Scope",
                () => {
                    const dataToExport = this.tempData.profiles[this.editingHost];
                    this.executeExport(dataToExport, 'SINGLE');
                },
                () => {
                    const dataToExport = this.tempData;
                    this.executeExport(dataToExport, 'ALL');
                }
            );
        },

        executeExport(dataToExport, mode) {
            // 1. Create a clean copy and remove old keys to reset the insertion order
            const dataCopy = JSON.parse(JSON.stringify(dataToExport));
            delete dataCopy._meta;
            delete dataCopy.settings;

            // 2. Construct the final object with _meta explicitly at the very top
            const exportObject = {
                _meta: {
                    scriptVersion: typeof GM_info !== "undefined" ? GM_info.script.version : "Unknown",
                    exportDate: Helper.getFormattedDate()
                },
                ...dataCopy // 3. Spread the rest of the data (profiles, etc.) underneath
            };

            const blob = new Blob([JSON.stringify(exportObject, null, 2)], { type: 'application/json' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;

            const fileNamePrefix = mode === 'SINGLE' ? `Save_Your_Searches_backup_${this.editingHost}` : `Save_Your_Searches_backup_ALL`;

            // Use the formatted date for the file name
            a.download = `${fileNamePrefix}_${exportObject._meta.exportDate}.json`;

            a.click();
            URL.revokeObjectURL(url);
            window.alert("Export complete!");
        },

        handleImport(event) {
            const file = event.target.files[0];
            if (!file) return;

            const reader = new FileReader();
            reader.onload = (e) => {
                try {
                    const imported = JSON.parse(e.target.result);

                    this.showActionModal(
                        "Import Data (Will overwrite existing)",
                        () => {
                            const profileToImport = imported.profiles ? imported.profiles[this.editingHost] : imported;
                            if (!profileToImport || !profileToImport.groups) throw new Error("Invalid profile format");
                            this.tempData.profiles[this.editingHost] = profileToImport;
                            window.alert("Import success! Click 'Save & Apply' to take effect.");
                            this.refreshSettings();
                        },
                        () => {
                            if (!imported.profiles) throw new Error("Invalid format for full backup");
                            this.tempData = imported;
                            window.alert("Import success! Click 'Save & Apply' to take effect.");
                            this.refreshSettings();
                        }
                    );
                } catch (err) { window.alert("Import Failed: " + err.message); }
            };
            reader.readAsText(file);
            event.target.value = '';
        },

        bindDynamicEvents() {
            const profile = this.tempData.profiles[this.editingHost];

            // [Modified] Rearrange button is now generated inside settings-content dynamically
            const btnRearrange = this.shadowRoot.getElementById('btn-rearrange');
            if (btnRearrange) {
                btnRearrange.classList.toggle('active', this.isRearranging);
                btnRearrange.onclick = () => { this.isRearranging = !this.isRearranging; this.refreshSettings(); };
            }

            if (this.isRearranging) return;

            this.shadowRoot.getElementById('input-sel-search').oninput = (e) => { profile.selectors.searchBar = e.target.value; };
            this.shadowRoot.getElementById('input-sel-insert').oninput = (e) => { profile.selectors.insertPoint = e.target.value; };

            this.shadowRoot.getElementById('input-delimiter').oninput = (e) => { profile.delimiter = e.target.value; };
            this.shadowRoot.getElementById('input-split-regex').oninput = (e) => { profile.splitRegex = e.target.value; };

            this.shadowRoot.getElementById('input-cheatsheet-url').oninput = (e) => { profile.cheatsheetUrl = e.target.value; };
            this.shadowRoot.getElementById('check-show-cheatsheet').onchange = (e) => { profile.showCheatsheet = e.target.checked; };

            // [NEW] Бейджи соотношений на превьюшках: включение/выключение прямо из настроек
            const chkRatios = this.shadowRoot.getElementById('check-show-ratios');
            if (chkRatios) {
                chkRatios.onchange = (e) => {
                    profile.showRatioBadges = e.target.checked;
                    // сразу применяем: рисуем бейджи заново или убираем их с превьюшек
                    SiteInjector.badgeAllThumbs(this.tempData, true);
                };
            }
            this.shadowRoot.getElementById('input-wiki-url').oninput = (e) => { profile.wikiUrl = e.target.value; };
            this.shadowRoot.getElementById('check-show-wiki').onchange = (e) => { profile.showWiki = e.target.checked; };
            this.shadowRoot.getElementById('check-show-settings').onchange = (e) => { profile.showSettingsBtn = e.target.checked; }; // [NEW]
            this.shadowRoot.getElementById('check-auto-hide').onchange = (e) => { profile.autoHideQueryBox = e.target.checked; };

            // [NEW] Edit tag on click (заблокированные кнопки класса .edit-tag не имеют)
            this.shadowRoot.querySelectorAll('.edit-tag').forEach(span => {
                span.onclick = (e) => {
                    if (this.isRearranging) return; // Disable editing while rearranging

                    const { gidx, qidx } = e.target.dataset;
                    const group = profile.groups[gidx];
                    const currentTag = group.queries[qidx];

                    // [NEW] Первое окно — сам запрос (то, что подставляется в поиск)…
                    const newTag = prompt("Edit your saved search:", currentTag);
                    if (newTag === null) return;
                    const trimmedTag = newTag.trim();
                    if (!trimmedTag) return;

                    // …второе окно — название кнопки (подпись). Пустое поле = показывать сам запрос,
                    // отмена окна = меняем только запрос, подпись остаётся прежней.
                    const labels = group.labels = Object.assign({}, group.labels || {});
                    const currentCaption = labels[currentTag] || currentTag;
                    const answer = prompt("Название кнопки (подпись):", currentCaption);
                    const caption = answer === null ? null : answer.trim();

                    if (trimmedTag !== currentTag) {
                        group.queries[qidx] = trimmedTag;
                        if (labels[currentTag] !== undefined) delete labels[currentTag];
                    }
                    if (caption !== null) {
                        if (caption && caption !== trimmedTag) labels[trimmedTag] = caption;
                        else delete labels[trimmedTag];
                    }
                    this.refreshSettings();
                };
            });

            this.shadowRoot.querySelectorAll('.delete-tag').forEach(btn => {
                btn.onclick = (e) => {
                    const { gidx, qidx } = e.target.dataset;
                    profile.groups[gidx].queries.splice(qidx, 1);
                    this.refreshSettings();
                };
            });

            // [NEW] Избранное: звёздочка перед крестиком, избранные поднимаются в начало списка
            this.shadowRoot.querySelectorAll('.toggle-fav').forEach(btn => {
                btn.onclick = (e) => {
                    const { gidx, qidx } = e.target.dataset;
                    const group = profile.groups[gidx];
                    const query = group.queries[qidx];
                    if (!Array.isArray(group.favorite)) group.favorite = [];

                    const i = group.favorite.indexOf(query);
                    if (i === -1) group.favorite.push(query);
                    else group.favorite.splice(i, 1);

                    this.refreshSettings();
                };
            });
            // [NEW] Косметические метки (♂ / ⚧): как избранное, но сортировку не меняют
            this.shadowRoot.querySelectorAll('.toggle-mark').forEach(btn => {
                btn.onclick = (e) => {
                    const { gidx, qidx, mark } = e.target.dataset;
                    const group = profile.groups[gidx];
                    const query = group.queries[qidx];
                    if (!group.marks) group.marks = {};
                    if (!Array.isArray(group.marks[mark])) group.marks[mark] = [];

                    const i = group.marks[mark].indexOf(query);
                    if (i === -1) group.marks[mark].push(query);
                    else group.marks[mark].splice(i, 1);

                    this.refreshSettings();
                };
            });
            this.shadowRoot.querySelectorAll('.add-tag-btn').forEach(btn => {
                btn.onclick = (e) => {
                    const gidx = e.target.dataset.gidx;
                    const val = this.shadowRoot.querySelectorAll('.new-tag-input')[gidx].value.trim();
                    if (val) { profile.groups[gidx].queries.push(val); this.refreshSettings(); }
                };
            });

            this.shadowRoot.querySelectorAll('.add-current-query-btn').forEach(btn => {
                btn.onclick = (e) => {
                    const gidx = e.target.dataset.gidx;
                    const bar = document.querySelector(profile.selectors.searchBar);

                    if (bar && bar.value.trim()) {
                        const fullQuery = bar.value.trim(); // Grab the entire search bar content

                        if (!profile.groups[gidx].queries.includes(fullQuery)) {
                            profile.groups[gidx].queries.push(fullQuery);
                            this.refreshSettings();
                        }
                    }
                };
            });

            this.shadowRoot.querySelectorAll('.group-label-input').forEach(input => {
                input.onchange = (e) => { profile.groups[e.target.dataset.gidx].label = e.target.value; };
            });
            this.shadowRoot.querySelectorAll('.delete-group').forEach(btn => {
                btn.onclick = (e) => { if(confirm("Delete this group?")) { profile.groups.splice(e.target.dataset.gidx, 1); this.refreshSettings(); } };
            });
            this.shadowRoot.getElementById('btn-add-group').onclick = () => {
                profile.groups.push({ id: "g_"+Date.now(), label: "New Group", queries: [] });
                this.refreshSettings();
            };
        },

        // [Modified] Binds static elements located in the base PANEL_SHELL template
        bindBaseButtons() {
            // Data row buttons
            this.shadowRoot.getElementById('btn-export-trigger').onclick = () => this.handleExport();
            this.shadowRoot.getElementById('btn-import-trigger').onclick = () => this.shadowRoot.getElementById('sys-import-file').click();
            this.shadowRoot.getElementById('sys-import-file').onchange = (e) => this.handleImport(e);

            // Action row buttons
            this.shadowRoot.getElementById('btn-cancel').onclick = () => this.toggle();
            this.shadowRoot.getElementById('btn-save').onclick = () => { Storage.save(this.tempData); this.toggle(); };
            this.shadowRoot.getElementById('btn-reset').onclick = () => { if(confirm("Reset ALL data?")) { Storage.save(Helper.Config.DEFAULT_DATA); this.toggle(); } };
        },

        setupPanelDragging() {
            const handle = this.shadowRoot.getElementById('drag-handle');
            let startX, startY, initialLeft, initialTop;
            const onMouseMove = (e) => {
                this.panel.style.left = `${initialLeft + (e.clientX - startX)}px`;
                this.panel.style.top = `${initialTop + (e.clientY - startY)}px`;
            };
            const onMouseUp = () => {
                document.removeEventListener('mousemove', onMouseMove);
                document.removeEventListener('mouseup', onMouseUp);
            };
            handle.onmousedown = (e) => {
                if (e.button !== 0) return;
                startX = e.clientX; startY = e.clientY;
                initialLeft = this.panel.offsetLeft; initialTop = this.panel.offsetTop;
                document.addEventListener('mousemove', onMouseMove);
                document.addEventListener('mouseup', onMouseUp);
            };
        }
    };

    // ==========================================
    // [Module 5] SiteInjector
    // ==========================================
    const SiteInjector = {
        async init() {
            const host = window.location.host;
            let data = Storage.load();
            if (data.profiles[host]) this.injectUI(data.profiles[host]);
            EventBus.on('storage:updated', (newData) => {
                if (!newData.profiles[host]) return;
                this.injectUI(newData.profiles[host]);
                this.badgeAllThumbs(newData, true); // бейджи на превьюшках пересчитываются
            });
            if (data.profiles[host]) {
                // [NEW] страница поста: под строкой "Size:" показываем соотношение сторон
                this.watchRatioLine(data.profiles[host], data);
                // [NEW] список постов: бейдж соотношения на каждой превьюшке
                this.watchThumbBadges(data);
                // [NEW] бейдж NEW у новых постов отслеживаемых авторов
                this.watchNewBadges(data);
                // [NEW] кнопки формы: скрытая подстановка фильтров при поиске
                this.watchSilentFilters(data.profiles[host]);
            }
        },

        // [NEW] Прячет элементы сайта, перечисленные в profile.hideSelectors
        applyHideRules(profile) {
            const selectors = Array.isArray(profile.hideSelectors) ? profile.hideSelectors.filter(Boolean) : [];
            let styleEl = document.getElementById('sys-hide-css');

            if (!selectors.length) {
                if (styleEl) styleEl.remove();
                return;
            }
            if (!styleEl) {
                styleEl = document.createElement('style');
                styleEl.id = 'sys-hide-css';
                styleEl.type = 'text/css';
                document.head.appendChild(styleEl);
            }
            styleEl.textContent = selectors.map(sel => `${sel} { display: none !important; }`).join('\n');
        },

        // ==========================================
        // [NEW] Соотношение сторон на странице поста (rule34)
        // ==========================================
        RATIO_MAX_DEN: 32,     // максимальный знаменатель при округлении W:H к целым
        RATIO_TOLERANCE: 0.02, // общий допуск 2%
        RATIO_STRICT: 0.01,    // сначала ищем «красивое» целое с ошибкой до 1%

        // Округление пропорции к целым числам: 2236x932 -> 12:5
        integerRatio(width, height) {
            const w0 = Number(width), h0 = Number(height);
            if (!w0 || !h0 || !isFinite(w0) || !isFinite(h0)) return null;
            if (w0 < 1 || h0 < 1 || Math.max(w0, h0) > 20000000) return null;

            const target = w0 / h0;
            const candidates = [];
            const maxDen = Math.max(this.RATIO_MAX_DEN, 64);
            for (let h = 1; h <= maxDen; h++) {
                const w = Math.round(target * h);
                if (w < 1 || w > 199) continue;
                const err = Math.abs((w / h) - target) / target;
                if (err <= this.RATIO_TOLERANCE + 0.01) candidates.push({ w, h, err });
            }
            if (!candidates.length) {
                for (let h = 1; h <= 96; h++) {
                    const w = Math.round(target * h);
                    if (w < 1 || w > 199) continue;
                    const err = Math.abs((w / h) - target) / target;
                    if (err <= this.RATIO_TOLERANCE + 0.03) candidates.push({ w, h, err });
                }
            }

            // сначала самое простое целое с малой ошибкой, иначе — самое точное
            // prefer the one with smaller denominator first for readability, also prefer closer
            const preferredKey = (x) => {
                const k = x.w + ':' + x.h;
                if (k === '12:5' || k === '9:7' || k === '16:9' || k === '6:5' || k === '4:3' || k === '3:2' || k === '5:4' || k === '1:1' || k === '11:9' || k === '9:16' || k === '16:10' || k === '2:3') return 0;
                return 1;
            };
            const isNice = (x) => {
                if (x.h > this.RATIO_MAX_DEN) return false;
                if (x.w < 1 || x.w > 199) return false;
                const err = Math.abs((x.w / x.h) - target) / target;
                return err <= this.RATIO_STRICT;
            };
            const canonical = (x) => {
                const k = x.w + ':' + x.h;
                if (k === '12:5' || k === '9:7' || k === '16:9' || k === '6:5' || k === '8:5' || k === '9:4' || k === '3:4' || k === '5:12' || k === '1:1' || k === '9:16') return 0;
                return 1;
            };
            const key = (x) => [canonical(x), isNice(x) ? 0 : 1, x.err, Math.min(x.w, x.h), x.w + x.h, x.h, x.w];
            const strict = candidates.filter(c => c.err <= this.RATIO_STRICT)
                .sort((a, b) => {
                    const ka = key(a), kb = key(b);
                    for (let i = 0; i < ka.length; i++) { if (ka[i] !== kb[i]) return ka[i] - kb[i]; }
                    return 0;
                })[0];
            if (strict) return strict;

            const closest = candidates.sort((a, b) => {
                const ka = key(a), kb = key(b);
                for (let i = 0; i < ka.length; i++) { if (ka[i] !== kb[i]) return ka[i] - kb[i]; }
                return 0;
            })[0];
            if (closest) return closest;

            // запасной вариант — точное сокращение
            const gcd = (a, b) => (b ? gcd(b, a % b) : a);
            const div = gcd(w0, h0) || 1;
            const w2 = Math.round(w0 / div);
            const h2 = Math.round(h0 / div);
            if (w2 >= 1 && h2 >= 1 && w2 <= 199 && h2 <= 199) {
                return { w: w2, h: h2, err: Math.abs(w2 / h2 - target) / target };
            }
            // совсем аномалия — покажем как есть, но обрежем
            return { w: Math.min(199, w2 || Math.round(w0)), h: h2 || Math.round(h0), err: 0 };
        },

        // Размер оригинала со страницы поста: "#stats" -> "Size: 2236x932"
        // Если в статистике нет размеров (или они битые) — берём их у картинки/видео поста.
        postMedia() {
            const candidates = [
                document.getElementById('image'),
                document.querySelector('#image-container img'),
                document.querySelector('#gelcomVideoContainer video'),
                document.querySelector('#post-view img'),
                document.querySelector('video'),
                document.querySelector('#content img')
            ];
            for (let i = 0; i < candidates.length; i++) {
                if (candidates[i]) return candidates[i];
            }
            return null;
        },

        mediaSize(el) {
            if (!el) return null;
            const attrW = parseFloat((el.getAttribute && el.getAttribute('width')) || 0) || 0;
            const attrH = parseFloat((el.getAttribute && el.getAttribute('height')) || 0) || 0;
            const width = el.naturalWidth || el.videoWidth || el.width || attrW || 0;
            const height = el.naturalHeight || el.videoHeight || el.height || attrH || 0;
            return (width > 0 && height > 0) ? { width, height } : null;
        },

        // [NEW] Список характеристик поста: rule34 — #stats ul, aibooru — section#post-information ul
        statsList() {
            const a = document.querySelector('#stats ul');
            if (a) return a;
            return document.querySelector('#post-information ul') || null;
        },

        parsePostSize() {
            // 1) Строка Size в статистике
            const stats = this.statsList();
            if (stats) {
                const lis = Array.prototype.slice.call(stats.querySelectorAll('li'));
                const sizeLi = lis.find(el => /^\s*Size\s*:/i.test((el.textContent || '').replace(/\u00A0/g, ' ')));
                const text = ((sizeLi ? sizeLi.textContent : stats.textContent) || '').replace(/\u00A0/g, ' ');
                const m = String(text).match(/Size\s*:?\s*(\d{2,7})\s*[×xX*]\s*(\d{2,7})/i)
                    || String(text).match(/\b(\d{2,7})\s*[×xX*]\s*(\d{2,7})\b(?!\d)/);
                if (m) {
                    const width = parseInt(m[1], 10);
                    const height = parseInt(m[2], 10);
                    if (isFinite(width) && isFinite(height) && width > 0 && height > 0 && Math.max(width, height) <= 20000000) {
                        return { width, height, source: 'stats' };
                    }
                }
            }
            // 2) Картинка или видео поста
            const media = this.mediaSize(this.postMedia());
            if (media) return { width: media.width, height: media.height, source: 'media' };
            return null;
        },

        // Диагностика: что именно нашлось на странице (один раз на страницу)
        debugRatioSources() {
            const info = {
                url: location.href,
                stats: (this.statsList() || {}).innerText || null,
                media: null,
                images: []
            };
            const media = this.postMedia();
            if (media) {
                info.media = {
                    tag: media.tagName, id: media.id, cls: media.className,
                    natural: `${media.naturalWidth || media.videoWidth || 0}x${media.naturalHeight || media.videoHeight || 0}`,
                    shown: `${media.width || 0}x${media.height || 0}`,
                    src: (media.currentSrc || media.src || '').slice(0, 100)
                };
            }
            Array.prototype.slice.call(document.querySelectorAll('img, video')).slice(0, 15).forEach(el => {
                info.images.push({
                    tag: el.tagName, id: el.id, cls: String(el.className || '').slice(0, 40),
                    natural: `${el.naturalWidth || el.videoWidth || 0}x${el.naturalHeight || el.videoHeight || 0}`,
                    shown: `${el.width || 0}x${el.height || 0}`
                });
            });
            console.log('[SYS] ratio sources:', info);
            return info;
        },

        // Строка под "Size:": соотношение + кнопки категорий.
        // Зелёный — такое соотношение уже есть в сохранённых запросах, красный — новое.
        renderRatioLine(profile, data) {
            const size = this.parsePostSize();
            if (!size) {
                // картинка/видео могли ещё не загрузиться — сообщит watchRatioLine, если не найдём вовсе
                return false;
            }
            const ratio = this.integerRatio(size.width, size.height);
            if (!ratio) {
                console.warn('[SYS] ratio: integerRatio вернул null для', size);
                return false;
            }
            // Предупредим, если в итоге получилась совсем аномальная дробь по сравнению с исходной
            try {
                const src = size.width / size.height;
                const r = ratio.w / ratio.h;
                if (Math.abs(src - r) / src > 0.05) {
                    console.warn('[SYS] ratio: сильное расхождение округления', size, '→', ratio, '(src-r)/(src)=', ((Math.abs(src - r) / src).toFixed(3)));
                }
            } catch (e) {}

            const tag = Storage.ratioTag(ratio);
            const found = Storage.findAspectGroup(data, window.location.host);
            const aspect = found ? found.group : null;
            const choice = aspect ? Storage.choiceForRatio(aspect, ratio) : '';
            const known = !!choice
                || Storage.findRatio(data, ratio).length > 0
                || !!(aspect && (Array.isArray(aspect.queries) ? aspect.queries : []).some(q => Storage.queryCoversRatio(q, ratio)));

            let line = document.getElementById('sys-ratio-line');
            if (!line) {
                const statsUl = this.statsList();
                if (!statsUl) return false;
                line = document.createElement('li');
                line.id = 'sys-ratio-line';
                const sizeLi = Array.prototype.slice.call(statsUl.children)
                    .find(el => /^\s*Size:/i.test(el.textContent || el.innerText || ''));
                if (sizeLi) statsUl.insertBefore(line, sizeLi.nextSibling);
                else statsUl.appendChild(line);
            }

            line.textContent = '';
            line.style = Helper.Styles.RATIO_LINE;

            const name = document.createElement('span');
            name.innerText = `${ratio.w}:${ratio.h} (${(size.width / size.height).toFixed(2)})`;
            name.title = `Размер: ${size.width}x${size.height} → ${tag}. ` + (choice
                ? `Отнесено к категории «${choice}» (под капотом).`
                : (known
                    ? 'Такое соотношение уже есть в сохранённых запросах.'
                    : 'Новое соотношение — выбери, куда отнести (кнопки ниже).'));
            name.style = Helper.Styles.RATIO_NAME(known);
            line.appendChild(name);

            // кнопки остаются всегда, чтобы можно было перевыбрать категорию
            const categories = (aspect && Array.isArray(aspect.queries)) ? aspect.queries : [];
            if (categories.length) {
                const labels = aspect.labels || {};
                categories.forEach((query, qIdx) => {
                    const chip = document.createElement('span');
                    const caption = labels[query] || query;
                    chip.innerText = caption.length > 22 ? caption.slice(0, 21) + '…' : caption;
                    chip.title = `${query}\n\nКлик — отнести ${tag} к этой категории (в запрос не пишется)`;
                    const isChosen = choice
                        ? choice === Storage.categoryKey(aspect, query)
                        : Storage.queryCoversRatio(query, ratio);
                    chip.style = isChosen ? Helper.Styles.RATIO_CHIP_ACTIVE : Helper.Styles.RATIO_CHIP;
                    chip.onclick = (e) => { if (e && e.preventDefault) e.preventDefault(); this.assignRatio(ratio, qIdx); };
                    line.appendChild(chip);
                });
            }
            console.log('[SYS] ratio', `${size.width}x${size.height}`, '→', tag,
                choice ? `(под капотом: ${choice})` : (known ? '(значение уже есть)' : '(новое)'),
                'категорий:', categories.length, '| профили:', Object.keys((data && data.profiles) || {}).join(', '));
            return true;
        },

        // «Под капотом»: запомнить, что соотношение относится к выбранной категории.
        // Строка поиска и сохранённые запросы остаются чистыми — выбор лежит в aspect.hidden.
        assignRatio(ratio, targetQueryIdx) {
            const host = window.location.host;
            const data = Storage.load();
            const found = Storage.findAspectGroup(data, host);
            if (!found) return;
            const aspect = found.group;
            if (!Array.isArray(aspect.queries) || aspect.queries[targetQueryIdx] === undefined) return;

            const tag = Storage.ratioTag(ratio);
            const category = Storage.categoryKey(aspect, aspect.queries[targetQueryIdx]);

            const hidden = Object.assign({}, aspect.hidden || {});
            if (category) hidden[tag] = category; else delete hidden[tag];
            aspect.hidden = hidden;

            Storage.save(data); // пересоберёт виджет (storage:updated) и нормализует подписи
            this.renderRatioLine(data.profiles[found.host], data);
        },

        // Ждём появления #stats (страница поста) и рисуем строку
        watchRatioLine(profile, data) {
            let tries = 0;
            let hooked = false;
            const tick = () => {
                if (this.renderRatioLine(profile, data)) return;
                // размеры могут появиться позже: картинка/видео ещё грузится
                const media = this.postMedia();
                if (media && !hooked) {
                    hooked = true;
                    const retry = () => this.renderRatioLine(profile, data);
                    ['load', 'loadedmetadata', 'loadeddata', 'error'].forEach(ev => {
                        try { media.addEventListener(ev, retry, { once: true }); } catch (e) {}
                    });
                }
                if (++tries > 20) {
                    console.warn('[SYS] ratio: размеры поста не найдены — вот что есть на странице:');
                    this.debugRatioSources();
                    return;
                }
                setTimeout(tick, 250);
            };
            tick();
        },

        // ==========================================
        // [NEW] Бейдж соотношения на превьюшках в списке постов
        // ==========================================
        // Превьюшки rule34 сохраняют пропорции (масштаб до 250px), поэтому
        // соотношение считается по naturalWidth/naturalHeight картинки превью.
        thumbElements() {
            const seen = [];
            ['div.image-list > span.thumb', '#post-list span.thumb', 'span.thumb',
                // [NEW] aibooru.online: превью — <article id="post_N" class="post-preview">
                '#posts article.post-preview', 'article.post-preview'].forEach(sel => {
                    document.querySelectorAll(sel).forEach(el => { if (seen.indexOf(el) === -1) seen.push(el); });
                });
            return seen;
        },

        badgeThumb(thumb, data) {
            const profile = ((data || {}).profiles || {})[window.location.host];
            if (profile && profile.showRatioBadges === false) return; // [NEW] отключено в настройках
            if (thumb.getAttribute('data-sys-badge')) return;
            const img = thumb.querySelector('img');
            if (!img) return;

            const width = img.naturalWidth || img.width || 0;
            const height = img.naturalHeight || img.height || 0;

            // картинка ещё не загрузилась — подождём её load
            if (!width || !height) {
                if (!img.getAttribute('data-sys-badge-wait')) {
                    img.setAttribute('data-sys-badge-wait', '1');
                    img.addEventListener('load', () => this.badgeThumb(thumb, data));
                }
                return;
            }

            const ratio = this.integerRatio(width, height);
            if (!ratio) return;

            const tag = Storage.ratioTag(ratio);
            const caption = Storage.ratioCaption(data, window.location.host, ratio);
            const known = !!Storage.hiddenChoice(data, window.location.host, ratio)
                || Storage.findRatio(data, ratio).length > 0;

            if (getComputedStyle(thumb).position === 'static') thumb.style.position = 'relative';

            const badge = document.createElement('span');
            badge.innerText = `${ratio.w}:${ratio.h}`;
            badge.title = `${tag} (превью ${width}x${height})` + (known
                ? `\nЗначение уже есть в базе${caption ? ': ' + caption : ''}`
                : '\nНовое соотношение — открой пост и выбери категорию');
            badge.style = Helper.Styles.THUMB_BADGE(known);
            badge.setAttribute('data-sys-badge', '1');
            thumb.setAttribute('data-sys-badge', '1');
            thumb.appendChild(badge);
        },

        badgeAllThumbs(data, force) {
            const thumbs = this.thumbElements();
            const profile = ((data || {}).profiles || {})[window.location.host];
            // [NEW] настройка «показывать соотношения на миниатюрах»
            if (profile && profile.showRatioBadges === false) {
                thumbs.forEach(thumb => {
                    Array.prototype.slice.call(thumb.children || []).forEach(child => {
                        if (child.getAttribute && child.getAttribute('data-sys-badge')) thumb.removeChild(child);
                    });
                    thumb.removeAttribute('data-sys-badge');
                });
                return 0;
            }
            thumbs.forEach(thumb => {
                if (force) {
                    // пересчёт после правки данных: снимаем старый бейдж и рисуем заново
                    Array.prototype.slice.call(thumb.children || []).forEach(child => {
                        if (child.getAttribute && child.getAttribute('data-sys-badge')) thumb.removeChild(child);
                    });
                    thumb.removeAttribute('data-sys-badge');
                }
                this.badgeThumb(thumb, data);
            });
            return thumbs.length;
        },

        // первые секунды после загрузки страницы превьюшки могут догружаться
        watchThumbBadges(data) {
            let tries = 0;
            const tick = () => {
                this.badgeAllThumbs(data);
                if (++tries > 12) return;
                setTimeout(tick, 500);
            };
            tick();
        },

        // ==========================================
        // [NEW] Бейдж NEW у новых постов отслеживаемых авторов
        // ==========================================
        // «Следим» = ВСЕ авторы группы Authors.
        // Состояние на автора — profile.authorSeen[запрос] = { m, s } (см. _authorState/badgeNewPosts):
        //   m — «знак» (id <= m старые), s — показанные id > m, чтобы не помечать NEW повторно.
        // id на rule34 растут со временем: страницы идут по убыванию, старые страницы не бейджатся;
        // серия новых постов на несколько страниц помечается целиком, пока не дойдём до известного id.

        // Авторы, за которыми следим — ВСЕ запросы группы Authors (у кого есть «простой» тег).
        // (favorite ⊆ queries в обычном UI; на всякий случай объединяем.)
        followedAuthors(profile) {
            const group = (profile && profile.groups || []).find(g =>
                g.id === 'g_authors' || String(g.label || '').trim().toLowerCase() === 'authors');
            if (!group) return [];
            const all = [];
            (Array.isArray(group.queries) ? group.queries : []).forEach(q => { if (all.indexOf(q) === -1) all.push(q); });
            (Array.isArray(group.favorite) ? group.favorite : []).forEach(q => { if (all.indexOf(q) === -1) all.push(q); });
            return all.filter(q => this.authorTagsOf(q).length > 0);
        },

        // Простые теги из запроса автора: "user:ник" → ["user:ник"],
        // "( a ~ b )" → ["a","b"], "user:alpha -loli" → ["user:alpha"].
        // Минусы, звёздочки и служебные знаки отбрасываются (по ним матчить нельзя).
        authorTagsOf(query) {
            const q = String(query || '').trim().replace(/^\((.*)\)$/, '$1');
            return q.split(/[~\s]+/).map(s => s.trim()).filter(t => /^[\w:]+$/.test(t));
        },

        // Входит ли тег в строку тегов поста (по границам токена, без учёта регистра).
        hasToken(tagsStr, token) {
            if (!tagsStr || !token) return false;
            const t = String(token).toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
            return new RegExp('(^|\\s)' + t + '(?=\\s|$)').test(String(tagsStr).toLowerCase());
        },

        // id поста из ссылки превьюшки (?...&id=NNN или a#pNNN)
        thumbPostId(thumb) {
            if (!thumb) return null;
            // [NEW] aibooru.online: id у самого превью — <article data-id="181952">
            const did = (thumb.getAttribute && thumb.getAttribute('data-id')) || '';
            if (/^\d+$/.test(did)) return did;
            const a = thumb.querySelector('a[href]');
            if (!a) return null;
            const href = a.getAttribute('href') || '';
            const m = /(?:[?&]id=)(\d+)/i.exec(href);   // rule34: ...&id=18195106
            if (m) return m[1];
            const pm = /\/posts\/(\d+)/i.exec(href);     // aibooru: /posts/181952
            if (pm) return pm[1];
            const am = /^p(\d+)$/i.exec(a.getAttribute('id') || '');
            return am ? am[1] : null;
        },

        // Строка тегов поста: rule34 держит её в alt, aibooru — в data-tags / title
        // (его alt выглядит как «post #181952» — это НЕ теги).
        thumbTags(thumb) {
            if (!thumb) return '';
            const dt = (thumb.getAttribute && thumb.getAttribute('data-tags')) || '';
            if (dt) return String(dt).trim();
            const img = thumb.querySelector('img');
            const a = thumb.querySelector('a[href]');
            let t = '';
            if (img) {
                const alt = img.getAttribute('alt') || '';
                const title = img.getAttribute('title') || '';
                t = (/^\s*post\s*#\d+\s*$/i.test(alt) || !alt) ? (title || alt) : alt;
            }
            if (!t) t = (a && a.getAttribute('title')) || '';
            return String(t).trim();
        },

        addNewBadge(thumb) {
            if (!thumb || thumb.getAttribute('data-sys-new')) return;
            if (getComputedStyle(thumb).position === 'static') thumb.style.position = 'relative';
            const badge = document.createElement('span');
            badge.innerText = 'NEW';
            badge.title = 'Новый пост у отслеживаемого автора';
            badge.style = Helper.Styles.NEW_BADGE;
            badge.setAttribute('data-sys-new', '1');
            thumb.setAttribute('data-sys-new', '1');
            thumb.appendChild(badge);
        },

        // Состояние отслеживания автора: { m, s }.
        //   m — «знак»: все id <= m считаются старыми (бейджа не бывает);
        //   s — уже показанные id, большие m (чтобы не помечать NEW повторно на этой же серии страниц).
        // Старые форматы (число / массив id) приводятся к объекту.
        _authorState(seen, a) {
            let st = seen[a];
            if (Array.isArray(st)) st = { m: st.reduce((x, y) => Math.max(x, Number(y) || 0), 0), s: [] };
            else if (typeof st === 'number') st = { m: st > 0 ? st : 0, s: [] };
            else if (!st || typeof st !== 'object') st = { m: 0, s: [] };
            if (typeof st.m !== 'number' || !(st.m >= 0)) st.m = 0;
            if (!Array.isArray(st.s)) st.s = [];
            seen[a] = st;
            return st;
        },

        // Разметка превьюшек бейджем NEW.
        // Идея: id постов на rule34 растут со временем. Бейдж — только если id БОЛЬШЕ знака m
        // (страницы идут по убыванию id, поэтому старые страницы бейджей не получают).
        // Когда впервые доходим до страницы, где есть уже известный id (min <= m), «знак»
        // поднимается до максимума показанного — так серия новых постов, растянутая на
        // несколько страниц, помечается целиком, а повторный показ бейджа не даёт.
        // baseline — авторы, которых видим впервые: молча ставим знак = максимум страницы.
        badgeNewPosts(data, authors, baseline) {
            const profile = (data.profiles || {})[window.location.host];
            if (!profile) return true;
            const seen = profile.authorSeen || (profile.authorSeen = {});
            const thumbs = this.thumbElements();
            let dirty = false, needRetry = false;

            const pageIds = {}; // автор -> все его id постов на текущей странице
            authors.forEach(a => { pageIds[a] = []; });

            thumbs.forEach(thumb => {
                const postId = this.thumbPostId(thumb);
                if (!postId) return;
                const tags = this.thumbTags(thumb);
                if (!tags) { needRetry = true; return; } // img ещё не отдал теги
                const id = Number(postId);
                if (!id) return;
                let hit = authors.filter(a => this.authorTagsOf(a).some(t => this.hasToken(tags, t)));
                // [NEW] user:-авторы: в alt превьюшек тега `user:ник` нет (это системный тег),
                // поэтому по тегам пост к автору не привязывается. Но страница уже отфильтрована
                // этим автором (см. trackedAuthorsHere) — если на странице он один, все посты его.
                if (!hit.length && authors.length === 1) hit = [authors[0]];
                if (!hit.length) return;

                hit.forEach(a => { if (pageIds[a].indexOf(id) === -1) pageIds[a].push(id); });

                if (!thumb.getAttribute('data-sys-new')) { // на этой странице уже помечено — не дублируем
                    hit.forEach(a => {
                        if (baseline[a]) return; // первый показ автора — только затравка
                        const st = this._authorState(seen, a);
                        if (id > st.m && st.s.indexOf(id) === -1) {
                            this.addNewBadge(thumb);
                            st.s.push(id);
                            dirty = true;
                        }
                    });
                }
            });

            if (!needRetry) {
                authors.forEach(a => {
                    const ids = pageIds[a];
                    if (!ids.length) return;
                    const st = this._authorState(seen, a);
                    const min = Math.min.apply(null, ids);
                    const max = Math.max.apply(null, ids);
                    if (baseline[a]) {                                   // первый показ: знак = максимум
                        if (max > st.m) { st.m = max; dirty = true; }
                        st.s = [];
                    } else if (min <= st.m) {                            // дошли до известных постов — фиксируем знак
                        const top = Math.max(st.m, max, st.s.length ? Math.max.apply(null, st.s) : 0);
                        if (top !== st.m || st.s.length) { st.m = top; st.s = []; dirty = true; }
                    }
                    if (st.s.length > 500) {                             // «показанные» не растут безгранично
                        st.m = Math.max(st.m, Math.max.apply(null, st.s));
                        st.s = [];
                        dirty = true;
                    }
                });
            }

            if (dirty) Storage.save(data);
            return !needRetry;
        },

        // Теги/запрос текущей страницы: из URL (?tags=…) или из строки поиска.
        currentSearchTags() {
            let tags = '';
            const href = (window.location && window.location.href) || '';
            const m = /[?&]tags=([^&]*)/i.exec(href);
            if (m) {
                try { tags = decodeURIComponent(m[1].replace(/\+/g, ' ')); } catch (e) { tags = m[1]; }
            }
            if (!tags) {
                const bar = document.querySelector("input[name='tags']");
                if (bar && bar.value) tags = bar.value;
            }
            return String(tags).toLowerCase().trim();
        },

        // Отслеживаем бейджи ТОЛЬКО на странице самого автора: текущий поиск должен содержать
        // тег одного из авторов группы. В обычной ленте/главной (без такого фильтра) — ничего
        // не помечаем и НЕ пишем в историю.
        trackedAuthorsHere(profile) {
            const pageTags = this.currentSearchTags();
            if (!pageTags) return [];
            return this.followedAuthors(profile)
                .filter(a => this.authorTagsOf(a).some(t => this.hasToken(pageTags, t)));
        },

        watchNewBadges(data) {
            const profile = (data.profiles || {})[window.location.host];
            if (!profile) return;
            const authors = this.trackedAuthorsHere(profile);
            if (!authors.length) return; // не страница отслеживаемого автора — не вмешиваемся
            const seen = profile.authorSeen || (profile.authorSeen = {});
            // база один раз на страницу: кого видим впервые — «затравка» без бейджей
            const baseline = {};
            authors.forEach(a => { baseline[a] = !Object.prototype.hasOwnProperty.call(seen, a); });

            let tries = 0;
            const tick = () => {
                const done = this.badgeNewPosts(data, authors, baseline);
                if (done || ++tries > 12) return;
                setTimeout(tick, 500);
            };
            tick();
        },

        async injectUI(profile) {
            // [NEW] запоминаем, была ли панель тегов открыта: перестройка виджета (любое сохранение
            // настроек, клик по кнопке «под капотом») не должна сворачивать панель
            const prevBox = document.getElementById('sys-query-box');
            const wasOpen = !!(prevBox && prevBox.style && prevBox.style.display === 'block');
            // [NEW] защита от дублей: старые шапки убираем СИНХРОННО ПОСЛЕ await, а не до него.
            // До await два параллельных вызова (init + storage:updated) успевали оба вставить
            // обёртку и на странице появлялось два «Saved Searches» + «⚙️». Удаление+вставка
            // идут одной синхронной порцией, поэтому в итоге всегда ровно одна шапка.
            const dropWrappers = () => {
                const list = document.querySelectorAll('#sys-wrapper');
                for (let i = 0; i < list.length; i++) list[i].remove();
            };
            dropWrappers();
            this.applyHideRules(profile); // [NEW] прячем ненужные элементы сайта
            if (!profile.selectors.insertPoint) return;
            const target = await this.waitForElement(profile.selectors.insertPoint);
            if (!target) return;
            dropWrappers(); // снимаем всё, что успел вставить параллельный/повторный вызов

            const wrapper = document.createElement('div');
            wrapper.id = 'sys-wrapper';
            wrapper.style = Helper.Styles.INJECTOR_WRAPPER;

            const triggerBtn = document.createElement('button');
            triggerBtn.innerText = "🔍 Saved Searches";
            triggerBtn.style = Helper.Styles.TRIGGER_BTN;

            // Settings Button Logic
            let settingsBtn = null;
            if (profile.showSettingsBtn !== false) { // Defaults to true if undefined
                settingsBtn = document.createElement('button');
                settingsBtn.innerText = "⚙️";
                settingsBtn.style = Helper.Styles.SETTINGS_BTN;
                settingsBtn.onclick = (e) => {
                    e.preventDefault();
                    UIEngine.toggle(); // Opens the settings panel
                };
            }

            let cheatsheetBtn = null;
            if (profile.showCheatsheet && profile.cheatsheetUrl) {
                cheatsheetBtn = document.createElement('a');
                cheatsheetBtn.innerText = "📄 Cheatsheet";
                cheatsheetBtn.href = profile.cheatsheetUrl;
                cheatsheetBtn.target = "_blank";
                cheatsheetBtn.style = Helper.Styles.CHEATSHEET_BTN;
            }

            let wikiBtn = null;
            if (profile.showWiki && profile.wikiUrl) {
                wikiBtn = document.createElement('a');
                wikiBtn.innerText = "🌐 Wiki";
                wikiBtn.href = profile.wikiUrl;
                wikiBtn.target = "_blank";
                wikiBtn.style = Helper.Styles.WIKI_BTN;
            }

            const queryBox = this.createBox(profile);

            // [NEW] единая точка открытия/закрытия тегов (кнопка и её подсветка синхронно)
            const setBoxOpen = (open) => {
                queryBox.style.display = open ? "block" : "none";
                triggerBtn.style.background = open ? "#2196f3" : "#6c757d";
            };
            setBoxOpen(wasOpen); // закрыта при первом показе; при перестройке остаётся как была

            triggerBtn.onclick = (e) => {
                e.preventDefault();
                setBoxOpen(queryBox.style.display === "none");
            };

            // [NEW] автоскрытие/автооткрытие по наведению — только если включено в настройках
            if (profile.autoHideQueryBox !== false) {
                let closeTimer = null;
                const cancelClose = () => { if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; } };
                const scheduleClose = () => {
                    cancelClose();
                    closeTimer = setTimeout(() => setBoxOpen(false), Helper.HOVER_CLOSE_MS);
                };
                triggerBtn.addEventListener('mouseenter', () => { cancelClose(); setBoxOpen(true); });
                triggerBtn.addEventListener('mouseleave', () => scheduleClose());
                queryBox.addEventListener('mouseenter', () => cancelClose());
                queryBox.addEventListener('mouseleave', () => scheduleClose());
            }

            wrapper.appendChild(triggerBtn);
            if (settingsBtn) wrapper.appendChild(settingsBtn);
            if (cheatsheetBtn) wrapper.appendChild(cheatsheetBtn);
            if (wikiBtn) wrapper.appendChild(wikiBtn);
            wrapper.appendChild(queryBox);

            target.parentNode.insertBefore(wrapper, target.nextSibling);

            const searchBar = document.querySelector(profile.selectors.searchBar);
            if (searchBar) {
                searchBar.addEventListener('input', () => this.updateHighlights(profile));
                searchBar.addEventListener('change', () => this.updateHighlights(profile));
                this.updateHighlights(profile);
            }
        },

        createBox(profile) {
            const box = document.createElement('div');
            box.id = 'sys-query-box';
            box.style = Helper.Styles.QUERY_BOX;

            profile.groups.forEach((group, index) => {
                const card = document.createElement('div');
                card.style = Helper.Styles.GROUP_CARD(index === profile.groups.length - 1);
                const title = document.createElement('div');
                title.innerText = group.label;
                title.style = Helper.Styles.GROUP_TITLE;
                const tagsCont = document.createElement('div');
                tagsCont.style = "display: flex; flex-wrap: wrap; gap: 6px;";

                const labels = group.labels || {};
                const tristate = group.tristate || {};
                const radio = Array.isArray(group.radio) ? group.radio : [];
                const favorites = Array.isArray(group.favorite) ? group.favorite : [];
                const marks = group.marks || {};
                // [NEW] группы «под капотом» (Media / Sort / Aspect): кнопки не пишут в строку поиска
                const isSilent = Storage.isSilent(group);

                // [NEW] порядок как в настройках: алфавит + избранные в начале (Helper.orderedIndexes)
                Helper.orderedIndexes(group).forEach(qIdx => {
                    const query = group.queries[qIdx];
                    // [NEW] подпись кнопки (score/id) и трёхсостоянийный режим (video/-video)
                    const tristateAlt = tristate[query] || '';
                    const caption = labels[query] || query;

                    const tag = document.createElement('span');

                    const captionEl = document.createElement('span');
                    captionEl.innerText = caption;
                    tag.appendChild(captionEl);

                    // [NEW] косметические метки (♂ / ⚧) — после подписи (nick♂), сортировку не меняют.
                    // Показываем их только у авторов и Meta (см. Storage.allowsMarks)
                    if (Storage.allowsMarks(group)) Helper.MARKERS.forEach(m => {
                        if ((marks[m.key] || []).indexOf(query) === -1) return;
                        const mark = document.createElement('span');
                        mark.innerText = m.symbol;
                        mark.style = `color: ${m.color}; margin-left: 5px; font-size: 15px; line-height: 1;`;
                        tag.appendChild(mark);
                    });

                    // [NEW] Красная лампочка у автора, чью страницу ещё не открывали: пока нет записи
                    // в authorSeen, бейджам NEW не от чего считаться. Открыл страницу один раз —
                    // запоминается «знак», и лампочка пропадает.
                    if (Storage.isAuthorsGroup(group)) {
                        const seenState = (profile.authorSeen || {})[query];
                        if (!(seenState && seenState.m)) {
                            const lamp = document.createElement('span');
                            lamp.innerText = '●';
                            lamp.title = 'Нет данных об авторе: открой его страницу хотя бы раз — тогда включится подсчёт бейджей NEW';
                            lamp.style = Helper.Styles.NO_DATA_DOT;
                            lamp.setAttribute('data-sys-no-data', '1');
                            tag.appendChild(lamp);
                        }
                    }

                    tag.title = radio.indexOf(query) !== -1
                        ? `${query} (взаимоисключающая: активна только одна)`
                        : (tristateAlt ? `${query} → ${tristateAlt} → off` : query);
                    if (isSilent) {
                        // [NEW] «под капотом»: фильтр живёт внутри скрипта, в строку поиска не пишется
                        tag.title = `${caption}\n${tristateAlt ? `${query} → ${tristateAlt}` : query}\n\n`
                            + 'Фильтр применяется скрытно при поиске; в строке поиска ничего не появляется';
                    }
                    // [NEW] избранные — золотая рамка вместо звёздочки (только у авторов и Meta)
                    if (Storage.allowsMarks(group) && favorites.indexOf(query) !== -1) tag.setAttribute('data-sys-fav', '1');
                    this.applyTagStyle(tag, Helper.Styles.TAG_ITEM);
                    tag.setAttribute('data-sys-query', query);
                    if (isSilent) {
                        tag.setAttribute('data-sys-silent', '1');
                        tag.setAttribute('data-sys-gid', group.id);
                    }
                    if (tristateAlt) tag.setAttribute('data-sys-alt', tristateAlt);

                    tag.onclick = () => {
                        // [NEW] «под капотом»: только вкл/выкл внутри скрипта, строку поиска не трогаем
                        if (isSilent) { Storage.toggleSilent(window.location.host, group.id, query); return; }
                        // [NEW] score/id: либо один, либо никто
                        if (radio.indexOf(query) !== -1) { this.selectExclusive(profile, radio, query); return; }
                        if (tristateAlt) this.cycleTag(profile, query, tristateAlt);
                        else this.toggleQuery(profile, query);
                    };
                    tagsCont.appendChild(tag);
                });

                // [NEW] пустой блок виден в виджете, даже если кнопок ещё нет
                if (group.queries.length === 0) {
                    const empty = document.createElement('span');
                    empty.innerText = "пусто — добавьте в настройках";
                    empty.style = "font-size: 12px; font-style: italic; color: #adb5bd;";
                    tagsCont.appendChild(empty);
                }

                card.appendChild(title);
                card.appendChild(tagsCont);
                box.appendChild(card);
            });
            return box;
        },


        _escapeRegex(string) {
            return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        },

        // [NEW] Проверка наличия запроса в строке поиска по границам разделителя
        _hasQuery(value, query, delimiterRegexStr) {
            const delimiterRe = delimiterRegexStr || "\\s+";
            // [NEW] без учёта регистра: сайт может вернуть тег в другом регистре
            // (кнопка "InfWaifus" → запрос "user:InfWaifus", на результатах "user:infwaifus")
            return new RegExp(`(^|${delimiterRe})${this._escapeRegex(query)}(?=${delimiterRe}|$)`, 'i').test(value);
        },

        // [NEW] Взаимоисключающий выбор: активна только одна кнопка из набора, либо ни одной.
        // Повторный клик по активной — снимает её.
        selectExclusive(profile, members, query) {
            const bar = document.querySelector(profile.selectors.searchBar);
            if (!bar) return;

            const delimiterStr = profile.delimiter !== undefined ? profile.delimiter : " ";
            const delimiterRegexStr = profile.splitRegex || "\\s+";

            // активной считаем кнопку только если она единственная включённая:
            // если в строке оба сразу (грязное состояние) — клик нормализует к выбранной
            const wasActive = this._hasQuery(bar.value, query, delimiterRegexStr)
                && !members.some(member => member !== query && this._hasQuery(bar.value, member, delimiterRegexStr));

            // снять все кнопки набора, включая выбранную
            members.forEach(member => {
                if (!this._hasQuery(bar.value, member, delimiterRegexStr)) return;
                const re = new RegExp(`(^|${delimiterRegexStr})${this._escapeRegex(member)}(?=${delimiterRegexStr}|$)`, 'i');
                bar.value = bar.value.replace(re, (m, boundary) => boundary);
            });

            bar.value = bar.value.replace(new RegExp(`(${delimiterRegexStr})+`, 'g'), delimiterStr).trim();
            if (bar.value.length > 0) bar.value += delimiterStr;

            // и включить выбранную, если она не была активна (тогда это было выключение)
            if (!wasActive) {
                const currentVal = bar.value.trim();
                bar.value = (currentVal ? currentVal + delimiterStr : "") + query + delimiterStr;
            }

            bar.dispatchEvent(new Event('input', { bubbles: true }));
        },

        // [NEW] Трёхсостоянийная кнопка: none -> query (зелёный) -> alt (красный) -> none
        cycleTag(profile, query, altQuery) {
            const bar = document.querySelector(profile.selectors.searchBar);
            if (!bar) return;

            const delimiterStr = profile.delimiter !== undefined ? profile.delimiter : " ";
            const delimiterRegexStr = profile.splitRegex || "\\s+";

            const hasMain = this._hasQuery(bar.value, query, delimiterRegexStr);
            const hasAlt = this._hasQuery(bar.value, altQuery, delimiterRegexStr);

            if (hasMain) {
                // video -> -video
                const re = new RegExp(`(^|${delimiterRegexStr})${this._escapeRegex(query)}(?=${delimiterRegexStr}|$)`, 'i');
                bar.value = bar.value.replace(re, (m, boundary) => boundary + altQuery);
            } else if (hasAlt) {
                // -video -> off
                const re = new RegExp(`(^|${delimiterRegexStr})${this._escapeRegex(altQuery)}(?=${delimiterRegexStr}|$)`, 'i');
                bar.value = bar.value.replace(re, (m, boundary) => boundary)
                                     .replace(new RegExp(`(${delimiterRegexStr})+`, 'g'), delimiterStr)
                                     .trim();

                if (bar.value.length > 0) bar.value += delimiterStr;
            } else {
                // off -> video
                this.toggleQuery(profile, query);
                return; // toggleQuery already dispatched the input event
            }

            bar.dispatchEvent(new Event('input', { bubbles: true }));
        },

        // 替換原本的 toggleQuery 函式
        toggleQuery(profile, query) {
            const bar = document.querySelector(profile.selectors.searchBar);
            if (!bar) return;

            const delimiterStr = profile.delimiter !== undefined ? profile.delimiter : " ";
            const delimiterRegexStr = profile.splitRegex || "\\s+";

            // [NEW] регистр не важен: "user:InfWaifus" и "user:infwaifus" — один и тот же тег
            const matchRegex = new RegExp(`(^|${delimiterRegexStr})${this._escapeRegex(query)}(?=${delimiterRegexStr}|$)`, 'gi');

            if (matchRegex.test(bar.value)) {
                // 如果標籤存在,將其移除。
                // 將標籤替換為它前面的邊界符號 ($1),接著清理多餘的連續分隔符號
                bar.value = bar.value.replace(matchRegex, (m, boundary) => boundary)
                                     .replace(new RegExp(`(${delimiterRegexStr})+`, 'g'), delimiterStr).trim();

                if (bar.value.length > 0) bar.value += delimiterStr; // 保持結尾有空格的習慣
            } else {
                // 如果標籤不存在,將其加到最後
                let currentVal = bar.value.trim();
                bar.value = (currentVal ? currentVal + delimiterStr : "") + query + delimiterStr;
            }

            bar.dispatchEvent(new Event('input', { bubbles: true }));
        },

        // [NEW] Актуальный профиль из хранилища: объект в обработчиках устаревает после сохранений
        freshProfile(profile) {
            try {
                const data = Storage.load();
                const host = window.location.host;
                if (data.profiles && data.profiles[host]) return data.profiles[host];
            } catch (e) {}
            return profile;
        },

        // [NEW] Фильтры «под капотом» (Media / Sort / Aspect) одним фрагментом запроса
        silentBlock(profile) {
            return Storage.silentSearchBlock(this.freshProfile(profile));
        },

        // Подставить скрытые фильтры в момент отправки поиска, НЕ трогая видимое поле.
        // ВАЖНО: браузер снимает данные формы не в момент события submit, а когда уже планирует
        // переход, — «подменить value и вернуть» ненадёжно (фильтры могли не доехать до сервера,
        // а кнопки горели). Поэтому добавляем в форму скрытый `<input name="tags" value="<всё
        // вместе>">` и на время отправки снимаем имя у видимого поля: сервер получает ровно одно
        // значение — с фильтрами, а поле пользователя вообще не меняется (нет ни мелькания,
        // ни потери текста). Имя поля возвращается через 800 мс: если отправку не отменили —
        // страница к тому моменту уже ушла и таймер умирает вместе с ней.
        // (Сайтовый слайдер «Filter AI posts» сделан иначе: cookie `filter_ai` читает сам сервер.
        // Свои теги серверу нечем принять, кроме как в запросе, поэтому добавляем их сами.)
        injectSilentQuery(profile) {
            const block = this.silentBlock(profile);
            if (!block) return false;
            const bar = document.querySelector(profile.selectors.searchBar);
            if (!bar) return false;
            const delim = profile.splitRegex || "\\s+";
            const name = bar.getAttribute('name') || 'tags';
            const form = bar.form;
            const saved = bar.value;
            if (this._hasQuery(saved, block, delim)) return false;
            const full = (saved.trim() ? saved.trim() + ' ' : '') + block + ' ';

            // резерв: поле вне формы — подставляем значение и возвращаем его в том же тике
            if (!form || !form.appendChild) {
                bar.value = full;
                const back = () => { if (bar.value === full) bar.value = saved; };
                if (typeof queueMicrotask === 'function') queueMicrotask(back); else Promise.resolve().then(back);
                return true;
            }

            const prev = form.querySelector('input[data-sys-silent-input]');
            if (prev && prev.remove) prev.remove(); // повторный submit на той же странице

            const hidden = document.createElement('input');
            hidden.setAttribute('type', 'hidden');
            hidden.setAttribute('name', name);
            hidden.setAttribute('data-sys-silent-input', '1');
            hidden.value = full;
            form.appendChild(hidden);
            bar.removeAttribute('name'); // иначе сервер получил бы два «tags»
            setTimeout(() => {
                if (bar.getAttribute && bar.getAttribute('name') === null) bar.setAttribute('name', name);
            }, 800);
            console.log('[SYS] скрытые фильтры подставлены скрытым полем (видимое поле не менялось)');
            return true;
        },

        // Убрать такие фильтры из строки поиска (после перехода на результаты)
        // и запомнить их как включённые, чтобы кнопки горели и фильтр не потерялся.
        stripSilentQuery(profile) {
            const bar = document.querySelector(profile.selectors.searchBar);
            if (!bar || !bar.value) return false;
            const fresh = this.freshProfile(profile);
            const delim = profile.splitRegex || "\\s+";

            const marks = []; // { group, query, state }
            Storage.silentGroups(fresh).forEach(group => {
                (Array.isArray(group.queries) ? group.queries : []).forEach(query => {
                    if (this._hasQuery(bar.value, query, delim)) { marks.push({ group, query, state: 'on' }); return; }
                    const alt = (group.tristate || {})[query] || '';
                    if (alt && this._hasQuery(bar.value, alt, delim)) marks.push({ group, query, state: 'neg' });
                });
            });
            if (!marks.length) return false;

            let value = bar.value;
            marks.forEach(({ group, query, state }) => {
                const text = state === 'neg' ? ((group.tristate || {})[query] || query) : query;
                const re = new RegExp(`(^|${delim})${this._escapeRegex(text)}(?=${delim}|$)`, 'gi');
                value = value.replace(re, (m, boundary) => boundary);
            });
            bar.value = value.replace(new RegExp(`(${delim})+`, 'g'), " ").trim();
            try { bar.setAttribute('value', bar.value); } catch (e) {} // атрибут тоже чистим — он переживает перерисовки

            const data = Storage.load();
            const prof = (data.profiles || {})[window.location.host];
            if (prof && Array.isArray(prof.groups)) {
                marks.forEach(({ group, query, state }) => {
                    const g = prof.groups.find(x => x.id === group.id || x.label === group.label);
                    if (!g) return;
                    const active = (Array.isArray(g.active) ? g.active : []).filter(q => q !== query);
                    const neg = (Array.isArray(g.activeNeg) ? g.activeNeg : []).filter(q => q !== query);
                    if (state === 'neg') neg.push(query); else active.push(query);
                    g.active = active;
                    g.activeNeg = neg;
                });
                Storage.save(data);
            }
            console.log('[SYS] скрытые фильтры убраны из строки поиска (под капотом активны):', marks.length);
            return true;
        },

        // [NEW] Ранняя чистка поля поиска — ДО первой отрисовки страницы.
        // Сайт кладёт в <input name="tags"> все теги из URL, включая наши скрытые фильтры.
        // Скрипт стартует на document-start и ловит поле через MutationObserver, как только парсер
        // его создаст: колбэк выполняется в микрозадаче, т.е. до отрисовки — фильтры не видны ни кадра.
        // Чистка идёт через stripSilentQuery: убрать теги из поля И запомнить их активными под капотом.
        earlyStrip() {
            const run = () => {
                const data = Storage.load();
                const profile = (data.profiles || {})[window.location.host];
                if (!profile || !profile.selectors || !profile.selectors.searchBar) return false;
                if (!document.querySelector(profile.selectors.searchBar)) return false;
                try { this.stripSilentQuery(profile); } catch (e) { console.error('[SYS] ранняя чистка поля: ', e); }
                return true;
            };
            if (run()) return; // поле уже есть на момент запуска
            const root = document.documentElement;
            if (!root || typeof MutationObserver !== 'function') return; // дождётся обычный старт
            const obs = new MutationObserver(() => { if (run()) obs.disconnect(); });
            obs.observe(root, { childList: true, subtree: true });
        },

        // Подстановка фильтров вокруг отправки поиска + сброс при новом поиске по тегу
        watchSilentFilters(profile) {
            const attach = () => {
                const bar = document.querySelector(profile.selectors.searchBar);
                if (!bar) return false;
                if (bar.getAttribute('data-sys-silent-hook')) return true;
                bar.setAttribute('data-sys-silent-hook', '1');

                // Подстановка ТОЛЬКО в момент submit (capture — раньше обработчиков сайта).
                // injectSilentQuery ставит блок в value и возвращает поле в тот же тик,
                // до отрисовки — поэтому в поле фильтры НИКОГДА не мелькают. Enter в поле и
                // кнопка Search дают одно и то же событие submit, отдельные обработчики не нужны.
                const inject = () => { try { this.injectSilentQuery(profile); } catch (e) {} };
                document.addEventListener('submit', (e) => {
                    if (!e.target || !bar.form || e.target !== bar.form) return;
                    inject();
                }, true);
                document.addEventListener('click', (e) => {
                    const t = e.target;
                    // клик внутри формы (в т.ч. в самое поле) фильтры НЕ подставляет — это не отправка
                    if (t && bar.form && t.closest && t.closest('form') === bar.form) return;

                    // [NEW] клик по ссылке-тегу (список тегов, теги поста, поиск по тегу) —
                    // это новый поиск: прежние скрытые фильтры больше не действуют
                    const link = (t && t.closest) ? t.closest('a[href]') : null;
                    if (!link || !link.getAttribute) return;
                    if (link.closest && link.closest('#sys-query-box')) return; // свои кнопки не трогаем
                    const href = link.getAttribute('href') || '';
                    if (!/([?&]tags=|\/post\/|\/posts?\/|page=post)/i.test(href)) return;
                    // листалка результатов: фильтры должны пережить переход (они есть в URL).
                    // rule34 листает по pid=NNN, aibooru — по page=NNN (не путать с page=post)
                    if (/[?&](?:pid=\d|page=\d)/i.test(href) && (prof => Storage.silentSearchBlock(prof))(this.freshProfile(profile))) return;
                    try { Storage.resetSilentState(window.location.host); } catch (err) {}
                }, true);

                // на загруженной странице прячем фильтры из строки поиска
                // (сразу при attach — сайт уже положил теги в поле, убираем без ожидания)
                [0, 150, 600, 1500, 3000].forEach(delay => setTimeout(() => {
                    try { this.stripSilentQuery(profile); } catch (e) {}
                    this.updateHighlights(profile);
                }, delay));
                return true;
            };

            if (attach()) return;
            const wait = () => { if (!attach()) setTimeout(wait, 300); };
            wait();
        },

        // [NEW] базовый стиль кнопки + золотая рамка у избранных (без звёздочки)
        applyTagStyle(tagEl, css) {
            if (tagEl.getAttribute('data-sys-fav') === '1') css = css.replace(/;+\s*$/, '') + '; border: 2px solid #ffb300;';
            tagEl.style.cssText = css;
        },

        // 替換原本的 updateHighlights 函式
        updateHighlights(profile) {
            const bar = document.querySelector(profile.selectors.searchBar);
            if (!bar) return;

            const box = document.getElementById('sys-query-box');
            if (!box) return;

            const delimiterRegexStr = profile.splitRegex || "\\s+";
            const barValue = bar.value;
            const fresh = this.freshProfile(profile);

            box.querySelectorAll('span[data-sys-query]').forEach(tagEl => {
                const query = tagEl.getAttribute('data-sys-query');
                const altQuery = tagEl.getAttribute('data-sys-alt');

                // [NEW] кнопки «под капотом» (Media / Sort / Aspect): горят по внутреннему
                // состоянию из хранилища, а не по содержимому строки поиска
                if (tagEl.getAttribute('data-sys-silent') === '1') {
                    const gid = tagEl.getAttribute('data-sys-gid');
                    const group = ((fresh && fresh.groups) || []).find(g => g.id === gid || g.label === gid);
                    const state = Storage.silentStateOf(group, query);
                    this.applyTagStyle(tagEl, state === 'neg' ? Helper.Styles.TAG_ITEM_ACTIVE_NEG
                        : (state === 'on' ? Helper.Styles.TAG_ITEM_ACTIVE : Helper.Styles.TAG_ITEM));
                    return;
                }

                const isMatched = this._hasQuery(barValue, query, delimiterRegexStr);

                // [NEW] Трёхсостоянийная кнопка: зелёный = query, красный = alt-query
                if (altQuery) {
                    this.applyTagStyle(tagEl, isMatched
                        ? Helper.Styles.TAG_ITEM_ACTIVE_POS
                        : (this._hasQuery(barValue, altQuery, delimiterRegexStr) ? Helper.Styles.TAG_ITEM_ACTIVE_NEG : Helper.Styles.TAG_ITEM));
                    return;
                }

                this.applyTagStyle(tagEl, isMatched ? Helper.Styles.TAG_ITEM_ACTIVE : Helper.Styles.TAG_ITEM);
            });
        },

        waitForElement(selector) {
            return new Promise(resolve => {
                if (document.querySelector(selector)) return resolve(document.querySelector(selector));
                const obs = new MutationObserver(() => {
                    if (document.querySelector(selector)) { obs.disconnect(); resolve(document.querySelector(selector)); }
                });
                obs.observe(document.body, { childList: true, subtree: true });
            });
        }
    };

    // ==========================================
    // Boot Initialization
    // ==========================================
    // [NEW] Скрипт стартует на document-start: сначала чистим поле поиска (до первой отрисовки),
    // остальное — когда DOM готов (document.body ещё нет).
    try { SiteInjector.earlyStrip(); } catch (e) { console.error('[SYS] ранняя чистка поля: ', e); }
    const boot = () => {
        UIEngine.init();
        SiteInjector.init();
        GM_registerMenuCommand("Open Settings Panel", () => UIEngine.toggle());
        const targetWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
        targetWindow.toggleQM = () => UIEngine.toggle();
        console.log('[SYS] Save Your Searches (custom) v' + (typeof GM_info !== 'undefined' ? GM_info.script.version : '?') + ' loaded');
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, true);
    else boot();

})();