☰

kurosearch Studio

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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