☰

Pawchive Power Tools

Sort and archive-filter posts; discover creators by name, post titles/content, or comments with a redesigned creator finder.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         Pawchive Power Tools
// @namespace    https://pawchive.pw/
// @version      1.2.0
// @description  Sort and archive-filter posts; discover creators by name, post titles/content, or comments with a redesigned creator finder.
// @author       Codex
// @license      MIT
// @match        https://pawchive.pw/posts*
// @match        https://pawchive.pw/*/user/*
// @match        https://pawchive.pw/artists*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(() => {
  "use strict";

  const SCRIPT_ID = "pawchive-power-tools";
  const STORAGE_KEY = `${SCRIPT_ID}:settings:v1`;
  const ARTIST_SERVICES = ["patreon", "discord", "fanbox"];
  const ARCHIVE_PATTERN = /\.(?:zip|rar|7z|tar|gz|tgz|bz2|tbz2|xz|txz|zst|tzst|lz|lzma|cab|iso|001)(?:$|[?#])/i;
  const SERVICE_LABELS = {
    patreon: "Patreon",
    discord: "Discord",
    fanbox: "Pixiv Fanbox",
  };
  const SERVICE_COLORS = {
    patreon: "#fa5742",
    discord: "#5865f2",
    fanbox: "#2c333c",
  };

  const DEFAULT_SETTINGS = {
    posts: {
      sort: "date",
      direction: "desc",
      size: 50,
      services: [...ARTIST_SERVICES],
      include: "",
      exclude: "",
      includeMode: "all",
      archive: "all",
    },
    artists: {
      sort: "favorited",
      direction: "desc",
      size: 50,
      services: [...ARTIST_SERVICES],
      include: "",
      exclude: "",
      includeMode: "all",
      postTerms: "",
      postMatch: "any",
      postField: "both",
      postMethod: "global",
      pageFrom: 1,
      pageTo: 5,
      pageToLast: false,
      creatorLimit: "25",
      artistInitial: "all",
    },
  };

  const state = {
    postRun: 0,
    artistRun: 0,
    postAbort: null,
    artistAbort: null,
    artistDataPromise: null,
    archiveChecks: new Map(),
    nextDiscoveryRequestAt: 0,
    settings: loadSettings(),
  };

  // Capture the creators response if Pawchive requests it before our UI is ready.
  // This prevents a second download of the site's large creator index.
  const nativeFetch = window.fetch.bind(window);
  if (location.pathname === "/artists" || location.pathname === "/artists/updated") {
    window.fetch = async (...args) => {
      const response = await nativeFetch(...args);
      try {
        const url = new URL(
          typeof args[0] === "string" ? args[0] : args[0].url,
          location.origin,
        );
        if (url.origin === location.origin && url.pathname === "/api/v1/creators") {
          const captured = response.clone();
          state.artistDataPromise = captured.json();
        }
      } catch (error) {
        console.debug(`[${SCRIPT_ID}] Could not capture creator response`, error);
      }
      return response;
    };
  }

  addGlobalStyles();

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", route, { once: true });
  } else {
    route();
  }

  // Pawchive uses boosted navigation in several places. Re-run after a swap.
  document.addEventListener("htmx:afterSwap", () => window.setTimeout(route, 0));
  document.addEventListener("htmx:historyRestore", () => window.setTimeout(route, 0));

  function route() {
    const path = location.pathname.replace(/\/$/, "") || "/";
    if (path === "/artists" || path === "/artists/updated") {
      initArtistsPage();
      return;
    }

    const isPosts = path === "/posts";
    const isCreatorPosts = /^\/[^/]+\/user\/[^/]+$/.test(path);
    if (isPosts || isCreatorPosts) initPostsPage();
  }

  function initPostsPage() {
    const list = document.querySelector(".card-list__items");
    if (!list || !list.querySelector(".post-card")) return;

    const section = list.closest(".site-section") || document.querySelector("main");
    if (!section || section.querySelector("#ppp-post-panel")) return;

    const nativeSearch = findNativePostSearch(section);
    const panel = buildPostPanel(nativeSearch);
    const cardList = list.closest(".card-list");
    const firstPaginator = section.querySelector(".paginator");
    const insertionPoint = firstPaginator || cardList;
    insertionPoint.parentNode.insertBefore(panel, insertionPoint);
    section.classList.add("ppp-posts-active");

    if (nativeSearch) nativeSearch.classList.add("ppp-native-search-hidden");
    bindPostPanel(panel, list, section);
    void refreshPostBatch(panel, list, section);
  }

  function findNativePostSearch(section) {
    return [...section.querySelectorAll('form input[name="q"]')]
      .map((input) => input.closest("form"))
      .find(Boolean);
  }

  function buildPostPanel(nativeSearch) {
    const settings = state.settings.posts;
    const query =
      new URLSearchParams(location.search).get("q") ||
      nativeSearch?.querySelector('input[name="q"]')?.value ||
      "";
    const panel = element("section", {
      id: "ppp-post-panel",
      className: "ppp-panel",
      ariaLabel: "Pawchive post tools",
    });

    panel.innerHTML = `
      <div class="ppp-panel__title-row">
        <div>
          <h2 class="ppp-panel__title">Post tools</h2>
          <p class="ppp-panel__subtitle">Search the site, then sort and filter the loaded result batch.</p>
        </div>
        <button class="ppp-button ppp-button--quiet" type="button" data-action="reset">Reset</button>
      </div>

      <form class="ppp-server-search" data-role="server-search">
        <label class="ppp-field ppp-field--grow">
          <span>Site search</span>
          <input type="search" name="q" minlength="2" placeholder="Search post titles and content…">
        </label>
        <button class="ppp-button" type="submit">Search Pawchive</button>
      </form>

      <div class="ppp-grid ppp-grid--posts">
        <label class="ppp-field">
          <span>Include words or “phrases”</span>
          <input type="text" data-setting="include" placeholder="cat, \"bonus pack\"">
        </label>
        <label class="ppp-field">
          <span>Exclude words or “phrases”</span>
          <input type="text" data-setting="exclude" placeholder="preview, WIP">
        </label>
        <label class="ppp-field">
          <span>Included terms</span>
          <select data-setting="includeMode">
            <option value="all">Match all</option>
            <option value="any">Match any</option>
          </select>
        </label>
        <label class="ppp-field">
          <span>Sort by</span>
          <select data-setting="sort">
            <option value="date">Date created</option>
            <option value="attachments">Attachments</option>
          </select>
        </label>
        <label class="ppp-field">
          <span>Direction</span>
          <select data-setting="direction">
            <option value="desc">Descending</option>
            <option value="asc">Ascending</option>
          </select>
        </label>
        <label class="ppp-field">
          <span>Posts to load</span>
          <select data-setting="size">
            <option value="50">Site default (50)</option>
            <option value="100">100</option>
            <option value="200">200</option>
          </select>
        </label>
        <label class="ppp-field">
          <span>Archive files</span>
          <select data-setting="archive">
            <option value="all">All posts</option>
            <option value="with">With archive files</option>
            <option value="without">Without archive files</option>
          </select>
        </label>
      </div>

      <fieldset class="ppp-services">
        <legend>Sources</legend>
        ${serviceCheckboxes(settings.services)}
      </fieldset>

      <div class="ppp-results-row">
        <output class="ppp-status" data-role="status" aria-live="polite">Preparing posts…</output>
        <div class="ppp-batch-nav" data-role="batch-nav">
          <button class="ppp-button ppp-button--quiet" type="button" data-action="previous">← Previous batch</button>
          <button class="ppp-button ppp-button--quiet" type="button" data-action="next">Next batch →</button>
        </div>
      </div>
      <p class="ppp-help">Include/exclude terms inspect loaded card text. Sorting applies to the loaded batch—not Pawchive’s entire archive.</p>
      <p class="ppp-help">Archive filtering checks loaded posts’ file metadata, not titles. Unverified posts are hidden until checked; archive availability and passwords are not tested.</p>
      <button class="ppp-button ppp-button--quiet" type="button" data-action="retry-archives" hidden>Retry unverified archive checks</button>
    `;

    panel.querySelector('[name="q"]').value = query;
    setPanelValues(panel, settings);
    return panel;
  }

  function bindPostPanel(panel, list, section) {
    const reapply = debounce(() => {
      readPanelValues(panel, state.settings.posts);
      saveSettings();
      applyPostView(panel, list);
    }, 100);

    panel.addEventListener("input", (event) => {
      if (event.target.matches('[data-setting="include"], [data-setting="exclude"]')) reapply();
    });

    panel.addEventListener("change", (event) => {
      if (event.target.matches('[data-setting="size"]')) {
        readPanelValues(panel, state.settings.posts);
        saveSettings();
        void refreshPostBatch(panel, list, section);
      } else if (event.target.matches("[data-setting], [data-service]")) {
        reapply();
      }
    });

    panel.addEventListener("click", (event) => {
      const button = event.target.closest("button[data-action]");
      if (!button) return;
      const action = button.dataset.action;
      if (action === "retry-archives") {
        for (const card of list.querySelectorAll(":scope > .post-card")) {
          const key = postKey(card);
          if (state.archiveChecks.get(key)?.error) state.archiveChecks.delete(key);
        }
        applyPostView(panel, list);
      } else if (action === "reset") {
        state.settings.posts = structuredCloneSafe(DEFAULT_SETTINGS.posts);
        setPanelValues(panel, state.settings.posts);
        panel.querySelector('[name="q"]').value = "";
        saveSettings();
        void refreshPostBatch(panel, list, section);
      } else if (action === "previous" || action === "next") {
        navigatePostBatch(action === "next" ? 1 : -1, Number(state.settings.posts.size));
      }
    });

    panel.querySelector('[data-role="server-search"]').addEventListener("submit", (event) => {
      event.preventDefault();
      const q = panel.querySelector('[name="q"]').value.trim();
      if (q && q.length < 2) {
        setStatus(panel, "Enter at least two characters for a site search.", "error");
        return;
      }
      const url = new URL(location.href);
      q ? url.searchParams.set("q", q) : url.searchParams.delete("q");
      url.searchParams.delete("o");
      location.assign(url.toString());
    });
  }

  async function refreshPostBatch(panel, list, section) {
    const run = ++state.postRun;
    state.postAbort?.abort();
    state.postAbort = new AbortController();

    readPanelValues(panel, state.settings.posts);
    saveSettings();

    const wanted = Number(state.settings.posts.size) || 50;
    const currentCards = [...list.querySelectorAll(":scope > .post-card")];
    currentCards.forEach((card) => card.remove());

    // Only the cards delivered with this page are marked as native. Previously
    // fetched cards disappear on boosted navigation, so duplicates are avoided.
    const pageCards = currentCards.filter((card) => card.dataset.pppFetched !== "true");
    pageCards.forEach((card) => list.appendChild(card));

    const seen = new Set(pageCards.map(postKey));
    pageCards.forEach((card, index) => preparePostCard(card, index));

    const pageStep = Math.max(pageCards.length, 50);
    const baseOffset = numericOffset();
    const additionalPages = Math.max(0, Math.ceil(wanted / pageStep) - 1);
    toggleNativePaginators(section, wanted === 50);
    updateBatchButtons(panel, baseOffset, wanted, totalPostCount(section));

    if (!additionalPages) {
      applyPostView(panel, list);
      return;
    }

    setPanelBusy(panel, true);
    setStatus(panel, `Loading up to ${wanted} posts…`, "loading");

    try {
      for (let page = 1; page <= additionalPages; page += 1) {
        const url = new URL(location.href);
        url.searchParams.set("o", String(baseOffset + page * pageStep));
        const response = await nativeFetch(url.toString(), {
          credentials: "same-origin",
          signal: state.postAbort.signal,
          headers: { Accept: "text/html" },
        });
        if (!response.ok) throw new Error(`Pawchive returned HTTP ${response.status}`);

        const html = await response.text();
        const parsed = new DOMParser().parseFromString(html, "text/html");
        const fetched = [...parsed.querySelectorAll(".card-list__items > .post-card")];
        if (!fetched.length) break;

        for (const sourceCard of fetched) {
          const card = document.importNode(sourceCard, true);
          const key = postKey(card);
          if (seen.has(key)) continue;
          seen.add(key);
          card.dataset.pppFetched = "true";
          preparePostCard(card, list.children.length);
          list.appendChild(card);
        }

        if (run !== state.postRun) return;
        setStatus(panel, `Loaded ${list.querySelectorAll(":scope > .post-card").length} posts…`, "loading");
        if (fetched.length < pageStep) break;
      }
      applyPostView(panel, list);
    } catch (error) {
      if (error.name !== "AbortError") {
        console.error(`[${SCRIPT_ID}] Could not load post batch`, error);
        applyPostView(panel, list);
        setStatus(
          panel,
          `Loaded ${list.querySelectorAll(":scope > .post-card").length} posts; extra pages could not be loaded. ${error.message}`,
          "error",
        );
      }
    } finally {
      if (run === state.postRun) setPanelBusy(panel, false);
    }
  }

  function preparePostCard(card, index) {
    card.dataset.pppIndex = card.dataset.pppIndex || String(index);
    card.hidden = false;
  }

  function applyPostView(panel, list) {
    const settings = state.settings.posts;
    const cards = [...list.querySelectorAll(":scope > .post-card")];
    const include = parseTerms(settings.include);
    const exclude = parseTerms(settings.exclude);
    const allowed = new Set(settings.services);

    cards.sort((a, b) => {
      const aValue = settings.sort === "attachments" ? attachmentCount(a) : postDate(a);
      const bValue = settings.sort === "attachments" ? attachmentCount(b) : postDate(b);
      const primary = aValue - bValue;
      const datedTie = postDate(a) - postDate(b);
      const stableTie = Number(a.dataset.pppIndex) - Number(b.dataset.pppIndex);
      const compared = primary || datedTie || stableTie;
      return settings.direction === "asc" ? compared : -compared;
    });

    const fragment = document.createDocumentFragment();
    let visible = 0;
    let unchecked = 0;
    let failed = 0;
    const archiveMode = ["with", "without"].includes(settings.archive) ? settings.archive : "all";
    for (const card of cards) {
      const text = normalise(card.textContent);
      const serviceMatches = allowed.has(card.dataset.service || "");
      const includesMatch =
        include.length === 0 ||
        (settings.includeMode === "any"
          ? include.some((term) => text.includes(term))
          : include.every((term) => text.includes(term)));
      const excludesMatch = exclude.some((term) => text.includes(term));
      const check = state.archiveChecks.get(postKey(card));
      if (archiveMode !== "all") {
        if (check?.error) failed += 1;
        else if (typeof check?.hasArchive !== "boolean") unchecked += 1;
      }
      const archiveMatches = archiveMode === "all" ||
        (typeof check?.hasArchive === "boolean" && check.hasArchive === (archiveMode === "with"));
      card.hidden = !(serviceMatches && includesMatch && !excludesMatch && archiveMatches);
      if (!card.hidden) visible += 1;
      fragment.appendChild(card);
    }
    list.appendChild(fragment);

    const baseOffset = numericOffset();
    const archiveTotal = totalPostCount(list.closest(".site-section"));
    updateBatchButtons(panel, baseOffset, Number(settings.size), archiveTotal);
    setStatus(
      panel,
      `${visible.toLocaleString()} shown · ${cards.length.toLocaleString()} loaded${archiveTotal ? ` · ${archiveTotal.toLocaleString()} in these Pawchive results` : ""}${unchecked ? ` · checking archives: ${unchecked} remaining` : ""}${failed ? ` · ${failed} unverified (check failed)` : ""}`,
    );
    panel.querySelector('[data-action="retry-archives"]').hidden = failed === 0;
    if (archiveMode !== "all" && unchecked) void checkPostArchives(panel, list);
  }

  async function checkPostArchives(panel, list) {
    if (panel._pppArchiveChecking) return;
    panel._pppArchiveChecking = true;
    const cards = [...list.querySelectorAll(":scope > .post-card")];
    let next = 0;
    try {
      await Promise.all(Array.from({ length: Math.min(6, cards.length) }, async () => {
        while (next < cards.length && panel.isConnected && state.settings.posts.archive !== "all") {
          const card = cards[next++];
          const key = postKey(card);
          let check = state.archiveChecks.get(key);
          if (!check) {
            check = {};
            state.archiveChecks.set(key, check);
            check.promise = fetchPostArchiveFlag(card).then((hasArchive) => {
              check.hasArchive = hasArchive;
            }).catch((error) => {
              check.error = error.message;
              console.debug(`[${SCRIPT_ID}] Archive check failed for ${key}`, error);
            });
          }
          if (check.promise) await check.promise;
          if (panel.isConnected) applyPostView(panel, list);
        }
      }));
    } finally {
      panel._pppArchiveChecking = false;
      if (panel.isConnected) applyPostView(panel, list);
    }
  }

  async function fetchPostArchiveFlag(card) {
    const { service, user, id } = card.dataset;
    if (!service || !user || !id) throw new Error("Missing post identifiers");
    const controller = new AbortController();
    const timer = window.setTimeout(() => controller.abort(), 20000);
    try {
      const url = `/api/v1/${encodeURIComponent(service)}/user/${encodeURIComponent(user)}/post/${encodeURIComponent(id)}`;
      const response = await nativeFetch(url, {
        credentials: "same-origin",
        signal: controller.signal,
        headers: { Accept: "application/json" },
      });
      if (!response.ok) throw new Error(`Pawchive returned HTTP ${response.status}`);
      const data = await response.json();
      const post = data?.post || data;
      if (!post || typeof post !== "object" || Array.isArray(post) || post.id == null ||
          !Array.isArray(post.attachments)) throw new Error("Unexpected post metadata");
      const files = [post.file, ...post.attachments].filter(Boolean);
      return files.some((file) => file.path &&
        (ARCHIVE_PATTERN.test(String(file.name || "")) || ARCHIVE_PATTERN.test(String(file.path))));
    } finally {
      window.clearTimeout(timer);
    }
  }

  function postDate(card) {
    const value = card.querySelector("time[datetime]")?.getAttribute("datetime") || "";
    const parsed = Date.parse(value.includes("T") ? value : value.replace(" ", "T") + "Z");
    return Number.isFinite(parsed) ? parsed : 0;
  }

  function attachmentCount(card) {
    const text = card.querySelector(".post-card__footer")?.textContent || card.textContent || "";
    const match = text.match(/(\d+)\s+attachments?/i);
    return match ? Number(match[1]) : 0;
  }

  function postKey(card) {
    return [card.dataset.service, card.dataset.user, card.dataset.id].join(":");
  }

  function numericOffset() {
    const value = Number(new URLSearchParams(location.search).get("o"));
    return Number.isFinite(value) && value >= 0 ? value : 0;
  }

  function totalPostCount(root) {
    if (!root) return 0;
    for (const small of root.querySelectorAll(".paginator small")) {
      const match = small.textContent.match(/\bof\s+([\d,]+)/i);
      if (match) return Number(match[1].replaceAll(",", ""));
    }
    return 0;
  }

  function toggleNativePaginators(section, show) {
    section.querySelectorAll(":scope > .paginator, .site-section__body > .paginator").forEach((paginator) => {
      paginator.classList.toggle("ppp-paginator-hidden", !show);
    });
  }

  function updateBatchButtons(panel, offset, size, total) {
    const previous = panel.querySelector('[data-action="previous"]');
    const next = panel.querySelector('[data-action="next"]');
    previous.disabled = offset <= 0;
    next.disabled = Boolean(total && offset + size >= total);
    panel.querySelector('[data-role="batch-nav"]').hidden = size === 50;
  }

  function navigatePostBatch(direction, size) {
    const url = new URL(location.href);
    const nextOffset = Math.max(0, numericOffset() + direction * size);
    nextOffset ? url.searchParams.set("o", String(nextOffset)) : url.searchParams.delete("o");
    location.assign(url.toString());
  }

  function initArtistsPage() {
    const section = document.querySelector(".site-section--artists");
    if (!section || section.querySelector("#ppp-artists-panel")) return;

    const panel = buildArtistsPanel();
    const heading = section.querySelector(".site-section__header");
    heading?.insertAdjacentElement("afterend", panel);
    section.classList.add("ppp-artists-active");
    bindArtistsPanel(panel);
    void loadArtists(panel);
  }

  function buildArtistsPanel() {
    const settings = state.settings.artists;
    const query = new URLSearchParams(location.search).get("q") || "";
    const panel = element("section", {
      id: "ppp-artists-panel",
      className: "ppp-panel",
      ariaLabel: "Pawchive creator finder",
    });

    panel.innerHTML = `
      <div class="ppp-panel__title-row">
        <div>
          <span class="ppp-eyebrow">PAWCHIVE / DISCOVER</span>
          <h2 class="ppp-panel__title">Find your next creator.</h2>
          <p class="ppp-panel__subtitle">Find a name—or discover creators through what they post.</p>
        </div>
        <button class="ppp-button ppp-button--quiet" type="button" data-action="reset">Reset</button>
      </div>

      <div class="ppp-grid ppp-grid--artists">
        <label class="ppp-field ppp-field--search">
          <span>Find creators</span>
          <input type="search" data-setting="include" placeholder="Name, public ID, or creator ID…" autocomplete="off">
        </label>
        <label class="ppp-field">
          <span>Exclude words or “phrases”</span>
          <input type="text" data-setting="exclude" placeholder="archive, inactive">
        </label>
        <label class="ppp-field">
          <span>Search terms</span>
          <select data-setting="includeMode">
            <option value="all">Match all</option>
            <option value="any">Match any</option>
          </select>
        </label>
        <label class="ppp-field">
          <span>Sort by</span>
          <select data-setting="sort">
            <option value="favorited">Popularity</option>
            <option value="name">Name</option>
            <option value="updated">Date updated</option>
            <option value="indexed">Date indexed</option>
            <option value="service">Service</option>
          </select>
        </label>
        <label class="ppp-field">
          <span>Direction</span>
          <select data-setting="direction">
            <option value="desc">Descending</option>
            <option value="asc">Ascending</option>
          </select>
        </label>
        <label class="ppp-field">
          <span>Creators per page</span>
          <select data-setting="size">
            <option value="50">50</option>
            <option value="100">100</option>
            <option value="200">200</option>
          </select>
        </label>
      </div>

      <fieldset class="ppp-services">
        <legend>Sources</legend>
        ${serviceCheckboxes(settings.services)}
      </fieldset>

      <section class="ppp-discovery" aria-label="Search creators by their posts">
        <div class="ppp-discovery__heading"><h3>Discover by posts &amp; comments</h3><span class="ppp-feature-tag">CONTENT SEARCH</span></div>
        <div class="ppp-discovery__grid">
          <label class="ppp-field ppp-discovery__terms"><span>Words or quoted phrases</span>
            <input type="search" data-setting="postTerms" placeholder='landscape, watercolor, "work in progress"'>
          </label>
          <label class="ppp-field"><span>Terms in a post</span><select data-setting="postMatch"><option value="any">Match any term</option><option value="all">Match all terms</option></select></label>
          <label class="ppp-field"><span>Look in</span><select data-setting="postField"><option value="both">Title + content</option><option value="title">Titles only</option><option value="content">Content only</option><option value="comments">Post comments only</option><option value="everything">Title + content + comments</option></select></label>
          <label class="ppp-field"><span>Search method</span><select data-setting="postMethod"><option value="global">Fast site search</option><option value="scan">Scan filtered creators</option></select></label>
          <label class="ppp-field"><span>Creators to scan</span><select data-setting="creatorLimit"><option value="25">First 25 filtered creators</option><option value="100">First 100 filtered creators</option><option value="all">All filtered creators</option></select></label>
          <label class="ppp-field"><span>Artist name begins with</span><select data-setting="artistInitial">
            <option value="all">All names</option>
            <option value="digits">0–9</option>
            <option value="alphabet">A–Z (all letters)</option>
            <option value="other">Other (non-A–Z / non-0–9)</option>
            <optgroup label="Individual letters">
              ${"ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").map((letter) => `<option value="${letter}">${letter}</option>`).join("")}
            </optgroup>
          </select></label>
        </div>
        <fieldset class="ppp-page-range">
          <legend>Page range per query / creator</legend>
          <div class="ppp-page-range__numbers">
            <label class="ppp-field"><span>From page</span><input type="number" min="1" step="1" data-setting="pageFrom" aria-label="First page to search"></label>
            <span class="ppp-page-range__arrow" aria-hidden="true">→</span>
            <label class="ppp-field"><span>Through page (inclusive)</span><input type="number" min="1" step="1" data-setting="pageTo" aria-label="Last page to search"></label>
            <label class="ppp-check"><input type="checkbox" data-setting="pageToLast">Through current last page</label>
          </div>
          <div class="ppp-page-range__bar" data-role="page-range-bar">
            <div class="ppp-page-range__track"><span></span></div>
            <input type="range" min="1" max="20" step="1" value="1" data-range="from" aria-label="Drag start page">
            <input type="range" min="1" max="20" step="1" value="5" data-range="to" aria-label="Drag end page">
          </div>
          <div class="ppp-page-range__summary"><output data-role="page-range-label">Pages 1–5</output>
            <button class="ppp-button ppp-button--quiet" type="button" data-action="load-page-limits">Load current last pages</button></div>
          <output class="ppp-help ppp-page-range__status" data-role="page-range-status" aria-live="polite"></output>
          <p class="ppp-help">Drag the two handles or type exact page numbers. Pages are 1-based, 50 posts each. The bar uses the largest detected last page across selected queries/creators; shorter lists stop earlier. Refresh last-page limits after changing search terms or creator filters. Through current last page refreshes endpoints when the scan starts. Full ranges can take a long time.</p>
        </fieldset>
        <div class="ppp-discovery__actions">
          <button class="ppp-button" type="button" data-action="discover">Find matching creators</button>
          <button class="ppp-button ppp-button--quiet" type="button" data-action="stop-discovery" disabled>Stop</button>
          <button class="ppp-button ppp-button--quiet" type="button" data-action="clear-discovery">Clear content results</button>
        </div>
        <p class="ppp-help">Name/exclusion/source and first-character filters narrow candidates. First-character filtering ignores leading whitespace; A–Z is case-insensitive English letters, 0–9 is ASCII digits, and Other includes Japanese, Korean, Arabic, Chinese, accented letters, symbols, emoji, and unnamed creators. It filters the creator list immediately and applies to candidates when a content search starts.</p>
        <p class="ppp-help">Any/all terms are checked within one post (across the selected fields); quoted phrases stay together. Comments means available comments under a creator’s posts, not private messages or only comments written by the creator.</p>
        <p class="ppp-help">Fast search uses Pawchive’s index, then verifies returned text. Comments and terms shorter than 3 characters automatically use creator scanning. Scans follow the current creator sort order, start at newest posts, and stop on each creator’s first match. Limits are not proof that an artist has no matching posts.</p>
        <p class="ppp-help">Missing post bodies are loaded only when needed. Requests are paced, use up to four scan workers, and retry temporary errors. Only text and comments available on Pawchive can be searched; edits below apply when you start a new content search.</p>
        <output class="ppp-discovery__status" data-role="discovery-status" aria-live="polite">Ready when you are. No post requests until you start a search.</output>
      </section>

      <div class="ppp-results-row">
        <output class="ppp-status" data-role="status" aria-live="polite">Loading Pawchive’s creator index…</output>
        <span class="ppp-index-count" data-role="creator-count">INDEX LOADING</span>
      </div>
      <div class="ppp-artist-results" data-role="artist-results"></div>
      <nav class="ppp-artist-nav" data-role="artist-nav" aria-label="Creator result pages">
        <button class="ppp-button ppp-button--quiet" type="button" data-action="previous">← Previous</button>
        <span data-role="page-label">Page 1</span>
        <button class="ppp-button ppp-button--quiet" type="button" data-action="next">Next →</button>
      </nav>
      <p class="ppp-help">Name searches use the creator index locally after its first load. Content discovery makes additional requests; keep this tab open while it runs.</p>
    `;

    settings.include = query || settings.include;
    setPanelValues(panel, settings);
    panel.dataset.page = "0";
    syncPageRange(panel);
    return panel;
  }

  function bindArtistsPanel(panel) {
    const update = debounce(() => {
      readPanelValues(panel, state.settings.artists);
      panel.dataset.page = "0";
      saveSettings();
      renderArtists(panel);
      syncPageRange(panel);
    }, 120);

    panel.addEventListener("input", (event) => {
      if (event.target.matches('[data-range], [data-setting="pageFrom"], [data-setting="pageTo"]')) {
        syncPageRange(panel, event.target);
        return;
      }
      if (event.target.matches('[data-setting="postTerms"]')) {
        readPanelValues(panel, state.settings.artists);
        saveSettings();
        syncPageRange(panel);
      }
      if (event.target.matches('input[data-setting="include"], input[data-setting="exclude"]')) update();
    });
    panel.addEventListener("change", (event) => {
      if (event.target.matches('[data-range], [data-setting="pageFrom"], [data-setting="pageTo"], [data-setting="pageToLast"]')) {
        syncPageRange(panel, event.target);
        return;
      }
      if (event.target.matches("[data-setting], [data-service]")) update();
    });
    panel.addEventListener("click", (event) => {
      const button = event.target.closest("button[data-action]");
      if (!button) return;
      const action = button.dataset.action;
      if (action === "discover") {
        void discoverArtists(panel);
      } else if (action === "load-page-limits") {
        void loadDiscoveryPageLimits(panel);
      } else if (action === "stop-discovery") {
        panel._pppDiscovery?.controller.abort();
        panel._pppBoundsLoading?.controller.abort();
      } else if (action === "clear-discovery") {
        clearDiscovery(panel);
      } else if (action === "reset") {
        clearDiscovery(panel);
        state.settings.artists = structuredCloneSafe(DEFAULT_SETTINGS.artists);
        setPanelValues(panel, state.settings.artists);
        syncPageRange(panel);
        panel.dataset.page = "0";
        saveSettings();
        renderArtists(panel);
      } else if (action === "previous" || action === "next") {
        const change = action === "next" ? 1 : -1;
        panel.dataset.page = String(Math.max(0, Number(panel.dataset.page) + change));
        renderArtists(panel);
        panel.scrollIntoView({ behavior: "smooth", block: "start" });
      }
    });
  }

  async function loadArtists(panel) {
    const run = ++state.artistRun;
    state.artistAbort?.abort();
    state.artistAbort = new AbortController();
    setPanelBusy(panel, true);
    setStatus(panel, "Loading Pawchive’s creator index…", "loading");

    try {
      if (!state.artistDataPromise) {
        state.artistDataPromise = nativeFetch("/api/v1/creators", {
          credentials: "same-origin",
          signal: state.artistAbort.signal,
          headers: { Accept: "application/json" },
        }).then((response) => {
          if (!response.ok) throw new Error(`Pawchive returned HTTP ${response.status}`);
          return response.json();
        });
      }
      const artists = await state.artistDataPromise;
      if (run !== state.artistRun) return;
      if (!Array.isArray(artists)) throw new Error("The creator index had an unexpected format.");
      panel._pppArtists = artists;
      renderArtists(panel);
    } catch (error) {
      if (error.name !== "AbortError") {
        console.error(`[${SCRIPT_ID}] Could not load creators`, error);
        setStatus(panel, `Creator search could not load: ${error.message}`, "error");
        panel.querySelector('[data-role="artist-results"]').innerHTML =
          '<p class="ppp-empty">Creator search could not finish loading or rendering. See the error above, then try reloading the page.</p>';
      }
    } finally {
      if (run === state.artistRun) setPanelBusy(panel, false);
    }
  }

  function renderArtists(panel) {
    const artists = panel._pppArtists;
    if (!Array.isArray(artists)) return;

    const settings = state.settings.artists;
    const discovery = panel._pppDiscovery;
    // Progress updates sort only confirmed matches, not the entire large index.
    const results = filteredArtists(panel, discovery
      ? [...discovery.hits.keys()].map((key) => discovery.artistByKey.get(key)).filter(Boolean)
      : null);

    const size = Number(settings.size) || 50;
    const pages = Math.max(1, Math.ceil(results.length / size));
    const page = Math.min(Number(panel.dataset.page) || 0, pages - 1);
    panel.dataset.page = String(page);
    const start = page * size;
    const pageItems = results.slice(start, start + size);
    const container = panel.querySelector('[data-role="artist-results"]');
    // Compatible with userscript environments that do not expose replaceChildren.
    while (container.firstChild) container.removeChild(container.firstChild);

    if (!pageItems.length) {
      const empty = element("p", { className: "ppp-empty" });
      empty.textContent = panel._pppDiscovery ? "No confirmed content matches in the results checked so far. See scan coverage above." : "No creators match these filters.";
      container.appendChild(empty);
    } else {
      const fragment = document.createDocumentFragment();
      pageItems.forEach((artist) => fragment.appendChild(buildArtistCard(artist, panel._pppDiscovery?.hits.get(artistKey(artist)))));
      container.appendChild(fragment);
    }

    const end = Math.min(start + size, results.length);
    panel.querySelector('[data-role="creator-count"]').textContent = `${artists.length.toLocaleString()} INDEXED`;
    setStatus(panel, results.length
      ? `Showing ${start + 1}–${end} of ${results.length.toLocaleString()} matching creators`
      : `0 matching creators · ${artists.length.toLocaleString()} indexed`);
    panel.querySelector('[data-role="page-label"]').textContent = `Page ${page + 1} of ${pages}`;
    panel.querySelector('[data-action="previous"]').disabled = page === 0;
    panel.querySelector('[data-action="next"]').disabled = page >= pages - 1;
  }

  function filteredArtists(panel, source = null) {
    const settings = state.settings.artists;
    const include = parseTerms(settings.include);
    const exclude = parseTerms(settings.exclude);
    const allowed = new Set(settings.services);
    const collator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
    const results = (source || panel._pppArtists || [])
      .filter((artist) => allowed.has(artist.service))
      .filter((artist) => matchesArtistInitial(artist.name, settings.artistInitial))
      .filter((artist) => {
        const haystack = normalise(
          [artist.name, artist.public_id, artist.id, artist.service, SERVICE_LABELS[artist.service]]
            .filter(Boolean)
            .join(" "),
        );
        const includesMatch =
          include.length === 0 ||
          (settings.includeMode === "any"
            ? include.some((term) => haystack.includes(term))
            : include.every((term) => haystack.includes(term)));
        return includesMatch && !exclude.some((term) => haystack.includes(term));
      })
      .sort((a, b) => compareArtists(a, b, settings.sort, collator));

    if (settings.direction === "desc") results.reverse();

    return results;
  }

  function matchesArtistInitial(name, selection) {
    // Deliberately do not transliterate or Unicode-normalize: non-ASCII initials
    // belong to Other rather than being folded into an English letter/digit.
    const first = String(name || "").trimStart().charAt(0);
    if (selection === "digits") return /^[0-9]$/.test(first);
    if (selection === "alphabet") return /^[a-z]$/i.test(first);
    if (selection === "other") return !/^[a-z0-9]$/i.test(first);
    if (/^[A-Z]$/.test(selection || "")) return /^[a-z]$/i.test(first) && first.toUpperCase() === selection;
    return true;
  }

  function artistKey(artist) {
    return `${artist.service}:${artist.id}`;
  }

  function pageScopeKey() {
    const s = state.settings.artists;
    return JSON.stringify([s.include, s.exclude, s.includeMode, s.services, s.sort, s.direction,
      s.artistInitial, s.creatorLimit, s.postTerms, s.postMatch, s.postField, s.postMethod]);
  }

  function discoveryTargets(panel) {
    const s = state.settings.artists;
    const terms = parseTerms(s.postTerms);
    const method = ["comments", "everything"].includes(s.postField) || terms.some((term) => term.length < 3) ? "scan" : s.postMethod;
    if (method === "scan") {
      const artists = filteredArtists(panel);
      return artists.slice(0, s.creatorLimit === "all" ? artists.length : Number(s.creatorLimit) || 25)
        .map((artist) => `/api/v1/${encodeURIComponent(artist.service)}/user/${encodeURIComponent(artist.id)}`);
    }
    const queries = s.postMatch === "all" ? terms.slice(0, 1) : terms;
    return queries.length ? queries.map((term) => `/api/v1/posts?q=${encodeURIComponent(term)}`) : ["/api/v1/posts"];
  }

  function syncPageRange(panel, changed = null) {
    const fromInput = panel.querySelector('[data-setting="pageFrom"]');
    const toInput = panel.querySelector('[data-setting="pageTo"]');
    const lastInput = panel.querySelector('[data-setting="pageToLast"]');
    const fromSlider = panel.querySelector('[data-range="from"]');
    const toSlider = panel.querySelector('[data-range="to"]');
    if (!fromInput) return;
    if (changed?.dataset.range === "from") fromInput.value = changed.value;
    if (changed?.dataset.range === "to") toInput.value = changed.value;
    if (changed === toInput || changed === toSlider) lastInput.checked = false;
    // Permit normal typing through a temporarily empty number field.
    if ((changed === fromInput && !fromInput.value) || (changed === toInput && !toInput.value)) return;
    let from = Math.max(1, Math.floor(Number(fromInput.value) || 1));
    let to = Math.max(1, Math.floor(Number(toInput.value) || 1));
    const loaded = panel._pppPageLimits?.key === pageScopeKey() ? panel._pppPageLimits : null;
    const bounded = loaded && loaded.errors === 0 && loaded.limits.size > 0;
    const max = bounded ? Math.max(1, loaded.max) : Math.max(20, from, to, loaded?.max || 0);
    from = Math.min(from, max);
    to = lastInput.checked ? max : Math.min(to, max);
    if (changed === toInput || changed === toSlider) from = Math.min(from, to);
    else to = Math.max(from, to);
    fromInput.value = String(from);
    toInput.value = String(to);
    [fromInput, toInput].forEach((input) => bounded ? input.setAttribute("max", String(max)) : input.removeAttribute("max"));
    fromSlider.max = toSlider.max = String(max);
    fromSlider.value = String(from);
    toSlider.value = String(to);
    fromSlider.setAttribute("aria-valuetext", `Start at page ${from}`);
    toSlider.setAttribute("aria-valuetext", lastInput.checked ? `Through the current last page; detected maximum ${max}` : `End at page ${to}`);
    const bar = panel.querySelector('[data-role="page-range-bar"]');
    bar.style.setProperty("--range-from", `${max > 1 ? (from - 1) / (max - 1) * 100 : 0}%`);
    bar.style.setProperty("--range-to", `${max > 1 ? (to - 1) / (max - 1) * 100 : 100}%`);
    panel.querySelector('[data-role="page-range-label"]').textContent = lastInput.checked ? `Page ${from} → current last page of each list` : `Pages ${from}–${to} inclusive`;
    if (!panel._pppBoundsLoading) {
      panel.querySelector('[data-role="page-range-status"]').textContent = loaded
        ? `Detected maximum: ${loaded.max} pages across ${loaded.limits.size}/${loaded.total} lists. ${loaded.errors ? `${loaded.errors} limits unknown; those lists are not guaranteed by the slider. ` : ""}Loaded ${loaded.time}.`
        : "Last page not loaded for these filters. The slider scale is provisional; load current last pages to use the site's actual bound. You can also type larger numbers.";
    }
    Object.assign(state.settings.artists, { pageFrom: from, pageTo: to, pageToLast: lastInput.checked });
    saveSettings();
  }

  function discoveryButtons(panel) {
    const busy = panel._pppDiscovery?.running || Boolean(panel._pppBoundsLoading);
    panel.querySelector('[data-action="discover"]').disabled = busy;
    panel.querySelector('[data-action="load-page-limits"]').disabled = busy;
    panel.querySelector('[data-action="stop-discovery"]').disabled = !busy;
  }

  function lastPageFromHtml(html, pageUrl) {
    const doc = new DOMParser().parseFromString(html, "text/html");
    for (const link of doc.querySelectorAll('.paginator a[href]')) {
      if (link.rel !== "last" && !/^(?:>>|»|last)$/i.test(link.textContent.trim())) continue;
      const url = new URL(link.getAttribute("href"), pageUrl);
      const offset = Number(url.searchParams.get("o"));
      if (Number.isSafeInteger(offset) && offset >= 0) return Math.floor(offset / 50) + 1;
    }
    const count = totalPostCount(doc);
    if (count > 0) return Math.ceil(count / 50);
    const cards = doc.querySelectorAll(".post-card").length;
    if (cards > 0 && cards < 50) return 1;
    if (!cards && doc.querySelector(".site-section--posts, .site-section--user")) return 0;
    if (cards && [...doc.querySelectorAll(".paginator .pagination-button-disabled")].some((node) => node.textContent.trim() === ">>")) return 1;
    throw new Error("Pawchive did not report a last page");
  }

  async function fetchLastPage(baseUrl, run) {
    const url = new URL(baseUrl, location.origin);
    url.pathname = url.pathname.replace(/^\/api\/v1/, "");
    url.searchParams.delete("o");
    const html = await discoveryJson(url.toString(), run, false, "text");
    return lastPageFromHtml(html, url.toString());
  }

  async function loadDiscoveryPageLimits(panel) {
    if (panel._pppBoundsLoading || panel._pppDiscovery?.running) return;
    readPanelValues(panel, state.settings.artists);
    saveSettings();
    const key = pageScopeKey();
    const targets = discoveryTargets(panel);
    if (!targets.length) {
      panel.querySelector('[data-role="page-range-status"]').textContent = "No creators selected. Broaden the creator filters before loading limits.";
      return;
    }
    const run = { controller: new AbortController(), retries: 0, commentsMissing: 0 };
    panel._pppBoundsLoading = run;
    discoveryButtons(panel);
    let next = 0;
    let checked = 0;
    let errors = 0;
    const limits = new Map();
    try {
      await Promise.all(Array.from({ length: Math.min(4, targets.length) }, async () => {
        while (next < targets.length && !run.controller.signal.aborted && panel.isConnected) {
          const target = targets[next++];
          try { limits.set(target, await fetchLastPage(target, run)); }
          catch (error) { if (!run.controller.signal.aborted) errors++; }
          checked++;
          if (panel._pppBoundsLoading === run) panel.querySelector('[data-role="page-range-status"]').textContent = `Checking current last pages: ${checked}/${targets.length} lists… ${errors} unavailable. Use Stop to cancel.`;
        }
      }));
      if (pageScopeKey() === key && !run.controller.signal.aborted) {
        panel._pppPageLimits = { key, limits, max: [...limits.values()].reduce((max, last) => Math.max(max, last), 0), total: targets.length, errors, time: new Date().toLocaleTimeString() };
      }
    } finally {
      if (panel._pppBoundsLoading === run) {
        panel._pppBoundsLoading = null;
        discoveryButtons(panel);
        syncPageRange(panel);
        if (run.controller.signal.aborted) panel.querySelector('[data-role="page-range-status"]').textContent = "Last-page lookup stopped. Refresh to obtain current limits.";
      }
    }
  }

  function clearDiscovery(panel) {
    panel._pppDiscovery?.controller.abort();
    panel._pppBoundsLoading?.controller.abort();
    panel._pppDiscovery = null;
    discoveryButtons(panel);
    panel.querySelector('[data-role="discovery-status"]').textContent = "Content results cleared. Showing the local creator index again.";
    panel.dataset.page = "0";
    renderArtists(panel);
  }

  async function discoveryJson(url, run, empty404 = false, format = "json") {
    if (run.controller.signal.aborted) throw new DOMException("Search stopped", "AbortError");
    const controller = new AbortController();
    const abort = () => controller.abort();
    run.controller.signal.addEventListener("abort", abort, { once: true });
    const timer = window.setTimeout(abort, 25000);
    try {
      for (let attempt = 0; attempt < 3; attempt++) {
        const scheduled = Math.max(Date.now(), run.nextRequestAt || 0, state.nextDiscoveryRequestAt);
        run.nextRequestAt = scheduled + 300;
        state.nextDiscoveryRequestAt = run.nextRequestAt;
        await discoveryDelay(scheduled - Date.now(), controller.signal);
        const response = await nativeFetch(url, { credentials: "same-origin", signal: controller.signal, headers: { Accept: format === "text" ? "text/html" : "application/json" } });
        if (empty404 && response.status === 404) {
          run.commentsMissing++;
          return [];
        }
        if ([429, 502, 503, 504].includes(response.status) && attempt < 2) {
          const header = response.headers?.get("Retry-After");
          const retrySeconds = header && /^\d+$/.test(header) ? Number(header) : 0;
          const retryDate = header ? Date.parse(header) : NaN;
          const wait = Math.min(10000, Math.max(2000 * (attempt + 1), retrySeconds * 1000, Number.isFinite(retryDate) ? retryDate - Date.now() : 0));
          run.retries++;
          run.nextRequestAt = Math.max(run.nextRequestAt, Date.now() + wait);
          state.nextDiscoveryRequestAt = Math.max(state.nextDiscoveryRequestAt, run.nextRequestAt);
          await discoveryDelay(wait, controller.signal);
          continue;
        }
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return format === "text" ? await response.text() : await response.json();
      }
    } finally {
      window.clearTimeout(timer);
      run.controller.signal.removeEventListener("abort", abort);
    }
  }

  function discoveryDelay(ms, signal) {
    if (signal.aborted) return Promise.reject(new DOMException("Search stopped", "AbortError"));
    if (ms <= 0) return Promise.resolve();
    return new Promise((resolve, reject) => {
      const abort = () => {
        window.clearTimeout(timer);
        signal.removeEventListener("abort", abort);
        reject(new DOMException("Search stopped", "AbortError"));
      };
      const timer = window.setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
      signal.addEventListener("abort", abort, { once: true });
    });
  }

  function discoveryList(data) {
    if (Array.isArray(data)) return data;
    if (Array.isArray(data?.posts)) return data.posts;
    throw new Error("Unexpected post-list response");
  }

  function plainPostText(html) {
    const doc = new DOMParser().parseFromString(String(html || ""), "text/html");
    doc.querySelectorAll("script, style").forEach((node) => node.remove());
    doc.querySelectorAll("br, p, div, li").forEach((node) => node.appendChild(doc.createTextNode(" ")));
    return (doc.body.textContent || "").replace(/\s+/g, " ").trim();
  }

  function discoveryHit(post, comments, run) {
    const sources = [];
    if (["title", "both", "everything"].includes(run.field)) sources.push(["title", String(post.title || "")]);
    if (["content", "both", "everything"].includes(run.field)) sources.push(["content", plainPostText(post.content)]);
    if (["comments", "everything"].includes(run.field)) sources.push(["comments", comments.map((comment) => plainPostText(comment.content)).join(" ")]);
    const haystack = normalise(sources.map((source) => source[1]).join(" "));
    const matches = run.terms.map((term) => haystack.includes(term));
    if (!(run.match === "all" ? matches.every(Boolean) : matches.some(Boolean))) return null;
    const matchedSources = sources.filter((source) => run.terms.some((term) => normalise(source[1]).includes(term)));
    const text = matchedSources.map((source) => source[1]).join(" · ");
    const normal = normalise(text);
    const first = Math.min(...run.terms.map((term) => normal.indexOf(term)).filter((index) => index >= 0));
    const start = Math.max(0, first - 65);
    return {
      title: String(post.title || ""),
      fields: matchedSources.map((source) => source[0]).join(" + "),
      snippet: `${start ? "…" : ""}${text.slice(start, start + 220)}${text.length > start + 220 ? "…" : ""}`,
      url: `/${encodeURIComponent(post.service)}/user/${encodeURIComponent(post.user)}/post/${encodeURIComponent(post.id)}`,
    };
  }

  function discoveryProgress(panel, run, final = "") {
    if (panel._pppDiscovery !== run) return;
    if (!final && Date.now() - run.lastPaint < 250) return;
    run.lastPaint = Date.now();
    const prefix = final || "Searching";
    panel.querySelector('[data-role="discovery-status"]').textContent =
      `${prefix} · ${run.hits.size} creators matched · ${run.posts} posts checked · ${run.pages} pages fetched` +
      (run.method === "scan" ? ` · ${run.checked}/${run.candidates.length} selected creators checked (${run.totalCandidates} eligible)` : " · site-index results") +
      ` · ${run.comments} comment threads checked` +
      (run.details ? ` · ${run.details} post bodies loaded` : "") +
      (run.retries ? ` · ${run.retries} temporary-error retries` : "") +
      (run.capped ? ` · ${run.capped} query/creator page limits reached` : "") +
      (run.errors ? ` · ${run.errors} failed requests (unverified)` : "") +
      (run.commentsMissing ? ` · ${run.commentsMissing} comment endpoints returned 404 (unavailable or empty)` : "") +
      ` · pages ${run.startPage}–${Number.isFinite(run.endPage) ? run.endPage : "current last"} per list` +
      (run.outsideRange ? ` · ${run.outsideRange} lists end before the start page` : "") +
      ` · ${run.match === "all" ? "ALL" : "ANY"}: ${run.terms.join(", ")} · ${run.field}`;
    renderArtists(panel);
  }

  async function discoverArtists(panel) {
    readPanelValues(panel, state.settings.artists);
    syncPageRange(panel);
    saveSettings();
    const settings = { ...state.settings.artists };
    const terms = parseTerms(settings.postTerms);
    if (!terms.length) {
      panel.querySelector('[data-role="discovery-status"]').textContent = "Enter at least one word or quoted phrase before searching.";
      return;
    }
    panel._pppDiscovery?.controller.abort();
    const eligible = filteredArtists(panel);
    if (!eligible.length) {
      clearDiscovery(panel);
      panel.querySelector('[data-role="discovery-status"]').textContent = "No eligible creators. Broaden the name, exclusion, or source filters before searching posts.";
      return;
    }
    const needsComments = ["comments", "everything"].includes(settings.postField);
    const method = needsComments || terms.some((term) => term.length < 3) ? "scan" : settings.postMethod;
    const limit = settings.creatorLimit === "all" ? eligible.length : Number(settings.creatorLimit) || 25;
    const run = {
      controller: new AbortController(), hits: new Map(), terms, match: settings.postMatch,
      field: settings.postField, method, candidates: eligible.slice(0, limit), totalCandidates: eligible.length,
      artistByKey: new Map(eligible.map((artist) => [artistKey(artist), artist])),
      startPage: Math.max(1, Math.floor(Number(settings.pageFrom) || 1)),
      endPage: settings.pageToLast ? Infinity : Math.max(1, Math.floor(Number(settings.pageTo) || 5)),
      pageLimits: !settings.pageToLast && panel._pppPageLimits?.key === pageScopeKey() ? new Map(panel._pppPageLimits.limits) : new Map(),
      rangeKey: pageScopeKey(),
      outsideRange: 0, running: true,
      pages: 0, posts: 0, comments: 0, commentsMissing: 0, details: 0, retries: 0, checked: 0, errors: 0, capped: 0, lastPaint: 0,
    };
    panel._pppDiscovery = run;
    panel.dataset.page = "0";
    discoveryButtons(panel);
    discoveryProgress(panel, run);
    const eligibleKeys = new Set(eligible.map(artistKey));
    const seenPosts = new Set();

    async function inspect(post) {
      if (!post || post.id == null || post.user == null || !post.service) throw new Error("Post identifiers missing");
      const key = `${post.service}:${post.user}`;
      if (!eligibleKeys.has(key) || run.hits.has(key)) return;
      const postKey = `${key}:${post.id}`;
      if (seenPosts.has(postKey)) return;
      seenPosts.add(postKey);
      run.posts++;
      const postBase = `/api/v1/${encodeURIComponent(post.service)}/user/${encodeURIComponent(post.user)}/post/${encodeURIComponent(post.id)}`;
      const usesContent = ["both", "content", "everything"].includes(run.field);
      const usesTitle = ["both", "title", "everything"].includes(run.field);
      // List endpoints may omit content. A missing body must not become a false negative.
      const alreadyMatches = discoveryHit(post, [], run);
      if (!alreadyMatches && ((usesContent && typeof post.content !== "string") || (usesTitle && typeof post.title !== "string"))) {
        try {
          const data = await discoveryJson(postBase, run);
          const detail = data?.post || data;
          if (!detail || typeof detail !== "object" || Array.isArray(detail) || detail.id == null) throw new Error("Unexpected post-detail response");
          post = { ...post, ...detail };
          run.details++;
        } catch (error) {
          if (run.controller.signal.aborted) throw error;
          run.errors++;
          discoveryProgress(panel, run);
          return;
        }
      }
      let comments = [];
      if (needsComments && !discoveryHit(post, [], run)) {
        const url = `${postBase}/comments`;
        try {
          comments = await discoveryJson(url, run, true);
          if (!Array.isArray(comments)) throw new Error("Unexpected comments response");
          run.comments++;
        } catch (error) {
          if (run.controller.signal.aborted) throw error;
          run.errors++;
          comments = [];
        }
      }
      if (run.controller.signal.aborted) throw new DOMException("Search stopped", "AbortError");
      const hit = discoveryHit(post, comments, run);
      if (hit) run.hits.set(key, hit);
      discoveryProgress(panel, run);
    }

    async function pagesFor(baseUrl, artist = null) {
      let previousPage = "";
      let last = run.pageLimits.get(baseUrl);
      if (!Number.isFinite(run.endPage) && last === undefined) {
        last = await fetchLastPage(baseUrl, run);
        run.pageLimits.set(baseUrl, last);
      }
      const end = last === undefined ? run.endPage : Math.min(run.endPage, last);
      if (end < run.startPage) { run.outsideRange++; return; }
      for (let page = run.startPage; page <= end; page++) {
        if (run.controller.signal.aborted || !panel.isConnected) throw new DOMException("Search stopped", "AbortError");
        const url = new URL(baseUrl, location.origin);
        url.searchParams.set("o", String((page - 1) * 50));
        const posts = discoveryList(await discoveryJson(url.toString(), run));
        run.pages++;
        const signature = posts.map((post) => `${post.service}:${post.user}:${post.id}`).join("|");
        if (posts.length && signature === previousPage) throw new Error("Server repeated a page instead of advancing");
        previousPage = signature;
        for (const post of posts) await inspect(post);
        if (artist && run.hits.has(artistKey(artist))) return;
        discoveryProgress(panel, run);
        if (posts.length < 50) return;
        if (page === end && (last === undefined || end < last)) run.capped++;
      }
    }

    try {
      const jobs = method === "scan" ? run.candidates : (run.match === "all" ? terms.slice(0, 1) : terms);
      let next = 0;
      await Promise.all(Array.from({ length: Math.min(4, jobs.length) }, async () => {
        while (next < jobs.length && !run.controller.signal.aborted && panel.isConnected) {
          const job = jobs[next++];
          try {
            const url = method === "scan"
              ? `/api/v1/${encodeURIComponent(job.service)}/user/${encodeURIComponent(job.id)}`
              : `/api/v1/posts?q=${encodeURIComponent(job)}`;
            await pagesFor(url, method === "scan" ? job : null);
          } catch (error) {
            if (run.controller.signal.aborted || !panel.isConnected) break;
            run.errors++;
            console.debug(`[${SCRIPT_ID}] Content search request failed`, error);
          } finally {
            run.checked++;
            discoveryProgress(panel, run);
          }
        }
      }));
    } finally {
      if (panel._pppDiscovery === run) {
        run.running = false;
        discoveryButtons(panel);
        if (!Number.isFinite(run.endPage) && !run.controller.signal.aborted && run.rangeKey === pageScopeKey()) {
          const total = run.method === "scan" ? run.candidates.length : run.match === "all" ? 1 : run.terms.length;
          panel._pppPageLimits = { key: run.rangeKey, limits: run.pageLimits,
            max: [...run.pageLimits.values()].reduce((max, last) => Math.max(max, last), 0),
            total, errors: total - run.pageLimits.size, time: new Date().toLocaleTimeString() };
          syncPageRange(panel);
        }
        discoveryProgress(panel, run, run.controller.signal.aborted ? "Stopped — partial results" : "Finished — within selected coverage");
      }
    }
  }

  function compareArtists(a, b, field, collator) {
    let result = 0;
    if (field === "name" || field === "service") {
      result = collator.compare(String(a[field] || ""), String(b[field] || ""));
    } else {
      result = Number(a[field] || 0) - Number(b[field] || 0);
    }
    return result || collator.compare(String(a.name || ""), String(b.name || ""));
  }

  function buildArtistCard(artist, hit) {
    const service = String(artist.service || "");
    const id = String(artist.id || "");
    const card = element("article", {
      className: "ppp-artist-card",
    });
    const profile = element("a", {
      className: "ppp-artist-card__profile",
      href: `/${encodeURIComponent(service)}/user/${encodeURIComponent(id)}`,
    });

    const icon = element("img", {
      className: "ppp-artist-card__icon",
      src: `/icons/${encodeURIComponent(service)}/${encodeURIComponent(id)}`,
      alt: "",
      loading: "lazy",
    });
    icon.addEventListener("error", () => icon.classList.add("ppp-image-error"), { once: true });
    const avatar = element("span", { className: "ppp-artist-card__avatar", ariaHidden: "true" });
    const initials = element("span", { className: "ppp-artist-card__initials" });
    initials.textContent = String(artist.name || "?").slice(0, 2).toUpperCase();
    avatar.append(initials, icon);

    const content = element("span", { className: "ppp-artist-card__content" });
    const top = element("span", { className: "ppp-artist-card__top" });
    const name = element("strong", { className: "ppp-artist-card__name" });
    name.textContent = artist.name || "Unnamed creator";
    const badge = element("span", { className: "ppp-service-badge" });
    badge.textContent = SERVICE_LABELS[service] || service;
    badge.style.backgroundColor = SERVICE_COLORS[service] || "#59636e";
    top.append(name, badge);

    const meta = element("span", { className: "ppp-artist-card__meta" });
    const updated = formatUnixDate(artist.updated);
    const indexed = formatUnixDate(artist.indexed);
    meta.textContent = [
      `ID ${id}`,
      updated ? `Updated ${updated}` : "",
      indexed ? `Indexed ${indexed}` : "",
      `${Number(artist.favorited || 0).toLocaleString()} favorites`,
    ]
      .filter(Boolean)
      .join(" · ");
    content.append(top, meta);
    profile.append(avatar, content);
    card.appendChild(profile);
    if (hit) {
      const evidence = element("a", { className: "ppp-artist-card__evidence", href: hit.url });
      const label = element("strong");
      label.textContent = `Matched ${hit.fields} · ${hit.title || "Untitled post"}`;
      const snippet = element("span");
      snippet.textContent = hit.snippet;
      evidence.append(label, snippet);
      card.appendChild(evidence);
    }
    return card;
  }

  function formatUnixDate(value) {
    const numeric = Number(value);
    if (!numeric) return "";
    const date = new Date(numeric > 1e12 ? numeric : numeric * 1000);
    if (Number.isNaN(date.getTime())) return "";
    return new Intl.DateTimeFormat(undefined, {
      year: "numeric",
      month: "short",
      day: "numeric",
    }).format(date);
  }

  function serviceCheckboxes(selected) {
    const active = new Set(selected);
    return ARTIST_SERVICES.map(
      (service) => `
        <label class="ppp-check">
          <input type="checkbox" data-service="${service}" ${active.has(service) ? "checked" : ""}>
          <span class="ppp-check__dot" style="--ppp-service:${SERVICE_COLORS[service]}"></span>
          ${escapeHtml(SERVICE_LABELS[service])}
        </label>`,
    ).join("");
  }

  function setPanelValues(panel, settings) {
    panel.querySelectorAll("[data-setting]").forEach((control) => {
      const key = control.dataset.setting;
      if (key in settings) {
        if (control.type === "checkbox") control.checked = Boolean(settings[key]);
        else control.value = String(settings[key]);
      }
    });
    const selected = new Set(settings.services || []);
    panel.querySelectorAll("[data-service]").forEach((control) => {
      control.checked = selected.has(control.dataset.service);
    });
  }

  function readPanelValues(panel, settings) {
    panel.querySelectorAll("[data-setting]").forEach((control) => {
      const key = control.dataset.setting;
      settings[key] = control.type === "checkbox" ? control.checked
        : ["size", "pageFrom", "pageTo"].includes(key) ? Number(control.value) : control.value;
    });
    settings.services = [...panel.querySelectorAll("[data-service]:checked")].map(
      (control) => control.dataset.service,
    );
  }

  function parseTerms(value) {
    const terms = [];
    const expression = /"([^"]+)"|'([^']+)'|[^\s,]+/g;
    let match;
    while ((match = expression.exec(value || ""))) {
      const term = normalise(match[1] || match[2] || match[0]);
      if (term) terms.push(term);
    }
    return [...new Set(terms)];
  }

  function normalise(value) {
    return String(value || "")
      .normalize("NFKC")
      .toLocaleLowerCase()
      .replace(/\s+/g, " ")
      .trim();
  }

  function setPanelBusy(panel, busy) {
    panel.classList.toggle("ppp-is-busy", busy);
    panel.querySelectorAll("button, select").forEach((control) => {
      if (busy) {
        control.dataset.pppWasDisabled = String(control.disabled);
        control.disabled = true;
      } else if (control.dataset.pppWasDisabled !== undefined) {
        control.disabled = control.dataset.pppWasDisabled === "true";
        delete control.dataset.pppWasDisabled;
      }
    });
  }

  function setStatus(panel, message, tone = "normal") {
    const status = panel.querySelector('[data-role="status"]');
    if (!status) return;
    status.textContent = message;
    status.dataset.tone = tone;
  }

  function loadSettings() {
    try {
      const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null");
      const artistSettings = mergeSettings(DEFAULT_SETTINGS.artists, stored?.artists);
      if (stored?.artists?.postPages !== undefined && stored.artists.pageFrom === undefined) {
        artistSettings.pageFrom = 1;
        artistSettings.pageTo = stored.artists.postPages === "all" ? 5 : Math.max(1, Number(stored.artists.postPages) || 5);
        artistSettings.pageToLast = stored.artists.postPages === "all";
      }
      delete artistSettings.postPages;
      return {
        posts: mergeSettings(DEFAULT_SETTINGS.posts, stored?.posts),
        artists: artistSettings,
      };
    } catch (error) {
      console.debug(`[${SCRIPT_ID}] Could not load saved settings`, error);
      return structuredCloneSafe(DEFAULT_SETTINGS);
    }
  }

  function mergeSettings(defaults, stored) {
    const merged = { ...structuredCloneSafe(defaults), ...(stored || {}) };
    merged.services = Array.isArray(merged.services)
      ? merged.services.filter((service) => ARTIST_SERVICES.includes(service))
      : [...ARTIST_SERVICES];
    return merged;
  }

  function saveSettings() {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(state.settings));
    } catch (error) {
      console.debug(`[${SCRIPT_ID}] Could not save settings`, error);
    }
  }

  function structuredCloneSafe(value) {
    return typeof structuredClone === "function"
      ? structuredClone(value)
      : JSON.parse(JSON.stringify(value));
  }

  function debounce(callback, wait) {
    let timeout;
    return (...args) => {
      window.clearTimeout(timeout);
      timeout = window.setTimeout(() => callback(...args), wait);
    };
  }

  function element(tag, properties = {}) {
    const node = document.createElement(tag);
    Object.entries(properties).forEach(([key, value]) => {
      if (key === "className") node.className = value;
      else if (key === "ariaLabel") node.setAttribute("aria-label", value);
      else if (key === "ariaHidden") node.setAttribute("aria-hidden", value);
      else node[key] = value;
    });
    return node;
  }

  function escapeHtml(value) {
    return String(value)
      .replaceAll("&", "&amp;")
      .replaceAll("<", "&lt;")
      .replaceAll(">", "&gt;")
      .replaceAll('"', "&quot;")
      .replaceAll("'", "&#039;");
  }

  function addGlobalStyles() {
    const style = document.createElement("style");
    style.id = `${SCRIPT_ID}-styles`;
    style.textContent = `
      :root {
        --ppp-accent: #8b5cf6;
        --ppp-accent-strong: #7c3aed;
        --ppp-border: color-mix(in srgb, currentColor 20%, transparent);
        --ppp-surface: color-mix(in srgb, var(--colour1-primary, #24242c) 94%, white 6%);
        --ppp-surface-2: color-mix(in srgb, var(--colour1-primary, #24242c) 86%, white 14%);
        --ppp-muted: color-mix(in srgb, currentColor 65%, transparent);
      }

      .ppp-panel {
        box-sizing: border-box;
        width: min(1180px, calc(100% - 1rem));
        margin: 0.75rem auto 1.1rem;
        padding: 1rem;
        border: 1px solid var(--ppp-border);
        border-radius: 14px;
        background: var(--ppp-surface);
        box-shadow: 0 12px 30px rgb(0 0 0 / 14%);
      }

      .ppp-panel *, .ppp-panel *::before, .ppp-panel *::after { box-sizing: border-box; }
      .ppp-panel__title-row, .ppp-results-row, .ppp-server-search, .ppp-artist-nav,
      .ppp-artist-card__top, .ppp-services { display: flex; align-items: center; }
      .ppp-panel__title-row, .ppp-results-row { justify-content: space-between; gap: 1rem; }
      .ppp-panel__title { margin: 0; font-size: 1.15rem; line-height: 1.2; }
      .ppp-panel__subtitle { margin: 0.25rem 0 0; color: var(--ppp-muted); font-size: 0.88rem; }
      .ppp-server-search { gap: 0.65rem; margin: 0.9rem 0; align-items: end; }
      .ppp-grid { display: grid; gap: 0.7rem; margin: 0.8rem 0; }
      .ppp-grid--posts, .ppp-grid--artists { grid-template-columns: repeat(6, minmax(0, 1fr)); }
      .ppp-grid--posts .ppp-field:nth-child(-n+2), .ppp-field--search { grid-column: span 3; }
      .ppp-grid--artists .ppp-field:nth-child(2) { grid-column: span 3; }
      .ppp-field { display: flex; min-width: 0; flex-direction: column; gap: 0.3rem; }
      .ppp-field--grow { flex: 1; }
      .ppp-field > span, .ppp-services legend { font-size: 0.76rem; font-weight: 700; color: var(--ppp-muted); }

      .ppp-field input, .ppp-field select {
        width: 100%; min-width: 0; height: 2.5rem; padding: 0.45rem 0.65rem;
        border: 1px solid var(--ppp-border); border-radius: 8px;
        background: var(--ppp-surface-2); color: inherit; font: inherit;
      }
      .ppp-field input:focus, .ppp-field select:focus {
        outline: 2px solid color-mix(in srgb, var(--ppp-accent) 65%, transparent);
        outline-offset: 1px; border-color: var(--ppp-accent);
      }

      .ppp-button {
        min-height: 2.5rem; padding: 0.48rem 0.8rem; border: 0; border-radius: 8px;
        background: var(--ppp-accent); color: white; font: inherit; font-weight: 750;
        cursor: pointer; white-space: nowrap;
      }
      .ppp-button:hover:not(:disabled) { background: var(--ppp-accent-strong); }
      .ppp-button--quiet { border: 1px solid var(--ppp-border); background: transparent; color: inherit; }
      .ppp-button--quiet:hover:not(:disabled) { background: var(--ppp-surface-2); }
      .ppp-button:disabled { opacity: 0.42; cursor: not-allowed; }

      .ppp-services { flex-wrap: wrap; gap: 0.55rem 1rem; margin: 0.8rem 0; padding: 0.7rem 0.8rem;
        border: 1px solid var(--ppp-border); border-radius: 9px; }
      .ppp-services legend { padding: 0 0.25rem; }
      .ppp-check { display: inline-flex; align-items: center; gap: 0.35rem; cursor: pointer; font-size: 0.9rem; }
      .ppp-check input { accent-color: var(--ppp-accent); }
      .ppp-check__dot { width: 0.58rem; height: 0.58rem; border-radius: 50%; background: var(--ppp-service); }

      .ppp-results-row { min-height: 2.5rem; margin-top: 0.6rem; }
      .ppp-status { font-size: 0.88rem; font-weight: 700; }
      .ppp-status[data-tone="loading"]::before { content: ""; display: inline-block; width: 0.75rem; height: 0.75rem;
        margin-right: 0.5rem; border: 2px solid var(--ppp-border); border-top-color: var(--ppp-accent);
        border-radius: 50%; vertical-align: -0.08rem; animation: ppp-spin 0.75s linear infinite; }
      .ppp-status[data-tone="error"] { color: #ef6b73; }
      .ppp-batch-nav, .ppp-artist-nav { display: flex; align-items: center; gap: 0.55rem; }
      .ppp-help { margin: 0.55rem 0 0; color: var(--ppp-muted); font-size: 0.76rem; }
      .ppp-native-search-hidden, .ppp-paginator-hidden { display: none !important; }
      .post-card[hidden] { display: none !important; }

      .site-section--artists.ppp-artists-active > .search-form,
      .site-section--artists.ppp-artists-active > .hide-tags-filter__anchor,
      .site-section--artists.ppp-artists-active > .loading,
      .site-section--artists.ppp-artists-active > .paginator,
      .site-section--artists.ppp-artists-active > .card-list { display: none !important; }
      .site-section--artists.ppp-artists-active { opacity: 1 !important; }

      .ppp-artist-results {
        display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
        gap: 0.65rem; margin: 0.8rem 0;
      }
      .ppp-artist-card {
        display: flex; min-width: 0; align-items: center; gap: 0.75rem; padding: 0.7rem;
        border: 1px solid var(--ppp-border); border-radius: 10px; background: var(--ppp-surface-2);
        color: inherit; text-decoration: none; transition: transform 120ms ease, border-color 120ms ease;
      }
      .ppp-artist-card:hover { transform: translateY(-1px); border-color: var(--ppp-accent); }
      .ppp-artist-card__icon { width: 3.1rem; height: 3.1rem; flex: 0 0 auto; border-radius: 50%; object-fit: cover;
        background: color-mix(in srgb, var(--ppp-surface-2) 75%, black 25%); }
      .ppp-artist-card__icon.ppp-image-error { visibility: hidden; }
      .ppp-artist-card__content { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 0.32rem; }
      .ppp-artist-card__top { min-width: 0; justify-content: space-between; gap: 0.6rem; }
      .ppp-artist-card__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      .ppp-artist-card__meta { color: var(--ppp-muted); font-size: 0.74rem; line-height: 1.35; }
      .ppp-service-badge { flex: 0 0 auto; padding: 0.18rem 0.42rem; border-radius: 999px; color: white;
        font-size: 0.68rem; font-weight: 800; }
      .ppp-artist-nav { justify-content: center; margin-top: 0.8rem; }
      .ppp-empty { grid-column: 1 / -1; padding: 2rem 1rem; text-align: center; color: var(--ppp-muted); }

      /* Discovery workspace: scoped so post tools retain their existing appearance. */
      #ppp-artists-panel {
        --ppp-accent: #6b7cff; --ppp-accent-strong: #5265ee;
        --ppp-border: #303b54; --ppp-surface: #111827; --ppp-surface-2: #1a2438; --ppp-muted: #a8b5cf;
        width: min(1400px, calc(100% - 2rem)); padding: clamp(1rem, 2.5vw, 2rem);
        color: #edf2fc; border: 1px solid #303b54; border-radius: 22px;
        background: radial-gradient(ellipse at top right, #273557 0, transparent 48%), #111827;
        box-shadow: 0 20px 60px rgb(0 0 0 / 25%); color-scheme: dark;
      }
      #ppp-artists-panel .ppp-panel__title-row { align-items: start; margin-bottom: 1.5rem; }
      .ppp-eyebrow { display: block; color: #88ded1; font-size: .67rem; font-weight: 800; letter-spacing: .18em; margin-bottom: .6rem; }
      #ppp-artists-panel .ppp-panel__title { font-size: clamp(1.5rem, 3vw, 2.3rem); font-weight: 800; letter-spacing: -.04em; }
      #ppp-artists-panel .ppp-panel__subtitle { margin-top: .6rem; line-height: 1.5; }
      #ppp-artists-panel .ppp-field input, #ppp-artists-panel .ppp-field select { min-height: 2.75rem; border-radius: 10px; }
      #ppp-artists-panel .ppp-field input::placeholder { color: #8391aa; opacity: 1; }
      #ppp-artists-panel .ppp-button { border-radius: 10px; }
      #ppp-artists-panel .ppp-services { border: 0; padding: .5rem 0; gap: .65rem; }
      #ppp-artists-panel .ppp-check { padding: .55rem .75rem; border: 1px solid #303b54; border-radius: 999px; background: #172135; }
      #ppp-artists-panel .ppp-check:has(input:checked) { border-color: #6476b1; background: #253250; }
      .ppp-discovery { margin: 1.4rem 0; padding: 1.1rem; border: 1px solid #3e527c; border-radius: 15px; background: #152039; }
      .ppp-discovery__heading { display: flex; align-items: center; justify-content: space-between; gap: .6rem; margin-bottom: .9rem; }
      .ppp-discovery__heading h3 { margin: 0; font-size: 1rem; font-weight: 750; }
      .ppp-feature-tag { font-size: .6rem; letter-spacing: .1em; color: #8ee5d5; background: #203d48; border-radius: 5px; padding: .3rem .5rem; }
      .ppp-discovery__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .8rem; }
      .ppp-discovery__terms { grid-column: span 2; }
      .ppp-page-range { min-width: 0; margin: 1rem 0; padding: 1rem; border: 1px solid #405579; border-radius: 12px; background: #101c30; }
      .ppp-page-range legend { padding: 0 .4rem; font-size: .8rem; font-weight: 750; color: #cbd8f3; }
      .ppp-page-range__numbers { display: flex; flex-wrap: wrap; align-items: end; gap: .7rem; }
      .ppp-page-range__numbers .ppp-field { width: min(170px, 40%); }
      .ppp-page-range__numbers .ppp-check { min-height: 2.75rem; padding: .4rem .2rem; }
      .ppp-page-range__arrow { padding-bottom: .75rem; color: #88ded1; }
      .ppp-page-range__bar { position: relative; height: 2.6rem; margin: .8rem .65rem .4rem; }
      .ppp-page-range__track { position: absolute; left: 0; right: 0; top: calc(50% - 3px); height: 6px; border-radius: 999px; background: #35445e; }
      .ppp-page-range__track span { position: absolute; left: var(--range-from, 0%); right: calc(100% - var(--range-to, 25%)); height: 100%; background: linear-gradient(90deg, #88ded1, #8996ff); border-radius: inherit; }
      #ppp-artists-panel .ppp-page-range__bar input[type="range"] { appearance: none; -webkit-appearance: none; pointer-events: none; position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; z-index: 2; }
      #ppp-artists-panel .ppp-page-range__bar input[type="range"]:focus-visible { z-index: 3; outline: none; }
      .ppp-page-range__bar input::-webkit-slider-runnable-track { height: 6px; background: transparent; }
      .ppp-page-range__bar input::-webkit-slider-thumb { -webkit-appearance: none; pointer-events: auto; width: 22px; height: 22px; margin-top: -8px; border: 3px solid #e8eeff; border-radius: 50%; background: #6b7cff; box-shadow: 0 2px 10px #0006; cursor: ew-resize; }
      .ppp-page-range__bar input::-moz-range-track { height: 6px; background: transparent; }
      .ppp-page-range__bar input::-moz-range-progress { background: transparent; }
      .ppp-page-range__bar input::-moz-range-thumb { pointer-events: auto; width: 18px; height: 18px; border: 3px solid #e8eeff; border-radius: 50%; background: #6b7cff; box-shadow: 0 2px 10px #0006; cursor: ew-resize; }
      .ppp-page-range__bar input:focus-visible::-webkit-slider-thumb { outline: 3px solid #88ded1; outline-offset: 2px; }
      .ppp-page-range__bar input:focus-visible::-moz-range-thumb { outline: 3px solid #88ded1; outline-offset: 2px; }
      .ppp-page-range__summary { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .7rem; }
      .ppp-page-range__summary output { color: #b8e7df; font-size: .85rem; font-weight: 750; }
      .ppp-page-range__status { display: block; overflow-wrap: anywhere; }
      .ppp-discovery__actions { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
      #ppp-artists-panel .ppp-help { line-height: 1.55; }
      .ppp-discovery__status { display: block; font-size: .8rem; line-height: 1.65; margin-top: .9rem; padding-top: .8rem; border-top: 1px solid #303b54; color: #cbd8f3; overflow-wrap: anywhere; }
      #ppp-artists-panel .ppp-results-row { padding: .8rem 0; border-bottom: 1px solid #303b54; margin-top: 1.2rem; }
      .ppp-index-count { color: #88ded1; white-space: nowrap; letter-spacing: .1em; font-size: .65rem; font-weight: 700; }
      #ppp-artists-panel .ppp-artist-results { grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: .9rem; margin: 1rem 0; }
      #ppp-artists-panel .ppp-artist-card { display: flex; flex-direction: column; align-items: stretch; gap: 0; padding: 0; overflow: hidden; border-radius: 14px; background: linear-gradient(145deg, #202c43, #182236); transition: transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease; }
      #ppp-artists-panel .ppp-artist-card:hover { transform: translateY(-3px); border-color: #7589c5; box-shadow: 0 8px 22px #080e1b80; }
      .ppp-artist-card__profile { display: flex; align-items: center; gap: .85rem; padding: 1.1rem; color: inherit; text-decoration: none; min-width: 0; flex: 1; }
      .ppp-artist-card__profile:hover { color: inherit; }
      .ppp-artist-card__avatar { position: relative; flex: 0 0 3.6rem; height: 3.6rem; border-radius: 12px; overflow: hidden; background: #354465; }
      .ppp-artist-card__initials { display: grid; place-items: center; position: absolute; inset: 0; color: #c7d7ff; font-weight: 800; font-size: 1.15rem; }
      #ppp-artists-panel .ppp-artist-card__icon { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 0; }
      #ppp-artists-panel .ppp-artist-card__icon.ppp-image-error { display: none; }
      #ppp-artists-panel .ppp-artist-card__top { align-items: start; flex-direction: column; gap: .4rem; }
      #ppp-artists-panel .ppp-artist-card__name { white-space: normal; overflow-wrap: anywhere; font-size: 1.03rem; line-height: 1.35; }
      #ppp-artists-panel .ppp-service-badge { font-size: .63rem; padding: .22rem .5rem; }
      #ppp-artists-panel .ppp-artist-card__meta { margin-top: .25rem; line-height: 1.6; overflow-wrap: anywhere; }
      .ppp-artist-card__evidence { display: flex; flex-direction: column; gap: .45rem; padding: .9rem 1.1rem; border-top: 1px solid #354662; background: #142438; color: #acddd6; text-decoration: none; font-size: .76rem; line-height: 1.55; overflow-wrap: anywhere; }
      .ppp-artist-card__evidence:hover { background: #1c3248; color: #d3fff7; }
      .ppp-artist-card__evidence span { color: #b7c6dc; }
      #ppp-artists-panel a:focus-visible, #ppp-artists-panel button:focus-visible { outline: 2px solid #8de3d2; outline-offset: 3px; }
      #ppp-artists-panel .ppp-artist-nav { padding: 1rem 0 .3rem; justify-content: space-between; border-top: 1px solid #303b54; }
      #ppp-artists-panel .ppp-empty { border: 1px dashed #455574; border-radius: 14px; line-height: 1.6; }
      @media (max-width: 900px) {
        .ppp-discovery__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
        .ppp-discovery__terms { grid-column: 1 / -1; }
      }
      @media (max-width: 560px) {
        #ppp-artists-panel { width: calc(100% - .6rem); padding: .9rem; border-radius: 14px; }
        .ppp-discovery { padding: .8rem; }
        .ppp-discovery__grid { grid-template-columns: 1fr; }
        .ppp-discovery__heading { align-items: start; flex-direction: column; }
        .ppp-discovery__actions .ppp-button { flex: 1 1 100%; }
        #ppp-artists-panel .ppp-artist-nav { gap: .5rem; flex-wrap: wrap; }
      }
      @media (prefers-reduced-motion: reduce) {
        #ppp-artists-panel .ppp-artist-card { transition: none; }
        #ppp-artists-panel .ppp-artist-card:hover { transform: none; }
        .ppp-status[data-tone="loading"]::before { animation: none; }
      }

      @keyframes ppp-spin { to { transform: rotate(360deg); } }
      @media (max-width: 900px) {
        .ppp-grid--posts, .ppp-grid--artists { grid-template-columns: repeat(2, minmax(0, 1fr)); }
        .ppp-grid--posts .ppp-field:nth-child(-n+2), .ppp-field--search,
        .ppp-grid--artists .ppp-field:nth-child(2) { grid-column: span 1; }
      }
      @media (max-width: 560px) {
        .ppp-panel { width: calc(100% - 0.6rem); padding: 0.75rem; }
        .ppp-grid--posts, .ppp-grid--artists { grid-template-columns: 1fr; }
        .ppp-server-search, .ppp-results-row { align-items: stretch; flex-direction: column; }
        .ppp-batch-nav { width: 100%; }
        .ppp-batch-nav .ppp-button { flex: 1; }
        .ppp-artist-results { grid-template-columns: 1fr; }
      }
    `;
    (document.head || document.documentElement).appendChild(style);
  }
})();