☰

kurosearch Studio

Enhanced kurosearch.com search and viewer with tags, filters, recommendations, and fullscreen mode.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         kurosearch Studio
// @name:ru      kurosearch Studio
// @namespace    kurosearch-studio
// @version      1.0.1
// @license      MIT
// @description  Enhanced kurosearch.com search and viewer with tags, filters, recommendations, and fullscreen mode.
// @description:ru Интерфейс поиска и просмотра kurosearch.com с тегами, фильтрами, рекомендациями и полноэкранным режимом.
// @match        https://kurosearch.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(() => {
  'use strict';
  if (window.__kxStudio) return;
  window.__kxStudio = true;

  /* ====================== настройки ====================== */
  const API_URLS = [
    'https://rule34-api.netlify.app',
    'https://rule-34-api-vercel.vercel.app',
    'https://rule-34-api.shirozenzen.workers.dev'
  ];
  const AUTOCOMPLETE_URL = 'https://api.rule34.xxx/autocomplete.php';
  const LIMIT = 20;            // размер страницы API (как на самом сайте)
  const MIN_NEW = 24;          // сколько постов добирать за один раз (с учётом фильтров)
  const MAX_PAGES_PER_LOAD = 10;
  const FLIP_FRAMES = 6;       // сколько кадров крутить в превью видео
  const FLIP_MS = 240;         // скорость смены кадров, мс
  const NEXT_LIST_SIZE = 60;   // сколько артов показывать в правом списке «дальше»

  const CFG_KEY = 'kx-studio-v2';
  const DEFAULTS = {
    layout: 'feed',            // 'feed' — лента, 'grid' — сетка по 3 колонки
    sortProp: 'id',
    sortDir: 'desc',
    rating: 'all',
    minScore: 0,
    showImage: true,
    showGif: true,
    showVideo: true,
    blocked: '',               // автоскрытие тегов (поддерживает *)
    required: '',
    minW: 0,
    minH: 0,
    hideComics: false,
    tags: [],                  // [{m:'+'|'-'|'~', n:'tag_name'}]
    feed: 'foryou',            // 'all' — обычная выдача, 'foryou' — лента по вашим вкусам
    views: {},                 // сколько раз вы смотрели арты с тегом (профиль вкусов)
    favorites: [],             // избранные теги
    filtersOpen: false,
    lang: 'ru',
    ageOk: false
  };

  const loadCfg = () => {
    try { return { ...DEFAULTS, ...JSON.parse(localStorage.getItem(CFG_KEY) || '{}') }; }
    catch { return { ...DEFAULTS }; }
  };
  let cfg = loadCfg();
  if (!['ru', 'en'].includes(cfg.lang)) cfg.lang = 'ru';
  if (!cfg.views || typeof cfg.views !== 'object' || Array.isArray(cfg.views)) cfg.views = {};
  if (!Array.isArray(cfg.favorites)) cfg.favorites = [];
  const mediaSettingsValid = (cfg.showImage && cfg.showGif && cfg.showVideo) ||
    (cfg.showImage && !cfg.showGif && !cfg.showVideo) || (!cfg.showImage && cfg.showGif && cfg.showVideo);
  if (!mediaSettingsValid) cfg.showImage = cfg.showGif = cfg.showVideo = true;
  const saveCfg = () => { try { localStorage.setItem(CFG_KEY, JSON.stringify(cfg)); } catch {} };

  const TEXT = {
    ru: {
      languageLabel: 'Язык',
      origTitle: 'Показать оригинальный сайт',
      searchPlaceholder: 'Теги…  «-» исключить, «~» любой из',
      sortLabel: 'Сортировка',
      newest: 'Новые',
      highestScore: 'По оценке',
      recentlyUpdated: 'Обновлённые',
      random: 'Случайные',
      sortDirection: 'Направление',
      mediaLabel: 'Медиа',
      all: 'Все',
      images: 'Арты',
      animated: 'GIF и видео',
      blockedLabel: 'Автоскрытие тегов (ПКМ по тегу, чтобы добавить)',
      blockedPlaceholder: 'Тег или маска * — Enter',
      requiredLabel: 'Обязательные теги',
      hideComics: 'Скрывать комиксы',
      reset: 'Сбросить',
      favoritesTab: '★ Избранные',
      filtersTab: 'Фильтры',
      forYou: '✦ Для вас',
      closeViewer: 'Закрыть (Esc)',
      fullscreen: 'На весь экран (или клик по арту)',
      gateTitle: 'Только для взрослых',
      gateCopy: 'Сайт содержит материалы 18+. Продолжая, вы подтверждаете, что вам исполнилось 18 лет и просмотр такого контента разрешён там, где вы находитесь.',
      ageConfirm: 'Мне есть 18',
      leave: 'Уйти',
      backToStudio: 'Вернуться в Studio',
      currentTasteFeed: '✦ по вашим вкусам',
      posts: 'постов',
      shown: 'показано',
      hidden: 'скрыто',
      apiUnavailable: 'API недоступно',
      video: 'видео',
      grid: '▦ Сетка 3×',
      feed: '▤ Лента',
      loadFailed: 'Не удалось загрузить: ',
      retry: 'Повторить',
      nothingFound: 'Ничего не найдено. Попробуйте смягчить фильтры.',
      endOfResults: '— конец выдачи —',
      forYouFallback: '✦ «Для вас» пока работает как обычная лента. Добавьте избранные теги (ПКМ по тегу → «Добавить в избранное») или посмотрите несколько артов, и лента подстроится под вас.',
      addFavorite: 'Добавить в избранное',
      removeFavorite: 'Убрать из избранного',
      hideTag: 'Скрыть тег',
      unhideTag: 'Не скрывать тег',
      removeBlocked: 'Убрать из автоскрытия',
      cycleTag: 'Клик — сменить режим (+ / − / ~)',
      exclude: 'Исключить',
      anyOf: 'Любой из',
      currentResults: 'В текущей выдаче',
      enterTag: 'Начните вводить тег',
      tagHints: 'Подсказки появятся здесь по мере ввода, а после загрузки арты предложат свои теги.',
      postTags: 'Теги арта',
      postTagHelp: 'Клик — добавить в поиск, ПКМ — избранное или скрыть.',
      favoritesTitle: '★ Избранные теги',
      emptyFavorites: 'Пока пусто. Нажмите правой кнопкой по любому тегу и выберите «Добавить в избранное». На избранных авторах и персонажах строится лента «Для вас».',
      artists: 'Авторы',
      characters: 'Персонажи',
      copyrights: 'Лицензии',
      others: 'Остальные',
      interests: 'Похоже, вам нравится',
      interestsHelp: 'Собрано по просмотренным артам. ПКМ → избранное.',
      suggestions: 'Подсказки',
      enterToAdd: 'Enter — добавить «{term}»',
      next: 'Дальше',
      lastPost: 'Это последний арт',
      loading: 'Загрузка…',
      loadingOriginal: 'Загружаю оригинал…',
      originalFailed: 'Оригинал не загрузился, показано превью',
      post: 'Пост',
      file: 'Файл',
      source: 'Источник'
    },
    en: {
      languageLabel: 'Language',
      origTitle: 'Show original website',
      searchPlaceholder: 'Tags…  “-” exclude, “~” any of',
      sortLabel: 'Sort',
      newest: 'Newest',
      highestScore: 'Highest score',
      recentlyUpdated: 'Recently updated',
      random: 'Random',
      sortDirection: 'Sort direction',
      mediaLabel: 'Media',
      all: 'All',
      images: 'Images',
      animated: 'GIFs & videos',
      blockedLabel: 'Auto-hide tags (right-click a tag to add)',
      blockedPlaceholder: 'Tag or wildcard * — Enter',
      requiredLabel: 'Required tags',
      hideComics: 'Hide comics',
      reset: 'Reset',
      favoritesTab: '★ Favorites',
      filtersTab: 'Filters',
      forYou: '✦ For you',
      closeViewer: 'Close (Esc)',
      fullscreen: 'Fullscreen (or click the artwork)',
      gateTitle: 'Adults only',
      gateCopy: 'This site contains 18+ material. By continuing, you confirm that you are at least 18 years old and that viewing this content is permitted where you are located.',
      ageConfirm: 'I am 18 or older',
      leave: 'Leave',
      backToStudio: 'Return to Studio',
      currentTasteFeed: '✦ For you',
      posts: 'posts',
      shown: 'shown',
      hidden: 'hidden',
      apiUnavailable: 'API unavailable',
      video: 'video',
      grid: '▦ Grid 3×',
      feed: '▤ Feed',
      loadFailed: 'Could not load: ',
      retry: 'Retry',
      nothingFound: 'Nothing found. Try loosening your filters.',
      endOfResults: '— end of results —',
      forYouFallback: '✦ Your For you feed is using the regular results for now. Add favorite tags (right-click a tag → “Add to favorites”) or view a few artworks so the feed can learn your preferences.',
      addFavorite: 'Add to favorites',
      removeFavorite: 'Remove from favorites',
      hideTag: 'Hide tag',
      unhideTag: 'Unhide tag',
      removeBlocked: 'Remove from auto-hide',
      cycleTag: 'Click to change mode (+ / − / ~)',
      exclude: 'Exclude',
      anyOf: 'Any of',
      currentResults: 'In current results',
      enterTag: 'Start typing a tag',
      tagHints: 'Suggestions will appear as you type; loaded artworks will also provide tags.',
      postTags: 'Artwork tags',
      postTagHelp: 'Click to search; right-click to favorite or hide.',
      favoritesTitle: '★ Favorite tags',
      emptyFavorites: 'Nothing here yet. Right-click any tag and choose “Add to favorites”. Favorite artists and characters help build your For you feed.',
      artists: 'Artists',
      characters: 'Characters',
      copyrights: 'Copyrights',
      others: 'Other',
      interests: 'You may like',
      interestsHelp: 'Based on artworks you viewed. Right-click a tag to favorite it.',
      suggestions: 'Suggestions',
      enterToAdd: 'Press Enter to add “{term}”',
      next: 'Up next',
      lastPost: 'This is the last artwork',
      loading: 'Loading…',
      loadingOriginal: 'Loading original…',
      originalFailed: 'Could not load the original; showing preview',
      post: 'Post',
      file: 'File',
      source: 'Source'
    }
  };
  const t = (key) => TEXT[cfg.lang][key];

  /* ====================== утилиты ====================== */
  const el = (tag, cls, text) => {
    const e = document.createElement(tag);
    if (cls) e.className = cls;
    if (text != null) e.textContent = text;
    return e;
  };
  const debounce = (fn, ms) => {
    let t;
    return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
  };
  const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
  const decodeEntities = (s) =>
    String(s).replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
      .replace(/&quot;/g, '"').replace(/&#0?39;/g, "'");
  const splitTags = (s) => s.toLowerCase().split(/[\s,]+/).map((t) => t.trim()).filter(Boolean);
  const toRe = (pat) =>
    new RegExp('^' + pat.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
  const normName = (s) => s.trim().toLowerCase().replace(/\s+/g, '_');

  const isImg = (u = '') => /\.(jpe?g|png|webp)(\?|$)/i.test(u);
  const isGifUrl = (u = '') => /\.gif(\?|$)/i.test(u);
  const isVidUrl = (u = '') => /\.(mp4|webm)(\?|$)/i.test(u);
  const postType = (file) => (isVidUrl(file) ? 'video' : isGifUrl(file) ? 'gif' : 'image');

  /* ====================== состояние ====================== */
  const S = {
    gen: 0, ctrl: null, streams: [], noProfile: false,
    ids: new Set(), raw: [], view: [], rendered: 0,
    loading: false, ended: false, count: null, err: null,
    tagFreq: new Map(),
    fs: -1, cols: [], colH: [],
    sug: [], hl: -1
  };

  /* ====================== API ====================== */
  let apiIdx = 0;
  async function api(path, signal) {
    let lastErr;
    for (let k = 0; k < API_URLS.length; k++) {
      const i = (apiIdx + k) % API_URLS.length;
      try {
        const r = await fetch(API_URLS[i] + path, { signal });
        if (!r.ok) throw new Error('HTTP ' + r.status);
        apiIdx = i;
        return r;
      } catch (e) {
        if (e && e.name === 'AbortError') throw e;
        lastErr = e;
      }
    }
    throw lastErr || new Error(t('apiUnavailable'));
  }


  /* ====================== типы тегов (цвета и порядок) ====================== */
  const TYPE_KEY = 'kx-tagtypes-v1';
  const TYPE_IDS = ['general', 'artist', 'general', 'copyright', 'character', 'metadata']; // id как у сайта
  const TYPE_ORDER = { artist: 0, character: 1, copyright: 2, source: 3, metadata: 4, rating: 5, general: 6 };
  let typeCache = {};
  try { typeCache = JSON.parse(localStorage.getItem(TYPE_KEY) || '{}'); } catch {}
  const normalizeType = (type) => /^\d+$/.test(String(type)) ? TYPE_IDS[Number(type)] || 'general' : type;
  Object.keys(typeCache).forEach((n) => { typeCache[n] = normalizeType(typeCache[n]); });
  const saveTypes = debounce(() => {
    try {
      const keys = Object.keys(typeCache);
      if (keys.length > 6000) for (const k of keys.slice(0, keys.length - 5000)) delete typeCache[k];
      localStorage.setItem(TYPE_KEY, JSON.stringify(typeCache));
    } catch {}
  }, 1000);
  const typeOf = (n) => typeCache[n];
  const typeRank = (n) => (typeCache[n] in TYPE_ORDER ? TYPE_ORDER[typeCache[n]] : 7);
  const typeCls = (n) => (typeCache[n] && typeCache[n] !== 'general' ? ' t-' + typeCache[n] : '');
  const byImportance = (a, b) => typeRank(a) - typeRank(b) || (a < b ? -1 : a > b ? 1 : 0);

  const typeQueue = [], typeBusy = new Set(), typeFailed = new Set();
  let typeActive = 0, typeFails = 0, typeTrip = null;
  const onTypesUpdated = debounce(() => {
    renderActive();
    paintSuggestions();
    renderTagPane(true);
    if (tab === 'fav') renderFavPanel();
  }, 250);

  function wantTypes(names, urgent) {
    const fresh = names.filter((n) => n && !typeCache[n] && !typeBusy.has(n) && !typeFailed.has(n));
    fresh.forEach((n) => typeBusy.add(n));
    if (urgent) typeQueue.unshift(...fresh); else typeQueue.push(...fresh);
    pumpTypes();
  }
  function pumpTypes() {
    if (typeFails >= 6) {
      if (!typeTrip) typeTrip = setTimeout(() => { typeFails = 0; typeTrip = null; pumpTypes(); }, 60000);
      return;
    }
    while (typeActive < 4 && typeQueue.length) {
      const n = typeQueue.shift();
      typeActive++;
      fetchType(n).finally(() => { typeActive--; typeBusy.delete(n); pumpTypes(); });
    }
  }
  async function fetchType(n) {
    try {
      const r = await api(`/tag-details?name=${encodeURIComponent(n)}`);
      const m = /<tag\b[^>]*?\btype="(\d+)"/.exec(await r.text());
      if (m) { typeCache[n] = TYPE_IDS[Number(m[1])] || 'general'; typeFails = 0; saveTypes(); onTypesUpdated(); }
      else typeFailed.add(n);
    } catch { typeFails++; typeFailed.add(n); }
  }

  /* ====================== вкусы и рекомендации ====================== */
  let tab = '';
  const TYPE_MULT = { artist: 3, character: 2.2, copyright: 1.6, general: 0.15, metadata: 0.02 };
  const seenViews = new Set();
  function affinity(t) {
    const ty = typeCache[t] || 'general';
    let w = Math.log2(1 + (cfg.views[t] || 0)) * 4 * (TYPE_MULT[ty] ?? 0.15);
    if (cfg.favorites.includes(t)) w += ty === 'artist' ? 60 : ty === 'general' ? 8 : 24;
    return w;
  }
  const postScore = (p) => p.tags.reduce((sum, t) => sum + affinity(t), 0) / Math.sqrt(p.tags.length || 1);
  function recordView(p) {
    if (seenViews.has(p.id)) return;
    seenViews.add(p.id);
    for (const t of p.tags) cfg.views[t] = (cfg.views[t] || 0) + 1;
    const keys = Object.keys(cfg.views);
    if (keys.length > 800) keys.sort((a, b) => cfg.views[a] - cfg.views[b]).slice(0, keys.length - 600).forEach((k) => delete cfg.views[k]);
    saveCfg();
  }
  // теги, которые вы смотрите чаще всего (только авторы / персонажи / лицензии)
  function interestTags(limit, minViews = 3) {
    return Object.entries(cfg.views)
      .filter(([t, v]) => v >= minViews && ['artist', 'character', 'copyright'].includes(typeCache[t]))
      .map(([t, v]) => [t, Math.log2(1 + v) * TYPE_MULT[typeCache[t]]])
      .sort((a, b) => b[1] - a[1]).slice(0, limit).map((e) => e[0]);
  }
  function profile() {
    const blocked = splitTags(cfg.blocked).map(toRe);
    const inSearch = new Set(cfg.tags.map((t) => t.n));
    const ok = (n) => !inSearch.has(n) && !blocked.some((re) => re.test(n));
    const artists = cfg.favorites.filter((f) => typeCache[f] === 'artist' && ok(f));
    const favOther = cfg.favorites.filter((f) => ['character', 'copyright'].includes(typeCache[f]) && ok(f));
    const learned = interestTags(30).filter((n) => ok(n) && !artists.includes(n) && !favOther.includes(n));
    return { artists, disc: [...favOther, ...learned].slice(0, 8) };
  }

  const encTag = (n) => encodeURIComponent(n);

  const orGroup = (names) => `( ${names.map(encTag).join(' ~ ')} )`;
  function buildQuery(extra = []) {
    const parts = [`sort:${cfg.sortProp}:${cfg.sortDir}`];
    const plus = cfg.tags.filter((t) => t.m === '+').map((t) => encTag(t.n));
    const minus = cfg.tags.filter((t) => t.m === '-').map((t) => '-' + encTag(t.n));
    const any = cfg.tags.filter((t) => t.m === '~').map((t) => encTag(t.n));
    parts.push(...plus, ...minus);
    if (any.length) parts.push(`( ${any.join(' ~ ')} )`);
    parts.push(...extra);
    return parts.join('+');
  }

  function normPost(p) {
    const info = Array.isArray(p.tag_info) ? p.tag_info : null;
    let tags;
    if (info) {
      tags = info.map((t) => decodeEntities(t.tag).toLowerCase());
      info.forEach((t) => { if (t.type != null) typeCache[decodeEntities(t.tag).toLowerCase()] = normalizeType(t.type); });
      saveTypes();
    } else {
      tags = String(p.tags || '').split(' ').filter(Boolean).map((t) => decodeEntities(t).toLowerCase());
    }
    const file = p.file_url || '';
    return {
      id: Number(p.id),
      w: Number(p.width) || 0,
      h: Number(p.height) || 0,
      score: Number(p.score) || 0,
      rating: p.rating || '',
      file,
      sample: p.sample_url || '',
      prev: p.preview_url || '',
      source: p.source || '',
      type: postType(file),
      tags
    };
  }

  const thumbSrc = (p) => {
    if (p.type === 'image') return isImg(p.sample) ? p.sample : p.prev || p.file;
    if (p.type === 'gif') return isGifUrl(p.sample) ? p.prev : p.sample || p.prev;
    return p.prev || p.sample;
  };
  const videoSrc = (p) => (isVidUrl(p.sample) ? p.sample : p.file);
  const ratioOf = (p) => (p.w && p.h ? clamp(p.w / p.h, 0.4, 2.5) : 1);

  /* ====================== клиентские фильтры ====================== */
  let keep = () => true;
  function rebuildKeep() {
    const blocked = splitTags(cfg.blocked).map(toRe);
    const req = splitTags(cfg.required);
    keep = (p) => {
      if (p.type === 'image' && !cfg.showImage) return false;
      if (p.type === 'gif' && !cfg.showGif) return false;
      if (p.type === 'video' && !cfg.showVideo) return false;
      if (cfg.hideComics && p.w && p.h && p.w / p.h < 0.4) return false;
      if (blocked.length && p.tags.some((t) => blocked.some((re) => re.test(t)))) return false;
      if (req.length && !req.every((t) => p.tags.includes(t))) return false;
      return true;
    };
  }
  rebuildKeep();

  /* ====================== стили ====================== */
  const CSS = `
  #kx-root{--bg:#0b0b11;--fg:#ececf6;--mut:#8d8da4;--sur:rgba(255,255,255,.055);--sur2:rgba(255,255,255,.10);
    --acc:#8b5cf6;--acc2:#ec4899;--inc:rgba(139,92,246,.28);--exc:rgba(244,63,94,.28);--any:rgba(245,158,11,.26);
    --r:20px;--ease:cubic-bezier(.22,.8,.3,1);
    position:fixed;inset:0;z-index:2147483000;display:flex;background:var(--bg);color:var(--fg);
    font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
  #kx-root.away{display:none}
  #kx-root *{box-sizing:border-box;scrollbar-width:thin;scrollbar-color:#ffffff24 transparent}
  :where(#kx-root) :where(button,input,select,textarea){font:inherit;color:inherit;border:0;outline:0}
  :where(#kx-root) button{cursor:pointer;background:none}
  #kx-root :where(.kx-tag,.kx-chip,.kx-sug,.kx-li,.kx-ico,.kx-pill,.kx-seg button,.kx-tabs button,.kx-blocked-tag,.kx-tagmenu button){
    cursor:pointer;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}
  .kx-side{width:min(390px,100%);flex:none;min-height:0;display:flex;flex-direction:column;gap:14px;padding:20px 18px 16px;
    background:radial-gradient(120% 60% at 0% 0%,#1d1530 0%,transparent 60%),linear-gradient(180deg,#14141d,#0e0e15);
    box-shadow:10px 0 50px #0009;z-index:3}
  .kx-brand{display:flex;align-items:center;gap:10px}
  .kx-logo{font-weight:800;font-size:20px;letter-spacing:.3px;color:#ef2f3f;text-shadow:0 0 22px rgba(239,47,63,.35)}
  .kx-lang{width:82px;padding:7px 9px;border-radius:12px;background:var(--sur);cursor:pointer}
  .kx-sp{flex:1}
  .kx-ico{width:34px;height:34px;border-radius:50%;background:var(--sur);display:grid;place-items:center;transition:.25s var(--ease)}
  .kx-ico:hover{background:var(--sur2);transform:scale(1.06)}
  .kx-search{display:flex;align-items:center;gap:10px;padding:0 16px;height:50px;border-radius:999px;background:var(--sur);
    box-shadow:inset 0 2px 10px #0006;transition:.3s var(--ease)}
  .kx-search:focus-within{background:var(--sur2);box-shadow:inset 0 2px 10px #0006,0 0 0 4px rgba(139,92,246,.18)}
  .kx-search input{flex:1;min-width:0;background:none;font-size:15px}
  .kx-search input::placeholder{color:var(--mut)}
  .kx-active{display:flex;flex-wrap:wrap;gap:7px;min-height:0}
  .kx-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 8px 6px 12px;border-radius:999px;background:var(--inc);
    transition:.25s var(--ease);max-width:100%;cursor:pointer}
  .kx-chip.m-minus{background:var(--exc)} .kx-chip.m-any{background:var(--any)}
  .kx-chip::before,.kx-sug::before{content:"";flex:none;width:8px;height:8px;border-radius:50%;background:var(--tc,#6b6b80)}
  .kx-chip{padding-left:10px}
  .t-artist{--tc:#ef4444;--tb:rgba(239,68,68,.28);--tt:#fecaca}
  .t-character{--tc:#22c55e;--tb:rgba(34,197,94,.25);--tt:#bbf7d0}
  .t-copyright{--tc:#eab308;--tb:rgba(234,179,8,.25);--tt:#fef08a}
  .t-metadata{--tc:#38bdf8;--tb:rgba(56,189,248,.22);--tt:#bae6fd}
  .kx-chip:hover{transform:translateY(-1px);filter:brightness(1.2)}
  .kx-chip .n{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .kx-chip .x{width:20px;height:20px;border-radius:50%;background:#ffffff1f;display:grid;place-items:center;font-size:12px;line-height:1}
  .kx-chip .x:hover{background:#ffffff40}
  .kx-tagpane{flex:1;min-height:0;overflow:auto;border-radius:var(--r);background:var(--sur);padding:14px;
    display:flex;flex-direction:column;gap:12px}
  .kx-sec{font-size:11px;letter-spacing:.9px;text-transform:uppercase;color:var(--mut);margin:0 0 2px}
  .kx-cloud{display:flex;flex-wrap:wrap;gap:7px}
  .kx-tag{display:inline-flex;align-items:center;gap:7px;padding:6px 6px 6px 12px;border-radius:999px;
    background:var(--tb,rgba(255,255,255,.09));color:var(--tt,#d6d6e2);transition:.25s var(--ease);max-width:100%;cursor:pointer}
  .kx-tag:hover{filter:brightness(1.4);transform:translateY(-1px)}
  .kx-tag .n{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .kx-tag .c{color:var(--mut);font-size:12px}
  .kx-tag .ex{width:20px;height:20px;border-radius:50%;background:#ffffff1a;display:grid;place-items:center;font-size:13px;line-height:1;opacity:.6}
  .kx-tag .ex:hover{background:var(--exc);opacity:1}
  .kx-sug{display:flex;align-items:center;gap:8px;padding:9px 8px 9px 14px;border-radius:14px;transition:.2s var(--ease);cursor:pointer}
  .kx-sug:hover,.kx-sug.hl{background:var(--tb,rgba(255,255,255,.1))}
  .kx-sug .n{color:var(--tt,var(--fg))}
  .kx-tag.used{opacity:.45}
  .kx-sug .n{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .kx-sug .c{color:var(--mut);font-size:12px}
  .kx-sug .b{width:24px;height:24px;border-radius:50%;background:#ffffff14;display:grid;place-items:center;font-size:13px}
  .kx-sug .b:hover{background:var(--sur2)}
  .kx-empty{color:var(--mut);padding:6px 2px}
  .kx-body{flex:1;min-height:0;display:flex;flex-direction:column}
  .kx-panel{flex:1;min-height:0;overflow:auto;border-radius:var(--r);background:var(--sur);padding:14px;display:none}
  .kx-side[data-tab="fav"] #kx-favpanel{display:flex;flex-direction:column;gap:14px;animation:kxin .3s var(--ease) both}
  .kx-side[data-tab="filters"] #kx-filters{display:block;animation:kxin .3s var(--ease) both}
  .kx-side[data-tab="fav"] #kx-tagpane,.kx-side[data-tab="filters"] #kx-tagpane{display:none}
  #kx-filters .kx-fin{padding:0}
  .kx-tabs{display:flex;gap:6px;padding:5px;border-radius:999px;background:var(--sur);flex:none}
  .kx-tabs button{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;padding:11px 10px;border-radius:999px;
    color:var(--mut);transition:.25s var(--ease)}
  .kx-tabs button:hover{color:var(--fg);background:var(--sur)}
  .kx-tabs button.on{background:rgba(255,255,255,.16);color:#fff}
  .kx-badge{min-width:20px;height:20px;padding:0 6px;border-radius:999px;background:linear-gradient(90deg,var(--acc),var(--acc2));
    font-size:12px;display:none;place-items:center;color:#fff}
  .kx-badge.on{display:grid}
  .kx-favmark{color:#facc15;font-size:11px}
  .kx-feedseg{flex:none;width:auto}
  .kx-feedseg button{flex:none;padding:7px 16px}
  .kx-note{max-width:900px;margin:4px auto 14px;padding:10px 18px;border-radius:18px;background:var(--sur);color:var(--mut);font-size:13px;text-align:center}
  .kx-fin{display:flex;flex-direction:column;gap:12px;padding:2px 16px 16px}
  .kx-row{display:flex;align-items:center;gap:8px}
  .kx-row>label,.kx-lbl{color:var(--mut);font-size:12px;min-width:74px}
  .kx-fin select,.kx-fin input[type=number],.kx-fin textarea{background:var(--sur2);border-radius:14px;padding:8px 12px;width:100%;min-width:0}
  .kx-fin select{appearance:none;cursor:pointer}
  .kx-fin select option{background:#1a1a24}
  .kx-fin textarea{min-height:54px;resize:vertical}
  .kx-blocked-list{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px 8px;border-radius:14px;background:var(--sur2);min-height:42px}
  .kx-blocked-list input{flex:1;min-width:110px;background:none;padding:4px}
  .kx-blocked-tag{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;background:var(--exc);font-size:12px;max-width:100%}
  .kx-blocked-tag span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .kx-blocked-tag b{font-size:13px;font-weight:400}
  .kx-seg{display:flex;flex:1;gap:4px;padding:4px;border-radius:999px;background:var(--sur)}
  .kx-seg button{flex:1;padding:6px 4px;border-radius:999px;font-size:12px;color:var(--mut);transition:.25s var(--ease)}
  .kx-seg button.on{background:rgba(255,255,255,.18);color:var(--fg)}
  .kx-pill{padding:8px 14px;border-radius:999px;background:var(--sur2);transition:.25s var(--ease);font-size:13px}
  .kx-pill:hover{background:rgba(139,92,246,.35)}
  .kx-pill.on{background:linear-gradient(90deg,var(--acc),var(--acc2))}
  .kx-main{flex:1;min-width:0;min-height:0;display:flex;flex-direction:column;position:relative}
  .kx-head{display:flex;align-items:center;gap:12px;padding:12px 20px;background:rgba(11,11,17,.72);backdrop-filter:blur(18px);
    -webkit-backdrop-filter:blur(18px);z-index:2}
  .kx-stat{color:var(--mut);font-size:13px;display:flex;align-items:center;gap:10px}
  .kx-spin{width:14px;height:14px;border-radius:50%;background:conic-gradient(var(--acc),var(--acc2),transparent);
    animation:kxs .8s linear infinite;-webkit-mask:radial-gradient(circle 4px at center,transparent 98%,#000);mask:radial-gradient(circle 4px at center,transparent 98%,#000)}
  @keyframes kxs{to{transform:rotate(360deg)}}
  .kx-scroll{flex:1;min-height:0;overflow:auto;scroll-behavior:smooth}
  .kx-grid{display:flex;gap:14px;padding:0 20px 20px;margin:0 auto;align-items:flex-start}
  .kx-grid.feed{max-width:900px}
  .kx-col{flex:1;min-width:0;display:flex;flex-direction:column;gap:14px}
  .kx-card{position:relative;border-radius:22px;overflow:hidden;background:#171722;cursor:zoom-in;width:100%;
    box-shadow:0 10px 30px #0006;transition:transform .35s var(--ease),box-shadow .35s var(--ease);animation:kxin .5s var(--ease) both}
  .kx-card:hover{transform:translateY(-3px) scale(1.012);box-shadow:0 18px 44px #000a}
  @keyframes kxin{from{opacity:0;transform:translateY(14px)}}
  .kx-card img,.kx-card video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top;display:block}
  .kx-card video{opacity:0;transition:opacity .3s}
  .kx-card video.on{opacity:1}
  .kx-tg{position:absolute;left:10px;bottom:10px;padding:3px 10px;border-radius:999px;background:rgba(0,0,0,.45);backdrop-filter:blur(8px);font-size:12px}
  .kx-sc{position:absolute;right:10px;bottom:10px;padding:3px 10px;border-radius:999px;background:rgba(0,0,0,.45);backdrop-filter:blur(8px);font-size:12px}
  .kx-msg{text-align:center;color:var(--mut);padding:40px 20px}
  .kx-msg button{margin-top:10px}
  /* полноэкранный режим */
  #kx-fs{position:absolute;inset:0;z-index:10;background:rgba(5,5,9,.96);opacity:0;pointer-events:none;transition:opacity .3s var(--ease)}
  #kx-fs.open{opacity:1;pointer-events:auto}
  .kx-stage{position:absolute;inset:0;transition:padding .3s var(--ease)}
  .kx-stage{cursor:zoom-in}
  #kx-fs.deep{position:fixed;z-index:40;background:#000}
  #kx-fs.deep .kx-stage{cursor:zoom-out}
  #kx-fs.deep .kx-fbar,#kx-fs.deep .kx-nav,#kx-fs.deep .kx-list,#kx-fs.deep .kx-hot{display:none}
  #kx-fs.deep.idle{cursor:none}
  #kx-fs[data-dir="next"].deep .kx-stage>*{animation:kxnext .38s var(--ease)}
  #kx-fs[data-dir="prev"].deep .kx-stage>*{animation:kxprev .38s var(--ease)}
  @keyframes kxnext{from{transform:translateY(70px)}}
  @keyframes kxprev{from{transform:translateY(-70px)}}
  .kx-m{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
  .kx-m.hi{opacity:0;transition:opacity .45s}.kx-m.hi.on{opacity:1}
  .kx-hq{position:absolute;top:64px;left:50%;transform:translateX(-50%);z-index:2;display:flex;align-items:center;gap:8px;
    padding:6px 14px;border-radius:999px;background:rgba(0,0,0,.55);backdrop-filter:blur(10px);font-size:12px;pointer-events:none}
  .kx-stage video{display:block;width:100%;height:100%;object-fit:contain;background:#000}
  .kx-fbar{position:absolute;top:0;left:0;right:0;display:flex;align-items:center;gap:10px;padding:12px 16px;
    background:linear-gradient(180deg,#000b,transparent);z-index:2;transition:opacity .4s var(--ease)}
  .kx-fbar a{color:var(--fg);text-decoration:none;padding:6px 12px;border-radius:999px;background:var(--sur);font-size:13px;transition:.25s}
  .kx-fbar a:hover{background:var(--sur2)}
  .kx-nav{position:absolute;top:50%;transform:translateY(-50%);width:46px;height:46px;border-radius:50%;background:var(--sur);
    font-size:22px;display:grid;place-items:center;z-index:2;transition:.25s var(--ease)}
  .kx-nav:hover{background:var(--sur2);transform:translateY(-50%) scale(1.08)}
  .kx-nav.p{left:12px}.kx-nav.n{right:42px}
  #kx-fs.idle .kx-fbar,#kx-fs.idle .kx-nav{opacity:0;pointer-events:none}
  #kx-fs.idle{cursor:none}
  .kx-tagmenu{position:fixed;z-index:30;min-width:180px;padding:6px;border:1px solid #ffffff1a;border-radius:14px;
    background:#1b1b26;box-shadow:0 12px 36px #000b}
  .kx-tagmenu button{display:block;width:100%;padding:9px 10px;border-radius:9px;text-align:left;font-size:13px}
  .kx-tagmenu button:hover{background:var(--sur2)}
  .kx-hot{position:absolute;top:0;right:0;bottom:0;width:30px;z-index:3}
  .kx-list{position:absolute;top:0;right:0;bottom:0;width:250px;z-index:4;padding:56px 14px 14px;overflow:auto;
    display:flex;flex-direction:column;gap:10px;background:linear-gradient(270deg,rgba(14,14,21,.97) 70%,rgba(14,14,21,.8));
    backdrop-filter:blur(16px);transform:translateX(105%);transition:transform .4s var(--ease);box-shadow:-14px 0 50px #000a}
  .kx-list.open{transform:none}
  .kx-list .t{font-size:11px;letter-spacing:.9px;text-transform:uppercase;color:var(--mut);margin-bottom:2px}
  .kx-li{position:relative;flex:none;border-radius:16px;overflow:hidden;background:#1a1a24;opacity:.72;transition:.25s var(--ease)}
  .kx-li:hover{opacity:1;transform:scale(1.03)}
  .kx-li.cur{opacity:1;transform:scale(1.04);box-shadow:0 0 0 3px var(--acc),0 10px 30px #000a}
  .kx-li img{display:block;width:100%;height:100%;object-fit:cover;object-position:top}
  .kx-li span{position:absolute;left:8px;bottom:8px;padding:2px 8px;border-radius:999px;background:#0008;font-size:11px}
  .kx-listbtn{display:none}
  /* возрастное подтверждение */
  #kx-gate{position:absolute;inset:0;z-index:20;display:grid;place-items:center;background:rgba(8,8,13,.9);backdrop-filter:blur(24px)}
  .kx-gatebox{max-width:420px;margin:20px;padding:30px;border-radius:28px;background:linear-gradient(160deg,#1c1830,#12121a);
    text-align:center;box-shadow:0 30px 80px #000c;display:flex;flex-direction:column;gap:14px}
  .kx-gatebox h2{margin:0;font-size:22px}
  .kx-gatebox p{margin:0;color:var(--mut)}
  #kx-back{position:fixed;left:14px;bottom:14px;z-index:2147483001;display:none;padding:10px 16px;border-radius:999px;border:0;
    background:linear-gradient(90deg,#8b5cf6,#ec4899);color:#fff;font:13px system-ui;cursor:pointer;box-shadow:0 10px 30px #0008}
  #kx-back.on{display:block}
  @media (max-width:900px){
    #kx-root{flex-direction:column}
    .kx-side{width:100%;max-height:50%;padding:12px;gap:10px}
    .kx-nav{display:none}
    .kx-listbtn{display:grid}
  }`;

  /* ====================== каркас ====================== */
  const style = el('style');
  style.textContent = CSS;
  document.head.appendChild(style);

  const root = el('div');
  root.id = 'kx-root';
  root.innerHTML = `
    <aside class="kx-side">
      <div class="kx-brand">
        <span class="kx-logo">kurosearch</span><span class="kx-sp"></span>
        <select class="kx-lang" id="kx-lang" data-i18n-title="languageLabel" data-i18n-aria-label="languageLabel"><option value="ru">Русский</option><option value="en">English</option></select>
        <button class="kx-ico" id="kx-orig" data-i18n-title="origTitle">⇄</button>
      </div>
      <div class="kx-search"><span>⌕</span>
        <input id="kx-q" data-i18n-placeholder="searchPlaceholder" autocomplete="off" spellcheck="false"></div>
      <div class="kx-active" id="kx-active"></div>
      <div class="kx-body">
        <div class="kx-tagpane" id="kx-tagpane"></div>
        <section class="kx-panel" id="kx-favpanel"></section>
        <section class="kx-panel" id="kx-filters"><div class="kx-fin">
          <div class="kx-row"><label data-i18n="sortLabel"></label>
            <select id="kx-sort"><option value="id" data-i18n="newest"></option><option value="score" data-i18n="highestScore"></option><option value="updated" data-i18n="recentlyUpdated"></option><option value="random" data-i18n="random"></option></select>
            <button class="kx-pill" id="kx-dir" data-i18n-title="sortDirection">↓</button></div>
          <div class="kx-row"><label data-i18n="mediaLabel"></label><div class="kx-seg" id="kx-media">
            <button data-mode="all" data-i18n="all"></button><button data-mode="images" data-i18n="images"></button><button data-mode="animated" data-i18n="animated"></button></div></div>
          <div><div class="kx-lbl" data-i18n="blockedLabel"></div>
            <div class="kx-blocked-list" id="kx-blocked-list"><input id="kx-blocked-input" data-i18n-placeholder="blockedPlaceholder"></div></div>
          <div><div class="kx-lbl" data-i18n="requiredLabel"></div><textarea id="kx-required" placeholder="sound animated"></textarea></div>
          <div class="kx-row"><button class="kx-pill" id="kx-comics" data-i18n="hideComics"></button><span class="kx-sp"></span><button class="kx-pill" id="kx-reset" data-i18n="reset"></button></div>
        </div></section>
      </div>
      <nav class="kx-tabs">
        <button id="kx-tab-fav" data-i18n="favoritesTab"></button>
        <button id="kx-tab-filters"><span data-i18n="filtersTab"></span><span class="kx-badge" id="kx-badge"></span></button>
      </nav>
    </aside>
    <main class="kx-main">
      <div class="kx-head">
        <div class="kx-seg kx-feedseg" id="kx-feed"><button data-v="all" data-i18n="all"></button><button data-v="foryou" data-i18n="forYou"></button></div>
        <div class="kx-stat" id="kx-stat"></div><span class="kx-sp"></span>
        <button class="kx-pill" id="kx-layout"></button>
      </div>
      <div class="kx-scroll" id="kx-scroll"><div id="kx-note"></div><div class="kx-grid" id="kx-grid"></div><div id="kx-msg"></div></div>
      <div id="kx-fs">
        <div class="kx-fbar"><button class="kx-ico" id="kx-fclose" data-i18n-title="closeViewer">✕</button>
          <span id="kx-finfo" class="kx-stat"></span><span class="kx-sp"></span><span id="kx-flinks" class="kx-row"></span><button class="kx-ico" id="kx-fdeep" data-i18n-title="fullscreen">⛶</button>
          <button class="kx-ico kx-listbtn" id="kx-flistbtn">▤</button></div>
        <div class="kx-stage" id="kx-stage"></div>
        <button class="kx-nav p" id="kx-fprev">‹</button><button class="kx-nav n" id="kx-fnext">›</button>
        <div class="kx-hot" id="kx-hot"></div>
        <aside class="kx-list" id="kx-list"></aside>
      </div>
    </main>
    <div id="kx-gate" style="display:none"><div class="kx-gatebox">
      <h2 data-i18n="gateTitle"></h2>
      <p data-i18n="gateCopy"></p>
      <button class="kx-pill on" id="kx-gate-ok" data-i18n="ageConfirm"></button>
      <button class="kx-pill" id="kx-gate-no" data-i18n="leave"></button></div></div>
    <div class="kx-tagmenu" id="kx-tagmenu" hidden></div>`;
  document.body.appendChild(root);

  const backBtn = el('button');
  backBtn.id = 'kx-back';
  document.body.appendChild(backBtn);

  const $ = (id) => root.querySelector('#' + id);
  const qEl = $('kx-q'), activeEl = $('kx-active'), tagPane = $('kx-tagpane');
  const scrollEl = $('kx-scroll'), grid = $('kx-grid'), msgEl = $('kx-msg'), statEl = $('kx-stat');
  const fsEl = $('kx-fs'), stage = $('kx-stage'), listEl = $('kx-list');
  const tagMenu = $('kx-tagmenu');
  const sideEl = root.querySelector('.kx-side'), favPanel = $('kx-favpanel');

  function applyStaticLanguage() {
    root.lang = cfg.lang;
    $('kx-lang').value = cfg.lang;
    root.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = t(node.dataset.i18n); });
    root.querySelectorAll('[data-i18n-title]').forEach((node) => { node.title = t(node.dataset.i18nTitle); });
    root.querySelectorAll('[data-i18n-aria-label]').forEach((node) => { node.setAttribute('aria-label', t(node.dataset.i18nAriaLabel)); });
    root.querySelectorAll('[data-i18n-placeholder]').forEach((node) => { node.placeholder = t(node.dataset.i18nPlaceholder); });
    backBtn.textContent = t('backToStudio');
  }

  /* ====================== поиск ====================== */
  async function fetchCount(gen, ctrl, query) {
    try {
      const r = await api(`/count?tags=${query}`, ctrl.signal);
      const m = (await r.text()).match(/count="(\d+)"/);
      if (gen === S.gen && m) { S.count = Number(m[1]); renderStat(); }
    } catch {}
  }

  // «Для вас»: несколько потоков (избранные авторы, любимые персонажи/лицензии, обычная выдача как запас)
  function buildStreams() {
    const base = { q: buildQuery(), pid: 0, ended: false, kind: 'base' };
    if (cfg.feed !== 'foryou' || cfg.tags.some((t) => t.m === '~')) return [base];
    wantTypes(Object.entries(cfg.views).sort((a, b) => b[1] - a[1]).slice(0, 60).map((e) => e[0]));
    const prof = profile();
    const streams = [];
    for (let i = 0; i < prof.artists.length && streams.length < 3; i += 8) {
      streams.push({ q: buildQuery([orGroup(prof.artists.slice(i, i + 8))]), pid: 0, ended: false, kind: 'fav' });
    }
    if (prof.disc.length) streams.push({ q: buildQuery([orGroup(prof.disc)]), pid: 0, ended: false, kind: 'disc' });
    if (!streams.length) { S.noProfile = true; return [base]; }
    base.kind = 'fallback';
    streams.push(base);
    return streams;
  }

  function newSearch() {
    if (S.fs >= 0) closeFS();
    S.gen++;
    if (S.ctrl) S.ctrl.abort();
    S.ctrl = new AbortController();
    Object.assign(S, { streams: [], noProfile: false, ids: new Set(), raw: [], view: [], loading: false, ended: false, count: null, err: null });
    S.tagFreq.clear();
    resetGrid();
    scrollEl.scrollTop = 0;
    if (cfg.ageOk) S.streams = buildStreams();
    renderStat(); renderMsg(); renderNote(); renderTagPane();
    if (!cfg.ageOk) return;
    if (S.streams.length === 1) fetchCount(S.gen, S.ctrl, S.streams[0].q);
    loadMore();
  }
  const scheduleSearch = debounce(newSearch, 280);

  function renderNote() {
    const n = $('kx-note');
    n.replaceChildren();
    if (cfg.ageOk && cfg.feed === 'foryou' && S.noProfile) {
      n.append(el('div', 'kx-note', t('forYouFallback')));
    }
  }

  async function loadMore() {
    if (S.loading || S.ended || !cfg.ageOk || !S.streams.length) return;
    const gen = S.gen, ctrl = S.ctrl;
    S.loading = true; S.err = null;
    renderStat();
    let added = 0, rounds = 0;
    try {
      while (added < MIN_NEW && !S.ended && rounds < MAX_PAGES_PER_LOAD) {
        let live = S.streams.filter((st) => !st.ended && st.kind !== 'fallback');
        if (!live.length) live = S.streams.filter((st) => !st.ended);
        if (!live.length) { S.ended = true; break; }
        let lastErr = null;
        const pages = await Promise.all(live.map(async (st) => {
          try {
            const r = await api(`/posts?limit=${LIMIT}&pid=${st.pid}&tags=${st.q}`, ctrl.signal);
            return { st, data: await r.json() };
          } catch (e) {
            if (e && e.name === 'AbortError') throw e;
            lastErr = e;
            return { st, data: [], failed: true };
          }
        }));
        if (gen !== S.gen) return;
        if (pages.every((x) => x.failed)) throw lastErr;
        rounds++;
        const batch = [];
        for (const { st, data, failed } of pages) {
          st.pid++;
          if (failed || !Array.isArray(data) || data.length === 0) { st.ended = true; continue; }
          for (const rawPost of data) {
            const id = Number(rawPost.id);
            if (S.ids.has(id)) continue;
            S.ids.add(id);
            batch.push(normPost(rawPost));
          }
        }
        if (S.streams.every((st) => st.ended)) S.ended = true;
        if (S.streams.length > 1) { // лучшие по совпадению с вашими вкусами — выше
          for (const q of batch) q.aff = postScore(q);
          batch.sort((a, b) => b.aff - a.aff || b.id - a.id);
        }
        for (const q of batch) {
          S.raw.push(q);
          for (const t of q.tags) S.tagFreq.set(t, (S.tagFreq.get(t) || 0) + 1);
          if (keep(q)) { S.view.push(q); added++; }
        }
      }
    } catch (e) {
      if (e && e.name === 'AbortError') return;
      S.err = e;
    }
    if (gen !== S.gen) return;
    S.loading = false;
    appendCards();
    renderStat(); renderMsg(); renderTagPane(true);
    if (S.fs >= 0 && listEl.querySelector('.kx-lend')) extendFsList(40);
    if (added > 0) ensureFilled();
  }

  function ensureFilled() {
    if (root.classList.contains('away') || S.ended || S.loading) return;
    if (scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 700) loadMore();
  }
  scrollEl.addEventListener('scroll', ensureFilled, { passive: true });

  function refilter() {
    rebuildKeep();
    const curId = S.fs >= 0 ? S.view[S.fs]?.id : null;
    S.view = S.raw.filter(keep);
    resetGrid();
    appendCards();
    if (S.fs >= 0) { // открыт просмотр: удерживаем текущий арт или переходим к следующему подходящему
      if (!S.view.length) closeFS();
      else {
        let ni = S.view.findIndex((q) => q.id === curId);
        if (ni < 0) {
          const ri = S.raw.findIndex((q) => q.id === curId);
          const nxt = S.raw.slice(ri + 1).find(keep);
          ni = nxt ? S.view.findIndex((q) => q.id === nxt.id) : S.view.length - 1;
        }
        S.fs = ni; buildFsList(); markFsCurrent(true); renderFS();
      }
    }
    renderStat(); renderMsg();
    updateBadge();
    if (S.view.length < MIN_NEW && !S.ended) loadMore(); else ensureFilled();
  }
  const scheduleRefilter = debounce(refilter, 350);

  /* ====================== сетка / лента ====================== */
  const colCount = () => (cfg.layout === 'feed' ? 1 : innerWidth < 720 ? 2 : 3);

  const flipIO = 'IntersectionObserver' in window
    ? new IntersectionObserver((entries) => {
        for (const e of entries) (e.isIntersecting ? startFlip : stopFlip)(e.target);
      }, { root: scrollEl, rootMargin: '300px' })
    : null;

  function startFlip(card) {
    if (card._v) return;
    const p = S.view[Number(card.dataset.i)];
    if (!p) return;
    const v = document.createElement('video');
    v.muted = true; v.playsInline = true; v.preload = 'metadata'; v.src = videoSrc(p);
    v.addEventListener('loadedmetadata', () => {
      const d = v.duration;
      if (!isFinite(d) || d <= 0) return;
      const times = Array.from({ length: FLIP_FRAMES }, (_, k) => (d * (k + 0.5)) / FLIP_FRAMES);
      let k = 0;
      v.currentTime = times[0];
      card._t = setInterval(() => { k = (k + 1) % FLIP_FRAMES; v.currentTime = times[k]; }, FLIP_MS);
    });
    v.addEventListener('seeked', () => v.classList.add('on'), { once: true });
    card.append(v);
    card._v = v;
  }
  function stopFlip(card) {
    clearInterval(card._t);
    if (card._v) { card._v.removeAttribute('src'); card._v.load(); card._v.remove(); card._v = null; }
  }

  function makeCard(p, i) {
    const c = el('div', 'kx-card');
    c.dataset.i = i;
    c.tabIndex = 0;
    c.style.aspectRatio = String(ratioOf(p));
    const img = el('img');
    img.loading = 'lazy'; img.decoding = 'async'; img.alt = ''; img.src = thumbSrc(p);
    c.append(img);
    if (p.type !== 'image') c.append(el('span', 'kx-tg', p.type === 'video' ? `▶ ${t('video')}` : 'GIF'));
    c.append(el('span', 'kx-sc', '★ ' + p.score));
    if (p.type === 'video' && flipIO) flipIO.observe(c);
    return c;
  }

  function resetGrid() {
    if (flipIO) grid.querySelectorAll('.kx-card').forEach((c) => { flipIO.unobserve(c); stopFlip(c); });
    grid.replaceChildren();
    const n = colCount();
    grid.className = 'kx-grid ' + (n === 1 ? 'feed' : 'multi');
    S.colH = Array(n).fill(0);
    S.cols = Array.from({ length: n }, () => { const c = el('div', 'kx-col'); grid.append(c); return c; });
    S.rendered = 0;
  }

  function appendCards() {
    for (; S.rendered < S.view.length; S.rendered++) {
      const p = S.view[S.rendered];
      let k = 0;
      for (let j = 1; j < S.colH.length; j++) if (S.colH[j] < S.colH[k]) k = j;
      S.cols[k].append(makeCard(p, S.rendered));
      S.colH[k] += 1 / ratioOf(p) + 0.04;
    }
  }

  grid.addEventListener('click', (e) => {
    const c = e.target.closest('.kx-card');
    if (c) openFS(Number(c.dataset.i));
  });
  grid.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') { const c = e.target.closest('.kx-card'); if (c) openFS(Number(c.dataset.i)); }
  });

  addEventListener('resize', debounce(() => {
    if (colCount() !== S.cols.length) { resetGrid(); appendCards(); }
  }, 200));

  function renderStat() {
    statEl.replaceChildren();
    if (S.loading) statEl.append(el('span', 'kx-spin'));
    const parts = [];
    if (S.streams.length > 1) parts.push(t('currentTasteFeed'));
    if (S.count != null) parts.push(S.count.toLocaleString(cfg.lang === 'ru' ? 'ru-RU' : 'en-US') + ' ' + t('posts'));
    parts.push(t('shown') + ' ' + S.view.length + (S.raw.length !== S.view.length ? ` (${t('hidden')} ${S.raw.length - S.view.length})` : ''));
    statEl.append(el('span', null, parts.join(' · ')));
    $('kx-layout').textContent = cfg.layout === 'feed' ? t('grid') : t('feed');
  }

  function renderMsg() {
    msgEl.replaceChildren();
    if (!cfg.ageOk) return;
    if (S.err) {
      const d = el('div', 'kx-msg', t('loadFailed') + (S.err.message || S.err));
      const b = el('button', 'kx-pill', t('retry'));
      b.onclick = () => { S.err = null; loadMore(); };
      d.append(el('br'), b);
      msgEl.append(d);
    } else if (!S.loading && S.view.length === 0 && (S.ended || S.raw.length)) {
      msgEl.append(el('div', 'kx-msg', t('nothingFound')));
    } else if (S.ended && S.view.length) {
      msgEl.append(el('div', 'kx-msg', t('endOfResults')));
    }
  }

  $('kx-layout').onclick = () => {
    cfg.layout = cfg.layout === 'feed' ? 'grid' : 'feed';
    saveCfg();
    resetGrid(); appendCards(); renderStat();
    ensureFilled();
  };

  /* ====================== теги: поиск и подсказки ====================== */
  function parseInput(v) {
    let mod = '+', t = v.trimStart();
    if (t[0] === '-') { mod = '-'; t = t.slice(1); }
    else if (t[0] === '~') { mod = '~'; t = t.slice(1); }
    else if (t[0] === '+') t = t.slice(1);
    return { mod, term: normName(t) };
  }

  function addTag(name, mod = '+') {
    name = normName(name);
    if (!name) return;
    if (S.fs >= 0) closeFS();
    const ex = cfg.tags.find((t) => t.n === name);
    if (ex) ex.m = mod; else cfg.tags.push({ m: mod, n: name });
    saveCfg();
    qEl.value = '';
    S.sug = []; S.hl = -1;
    renderActive(); renderTagPane();
    newSearch();
  }
  function removeTag(name) {
    cfg.tags = cfg.tags.filter((t) => t.n !== name);
    saveCfg(); renderActive(); renderTagPane(); newSearch();
  }
  function cycleTag(name) {
    const t = cfg.tags.find((x) => x.n === name);
    if (!t) return;
    t.m = t.m === '+' ? '-' : t.m === '-' ? '~' : '+';
    saveCfg(); renderActive(); newSearch();
  }

  const isFav = (n) => cfg.favorites.includes(n);
  function toggleFav(name) {
    const i = cfg.favorites.indexOf(name);
    if (i >= 0) cfg.favorites.splice(i, 1); else cfg.favorites.push(name);
    saveCfg();
    renderActive(); renderTagPane();
    if (tab === 'fav') renderFavPanel();
  }

  function showTagMenu(name, e) {
    e.preventDefault();
    tagMenu.replaceChildren();
    const option = (text, action) => {
      const b = el('button', null, text);
      b.onclick = () => { closeTagMenu(); action(); };
      tagMenu.append(b);
    };
    option(isFav(name) ? t('removeFavorite') : t('addFavorite'), () => toggleFav(name));
    const blocked = splitTags(cfg.blocked);
    const isBlocked = blocked.includes(name);
    option(isBlocked ? t('unhideTag') : t('hideTag'), () => {
      setBlockedTags(isBlocked ? blocked.filter((n) => n !== name) : [...blocked, name]);
    });
    tagMenu.hidden = false;
    const x = Math.min(e.clientX, innerWidth - tagMenu.offsetWidth - 8);
    const y = Math.min(e.clientY, innerHeight - tagMenu.offsetHeight - 8);
    tagMenu.style.left = Math.max(8, x) + 'px';
    tagMenu.style.top = Math.max(8, y) + 'px';
  }
  function closeTagMenu() {
    tagMenu.hidden = true;
  }
  root.addEventListener('contextmenu', (e) => {
    const tag = e.target.closest('.kx-tag,.kx-chip,.kx-sug,.kx-ftag');
    if (!tag) { closeTagMenu(); return; }
    const name = tag.dataset.n || tag.querySelector('.n')?.textContent?.replace(/^[−~] /, '');
    if (name) showTagMenu(normName(name), e);
  });
  root.addEventListener('click', (e) => {
    if (!e.target.closest('#kx-tagmenu')) closeTagMenu();
  });
  document.addEventListener('pointerdown', (e) => {
    if (!tagMenu.hidden && !tagMenu.contains(e.target)) closeTagMenu();
  });
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && !tagMenu.hidden) {
      e.preventDefault();
      e.stopPropagation();
      closeTagMenu();
    }
  }, true);

  function setBlockedTags(tags) {
    cfg.blocked = [...new Set(tags.map((t) => t.toLowerCase()))].join(' ');
    clientChange();
  }
  function renderBlockedTags() {
    const wrap = $('kx-blocked-list');
    const input = $('kx-blocked-input');
    wrap.querySelectorAll('.kx-blocked-tag').forEach((tag) => tag.remove());
    splitTags(cfg.blocked).forEach((name) => {
      const chip = el('button', 'kx-blocked-tag');
      chip.type = 'button';
      chip.title = t('removeBlocked');
      chip.append(el('span', null, name), el('b', null, '×'));
      chip.onclick = () => setBlockedTags(splitTags(cfg.blocked).filter((t) => t !== name));
      wrap.insertBefore(chip, input);
    });
  }
  function commitBlockedInput() {
    const input = $('kx-blocked-input');
    const incoming = splitTags(input.value);
    if (!incoming.length) return;
    setBlockedTags([...splitTags(cfg.blocked), ...incoming]);
    input.value = '';
  }

  function renderActive() {
    activeEl.replaceChildren();
    for (const tag of cfg.tags) {
      const chip = el('span', 'kx-chip ' + (tag.m === '-' ? 'm-minus' : tag.m === '~' ? 'm-any' : '') + typeCls(tag.n));
      chip.dataset.n = tag.n;
      chip.title = t('cycleTag');
      chip.append(el('span', 'n', (tag.m === '-' ? '− ' : tag.m === '~' ? '~ ' : '') + tag.n));
      const x = el('button', 'x', '✕');
      x.onclick = (e) => { e.stopPropagation(); removeTag(tag.n); };
      chip.append(x);
      chip.onclick = () => cycleTag(tag.n);
      activeEl.append(chip);
    }
    wantTypes(cfg.tags.map((t) => t.n));
  }

  function tagChip(name, count) {
    const b = el('span', 'kx-tag' + typeCls(name));
    b.dataset.n = name;
    const nm = el('span', 'n', name);
    if (cfg.favorites.includes(name)) nm.append(el('span', 'kx-favmark', ' ★'));
    b.append(nm);
    if (count != null) b.append(el('span', 'c', String(count)));
    if (cfg.tags.some((t) => t.n === name)) b.classList.add('used');
    const ex = el('button', 'ex', '−');
    ex.title = t('exclude');
    ex.onclick = (e) => { e.stopPropagation(); addTag(name, '-'); };
    b.append(ex);
    b.onclick = () => addTag(name, '+');
    return b;
  }

  function renderTagPane(soft) {
    const { term } = parseInput(qEl.value);
    if (term.length >= 2) { renderSuggestions(); return; }
    const mode = S.fs >= 0 ? 'post' : 'default';
    if (soft && tagPane.dataset.mode === mode && tagPane.matches(':hover')) return;
    tagPane.dataset.mode = mode;
    tagPane.replaceChildren();
    if (S.fs >= 0) { renderPostTags(); return; }
    const used = new Set(cfg.tags.map((t) => t.n));
    const favSet = new Set(cfg.favorites);
    const blocked = splitTags(cfg.blocked).map(toRe);
    const rel = [...S.tagFreq.entries()]
      .filter(([n]) => !used.has(n) && !favSet.has(n) && !blocked.some((re) => re.test(n)))
      .sort((a, b) => b[1] - a[1]).slice(0, 36)
      .sort((a, b) => typeRank(a[0]) - typeRank(b[0]) || b[1] - a[1]);
    wantTypes(rel.map((r) => r[0]));
    tagPane.append(el('div', 'kx-sec', rel.length ? t('currentResults') : t('enterTag')));
    if (rel.length) {
      const cl = el('div', 'kx-cloud');
      rel.forEach(([n, c]) => cl.append(tagChip(n, c)));
      tagPane.append(cl);
    } else {
      tagPane.append(el('div', 'kx-empty', t('tagHints')));
    }
  }

  // теги открытого арта: избранные первыми, дальше по важности (авторы → персонажи → лицензии → остальные)
  function renderPostTags() {
    const p = S.view[S.fs];
    if (!p) return;
    const tags = [...new Set(p.tags)].sort((a, b) => Number(isFav(b)) - Number(isFav(a)) || byImportance(a, b));
    tagPane.append(el('div', 'kx-sec', `${t('postTags')} · ${tags.length}`));
    const cl = el('div', 'kx-cloud');
    tags.forEach((t) => cl.append(tagChip(t)));
    tagPane.append(cl);
    tagPane.append(el('div', 'kx-empty', t('postTagHelp')));
    wantTypes(tags, true);
  }

  /* ====================== вкладки «Избранные» и «Фильтры» ====================== */
  function setTab(t) {
    tab = t;
    sideEl.dataset.tab = t;
    $('kx-tab-fav').classList.toggle('on', t === 'fav');
    $('kx-tab-filters').classList.toggle('on', t === 'filters');
    if (t === 'fav') renderFavPanel();
  }
  $('kx-tab-fav').onclick = () => setTab(tab === 'fav' ? '' : 'fav');
  $('kx-tab-filters').onclick = () => setTab(tab === 'filters' ? '' : 'filters');

  function renderFavPanel() {
    favPanel.replaceChildren();
    const favs = cfg.favorites;
    wantTypes(favs, true);
    favPanel.append(el('div', 'kx-sec', t('favoritesTitle')));
    if (!favs.length) {
      favPanel.append(el('div', 'kx-empty', t('emptyFavorites')));
    } else {
      const groups = [['artist', t('artists')], ['character', t('characters')], ['copyright', t('copyrights')], ['other', t('others')]];
      for (const [key, title] of groups) {
        const list = favs.filter((f) => (['artist', 'character', 'copyright'].includes(typeCache[f]) ? typeCache[f] : 'other') === key).sort(byImportance);
        if (!list.length) continue;
        const box = el('div');
        box.append(el('div', 'kx-sec', title));
        const cl = el('div', 'kx-cloud');
        list.forEach((f) => cl.append(tagChip(f)));
        box.append(cl);
        favPanel.append(box);
      }
    }
    const likes = interestTags(14).filter((n) => !isFav(n));
    if (likes.length) {
      const box = el('div');
      box.append(el('div', 'kx-sec', t('interests')));
      const cl = el('div', 'kx-cloud');
      likes.forEach((n) => cl.append(tagChip(n)));
      box.append(cl);
      box.append(el('div', 'kx-empty', t('interestsHelp')));
      favPanel.append(box);
    }
  }

  // подсказки: сначала подходящие избранные, затем ответ API
  function sugView() {
    const { term } = parseInput(qEl.value);
    const favs = cfg.favorites.filter((f) => term.length >= 2 && f.includes(term)).sort(byImportance)
      .map((name) => ({ name, count: null }));
    const seen = new Set(favs.map((f) => f.name));
    return [...favs, ...S.sug.filter((x) => !seen.has(x.name))];
  }

  function renderSuggestions() {
    tagPane.dataset.mode = 'sug';
    tagPane.replaceChildren();
    const { mod, term } = parseInput(qEl.value);
    tagPane.append(el('div', 'kx-sec', t('suggestions')));
    const list = sugView();
    if (!list.length) {
      tagPane.append(el('div', 'kx-empty', t('enterToAdd').replace('{term}', term)));
      return;
    }
    list.forEach((s, i) => {
      const row = el('div', 'kx-sug' + (i === S.hl ? ' hl' : '') + typeCls(s.name));
      row.dataset.n = s.name;
      row.append(el('span', 'n', s.name), el('span', 'c', s.count != null ? String(s.count) : ''));
      const minus = el('button', 'b', '−'); minus.title = t('exclude');
      const any = el('button', 'b', '~'); any.title = t('anyOf');
      minus.onclick = (e) => { e.stopPropagation(); addTag(s.name, '-'); };
      any.onclick = (e) => { e.stopPropagation(); addTag(s.name, '~'); };
      row.append(minus, any);
      row.onclick = () => addTag(s.name, mod);
      tagPane.append(row);
    });
  }

  function paintSuggestions() {
    tagPane.querySelectorAll('.kx-sug[data-n]').forEach((row) => {
      row.classList.remove('t-artist', 't-character', 't-copyright', 't-metadata');
      const t = typeOf(row.dataset.n);
      if (t && t !== 'general') row.classList.add('t-' + t);
    });
  }

  let sugCtrl = null;
  const fetchSuggest = debounce(async () => {
    const { term } = parseInput(qEl.value);
    if (term.length < 2) return;
    if (sugCtrl) sugCtrl.abort();
    sugCtrl = new AbortController();
    try {
      const r = await fetch(`${AUTOCOMPLETE_URL}?q=${encodeURIComponent(term)}`, { signal: sugCtrl.signal });
      const json = await r.json();
      if (parseInput(qEl.value).term !== term) return;
      S.sug = Array.isArray(json)
        ? json.map((x) => {
            const label = String(x.label || '');
            const m = label.match(/\((\d+)\)\s*$/);
            return { name: decodeEntities(x.value), count: m ? Number(m[1]) : null };
          })
        : [];
      S.hl = -1;
      renderSuggestions();
      wantTypes(S.sug.slice(0, 12).map((x) => x.name), true);
    } catch (e) {
      if (e && e.name === 'AbortError') return;
      S.sug = []; renderSuggestions();
    }
  }, 160);

  qEl.addEventListener('input', () => {
    if (tab && parseInput(qEl.value).term.length >= 2) setTab('');
    const { term } = parseInput(qEl.value);
    if (term.length < 2) { S.sug = []; S.hl = -1; renderTagPane(); }
    else { S.hl = -1; if (cfg.favorites.some((f) => f.includes(term))) renderSuggestions(); fetchSuggest(); }
  });
  qEl.addEventListener('keydown', (e) => {
    const { mod, term } = parseInput(qEl.value);
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      const n = sugView().length;
      if (!n) return;
      e.preventDefault();
      S.hl = clamp(S.hl + (e.key === 'ArrowDown' ? 1 : -1), 0, n - 1);
      renderSuggestions();
      tagPane.querySelector('.kx-sug.hl')?.scrollIntoView({ block: 'nearest' });
    } else if (e.key === 'Enter') {
      e.preventDefault();
      const picked = sugView()[S.hl];
      if (S.hl >= 0 && picked) addTag(picked.name, mod);
      else if (term) addTag(term, mod);
    } else if (e.key === 'Backspace' && !qEl.value && cfg.tags.length) {
      removeTag(cfg.tags[cfg.tags.length - 1].n);
    } else if (e.key === 'Escape') {
      qEl.value = ''; S.sug = []; renderTagPane(); qEl.blur();
    }
  });
  /* ====================== панель фильтров ====================== */
  function activeFilterCount() {
    let n = 0;
    if (cfg.sortProp !== 'id' || cfg.sortDir !== 'desc') n++;
    if (!cfg.showImage || !cfg.showGif || !cfg.showVideo) n++;
    if (cfg.blocked.trim()) n++;
    if (cfg.required.trim()) n++;
    if (cfg.hideComics) n++;
    return n;
  }
  function updateBadge() {
    const n = activeFilterCount(), b = $('kx-badge');
    b.textContent = n; b.classList.toggle('on', n > 0);
  }

  function syncFilterUI() {
    $('kx-feed').querySelectorAll('button').forEach((b) => b.classList.toggle('on', b.dataset.v === cfg.feed));
    $('kx-sort').value = cfg.sortProp;
    $('kx-dir').textContent = cfg.sortDir === 'desc' ? '↓' : '↑';
    renderBlockedTags();
    $('kx-required').value = cfg.required;
    $('kx-comics').classList.toggle('on', cfg.hideComics);
    const mode = cfg.showImage && cfg.showGif && cfg.showVideo ? 'all' :
      cfg.showImage ? 'images' : 'animated';
    $('kx-media').querySelectorAll('button').forEach((b) => b.classList.toggle('on', b.dataset.mode === mode));
    updateBadge();
  }

  $('kx-lang').onchange = (e) => {
    cfg.lang = e.target.value;
    saveCfg();
    applyStaticLanguage();
    syncFilterUI();
    renderActive();
    renderTagPane();
    if (tab === 'fav') renderFavPanel();
    renderStat();
    renderMsg();
    renderNote();
    if (S.fs >= 0) { renderFS(); buildFsList(); }
  };

  const serverChange = () => { saveCfg(); syncFilterUI(); scheduleSearch(); };
  const clientChange = () => { saveCfg(); syncFilterUI(); scheduleRefilter(); renderTagPane(true); };

  $('kx-feed').onclick = (e) => {
    const b = e.target.closest('button');
    if (!b || b.dataset.v === cfg.feed) return;
    cfg.feed = b.dataset.v; saveCfg(); syncFilterUI(); newSearch();
  };
  $('kx-sort').onchange = (e) => { cfg.sortProp = e.target.value; serverChange(); };
  $('kx-dir').onclick = () => { cfg.sortDir = cfg.sortDir === 'desc' ? 'asc' : 'desc'; serverChange(); };
  $('kx-media').onclick = (e) => {
    const b = e.target.closest('button'); if (!b) return;
    cfg.showImage = b.dataset.mode !== 'animated';
    cfg.showGif = b.dataset.mode !== 'images';
    cfg.showVideo = b.dataset.mode !== 'images';
    clientChange();
  };
  $('kx-blocked-input').onkeydown = (e) => {
    if (e.key === 'Enter' || e.key === ' ' || e.key === ',') {
      e.preventDefault();
      commitBlockedInput();
    } else if (e.key === 'Backspace' && !e.target.value) {
      const tags = splitTags(cfg.blocked);
      if (tags.length) setBlockedTags(tags.slice(0, -1));
    }
  };
  $('kx-blocked-input').onblur = commitBlockedInput;
  $('kx-required').oninput = (e) => { cfg.required = e.target.value; clientChange(); };
  $('kx-comics').onclick = () => { cfg.hideComics = !cfg.hideComics; clientChange(); };
  $('kx-reset').onclick = () => {
    const keepStuff = { tags: cfg.tags, favorites: cfg.favorites, views: cfg.views, feed: cfg.feed, layout: cfg.layout, lang: cfg.lang, ageOk: cfg.ageOk };
    cfg = { ...DEFAULTS, ...keepStuff };
    saveCfg(); syncFilterUI(); rebuildKeep(); newSearch();
  };

  /* ====================== полноэкранный просмотр ====================== */
  function clearStage() {
    stage.querySelectorAll('video').forEach((v) => { v.pause(); v.removeAttribute('src'); v.load(); });
    stage.replaceChildren();
  }

  let fsBase = 0, fsListEnd = 0, idleTimer = null, viewTimer = null, deep = false;

  function wakeFS() {
    fsEl.classList.remove('idle');
    clearTimeout(idleTimer);
    idleTimer = setTimeout(function tick() {
      if (S.fs < 0) return;
      if (root.querySelector('.kx-fbar:hover,.kx-list.open')) { idleTimer = setTimeout(tick, 1500); return; }
      fsEl.classList.add('idle');
    }, 2500);
  }
  fsEl.addEventListener('mousemove', wakeFS);
  fsEl.addEventListener('touchstart', wakeFS, { passive: true });

  function openFS(i, opts = {}) {
    if (i < 0 || i >= S.view.length) return;
    const wasOpen = S.fs >= 0;
    S.fs = i;
    if (opts.dir) fsEl.dataset.dir = opts.dir; else delete fsEl.dataset.dir;
    if (!wasOpen && tab) setTab('');
    fsEl.classList.add('open');
    if (!wasOpen || i < fsBase) buildFsList();
    else while (i >= fsListEnd && fsListEnd < S.view.length) extendFsList(40);
    markFsCurrent(!opts.fromList);
    renderFS();
    wakeFS();
  }
  function closeFS() {
    const i = S.fs;
    S.fs = -1;
    clearTimeout(idleTimer); clearTimeout(viewTimer);
    setDeep(false);
    fsEl.classList.remove('open', 'idle');
    renderTagPane();
    listEl.classList.remove('open');
    clearStage();
    grid.querySelector(`.kx-card[data-i="${i}"]`)?.scrollIntoView({ block: 'center', behavior: 'instant' });
  }
  function stepFS(d) {
    const n = S.fs + d;
    if (n < 0) return;
    const dir = d > 0 ? 'next' : 'prev';
    if (n >= S.view.length) {
      if (!S.ended) loadMore().then(() => { if (S.fs >= 0 && S.view.length > n) openFS(n, { dir }); });
      return;
    }
    openFS(n, { dir });
  }

  // «фулл-фулл экран»: без интерфейса, только прокрутка артов
  function setDeep(on) {
    if (on === deep) return;
    deep = on;
    fsEl.classList.toggle('deep', on);
    const v = stage.querySelector('video');
    if (v) v.controls = !on;
    try {
      if (on) { const r = root.requestFullscreen && root.requestFullscreen(); if (r && r.catch) r.catch(() => {}); }
      else if (document.fullscreenElement) { const r = document.exitFullscreen(); if (r && r.catch) r.catch(() => {}); }
    } catch {}
    wakeFS();
  }
  document.addEventListener('fullscreenchange', () => {
    if (!document.fullscreenElement && deep) {
      deep = false; fsEl.classList.remove('deep');
      const v = stage.querySelector('video'); if (v) v.controls = true;
    }
  });
  stage.addEventListener('click', () => {
    const p = S.view[S.fs];
    if (!p) return;
    if (p.type === 'video') { // у видео клик — пауза, двойной клик — режим «фулл-фулл»
      if (deep) { const v = stage.querySelector('video'); if (v) v.paused ? v.play() : v.pause(); }
      return;
    }
    setDeep(!deep);
  });
  stage.addEventListener('dblclick', () => { const p = S.view[S.fs]; if (p && p.type === 'video') setDeep(!deep); });
  let wheelLock = 0, touchY0 = null;
  fsEl.addEventListener('wheel', (e) => {
    if (!deep) return;
    e.preventDefault();
    if (Date.now() < wheelLock || Math.abs(e.deltaY) < 6) return;
    wheelLock = Date.now() + 450;
    stepFS(e.deltaY > 0 ? 1 : -1);
  }, { passive: false });
  fsEl.addEventListener('touchstart', (e) => { touchY0 = e.touches[0] ? e.touches[0].clientY : null; }, { passive: true });
  fsEl.addEventListener('touchend', (e) => {
    if (!deep || touchY0 == null) return;
    const dy = touchY0 - (e.changedTouches[0] ? e.changedTouches[0].clientY : touchY0);
    touchY0 = null;
    if (Math.abs(dy) > 70) stepFS(dy > 0 ? 1 : -1);
  }, { passive: true });

  function renderFS() {
    const p = S.view[S.fs];
    if (!p) return;
    clearStage();
    fsEl.classList.toggle('vid', p.type === 'video');
    if (p.type === 'video') {
      const v = document.createElement('video');
      v.controls = !deep; v.loop = true; v.playsInline = true; v.src = p.file; v.poster = p.prev;
      stage.append(v);
      v.play().catch(() => { v.muted = true; v.play().catch(() => {}); });
    } else if (p.type === 'gif') {
      const im = el('img', 'kx-m'); im.src = p.file; im.alt = '';
      stage.append(im);
    } else {
      // сначала быстро показываем превью, поверх плавно проявляется оригинал (file_url)
      const lo = el('img', 'kx-m'); lo.src = thumbSrc(p); lo.alt = '';
      const hi = el('img', 'kx-m hi'); hi.alt = ''; hi.decoding = 'async'; hi.fetchPriority = 'high';
      const hq = el('div', 'kx-hq');
      hq.append(el('span', 'kx-spin'), el('span', null, t('loadingOriginal')));
      hi.onload = () => { hi.classList.add('on'); hq.remove(); };
      hi.onerror = () => { hi.remove(); hq.replaceChildren(el('span', null, t('originalFailed'))); };
      hi.src = p.file;
      stage.append(lo, hi, hq);
    }

    $('kx-finfo').textContent = `${S.fs + 1} / ${S.view.length}  ·  #${p.id}  ·  ★ ${p.score}  ·  ${p.rating}`;
    const links = $('kx-flinks');
    links.replaceChildren();
    const mk = (text, href) => {
      const a = el('a', null, text); a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer'; links.append(a);
    };
    mk(t('post'), `https://rule34.xxx/index.php?page=post&s=view&id=${p.id}`);
    mk(t('file'), p.file);
    if (/^https?:\/\//i.test(p.source)) mk(t('source'), p.source);

    renderTagPane();
    clearTimeout(viewTimer);
    viewTimer = setTimeout(() => recordView(p), 2500); // арт, на который вы смотрели, уточняет ваши вкусы
    // предзагрузка оригиналов соседних артов, чтобы листать без ожидания
    [S.view[S.fs + 1], S.view[S.fs + 2], S.view[S.fs - 1]].forEach((q) => {
      if (q && q.type === 'image') new Image().src = q.file;
    });
    if (S.view.length - S.fs < 10 && !S.ended) loadMore();
  }

  /* Список «дальше» строится один раз при открытии и потом только дополняется,
     поэтому при клике по элементу он не «уезжает». */
  function buildFsList() {
    listEl.replaceChildren();
    listEl.append(el('div', 't', t('next')));
    fsBase = S.fs;
    fsListEnd = S.fs;
    extendFsList(NEXT_LIST_SIZE);
  }
  function extendFsList(n = 40) {
    listEl.querySelector('.kx-lend')?.remove();
    const end = Math.min(S.view.length, fsListEnd + n);
    for (let i = fsListEnd; i < end; i++) {
      const p = S.view[i];
      const li = el('button', 'kx-li');
      li.dataset.i = i;
      li.style.aspectRatio = String(ratioOf(p));
      const im = el('img'); im.loading = 'lazy'; im.alt = ''; im.src = p.prev || thumbSrc(p);
      li.append(im);
      if (p.type !== 'image') li.append(el('span', null, p.type === 'video' ? '▶' : 'GIF'));
      li.onclick = () => openFS(i, { fromList: true });
      listEl.append(li);
    }
    fsListEnd = end;
    if (fsListEnd >= S.view.length) listEl.append(el('div', 'kx-empty kx-lend', S.ended ? t('lastPost') : t('loading')));
    markFsCurrent(false);
  }
  function markFsCurrent(scroll) {
    listEl.querySelectorAll('.kx-li.cur').forEach((x) => x.classList.remove('cur'));
    const cur = listEl.querySelector(`.kx-li[data-i="${S.fs}"]`);
    if (cur) { cur.classList.add('cur'); if (scroll) cur.scrollIntoView({ block: 'nearest' }); }
  }

  $('kx-fclose').onclick = closeFS;
  $('kx-fprev').onclick = () => stepFS(-1);
  $('kx-fnext').onclick = () => stepFS(1);
  $('kx-hot').addEventListener('mouseenter', () => listEl.classList.add('open'));
  listEl.addEventListener('mouseleave', () => listEl.classList.remove('open'));
  $('kx-flistbtn').onclick = () => listEl.classList.toggle('open');
  listEl.addEventListener('scroll', () => {
    if (listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight > 400) return;
    if (fsListEnd < S.view.length) extendFsList(40); else if (!S.ended) loadMore();
  }, { passive: true });
  $('kx-fdeep').onclick = () => setDeep(!deep);

  document.addEventListener('keydown', (e) => {
    if (root.classList.contains('away')) return;
    const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
    if (S.fs >= 0) {
      let handled = true;
      if (e.key === 'Escape') { if (deep) setDeep(false); else closeFS(); }
      else if (e.key === 'ArrowRight') stepFS(1);
      else if (e.key === 'ArrowLeft') stepFS(-1);
      else if (deep && (e.key === 'ArrowDown' || e.key === 'PageDown' || e.key === ' ')) stepFS(1);
      else if (deep && (e.key === 'ArrowUp' || e.key === 'PageUp')) stepFS(-1);
      else handled = false;
      if (handled) e.preventDefault();
    } else if (e.key === '/' && !typing) {
      e.preventDefault(); qEl.focus();
    }
    if (!typing) e.stopPropagation(); // не отдаём клавиши оригинальному сайту
  });

  /* ====================== оригинал / возрастной экран ====================== */
  $('kx-orig').onclick = () => { root.classList.add('away'); backBtn.classList.add('on'); };
  backBtn.onclick = () => { root.classList.remove('away'); backBtn.classList.remove('on'); ensureFilled(); };

  const gate = $('kx-gate');
  function showGate() { gate.style.display = cfg.ageOk ? 'none' : 'grid'; }
  $('kx-gate-ok').onclick = () => { cfg.ageOk = true; saveCfg(); showGate(); newSearch(); };
  $('kx-gate-no').onclick = () => { location.href = 'about:blank'; };

  /* ====================== старт ====================== */
  applyStaticLanguage();
  renderActive();
  syncFilterUI();
  showGate();
  setTab('');
  newSearch();
})();