☰

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.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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();

})();