☰

Pawchive Power Tools

Square charcoal post tools: accurate filters, paced batches, infinite scroll, archive checks, link export, and creator discovery.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Pawchive Power Tools
// @namespace    https://pawchive.pw/
// @version      2.0.1
// @description  Square charcoal post tools: accurate filters, paced batches, infinite scroll, archive checks, link export, and creator discovery.
// @author       Codex, Jo.To
// @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 TERM_FILTER_HELP = 'Separate phrases with commas or new lines. Without commas, spaces separate words; use "bonus pack" to group a phrase. Whole words is the default; Contains also matches inside words. Phrases match across spaces, hyphens, or punctuation. Capitalization and dash styles do not matter.';

  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: "#b6a076",
    fanbox: "#555555",
  };

  const DEFAULT_SETTINGS = {
    posts: {
      sort: "date",
      direction: "desc",
      size: 50,
      services: [...ARTIST_SERVICES],
      include: "",
      exclude: "",
      includeMode: "all",
      archive: "all",
      pace: "careful",
      matchMode: "word",
      textField: "title",
      attachments: "all",
      minAttachments: 0,
      dateFrom: "",
      dateTo: "",
      layout: "comfortable",
    },
    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",
      matchMode: "word",
    },
  };

  const state = {
    postRun: 0,
    artistRun: 0,
    postAbort: null,
    postBindings: new WeakMap(),
    postSession: null,
    selectedPosts: new Set(),
    artistBindings: new WeakMap(),
    artistPanel: null,
    artistDataSignal: 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);
  const requests = createRequestScheduler(nativeFetch, { namespace: SCRIPT_ID, pace: state.settings.posts.pace, onStatus: updateNetworkStatus });
  window.setInterval(() => { if (!document.hidden) updateNetworkStatus(); }, 1000);
  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") {
          if (response.ok) {
            const captured = response.clone();
            const capturedPromise = captured.json().catch(() => {
              if (state.artistDataPromise === capturedPromise) { state.artistDataPromise = null; state.artistDataSignal = null; }
              return null;
            });
            state.artistDataPromise = capturedPromise;
            state.artistDataSignal = null;
          }
        }
      } 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));
  window.addEventListener("pageshow", route);
  window.addEventListener("pagehide", () => {
    cancelPostBatch();
    cancelArtistTasks();
  });

  function route() {
    const path = location.pathname.replace(/\/$/, "") || "/";
    const isPosts = path === "/posts" || path.startsWith("/posts/");
    const isCreatorPosts = /^\/[^/]+\/user\/[^/]+$/.test(path);
    if (!isPosts && !isCreatorPosts) cancelPostBatch();
    if (path !== "/artists" && path !== "/artists/updated") cancelArtistTasks();
    if (path === "/artists" || path === "/artists/updated") {
      initArtistsPage();
      return;
    }

    if (isPosts || isCreatorPosts) initPostsPage();
  }

  function cancelPostBatch() {
    ++state.postRun;
    state.postAbort?.abort();
    state.postAbort = null;
    if (state.postSession) {
      state.postBindings.get(state.postSession.panel)?.controller.abort();
      state.postSession.controller.abort();
      state.postSession.observer?.disconnect();
      state.postSession.navObserver?.disconnect();
      state.postSession = null;
    }
  }

  function initPostsPage() {
    const list = document.querySelector(".card-list__items");
    if (!list) return;
    const section = list.closest(".site-section") || document.querySelector("main");
    if (!section) return;
    let panel = section.querySelector("#ppp-post-panel");
    if (panel) positionPostPanel(panel, list, section);
    const existing = panel && state.postBindings.get(panel);
    if (existing?.list === list && state.postSession?.panel === panel) return;
    existing?.controller.abort();
    cancelPostBatch();
    const nativeSearch = findNativePostSearch(section);
    if (!panel) {
      panel = buildPostPanel(nativeSearch);
      positionPostPanel(panel, list, section);
    }
    section.classList.add("ppp-posts-active");
    nativeSearch?.classList.add("ppp-native-search-hidden");
    const controller = new AbortController();
    const nativeCards = [...list.querySelectorAll(":scope > .post-card")]
      .filter(card => card.dataset.pppFetched !== "true");
    const restoredCards = [...list.querySelectorAll(":scope > .post-card")];
    let snapshotCards = nativeCards;
    try {
      if (panel.dataset.pppNativePage) {
        const saved = new DOMParser().parseFromString(panel.dataset.pppNativePage, "text/html");
        snapshotCards = [...saved.querySelectorAll(".post-card")].map(card => document.importNode(card, true));
      } else panel.dataset.pppNativePage = nativeCards.map(card => card.outerHTML).join("");
    } catch { /* A fresh native page remains usable without a history snapshot. */ }
    const session = {
      panel, list, section, controller: new AbortController(), nativeCards: snapshotCards,
      baseUrl: location.href, baseOffset: numericOffset(),
      pageStep: 50, nextOffset: numericOffset() + 50,
      total: totalPostCount(section), seen: new Set(),
      ended: snapshotCards.length < 50, loading: false, paused: false,
      issue: "", visible: 0, autoLimit: 1000, autoBlocked: false,
    };
    state.postSession = session;
    preserveCreatorNavigation(session);
    state.postBindings.set(panel, { list, section, controller });
    setPanelValues(panel, state.settings.posts);
    panel.querySelector('[name="q"]').value = new URLSearchParams(location.search).get("q") || "";
    bindPostPanel(panel, list, section, controller.signal);
    // HTML restored by htmx retains appended cards but loses event handlers.
    const restoredWanted = state.settings.posts.size === "infinite" ? Infinity : Number(state.settings.posts.size);
    if (restoredCards.some(card => card.dataset.pppFetched === "true") && panel.dataset.pppBaseUrl === session.baseUrl && (restoredWanted === Infinity || restoredCards.length === restoredWanted)) {
      const offset = Number(panel.dataset.pppNextOffset);
      session.nextOffset = Number.isFinite(offset) ? offset : session.nextOffset;
      session.ended = panel.dataset.pppEnded === "true";
      session.seen = new Set(restoredCards.map(postKey));
      restoredCards.forEach((card, index) => preparePostCard(card, index));
      installPostSentinel(session);
      applyPostView(panel, list);
      syncPostControls(session);
    } else void refreshPostBatch(panel, list, section);
  }

  function positionPostPanel(panel, list, section) {
    const creatorNav = [...section.querySelectorAll(".paginator")].find(node => node.querySelector(".tabs"));
    if (creatorNav) {
      // The site's creator tabs keep their original container and position.
      // Tools follow the navigation instead of preceding it.
      if (creatorNav.nextElementSibling !== panel) creatorNav.insertAdjacentElement("afterend", panel);
    } else {
      const point = section.querySelector(".paginator") || list.closest(".card-list") || list;
      if (panel.nextElementSibling !== point) point.parentNode.insertBefore(panel, point);
    }
  }

  function findNativePostSearch(section) {
    return [...section.querySelectorAll('form input[name="q"]')]
      .filter(input => !input.closest("#ppp-post-panel"))
      .map(input => input.closest("form")).find(Boolean);
  }

  function preserveCreatorNavigation(session) {
    if (!/^\/[^/]+\/user\/[^/]+\/?$/.test(location.pathname)) return;
    const nav = [...session.section.querySelectorAll(".paginator")].find(node => node.querySelector(".tabs"));
    if (!nav) return;
    const restore = () => {
      if (state.postSession !== session) return;
      const tabs = nav.querySelector(".tabs");
      if (!tabs) return;
      for (const node of [nav, tabs, ...tabs.querySelectorAll("li, a")]) {
        if (node.hidden) node.hidden = false;
        if (node.style.display === "none") node.style.removeProperty("display");
        if (node.style.visibility === "hidden") node.style.removeProperty("visibility");
      }
      if (nav.classList.contains("ppp-paginator-hidden")) nav.classList.remove("ppp-paginator-hidden");
      if (!nav.classList.contains("ppp-preserve-navigation")) nav.classList.add("ppp-preserve-navigation");
    };
    restore();
    // Watch only the native tab container. A downloader may update it after our
    // initialization; unrelated site navigation and downloader controls stay alone.
    session.navObserver = new MutationObserver(restore);
    session.navObserver.observe(nav, { attributes: true, subtree: true, childList: true, attributeFilter: ["class", "style", "hidden"] });
  }

  function buildPostPanel(nativeSearch) {
    const panel = element("section", { id: "ppp-post-panel", className: "ppp-panel", ariaLabel: "Pawchive post tools" });
    panel.innerHTML = `
      <div class="ppp-post-main">
      <div class="ppp-panel__title-row">
        <div><h2 class="ppp-panel__title">Post tools</h2></div>
        <button class="ppp-button ppp-button--quiet" type="button" data-action="reset">Reset tools</button>
      </div>
      <form class="ppp-server-search" data-role="server-search">
        <label class="ppp-field ppp-field--grow"><span>Search Pawchive</span>
          <input type="search" name="q" minlength="2" placeholder="Search the site's post index…"></label>
        <button class="ppp-button" type="submit">Search</button>
      </form>
      <div class="ppp-grid ppp-grid--posts">
        <label class="ppp-field"><span>Posts to load</span><select data-setting="size">
          <option value="25">25</option><option value="50">50</option><option value="100">100</option>
          <option value="200">200</option><option value="infinite">Infinite scroll</option></select></label>
        <label class="ppp-field"><span>Request pace</span><select data-setting="pace">
          <option value="careful">Careful · 3.5s minimum</option><option value="standard">Steady · 2.5s minimum</option>
          <option value="very-slow">Very slow · 6s minimum</option></select></label>
        <label class="ppp-field"><span>Sort loaded posts</span><select data-setting="sort">
          <option value="date">Date created</option><option value="attachments">Attachment count</option><option value="title">Title</option></select></label>
        <label class="ppp-field"><span>Order</span><select data-setting="direction">
          <option value="desc">Descending</option><option value="asc">Ascending</option></select></label>
        <label class="ppp-field ppp-field--wide"><span>Include words or phrases</span>
          <textarea rows="1" data-setting="include" placeholder='cat, "bonus pack"' aria-describedby="ppp-filter-help"></textarea></label>
        <label class="ppp-field ppp-field--wide"><span>Exclude words or phrases</span>
          <textarea rows="1" data-setting="exclude" placeholder='preview, "work in progress", WIP' aria-describedby="ppp-filter-help"></textarea></label>
      </div></div>
      <details class="ppp-advanced"><summary>More filters & display</summary><div class="ppp-grid ppp-grid--posts">
        <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>Text matching</span><select data-setting="matchMode"><option value="word">Whole words & phrases</option><option value="substring">Contains text</option></select></label>
        <label class="ppp-field"><span>Inspect text in</span><select data-setting="textField">
          <option value="title">Post title</option><option value="card">Visible card text</option><option value="body">Title + post body · slower</option></select></label>
        <label class="ppp-field"><span>Archive files</span><select data-setting="archive"><option value="all">Any post</option><option value="with">Has an archive</option><option value="without">No archive</option></select></label>
        <label class="ppp-field"><span>Attachments</span><select data-setting="attachments"><option value="all">Any count</option><option value="with">At least one</option><option value="without">None</option></select></label>
        <label class="ppp-field"><span>Minimum attachments</span><input type="number" min="0" max="100000" step="1" data-setting="minAttachments"></label>
        <label class="ppp-field"><span>Created on / after (UTC)</span><input type="date" data-setting="dateFrom"></label>
        <label class="ppp-field"><span>Created on / before (UTC)</span><input type="date" data-setting="dateTo"></label>
        <label class="ppp-field"><span>Card spacing</span><select data-setting="layout"><option value="comfortable">Comfortable</option><option value="compact">Compact</option></select></label>
      </div></details>
      <div class="ppp-post-footer">
      <fieldset class="ppp-services"><legend>Sources</legend>${serviceCheckboxes(state.settings.posts.services)}</fieldset>
      <div class="ppp-toolbar"><button class="ppp-button" type="button" data-action="pause">Pause loading</button>
        <button class="ppp-button" type="button" data-action="resume" hidden>Resume loading</button>
        <button class="ppp-button ppp-button--quiet" type="button" data-action="retry-archives" hidden>Retry unverified posts</button>
        <button class="ppp-button ppp-button--quiet" type="button" data-action="clear-filters">Clear filters</button>
        <button class="ppp-button ppp-button--quiet" type="button" data-action="select-visible">Select visible</button>
        <button class="ppp-button ppp-button--quiet" type="button" data-action="clear-selection">Clear selection</button>
        <button class="ppp-button ppp-button--quiet" type="button" data-action="export">Export links</button>
        <span class="ppp-selection-count" data-role="selection-count">0 selected</span></div>
      </div>
      <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</button>
          <button class="ppp-button ppp-button--quiet" type="button" data-action="next">Next →</button></div></div>
      <output class="ppp-network-status" data-role="network-status" aria-live="polite"></output>
      <output class="ppp-filter-summary" data-role="filter-summary" aria-live="polite"></output>
      <details class="ppp-help-details"><summary>Filter syntax & loading notes</summary>
      <p class="ppp-help" id="ppp-filter-help">Separate terms with commas or new lines; use quotes for a phrase. Without commas or new lines, spaces separate words. Excluding “cat” keeps “category”; “work in progress” also matches “work-in-progress”. Exclude hides a post if any excluded term matches.</p>
      <p class="ppp-help">Archive and body filters check each eligible post once, using a shared queue and temporary cache. Unverified posts stay hidden. Infinite scroll pauses if no matches are visible, after 1,000 loaded posts, or after repeated errors; Resume or Load more continues. Alt+F focuses Include; Escape pauses loading.</p>
      </details>`;
    panel.querySelector('[name="q"]').value = new URLSearchParams(location.search).get("q") || nativeSearch?.querySelector('[name="q"]')?.value || "";
    setPanelValues(panel, state.settings.posts);
    return panel;
  }

  function bindPostPanel(panel, list, section, signal) {
    const reapply = debounce(() => {
      if (signal.aborted || !panel.isConnected) return;
      readPanelValues(panel, state.settings.posts);
      requests.setPace(state.settings.posts.pace);
      saveSettings();
      applyPostView(panel, list);
    }, 200);
    panel.addEventListener("input", event => { if (event.target.matches("[data-setting]")) reapply(); }, { signal });
    panel.addEventListener("change", event => {
      if (!event.target.matches("[data-setting], [data-service]")) return;
      readPanelValues(panel, state.settings.posts);
      requests.setPace(state.settings.posts.pace);
      saveSettings();
      if (event.target.matches('[data-setting="size"]')) void refreshPostBatch(panel, list, section);
      else applyPostView(panel, list);
    }, { signal });
    panel.addEventListener("click", event => {
      const action = event.target.closest("button[data-action]")?.dataset.action;
      const session = state.postSession;
      if (!action || session?.panel !== panel) return;
      if (action === "pause") pausePosts(session);
      else if (action === "resume" || action === "load-more") {
        session.paused = false;
        session.autoBlocked = false;
        session.issue = "";
        if (session.controller.signal.aborted) session.controller = new AbortController();
        if (list.querySelectorAll(":scope > .post-card").length >= session.autoLimit) session.autoLimit += 1000;
        syncPostControls(session);
        if (state.settings.posts.size === "infinite") void loadPostPages(session, 1);
        else void fillPostBatch(session);
        applyPostView(panel, list);
      } else if (action === "retry-archives") {
        requests.clearCache();
        for (const card of list.querySelectorAll(":scope > .post-card")) {
          if (state.archiveChecks.get(postKey(card))?.error) state.archiveChecks.delete(postKey(card));
        }
        session.paused = false;
        if (session.controller.signal.aborted) session.controller = new AbortController();
        applyPostView(panel, list);
      } else if (action === "reset") {
        state.settings.posts = structuredCloneSafe(DEFAULT_SETTINGS.posts);
        setPanelValues(panel, state.settings.posts);
        requests.setPace(state.settings.posts.pace);
        saveSettings();
        void refreshPostBatch(panel, list, section);
      } else if (action === "clear-filters") {
        const keep = { size: state.settings.posts.size, pace: state.settings.posts.pace, sort: state.settings.posts.sort, direction: state.settings.posts.direction, layout: state.settings.posts.layout };
        state.settings.posts = { ...structuredCloneSafe(DEFAULT_SETTINGS.posts), ...keep };
        setPanelValues(panel, state.settings.posts);
        saveSettings();
        applyPostView(panel, list);
      } else if (action === "previous" || action === "next") navigatePostBatch(action === "next" ? 1 : -1, Number(state.settings.posts.size) || 50);
      else if (action === "select-visible") {
        for (const card of list.querySelectorAll(":scope > .post-card")) if (!card.hidden) state.selectedPosts.add(postKey(card));
        updatePostSelection(panel, list);
      } else if (action === "clear-selection") { state.selectedPosts.clear(); updatePostSelection(panel, list); }
      else if (action === "export") exportPostLinks(panel, list);
    }, { signal });
    list.addEventListener("change", event => {
      if (!event.target.matches("[data-post-select]")) return;
      const key = postKey(event.target.closest(".post-card"));
      event.target.checked ? state.selectedPosts.add(key) : state.selectedPosts.delete(key);
      updatePostSelection(panel, list);
    }, { signal });
    document.addEventListener("keydown", event => {
      if (signal.aborted || state.postSession?.panel !== panel) return;
      if (event.altKey && event.key.toLowerCase() === "f") { event.preventDefault(); panel.querySelector('[data-setting="include"]').focus(); }
      if (event.key === "Escape") pausePosts(state.postSession);
    }, { signal });
    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 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());
    }, { signal });
  }

  async function refreshPostBatch(panel, list, section) {
    const session = state.postSession;
    if (session?.panel !== panel) return;
    session.controller.abort();
    session.controller = new AbortController();
    session.observer?.disconnect();
    session.loading = false;
    session.paused = false;
    session.issue = "";
    session.autoBlocked = false;
    session.nextOffset = session.baseOffset + 50;
    session.ended = session.nativeCards.length < 50;
    readPanelValues(panel, state.settings.posts);
    requests.setPace(state.settings.posts.pace);
    saveSettings();
    const wanted = state.settings.posts.size === "infinite" ? 50 : Number(state.settings.posts.size);
    session.seen = new Set();
    list.replaceChildren();
    session.nativeCards.slice(0, wanted).forEach((card, index) => {
      preparePostCard(card, index);
      session.seen.add(postKey(card));
      list.appendChild(card);
    });
    state.selectedPosts.clear();
    toggleNativePaginators(section, false);
    installPostSentinel(session);
    applyPostView(panel, list);
    if (wanted > 50) await fillPostBatch(session);
    syncPostControls(session);
  }

  function installPostSentinel(session) {
    session.section.querySelectorAll(".ppp-sentinel").forEach(node => node.remove());
    const sentinel = element("div", { className: "ppp-sentinel" });
    const button = element("button", { className: "ppp-button", type: "button", textContent: "Load next 50 posts" });
    button.dataset.action = "load-more";
    const message = element("p", { className: "ppp-help" });
    message.dataset.role = "stream-status";
    sentinel.append(message, button);
    (session.list.closest(".card-list") || session.list).insertAdjacentElement("afterend", sentinel);
    session.sentinel = sentinel;
    button.addEventListener("click", () => session.panel.querySelector('[data-action="resume"]').click(), { signal: state.postBindings.get(session.panel).controller.signal });
    if (typeof IntersectionObserver === "function") {
      session.observer = new IntersectionObserver(entries => {
        if (!entries.some(entry => entry.isIntersecting) || state.settings.posts.size !== "infinite" || session.paused || session.loading || session.ended || !session.visible || session.autoBlocked || document.hidden) return;
        const loaded = session.list.querySelectorAll(":scope > .post-card").length;
        if (loaded >= session.autoLimit) { pausePosts(session, `Automatic loading paused at ${session.autoLimit.toLocaleString()} loaded posts. Continue when ready.`); return; }
        // One page per intersection. Filtered-out pages do not start an unbounded scan.
        session.autoBlocked = true;
        void loadPostPages(session, 1);
      }, { rootMargin: "200px 0px", threshold: 0 });
      session.observer.observe(sentinel);
      window.addEventListener("scroll", () => { session.autoBlocked = false; }, { passive: true, signal: state.postBindings.get(session.panel).controller.signal });
    }
    syncPostControls(session);
  }

  async function fillPostBatch(session) {
    const wanted = Number(state.settings.posts.size) || 50;
    const loaded = session.list.querySelectorAll(":scope > .post-card").length;
    await loadPostPages(session, Math.max(0, Math.ceil((wanted - loaded) / 50)));
  }

  async function loadPostPages(session, count) {
    if (session.loading || session.paused || session.ended || !count) return;
    const controller = session.controller;
    const current = () => state.postSession === session && session.controller === controller && !controller.signal.aborted && session.list.isConnected;
    session.loading = true;
    syncPostControls(session);
    try {
      for (let page = 0; page < count && current() && !session.paused && !session.ended; page++) {
        const url = new URL(session.baseUrl);
        url.searchParams.set("o", String(session.nextOffset));
        const html = await requests.requestText(url.toString(), { signal: controller.signal });
        if (!current()) return;
        const doc = new DOMParser().parseFromString(html, "text/html");
        const fetched = [...doc.querySelectorAll(".card-list__items > .post-card")];
        if (!doc.querySelector(".card-list__items")) throw new Error("The response has no post list; it may be a challenge or temporary error page. Loading paused.");
        let added = 0;
        for (const source of fetched) {
          const key = postKey(source);
          if (session.seen.has(key)) continue;
          const card = document.importNode(source, true);
          // Imported HTML is untrusted. Keep content inert and links on trusted schemes.
          card.querySelectorAll("script, iframe, object, embed").forEach(node => node.remove());
          for (const node of [card, ...card.querySelectorAll("*")]) for (const attr of [...node.attributes]) {
            if (/^on/i.test(attr.name) || attr.name === "srcdoc" || (/^(?:href|src|action)$/i.test(attr.name) && /^\s*(?:javascript|data|vbscript):/i.test(attr.value))) node.removeAttribute(attr.name);
          }
          session.seen.add(key);
          card.dataset.pppFetched = "true";
          preparePostCard(card, session.list.children.length);
          session.list.appendChild(card);
          added++;
        }
        if (fetched.length && !added) throw new Error("Pawchive repeated a page. Loading paused to avoid duplicate requests.");
        session.nextOffset += 50;
        session.ended = fetched.length < 50 || Boolean(session.total && session.nextOffset >= session.total);
        session.panel.dataset.pppBaseUrl = session.baseUrl;
        session.panel.dataset.pppNextOffset = String(session.nextOffset);
        session.panel.dataset.pppEnded = String(session.ended);
        applyPostView(session.panel, session.list);
      }
    } catch (error) {
      if (error.name !== "AbortError" && current()) pausePosts(session, error.message);
    } finally {
      if (session.controller === controller) { session.loading = false; syncPostControls(session); }
    }
  }

  function pausePosts(session, message = "Loading paused. Loaded posts remain usable.") {
    if (!session) return;
    session.paused = true;
    session.issue = message;
    session.controller.abort();
    session.loading = false;
    syncPostControls(session);
    applyPostView(session.panel, session.list);
  }

  function syncPostControls(session) {
    if (state.postSession !== session || !session.panel.isConnected) return;
    const { panel } = session;
    const infinite = state.settings.posts.size === "infinite";
    panel.classList.toggle("ppp-is-busy", session.loading);
    panel.setAttribute("aria-busy", String(session.loading));
    panel.querySelector('[data-action="pause"]').hidden = session.paused;
    panel.querySelector('[data-action="resume"]').hidden = !session.paused && !session.issue;
    panel.querySelector('[data-action="previous"]').disabled = session.loading || session.baseOffset <= 0;
    panel.querySelector('[data-action="next"]').disabled = session.loading || Boolean(session.total && session.baseOffset + (Number(state.settings.posts.size) || 50) >= session.total);
    panel.querySelector('[data-role="batch-nav"]').hidden = infinite;
    if (session.sentinel) {
      session.sentinel.hidden = !infinite;
      const button = session.sentinel.querySelector("button");
      button.disabled = session.loading || session.ended;
      button.textContent = session.ended ? "End of results" : session.loading ? "Loading…" : "Load next 50 posts";
      session.sentinel.querySelector('[data-role="stream-status"]').textContent = session.issue || (session.ended ? "All available pages in these results are loaded." : session.visible ? "Scroll for the next page, or load it here." : "No visible matches. Load another page manually to continue searching.");
    }
    updateNetworkStatus();
  }

  function preparePostCard(card, index) {
    card.dataset.pppIndex = String(index);
    card.hidden = false;
    if (!card.querySelector(".ppp-post-card-tools")) {
      const tools = element("label", { className: "ppp-post-card-tools" });
      const checkbox = element("input", { type: "checkbox" });
      checkbox.dataset.postSelect = "";
      checkbox.setAttribute("aria-label", `Select ${card.querySelector(".post-card__header")?.textContent.trim() || "post"}`);
      tools.append(checkbox, document.createTextNode(" Select link"));
      card.appendChild(tools);
    }
  }

  function postTitle(card) { return card.querySelector(".post-card__header")?.textContent.trim() || ""; }

  function postBaseMatch(card, settings) {
    const count = attachmentCount(card);
    const date = postDate(card);
    const from = settings.dateFrom ? Date.parse(`${settings.dateFrom}T00:00:00Z`) : 0;
    const until = settings.dateTo ? Date.parse(`${settings.dateTo}T23:59:59.999Z`) : Infinity;
    return settings.services.includes(card.dataset.service || "") && count >= Number(settings.minAttachments || 0) &&
      (settings.attachments !== "with" || count > 0) && (settings.attachments !== "without" || count === 0) && date >= from && date <= until;
  }

  function applyPostView(panel, list) {
    const session = state.postSession;
    if (session?.panel !== panel || !panel.isConnected) return;
    const settings = state.settings.posts;
    const cards = [...list.querySelectorAll(":scope > .post-card")];
    const include = parseTerms(settings.include);
    const exclude = parseTerms(settings.exclude);
    const mode = settings.matchMode;
    const needsBody = settings.textField === "body" && (include.length > 0 || exclude.length > 0);
    const needsArchive = settings.archive !== "all";
    session.section.classList.toggle("ppp-layout--compact", settings.layout === "compact");
    const collator = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
    cards.sort((a, b) => {
      const primary = settings.sort === "title" ? collator.compare(postTitle(a), postTitle(b)) : settings.sort === "attachments" ? attachmentCount(a) - attachmentCount(b) : postDate(a) - postDate(b);
      const directed = settings.direction === "asc" ? primary : -primary;
      return directed || Number(a.dataset.pppIndex) - Number(b.dataset.pppIndex);
    });
    const fragment = document.createDocumentFragment();
    let visible = 0, pending = 0, failed = 0;
    for (const card of cards) {
      const check = state.archiveChecks.get(postKey(card));
      let text = settings.textField === "card" ? `${postTitle(card)} ${card.querySelector(".post-card__footer")?.textContent || ""}` : postTitle(card);
      if (needsBody && typeof check?.content === "string") text = `${check.title || text} ${check.content}`;
      const basic = postBaseMatch(card, settings);
      const textMatch = (!include.length || termsMatch(text, include, settings.includeMode, mode)) && !exclude.some(term => termMatches(text, term, mode));
      const titleExcluded = needsBody && exclude.some(term => termMatches(postTitle(card), term, mode));
      const candidate = basic && !titleExcluded && (needsBody || textMatch);
      const verified = (!needsArchive || typeof check?.hasArchive === "boolean") && (!needsBody || typeof check?.content === "string");
      if (candidate && !verified) check?.error ? failed++ : pending++;
      const archiveMatch = !needsArchive || (typeof check?.hasArchive === "boolean" && check.hasArchive === (settings.archive === "with"));
      card.hidden = !(basic && !titleExcluded && textMatch && verified && archiveMatch);
      if (!card.hidden) visible++;
      fragment.appendChild(card);
    }
    list.appendChild(fragment);
    session.visible = visible;
    const tail = session.issue ? ` · ${session.issue}` : session.loading ? " · loading more…" : session.paused ? " · paused" : "";
    setStatus(panel, `${visible.toLocaleString()} shown / ${cards.length.toLocaleString()} loaded${pending ? ` · ${pending} awaiting metadata` : ""}${failed ? ` · ${failed} unverified` : ""}${tail}`, session.issue && !session.paused ? "error" : "normal");
    panel.querySelector('[data-action="retry-archives"]').hidden = !failed;
    const warnings = [parseTermsDetailed(settings.include).warnings, parseTermsDetailed(settings.exclude).warnings].flat();
    panel.querySelector('[data-role="filter-summary"]').textContent = `${include.length} included · ${exclude.length} excluded · ${mode === "substring" ? "contains text" : "whole words & phrases"}${settings.dateFrom && settings.dateTo && settings.dateFrom > settings.dateTo ? " · Date range is reversed" : ""}${warnings.length ? ` · ${[...new Set(warnings)].join(" ")}` : ""}`;
    updatePostSelection(panel, list);
    syncPostControls(session);
    if (pending && !session.paused && (needsBody || needsArchive)) void checkPostArchives(panel, list);
  }

  async function checkPostArchives(panel, list) {
    const session = state.postSession;
    if (session?.panel !== panel || session.checking || session.paused) return;
    session.checking = true;
    const controller = session.controller;
    const current = () => state.postSession === session && session.controller === controller && !controller.signal.aborted && list.isConnected;
    try {
      for (const card of list.querySelectorAll(":scope > .post-card")) {
        if (!current() || session.paused) break;
        const settings = state.settings.posts;
        const include = parseTerms(settings.include), exclude = parseTerms(settings.exclude);
        const needsBody = settings.textField === "body" && (include.length || exclude.length);
        if (settings.archive === "all" && !needsBody) break;
        if (!postBaseMatch(card, settings)) continue;
        const titleText = settings.textField === "card" ? `${postTitle(card)} ${card.querySelector(".post-card__footer")?.textContent || ""}` : postTitle(card);
        if (needsBody && exclude.some(term => termMatches(postTitle(card), term, settings.matchMode))) continue;
        if (!needsBody && ((include.length && !termsMatch(titleText, include, settings.includeMode, settings.matchMode)) || exclude.some(term => termMatches(titleText, term, settings.matchMode)))) continue;
        const key = postKey(card);
        const previous = state.archiveChecks.get(key);
        if (previous?.error || (typeof previous?.hasArchive === "boolean" && (!needsBody || typeof previous.content === "string"))) continue;
        try {
          const metadata = await fetchPostMetadata(card, controller.signal);
          if (!current()) break;
          if (needsBody && typeof metadata.content !== "string") throw new Error("This post's body is unavailable; retry to verify it later");
          state.archiveChecks.set(key, metadata);
          if (state.archiveChecks.size > 2500) {
            const stale = [...state.archiveChecks.keys()].find(key => !session.seen.has(key));
            if (stale) state.archiveChecks.delete(stale);
          }
        } catch (error) {
          if (error.name === "AbortError" || !current()) break;
          state.archiveChecks.set(key, { error: error.message });
          // Stop the whole batch after an exhausted server error, avoiding a new
          // retry cycle for every post against a service that is still throttling.
          if (error.status === 429 || error.retryable) { pausePosts(session, error.message); break; }
        }
        if (current()) applyPostView(panel, list);
      }
    } finally {
      session.checking = false;
      if (state.postSession === session && list.isConnected) queueMicrotask(() => applyPostView(panel, list));
    }
  }

  async function fetchPostMetadata(card, signal) {
    const { service, user, id } = card.dataset;
    if (!service || !user || !id) throw new Error("Post identifiers are missing");
    const url = `/api/v1/${encodeURIComponent(service)}/user/${encodeURIComponent(user)}/post/${encodeURIComponent(id)}`;
    const data = await requests.requestJson(url, { signal });
    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; this post remains unverified");
    const files = [post.file, ...post.attachments].filter(Boolean);
    return {
      hasArchive: files.some(file => ARCHIVE_PATTERN.test(String(file.name || "")) || ARCHIVE_PATTERN.test(String(file.path || ""))),
      title: String(post.title || postTitle(card)),
      content: typeof post.content === "string" ? plainPostText(post.content) : Object.hasOwn(post, "content") && post.content == null ? "" : undefined,
    };
  }

  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 match = (card.querySelector(".post-card__footer")?.textContent || "").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 ? Math.floor(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) {
    // Creator pagination and tabs are shared with downloader scripts. Preserve
    // the site's navigation on those pages at every selected batch size.
    const creator = /^\/[^/]+\/user\/[^/]+\/?$/.test(location.pathname);
    if (creator) show = true;
    section.querySelectorAll(".paginator").forEach(node => {
      node.classList.toggle("ppp-paginator-hidden", !show);
      if (creator) node.classList.add("ppp-preserve-navigation");
    });
  }
  function navigatePostBatch(direction, size) {
    const url = new URL(location.href);
    const offset = Math.max(0, numericOffset() + direction * size);
    offset ? url.searchParams.set("o", String(offset)) : url.searchParams.delete("o");
    location.assign(url.toString());
  }
  function updatePostSelection(panel, list) {
    let count = 0;
    for (const card of list.querySelectorAll(":scope > .post-card")) {
      const selected = state.selectedPosts.has(postKey(card));
      const control = card.querySelector("[data-post-select]");
      if (control) control.checked = selected;
      card.classList.toggle("ppp-post-selected", selected);
      if (selected) count++;
    }
    panel.querySelector('[data-role="selection-count"]').textContent = `${count} selected`;
  }
  function exportPostLinks(panel, list) {
    const cards = [...list.querySelectorAll(":scope > .post-card")];
    const selected = cards.filter(card => state.selectedPosts.has(postKey(card)));
    const results = (selected.length ? selected : cards.filter(card => !card.hidden)).map(card => {
      const link = card.querySelector('a[href*="/post/"]');
      return link ? `${postTitle(card).replace(/\s+/g, " ")}\t${new URL(link.getAttribute("href"), location.origin).href}` : "";
    }).filter(Boolean);
    if (!results.length) { setStatus(panel, "There are no visible or selected links to export."); return; }
    const url = URL.createObjectURL(new Blob([results.join("\n") + "\n"], { type: "text/plain;charset=utf-8" }));
    const link = element("a", { href: url, download: `pawchive-post-links-${new Date().toISOString().slice(0, 10)}.txt` });
    document.body.appendChild(link);
    link.click();
    link.remove();
    window.setTimeout(() => URL.revokeObjectURL(url), 10000);
    setStatus(panel, `Exported ${results.length} ${selected.length ? "selected" : "visible"} post links.`);
  }

  function updateNetworkStatus() {
    if (typeof requests === "undefined") return;
    const status = requests.getStatus();
    const session = state.postSession;
    const output = session?.panel.querySelector('[data-role="network-status"]') || document.querySelector('#ppp-artists-panel [data-role="network-status"]');
    if (!output) return;
    const wait = Math.max(0, Math.ceil((status.retryAt - Date.now()) / 1000));
    const cooldown = status.cooldownUntil > Date.now();
    output.textContent = wait ? `${cooldown ? "Server cooldown" : "Request pacing"} · ${wait}s remaining. The queue will wait automatically.` : session?.issue || (status.phase === "requesting" ? "Fetching one request…" : `Requests are queued one at a time · ${state.settings.posts.pace} pace.`);
  }

  function cancelArtistTasks() {
    ++state.artistRun;
    state.artistAbort?.abort();
    if (state.artistDataSignal?.aborted) {
      state.artistDataPromise = null;
      state.artistDataSignal = null;
    }
    if (state.artistPanel) {
      state.artistPanel._pppDiscovery?.controller.abort();
      state.artistPanel._pppBoundsLoading?.controller.abort();
      state.artistBindings.get(state.artistPanel)?.abort();
    }
    state.artistPanel = null;
  }

  function initArtistsPage() {
    const section = document.querySelector(".site-section--artists");
    if (!section) return;
    let panel = section.querySelector("#ppp-artists-panel");
    if (panel && state.artistPanel === panel && state.artistBindings.has(panel)) return;
    cancelArtistTasks();
    if (!panel) {
      panel = buildArtistsPanel();
      const heading = section.querySelector(".site-section__header");
      if (heading) heading.insertAdjacentElement("afterend", panel);
      else section.prepend(panel);
    }
    state.artistPanel = panel;
    const controller = new AbortController();
    state.artistBindings.set(panel, controller);
    if (!panel.querySelector('[data-setting="matchMode"]')) {
      const label = element("label", { className: "ppp-field" });
      label.innerHTML = '<span>Text matching</span><select data-setting="matchMode"><option value="word">Whole words & phrases</option><option value="substring">Contains text</option></select>';
      panel.querySelector(".ppp-grid--artists").appendChild(label);
      const output = element("output", { className: "ppp-network-status" });
      output.dataset.role = "network-status";
      panel.querySelector('[data-role="status"]').insertAdjacentElement("afterend", output);
      const help = element("p", { className: "ppp-help", textContent: TERM_FILTER_HELP });
      label.closest(".ppp-grid").insertAdjacentElement("afterend", help);
    }
    section.classList.add("ppp-artists-active");
    setPanelBusy(panel, false);
    setPanelValues(panel, state.settings.artists);
    bindArtistsPanel(panel, controller.signal);
    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, signal) {
    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();
    }, { signal });
    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();
    }, { signal });
    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" });
      }
    }, { signal });
  }

  async function loadArtists(panel) {
    const run = ++state.artistRun;
    state.artistAbort?.abort();
    state.artistAbort = new AbortController();
    const current = () => run === state.artistRun && panel.isConnected && state.artistPanel === panel;
    setPanelBusy(panel, true);
    setStatus(panel, "Loading Pawchive’s creator index…", "loading");
    let ownedPromise;
    const startIndex = () => {
      const promise = requests.requestJson("/api/v1/creators", { signal: state.artistAbort.signal, ttl: 600000 });
      state.artistDataPromise = promise;
      state.artistDataSignal = state.artistAbort.signal;
      return promise;
    };
    try {
      if (state.artistDataSignal?.aborted) { state.artistDataPromise = null; state.artistDataSignal = null; }
      ownedPromise = state.artistDataPromise || startIndex();
      let artists = await ownedPromise;
      if (!artists && current()) {
        ownedPromise = startIndex();
        artists = await ownedPromise;
      }
      if (!current()) return;
      if (!Array.isArray(artists)) throw new Error("The creator index had an unexpected format");
      if (state.artistDataPromise === ownedPromise) state.artistDataSignal = null;
      panel._pppArtists = artists;
      renderArtists(panel);
    } catch (error) {
      if (state.artistDataPromise === ownedPromise) { state.artistDataPromise = null; state.artistDataSignal = null; }
      if (error.name !== "AbortError" && current()) {
        setStatus(panel, 'Creator search could not load: ' + error.message, "error");
        const container = panel.querySelector('[data-role="artist-results"]');
        container.replaceChildren();
        const retry = element("button", { type: "button", className: "ppp-button", textContent: "Retry creator index" });
        retry.addEventListener("click", () => { if (current()) void loadArtists(panel); }, { once: true });
        container.appendChild(retry);
      }
    } finally { if (current()) 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) => termMatches(haystack, term, settings.matchMode))
            : include.every((term) => termMatches(haystack, term, settings.matchMode)));
        return includesMatch && !exclude.some((term) => termMatches(haystack, term, settings.matchMode));
      })
      .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, errors: 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 = run.stopReason ? "Last-page lookup paused: " + run.stopReason : "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");
    try {
      return await (format === "text" ? requests.requestText(url, { signal: run.controller.signal }) : requests.requestJson(url, { signal: run.controller.signal }));
    } catch (error) {
      if (empty404 && error.status === 404) { run.commentsMissing++; return []; }
      if (error.retryable || error.status === 429) {
        run.stopReason = error.message;
        run.errors++;
        run.controller.abort();
      }
      throw error;
    }
  }

  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) => termMatches(haystack, term, run.matchMode));
    if (!(run.match === "all" ? matches.every(Boolean) : matches.some(Boolean))) return null;
    const matchedSources = sources.filter((source) => run.terms.some((term) => termMatches(source[1], term, run.matchMode)));
    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, matchMode: settings.matchMode,
      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 };
          if (usesContent && typeof post.content !== "string" && post.content !== null) throw new Error("Requested post body is unavailable; coverage is unverified");
          if (usesTitle && typeof post.title !== "string" && post.title !== null) throw new Error("Requested post title is unavailable; coverage is unverified");
          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.stopReason ? "Paused — partial results: " + run.stopReason : 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] || "#555555";
    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
        : key === "size" && control.value === "infinite" ? "infinite"
        : ["size", "pageFrom", "pageTo", "minAttachments"].includes(key) ? Math.max(0, Math.floor(Number(control.value) || 0)) : control.value;
    });
    settings.services = [...panel.querySelectorAll("[data-service]:checked")].map(
      (control) => control.dataset.service,
    );
  }

  // A comma or a new line separates phrases. Without either, spaces separate
  // words and quotes group phrases. User input is always literal, never regex.

  function parseTerms(value) {
    return parseTermsDetailed(value).terms;
  }

  function parseTermsDetailed(value) {
    const input = String(value ?? "").normalize("NFKC");
    const warnings = [];
    const closingQuote = (character) => ({ '"': '"', "'": "'", "\u201c": "\u201d", "\u2018": "\u2019" })[character];
    const startsQuote = (character, index) => Boolean(closingQuote(character)) &&
      (index === 0 || /[\s,]/u.test(input[index - 1]));
    const closesQuote = (character, index, closer) => character === closer &&
      (!["'", "\u2019"].includes(closer) || index === input.length - 1 || /[\s,]/u.test(input[index + 1]) || input[index + 1] === closer);
    const listSeparator = (character) => /[,\r\n\u2028\u2029]/u.test(character);
    let quote = null;
    let listMode = false;
    // Decide the syntax using separators outside quotes, so "one, two" is a
    // single quoted phrase and does not silently change how other terms parse.
    for (let index = 0; index < input.length; index += 1) {
      const character = input[index];
      if (character === "\\" && [quote, "\\"].includes(input[index + 1])) {
        index += 1;
      } else if (quote) {
        if (closesQuote(character, index, quote)) {
          if (input[index + 1] === quote) index += 1;
          else quote = null;
        }
      } else if (startsQuote(character, index)) {
        quote = closingQuote(character);
      } else if (listSeparator(character)) {
        listMode = true;
      }
    }

    const terms = [];
    let buffer = "";
    let quoteStart = -1;
    quote = null;
    const flush = () => {
      const term = normalizeTermText(buffer);
      if (term) terms.push(term);
      buffer = "";
    };
    for (let index = 0; index < input.length; index += 1) {
      const character = input[index];
      if (character === "\\" && [quote, "\\"].includes(input[index + 1])) {
        buffer += input[index + 1];
        index += 1;
      } else if (quote) {
        if (closesQuote(character, index, quote)) {
          if (input[index + 1] === quote) {
            buffer += character;
            index += 1;
          } else {
            quote = null;
          }
        } else {
          buffer += character;
        }
      } else if (startsQuote(character, index)) {
        quote = closingQuote(character);
        quoteStart = index;
      } else if (listSeparator(character) ||
        (!listMode && /\s/u.test(character))) {
        flush();
      } else {
        buffer += character;
      }
    }
    if (quote) warnings.push(`The quote starting at character ${quoteStart + 1} is not closed; the remaining text is treated as one phrase.`);
    flush();
    return { terms: [...new Set(terms)], warnings };
  }

  function normalizeTermText(value, { foldAccents = false } = {}) {
    let text = String(value ?? "")
      .normalize("NFKC")
      .toLowerCase()
      .replace(/[\u2018\u2019\u201b\u02bc\uff07]/gu, "'")
      .replace(/[\p{Pd}\u2212]/gu, "-")
      .replace(/\s+/gu, " ")
      .trim();
    if (foldAccents) text = text.normalize("NFD").replace(/\p{M}/gu, "").normalize("NFC");
    return text;
  }

  // Whole-word boundaries include Unicode letters, numbers, and combining
  // marks. Apostrophes inside words and technical suffixes such as C++ and C#
  // also belong to the word. This prevents cat/category and art/party matches.
  // CJK terms use these exact boundaries; no dictionary word guesses are made.
  function termWordCharacter(character) {
    return Boolean(character) && /[\p{L}\p{M}\p{N}_+#]/u.test(character);
  }

  function termCharacterBefore(text, index) {
    return index > 0 ? [...text.slice(Math.max(0, index - 2), index)].pop() || "" : "";
  }

  function termCharacterAfter(text, index) {
    return [...text.slice(index, index + 2)][0] || "";
  }

  function termBoundary(text, index) {
    const before = termCharacterBefore(text, index);
    const after = termCharacterAfter(text, index);
    if (before === "'" && termWordCharacter(termCharacterBefore(text, index - 1)) && termWordCharacter(after)) return false;
    if (after === "'" && termWordCharacter(before) && termWordCharacter(termCharacterAfter(text, index + 1))) return false;
    return !(termWordCharacter(before) && termWordCharacter(after));
  }

  function termPhraseSeparator(text, index) {
    const character = termCharacterAfter(text, index);
    if (!character || !/[\s\p{P}]/u.test(character)) return false;
    if (character === "#") return false;
    if (character === "'" && termWordCharacter(termCharacterBefore(text, index)) &&
      termWordCharacter(termCharacterAfter(text, index + character.length))) return false;
    return true;
  }

  function normalizedTermMatches(text, term, mode = "word") {
    if (!term) return false;
    // Treat hyphens and whitespace inside a phrase as separators. Other
    // characters in an atom stay literal: C++, a.b, [cat], and a|b are safe.
    const atoms = term.split(/[\s-]+/u).filter(Boolean);
    if (!atoms.length) return text.includes(term);
    let start = text.indexOf(atoms[0]);
    while (start !== -1) {
      if (mode === "substring" || termBoundary(text, start)) {
        let cursor = start + atoms[0].length;
        let matched = true;
        for (let index = 1; index < atoms.length; index += 1) {
          const gapStart = cursor;
          while (cursor < text.length && termPhraseSeparator(text, cursor)) {
            cursor += termCharacterAfter(text, cursor).length;
            if (text.startsWith(atoms[index], cursor)) break;
          }
          if (cursor === gapStart || !text.startsWith(atoms[index], cursor)) {
            matched = false;
            break;
          }
          cursor += atoms[index].length;
        }
        if (matched && (mode === "substring" || termBoundary(text, cursor))) return true;
      }
      start = text.indexOf(atoms[0], start + 1);
    }
    return false;
  }

  function termMatches(text, term, mode = "word", options = {}) {
    return normalizedTermMatches(normalizeTermText(text, options), normalizeTermText(term, options), mode);
  }

  function termsMatch(text, terms, operator = "any", mode = "word", options = {}) {
    const haystack = normalizeTermText(text, options);
    const values = Array.isArray(terms) ? terms : parseTerms(terms);
    const matches = (term) => normalizedTermMatches(haystack, normalizeTermText(term, options), mode);
    return operator === "all" ? values.every(matches) : values.some(matches);
  }

  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);
    for (const key of Object.keys(defaults)) if (stored && Object.hasOwn(stored, key)) merged[key] = stored[key];
    merged.services = Array.isArray(merged.services) ? merged.services.filter(service => ARTIST_SERVICES.includes(service)) : [...ARTIST_SERVICES];
    for (const key of ["include", "exclude", "postTerms", "dateFrom", "dateTo"]) if (key in merged) merged[key] = typeof merged[key] === "string" ? merged[key].slice(0, 10000) : defaults[key];
    for (const [key, allowed] of Object.entries({ pace: ["standard", "careful", "very-slow"], matchMode: ["word", "substring"], textField: ["title", "card", "body"], attachments: ["all", "with", "without"], archive: ["all", "with", "without"], layout: ["comfortable", "compact"], includeMode: ["all", "any"], direction: ["asc", "desc"] })) {
      if (key in merged && !allowed.includes(merged[key])) merged[key] = defaults[key];
    }
    if ("pace" in merged && ![25, 50, 100, 200, "infinite"].includes(merged.size)) merged.size = defaults.size;
    if ("minAttachments" in merged) merged.minAttachments = Math.min(100000, Math.max(0, Math.floor(Number(merged.minAttachments) || 0)));
    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;");
  }

  // Every request made by this userscript should use this scheduler. It obeys
  // server cooldowns; it does not rotate identities or circumvent rate limits.
  function createRequestScheduler(fetchImpl, options = {}) {
    const env = options.env || {};
    const now = env.now || (() => Date.now());
    const random = env.random || Math.random;
    const setTimer = env.setTimeout || ((fn, ms) => window.setTimeout(fn, ms));
    const clearTimer = env.clearTimeout || ((id) => window.clearTimeout(id));
    const storage = env.storage !== undefined ? env.storage : (() => {
      try { return window.localStorage; } catch { return null; }
    })();
    const locks = env.locks !== undefined ? env.locks : navigator.locks;
    const baseURL = env.baseURL || location.href;
    const namespace = options.namespace || "pawchive-power-tools";
    const sharedKey = `${namespace}:request-cooldown:v2`;
    const lockName = `${namespace}:network:v2`;
    const paces = { standard: 2500, careful: 3500, "very-slow": 6000 };
    const retryStatuses = new Set([429, 502, 503, 504]);
    const maxRetries = Math.floor(Math.max(0, Math.min(3, Number(options.maxRetries ?? 2) || 0)));
    const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 30000);
    const maxEntries = Math.max(1, Number(options.maxCacheEntries) || 40);
    const maxCacheChars = Math.max(1, Number(options.maxCacheChars) || 16000000);
    const maxEntryChars = Math.min(maxCacheChars, Number(options.maxEntryChars) || 8000000);
    const cache = new Map();
    const inFlight = new Map();
    let cachedChars = 0;
    let pace = paces[options.pace] ? options.pace : "careful";
    let tail = Promise.resolve();
    let cooldownUntil = 0;
    let nextRequestAt = 0;
    let waiting = 0;
    let activeJob = null;
    let status = { phase: "idle", retryAt: 0, httpStatus: 0, attempt: 0, url: "" };

    class RequestError extends Error {
      constructor(message, details = {}) {
        super(message);
        this.name = "RequestError";
        this.status = details.status || 0;
        this.retryAt = details.retryAt || 0;
        this.retryable = Boolean(details.retryable);
        this.timedOut = Boolean(details.timedOut);
        if (details.cause) this.cause = details.cause;
      }
    }

    function abortError() {
      return new DOMException("Request stopped", "AbortError");
    }

    function snapshot() {
      readShared();
      return { ...status, pace, intervalMs: paces[pace], queued: waiting,
        active: Boolean(activeJob), cooldownUntil, nextRequestAt,
        crossTabLock: Boolean(locks?.request), cacheEntries: cache.size };
    }

    function emit(details) {
      status = { ...status, ...details };
      try { options.onStatus?.(snapshot()); } catch (error) {
        console.debug(`[${namespace}] Request status callback failed`, error);
      }
    }

    function readShared() {
      try {
        const value = JSON.parse(storage?.getItem(sharedKey) || "null");
        if (Number.isFinite(value?.cooldownUntil) && value.cooldownUntil > 0) {
          cooldownUntil = Math.max(cooldownUntil, value.cooldownUntil);
        }
        if (Number.isFinite(value?.nextRequestAt) && value.nextRequestAt > 0) {
          nextRequestAt = Math.max(nextRequestAt, value.nextRequestAt);
        }
      } catch { /* Private browsing and storage restrictions remain usable. */ }
    }

    function writeShared() {
      readShared();
      try { storage?.setItem(sharedKey, JSON.stringify({ cooldownUntil, nextRequestAt })); }
      catch { /* Local pacing still applies if persistence is unavailable. */ }
    }

    function delay(ms, signal) {
      if (signal.aborted) return Promise.reject(abortError());
      if (ms <= 0) return Promise.resolve();
      return new Promise((resolve, reject) => {
        const onAbort = () => {
          clearTimer(timer);
          signal.removeEventListener("abort", onAbort);
          reject(abortError());
        };
        const timer = setTimer(() => {
          signal.removeEventListener("abort", onAbort);
          resolve();
        }, ms);
        signal.addEventListener("abort", onAbort, { once: true });
      });
    }

    async function waitForSlot(job, attempt) {
      let reportedDeadline = -1;
      for (;;) {
        if (job.controller.signal.aborted) throw abortError();
        readShared();
        const deadline = Math.max(nextRequestAt, cooldownUntil);
        if (deadline <= now()) return;
        if (deadline !== reportedDeadline) {
          reportedDeadline = deadline;
          emit({ phase: cooldownUntil > now() ? "cooldown" : "waiting",
            retryAt: deadline, url: job.url, attempt });
        }
        // Re-read persistent cooldowns regularly when Web Locks are unavailable.
        await delay(Math.min(1000, deadline - now()), job.controller.signal);
      }
    }

    function retryAfter(header) {
      if (!header) return 0;
      const trimmed = String(header).trim();
      if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
        const until = now() + Number(trimmed) * 1000;
        return Number.isFinite(until) && until <= 8640000000000000 ? until : 0;
      }
      const date = Date.parse(trimmed);
      return Number.isFinite(date) ? Math.max(now(), date) : 0;
    }

    function extendCooldown(response, attempt) {
      const base = response.status === 429 ? 15000 : 5000;
      const backoff = Math.min(300000, base * 2 ** attempt) + Math.floor(random() * 2500);
      cooldownUntil = Math.max(cooldownUntil,
        retryAfter(response.headers.get("Retry-After")), now() + backoff);
      writeShared();
      return cooldownUntil;
    }

    async function attemptRequest(job, attempt) {
      await waitForSlot(job, attempt + 1);
      const controller = new AbortController();
      let timedOut = false;
      let responseStatus = 0;
      let responseRetryAt = 0;
      const onAbort = () => controller.abort();
      job.controller.signal.addEventListener("abort", onAbort, { once: true });
      // Timeout begins at the network operation, after pacing and any cooldown.
      const timer = setTimer(() => { timedOut = true; controller.abort(); }, timeoutMs);
      nextRequestAt = Math.max(nextRequestAt, now() + paces[pace]);
      writeShared();
      emit({ phase: "requesting", retryAt: 0, httpStatus: 0,
        url: job.url, attempt: attempt + 1 });
      try {
        const response = await fetchImpl(job.url, {
          credentials: "same-origin", headers: job.headers, signal: controller.signal,
        });
        // Save a server cooldown as soon as headers arrive, even if downloading
        // the error body is interrupted. The next tab must still respect it.
        const retryable = retryStatuses.has(response.status);
        const retryAt = !response.ok && retryable ? extendCooldown(response, attempt) : 0;
        responseStatus = response.status;
        responseRetryAt = retryAt;
        // Read the full body, including failures, before releasing the shared lock.
        // Otherwise a second request can start while the first is downloading.
        const text = await response.text();
        if (job.controller.signal.aborted) throw abortError();
        if (timedOut) throw new RequestError("Request timed out; retry manually when ready", {
          timedOut: true, retryAt,
        });
        if (!response.ok) {
          throw new RequestError(retryable
            ? `HTTP ${response.status}; server cooldown until ${new Date(retryAt).toLocaleTimeString()}`
            : `HTTP ${response.status}`, { status: response.status, retryAt, retryable });
        }
        return text;
      } catch (error) {
        if (job.controller.signal.aborted) throw abortError();
        if (timedOut) throw new RequestError("Request timed out; retry manually when ready", {
          timedOut: true, status: responseStatus, retryAt: responseRetryAt, cause: error,
        });
        if (error instanceof RequestError) throw error;
        throw new RequestError("Network request failed; retry manually when ready", {
          status: responseStatus, retryAt: responseRetryAt, cause: error,
        });
      } finally {
        clearTimer(timer);
        job.controller.signal.removeEventListener("abort", onAbort);
        // Deliberately leave a quiet interval after the entire body finishes.
        nextRequestAt = Math.max(nextRequestAt, now() + paces[pace]);
        writeShared();
      }
    }

    async function execute(job) {
      for (let attempt = 0; attempt <= maxRetries; attempt++) {
        if (job.controller.signal.aborted) throw abortError();
        try {
          const run = () => attemptRequest(job, attempt);
          return locks?.request
            ? await locks.request(lockName, { mode: "exclusive", signal: job.controller.signal }, run)
            : await run();
        } catch (error) {
          if (!error.retryable || attempt >= maxRetries) throw error;
          emit({ phase: "cooldown", retryAt: error.retryAt,
            httpStatus: error.status, url: job.url, attempt: attempt + 1 });
        }
      }
    }

    function removeCache(key) {
      const entry = cache.get(key);
      if (entry) cachedChars -= entry.text.length;
      cache.delete(key);
    }

    function putCache(job, text) {
      if (!job.cache || text.length > maxEntryChars) return;
      for (const [key, entry] of cache) if (entry.expiresAt <= now()) removeCache(key);
      removeCache(job.key);
      cache.set(job.key, { text, createdAt: now(), expiresAt: now() + job.ttl });
      cachedChars += text.length;
      while (cache.size > maxEntries || cachedChars > maxCacheChars) {
        removeCache(cache.keys().next().value);
      }
    }

    function subscribe(job, signal) {
      if (signal?.aborted) return Promise.reject(abortError());
      job.subscribers++;
      return new Promise((resolve, reject) => {
        let settled = false;
        function cleanup() {
          if (settled) return false;
          settled = true;
          signal?.removeEventListener("abort", onAbort);
          job.subscribers--;
          return true;
        }
        function onAbort() {
          if (!cleanup()) return;
          reject(abortError());
          // A consumer stopping must not stop another consumer of the same URL.
          if (!job.subscribers && !job.finished) {
            job.controller.abort();
            if (inFlight.get(job.key) === job) inFlight.delete(job.key);
          }
        }
        signal?.addEventListener("abort", onAbort, { once: true });
        job.promise.then(
          (value) => { if (cleanup()) resolve(value); },
          (error) => { if (cleanup()) reject(error); },
        );
      });
    }

    function requestText(url, requestOptions = {}) {
      const signal = requestOptions.signal;
      if (signal?.aborted) return Promise.reject(abortError());
      const absoluteURL = new URL(url, baseURL).href;
      // Avoid unintended external uploads or authenticated cross-origin calls.
      if (new URL(absoluteURL).origin !== new URL(baseURL).origin) {
        return Promise.reject(new RequestError("Only same-origin requests are supported"));
      }
      const headers = new Headers(requestOptions.headers || { Accept: "text/html" });
      const key = `${absoluteURL}\n${JSON.stringify([...headers.entries()].sort())}`;
      const ttl = Math.max(0, Math.min(86400000, Number(requestOptions.ttl ?? 180000) || 0));
      const useCache = requestOptions.cache !== false && ttl > 0;
      const entry = cache.get(key);
      if (entry && useCache && entry.expiresAt > now() && now() - entry.createdAt < ttl) {
        cache.delete(key);
        cache.set(key, entry);
        return Promise.resolve(entry.text);
      }
      if (entry?.expiresAt <= now()) removeCache(key);
      let job = inFlight.get(key);
      if (job && !job.controller.signal.aborted) {
        job.cache ||= useCache;
        job.ttl = Math.max(job.ttl, ttl);
        return subscribe(job, signal);
      }
      job = { key, url: absoluteURL, headers, cache: useCache, ttl,
        controller: new AbortController(), subscribers: 0, finished: false, promise: null };
      waiting++;
      inFlight.set(key, job);
      job.promise = tail.then(async () => {
        waiting--;
        activeJob = job;
        try {
          const text = await execute(job);
          putCache(job, text);
          return text;
        } catch (error) {
          emit({ phase: error.name === "AbortError" ? "stopped" : "error",
            retryAt: error.retryAt || 0, httpStatus: error.status || 0, url: job.url });
          throw error;
        } finally {
          job.finished = true;
          if (inFlight.get(key) === job) inFlight.delete(key);
          activeJob = null;
          if (status.phase !== "error") emit({ phase: waiting ? "queued" : "idle", url: "", attempt: 0 });
        }
      });
      tail = job.promise.catch(() => {});
      emit({ phase: activeJob ? status.phase : "queued" });
      return subscribe(job, signal);
    }

    async function requestJson(url, requestOptions = {}) {
      const headers = new Headers(requestOptions.headers || {});
      if (!headers.has("Accept")) headers.set("Accept", "application/json");
      const text = await requestText(url, { ...requestOptions, headers,
        ttl: requestOptions.ttl ?? 600000 });
      try { return JSON.parse(text); } catch (error) {
        const absoluteURL = new URL(url, baseURL).href;
        removeCache(`${absoluteURL}\n${JSON.stringify([...headers.entries()].sort())}`);
        throw new RequestError("Server returned invalid JSON", { cause: error });
      }
    }

    return {
      requestText, requestJson,
      setPace(value) {
        if (!paces[value]) throw new RangeError("Unknown request pace");
        pace = value;
        emit({});
      },
      getStatus: snapshot,
      clearCache() { cache.clear(); cachedChars = 0; emit({}); },
    };
  }

  function addGlobalStyles() {
    if (document.getElementById(`${SCRIPT_ID}-styles`)) return;
    const style = document.createElement("style");
    style.id = `${SCRIPT_ID}-styles`;
    style.textContent = `
      /* Deliberately scoped: a square charcoal workspace with amber accents. */
      .ppp-panel, .ppp-posts-active {
        --ppp-accent: #ffc15b;
        --ppp-accent-strong: #f0a739;
        --ppp-border: #555555;
        --ppp-surface: #181818;
        --ppp-surface-2: #242424;
        --ppp-surface-3: #303030;
        --ppp-text: #f4f1e8;
        --ppp-muted: #c2c2c2;
        --ppp-error: #ffb6a8;
      }
      .ppp-panel {
        box-sizing: border-box;
        width: 100%;
        margin: 1rem auto 1.2rem;
        padding: clamp(1rem, 2.2vw, 1.65rem);
        border: 1px solid var(--ppp-border);
        border-top: 3px solid var(--ppp-accent);
        background: var(--ppp-surface);
        background-image: none;
        color: var(--ppp-text);
        color-scheme: dark;
        font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
        font-size: .9rem;
        line-height: 1.5;
        box-shadow: none;
      }
      .ppp-panel, .ppp-panel *, .ppp-panel *::before, .ppp-panel *::after,
      .ppp-posts-active .post-card, .ppp-posts-active .post-card *,
      .ppp-posts-active .post-card *::before, .ppp-posts-active .post-card *::after {
        box-sizing: border-box;
        border-radius: 0 !important;
      }
      .ppp-panel [hidden], .ppp-posts-active .post-card[hidden],
      .ppp-posts-active .post-card [hidden], .ppp-sentinel[hidden] { display: none !important; }
      .ppp-panel h2, .ppp-panel h3, .ppp-panel p { color: inherit; }
      .ppp-panel a { color: var(--ppp-accent); }
      .ppp-panel a:hover { color: #ffda96; }
      .ppp-panel__title-row, .ppp-results-row, .ppp-server-search,
      .ppp-artist-nav, .ppp-artist-card__top, .ppp-services,
      .ppp-toolbar, .ppp-stream-controls { display: flex; align-items: center; }
      .ppp-panel__title-row { justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1rem; }
      .ppp-panel__title-row > div { min-width: 0; }
      .ppp-panel__title {
        margin: 0;
        font-size: clamp(1.35rem, 2.4vw, 1.9rem);
        line-height: 1.2;
        font-weight: 800;
        letter-spacing: -.025em;
      }
      .ppp-panel .ppp-panel__subtitle { margin: .4rem 0 0; color: var(--ppp-muted); font-size: .88rem; }
      .ppp-eyebrow { display: block; color: var(--ppp-accent); font-size: .65rem; font-weight: 800; letter-spacing: .14em; margin-bottom: .5rem; }
      .ppp-server-search { gap: .65rem; margin: 1rem 0; align-items: flex-end; }
      .ppp-grid { display: grid; gap: .85rem; margin: 1rem 0; }
      .ppp-grid--posts { grid-template-columns: repeat(4, minmax(0, 1fr)); }
      .ppp-grid--artists { grid-template-columns: repeat(6, minmax(0, 1fr)); }
      .ppp-grid--artists .ppp-field:nth-child(-n+2) { grid-column: span 3; }
      .ppp-field { display: flex; min-width: 0; flex-direction: column; gap: .35rem; }
      .ppp-field--grow { flex: 1; }
      .ppp-field--wide { grid-column: span 2; }
      .ppp-field > span, .ppp-services legend { font-size: .76rem; font-weight: 700; color: var(--ppp-muted); }
      .ppp-field input, .ppp-field select, .ppp-field textarea {
        width: 100%; min-width: 0; min-height: 2.75rem; margin: 0;
        padding: .55rem .65rem; border: 1px solid var(--ppp-border);
        background: var(--ppp-surface-2); background-image: none;
        color: var(--ppp-text); font: inherit; line-height: 1.4; box-shadow: none;
      }
      .ppp-field select { padding-right: 1.8rem; }
      .ppp-field select option { background: var(--ppp-surface-2); color: var(--ppp-text); }
      .ppp-field textarea { min-height: 5.25rem; resize: vertical; white-space: pre-wrap; }
      .ppp-field input::placeholder, .ppp-field textarea::placeholder { color: #ababab; opacity: 1; }
      .ppp-field input:disabled, .ppp-field select:disabled, .ppp-field textarea:disabled { color: #aaaaaa; background: #202020; cursor: not-allowed; }
      .ppp-panel :is(input, select, textarea, button, a, summary):focus-visible,
      .ppp-post-card-tools :is(input, button, a):focus-visible {
        outline: 2px solid var(--ppp-accent); outline-offset: 3px;
      }
      .ppp-field :is(input, select, textarea):focus-visible { border-color: var(--ppp-accent); }
      .ppp-panel ::selection { color: #181818; background: var(--ppp-accent); }
      .ppp-button {
        display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
        min-height: 2.75rem; padding: .55rem .85rem;
        border: 1px solid var(--ppp-accent); background: var(--ppp-accent);
        background-image: none; color: #171717; font: inherit;
        font-weight: 750; line-height: 1.3; text-decoration: none;
        cursor: pointer; white-space: normal; text-align: center; box-shadow: none;
        transition: background-color 120ms ease, border-color 120ms ease;
      }
      .ppp-button:hover:not(:disabled) { border-color: var(--ppp-accent-strong); background: var(--ppp-accent-strong); color: #171717; }
      .ppp-button--quiet { border-color: var(--ppp-border); background: var(--ppp-surface-2); color: var(--ppp-text); }
      .ppp-button--quiet:hover:not(:disabled) { border-color: #999999; background: var(--ppp-surface-3); color: var(--ppp-text); }
      .ppp-button:disabled { opacity: .6; cursor: not-allowed; }
      .ppp-services { flex-wrap: wrap; gap: .55rem; min-width: 0; margin: 1rem 0; padding: .7rem .8rem .85rem; border: 1px solid var(--ppp-border); }
      .ppp-services legend { padding: 0 .35rem; }
      .ppp-check { display: inline-flex; align-items: center; gap: .45rem; min-height: 2.5rem; font-size: .85rem; cursor: pointer; }
      .ppp-services .ppp-check { padding: .4rem .65rem; border: 1px solid var(--ppp-border); background: var(--ppp-surface-2); }
      .ppp-services .ppp-check:has(input:checked) { border-color: var(--ppp-accent); background: #332a1c; }
      .ppp-panel input[type="checkbox"], .ppp-post-card-tools input[type="checkbox"] {
        appearance: none; -webkit-appearance: none; display: inline-grid; place-content: center;
        width: 1.1rem; min-width: 1.1rem; height: 1.1rem; min-height: 1.1rem;
        flex: 0 0 1.1rem; padding: 0; margin: 0;
        border: 1px solid #aaaaaa; background: #202020; vertical-align: middle; cursor: pointer;
      }
      .ppp-panel input[type="checkbox"]::before, .ppp-post-card-tools input[type="checkbox"]::before {
        content: ""; width: .6rem; height: .6rem; background: #171717; transform: scale(0);
      }
      .ppp-panel input[type="checkbox"]:checked, .ppp-post-card-tools input[type="checkbox"]:checked { border-color: var(--ppp-accent); background: var(--ppp-accent); }
      .ppp-panel input[type="checkbox"]:checked::before, .ppp-post-card-tools input[type="checkbox"]:checked::before { transform: scale(1); }
      .ppp-panel input[type="checkbox"]:disabled, .ppp-post-card-tools input[type="checkbox"]:disabled { opacity: .5; cursor: not-allowed; }
      .ppp-check__dot { width: .55rem; height: .55rem; flex: 0 0 .55rem; background: var(--ppp-service, #ffc15b); }
      .ppp-results-row { justify-content: space-between; flex-wrap: wrap; gap: .75rem; min-height: 3rem; margin-top: 1rem; padding: .75rem 0; border-top: 1px solid var(--ppp-border); }
      .ppp-status { font-size: .85rem; font-weight: 700; overflow-wrap: anywhere; }
      .ppp-status[data-tone="loading"]::before {
        content: ""; display: inline-block; width: 1.35rem; height: .25rem;
        margin-right: .55rem; background: var(--ppp-accent); vertical-align: .15rem;
        animation: ppp-progress 1.15s ease-in-out infinite; transform-origin: left center;
      }
      .ppp-status[data-tone="error"], .ppp-network-status[data-tone="error"] { color: var(--ppp-error); }
      .ppp-network-status[data-tone="waiting"], .ppp-network-status[data-tone="loading"] { color: var(--ppp-accent); }
      .ppp-batch-nav, .ppp-artist-nav { display: flex; align-items: center; flex-wrap: wrap; gap: .6rem; }
      .ppp-toolbar, .ppp-stream-controls { flex-wrap: wrap; gap: .6rem; margin: .85rem 0; }
      .ppp-toolbar { padding: .7rem 0; border-block: 1px solid var(--ppp-border); }
      .ppp-toolbar .ppp-button { min-height: 2.45rem; padding: .45rem .65rem; font-size: .8rem; }
      .ppp-network-status, .ppp-filter-summary { display: block; margin: .6rem 0; font-size: .8rem; color: var(--ppp-muted); overflow-wrap: anywhere; }
      .ppp-network-status { padding: .65rem .75rem; border: 1px solid var(--ppp-border); background: #202020; }
      .ppp-filter-summary { line-height: 1.6; }
      .ppp-selection-count { color: var(--ppp-accent); font-size: .8rem; font-weight: 750; }
      .ppp-stat { display: inline-flex; gap: .3rem; padding: .25rem .5rem; border: 1px solid var(--ppp-border); color: var(--ppp-text); font-size: .75rem; font-variant-numeric: tabular-nums; }
      .ppp-panel .ppp-help { margin: .55rem 0 0; color: var(--ppp-muted); font-size: .76rem; line-height: 1.6; overflow-wrap: anywhere; }
      .ppp-advanced { margin: 1rem 0; border: 1px solid var(--ppp-border); background: #202020; }
      .ppp-advanced > summary { padding: .8rem .9rem; font-weight: 750; cursor: pointer; color: var(--ppp-text); }
      .ppp-advanced > summary::marker { color: var(--ppp-accent); }
      .ppp-advanced[open] > summary { border-bottom: 1px solid var(--ppp-border); }
      .ppp-advanced > :not(summary) { margin-inline: .9rem; }
      .ppp-advanced > :last-child { margin-bottom: .9rem; }
      form.ppp-native-search-hidden, .site-section--posts .ppp-paginator-hidden { display: none !important; }
      .ppp-posts-active .paginator.ppp-preserve-navigation { display: block !important; visibility: visible !important; }
      .ppp-posts-active .ppp-preserve-navigation .tabs { display: flex !important; visibility: visible !important; }
      .ppp-posts-active .ppp-preserve-navigation .tabs :is(li, a) { visibility: visible !important; border-color: #555555; background: #242424; color: #f4f1e8; }
      .ppp-posts-active .ppp-preserve-navigation .tabs a.active { color: #ffc15b; border-color: #ffc15b; }
      .ppp-posts-active :is(.hide-tags-filter, .hide-tags-filter__anchor, .paginator),
      .ppp-posts-active :is(.hide-tags-filter, .paginator) *,
      .ppp-posts-active :is(.hide-tags-filter, .paginator) *::before,
      .ppp-posts-active :is(.hide-tags-filter, .paginator) *::after { border-radius: 0 !important; }
      .ppp-posts-active .hide-tags-filter { background: #202020; background-image: none; border-color: #555555; color: #f4f1e8; }
      .ppp-posts-active .hide-tags-filter__anchor { color: var(--ppp-accent); background-image: none; }
      .ppp-posts-active .hide-tags-filter :is(a, label) { color: inherit; }
      .ppp-posts-active .hide-tags-filter :is(button, input, select, textarea) { border-color: #555555; background: #242424; background-image: none; color: #f4f1e8; accent-color: var(--ppp-accent); }
      .ppp-posts-active :is(.hide-tags-filter, .paginator) :is(input, select, textarea, button, a):focus-visible { outline: 2px solid var(--ppp-accent); outline-offset: 3px; }
      .ppp-posts-active .hide-tags-filter input[type="checkbox"] { appearance: none; -webkit-appearance: none; display: inline-grid; place-content: center; width: 1.1rem; height: 1.1rem; padding: 0; border: 1px solid #aaaaaa; }
      .ppp-posts-active .hide-tags-filter input[type="checkbox"]::before { content: ""; width: .6rem; height: .6rem; background: #171717; transform: scale(0); }
      .ppp-posts-active .hide-tags-filter input[type="checkbox"]:checked { border-color: var(--ppp-accent); background: var(--ppp-accent); }
      .ppp-posts-active .hide-tags-filter input[type="checkbox"]:checked::before { transform: scale(1); }
      .ppp-posts-active .paginator { color: #f4f1e8; }
      .ppp-posts-active .paginator :is(a, button) { border-color: #555555; background: #242424; background-image: none; color: #f4f1e8; }
      .ppp-posts-active .paginator :is(a, button):hover { border-color: var(--ppp-accent); background: #332a1c; color: var(--ppp-accent); }
      .ppp-posts-active .paginator :is([aria-current="page"], .paginator__current) { border-color: var(--ppp-accent); background: #332a1c; color: var(--ppp-accent); }
      .ppp-sentinel { min-height: 1px; width: 100%; text-align: center; color: #c2c2c2; }
      .ppp-posts-active .post-card { border-radius: 0 !important; border-color: #555555; background: #242424; color: #f4f1e8; }
      .ppp-posts-active .post-card a { color: inherit; }
      .ppp-posts-active .post-card a:hover { color: var(--ppp-accent); }
      .ppp-posts-active .post-card a:focus-visible { outline: 2px solid var(--ppp-accent); outline-offset: 2px; }
      .ppp-post-card-tools { display: flex; align-items: center; flex-wrap: wrap; gap: .45rem; padding: .6rem; border-top: 1px solid #555555; background: #181818; color: #f4f1e8; font-size: .76rem; }
      .ppp-post-card-tools .ppp-button { min-height: 2rem; padding: .3rem .45rem; font-size: .74rem; }
      .ppp-post-card-tools label { display: inline-flex; align-items: center; gap: .4rem; min-height: 2rem; cursor: pointer; }
      .ppp-posts-active .post-card.ppp-selected, .ppp-posts-active .post-card.ppp-post-selected,
      .ppp-posts-active .post-card[data-selected="true"] { outline: 2px solid var(--ppp-accent); outline-offset: -2px; }
      .ppp-layout--compact .card-list__items, .card-list__items.ppp-layout--compact { gap: .65rem; }
      .ppp-layout--compact .post-card { font-size: .82rem; }
      .ppp-layout--compact .post-card__image { max-height: 180px; object-fit: cover; }

      /* Post tools use the section's width and keep frequent controls close together. */
      .site-section--user.ppp-posts-active,
      .site-section--posts.ppp-posts-active { width: 100%; max-width: none !important; }
      #ppp-post-panel {
        width: 100%; max-width: none;
        margin: .55rem 0 .7rem; padding: .7rem .9rem;
        font-size: .84rem; line-height: 1.35;
      }
      #ppp-post-panel .ppp-post-main {
        display: grid; grid-template-columns: minmax(250px, .9fr) minmax(310px, 1.3fr);
        align-items: end; gap: .55rem .9rem;
      }
      #ppp-post-panel .ppp-panel__title-row { align-items: center; gap: .6rem; margin: 0; }
      #ppp-post-panel .ppp-panel__title { font-size: clamp(1.1rem, 1.4vw, 1.3rem); line-height: 1.2; }
      #ppp-post-panel .ppp-eyebrow { margin-bottom: .15rem; font-size: .6rem; letter-spacing: .08em; }
      #ppp-post-panel .ppp-panel__subtitle { margin: .2rem 0 0; font-size: .74rem; }
      #ppp-post-panel .ppp-panel-header-actions { display: flex; align-items: center; flex-wrap: wrap; gap: .35rem; }
      #ppp-post-panel .ppp-server-search { gap: .4rem; margin: 0; }
      #ppp-post-panel .ppp-post-main > .ppp-grid { grid-column: 1 / -1; }
      #ppp-post-panel .ppp-grid { gap: .45rem .65rem; margin: .5rem 0; }
      #ppp-post-panel .ppp-post-main > .ppp-grid { margin: 0; }
      #ppp-post-panel .ppp-field { gap: .2rem; }
      #ppp-post-panel .ppp-field > span { font-size: .7rem; line-height: 1.2; }
      #ppp-post-panel .ppp-field :is(input, select, textarea) {
        min-height: 2.1rem; padding: .3rem .5rem; line-height: 1.3;
      }
      #ppp-post-panel .ppp-field select { padding-right: 1.6rem; }
      #ppp-post-panel .ppp-field textarea { min-height: 2.1rem; height: 2.1rem; }
      #ppp-post-panel .ppp-button { min-height: 2.1rem; padding: .3rem .55rem; gap: .3rem; font-size: .78rem; }
      #ppp-post-panel .ppp-advanced { margin: .5rem 0; }
      #ppp-post-panel .ppp-advanced > summary { padding: .4rem .6rem; font-size: .78rem; }
      #ppp-post-panel .ppp-advanced > :not(summary) { margin-inline: .6rem; }
      #ppp-post-panel .ppp-advanced > :last-child { margin-bottom: .55rem; }
      #ppp-post-panel .ppp-post-footer { display: flex; align-items: center; flex-wrap: wrap; gap: .45rem .7rem; margin: .45rem 0; }
      #ppp-post-panel .ppp-services { margin: 0; padding: 0; gap: .3rem; border: 0; }
      #ppp-post-panel .ppp-services legend { float: left; margin: 0 .25rem 0 0; padding: .35rem 0; font-size: .7rem; }
      #ppp-post-panel .ppp-services .ppp-check { min-height: 2rem; padding: .25rem .4rem; gap: .3rem; font-size: .75rem; }
      #ppp-post-panel .ppp-check__dot { width: .4rem; height: .4rem; flex-basis: .4rem; }
      #ppp-post-panel .ppp-toolbar { flex: 1 1 auto; margin: 0; padding: 0; gap: .3rem; border: 0; }
      #ppp-post-panel .ppp-selection-count { font-size: .75rem; }
      #ppp-post-panel .ppp-results-row {
        gap: .4rem .8rem; min-height: 2.1rem; margin-top: .45rem;
        padding: .45rem 0 .15rem; border-top: 1px solid var(--ppp-border);
      }
      #ppp-post-panel .ppp-status { font-size: .77rem; }
      #ppp-post-panel .ppp-batch-nav { gap: .3rem; }
      #ppp-post-panel .ppp-network-status {
        margin: .2rem 0; padding: 0; border: 0; background: transparent;
        font-size: .74rem; line-height: 1.35;
      }
      #ppp-post-panel .ppp-filter-summary { margin: .3rem 0; font-size: .74rem; line-height: 1.35; }
      #ppp-post-panel .ppp-help-details { margin: .25rem 0 0; font-size: .74rem; color: var(--ppp-muted); }
      #ppp-post-panel .ppp-help-details > summary { padding: .2rem 0; min-height: 1.5rem; cursor: pointer; }
      #ppp-post-panel .ppp-help-details > summary::marker { color: var(--ppp-accent); }
      #ppp-post-panel .ppp-help { margin: .35rem 0 0; font-size: .72rem; line-height: 1.45; }

      /* Creator finder retains its current controls and page-range mechanics. */
      .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-discovery { margin: 1.35rem 0; padding: 1rem; border: 1px solid var(--ppp-border); background: #202020; }
      .ppp-discovery__heading { display: flex; align-items: center; justify-content: space-between; gap: .65rem; margin-bottom: .9rem; }
      .ppp-discovery__heading h3 { margin: 0; font-size: 1rem; font-weight: 750; }
      .ppp-feature-tag { font-size: .61rem; font-weight: 750; letter-spacing: .08em; color: var(--ppp-accent); border: 1px solid #685230; background: #332a1c; padding: .25rem .45rem; }
      .ppp-discovery__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .85rem; }
      .ppp-discovery__terms { grid-column: span 2; }
      .ppp-page-range { min-width: 0; margin: 1rem 0; padding: 1rem; border: 1px solid var(--ppp-border); background: var(--ppp-surface); }
      .ppp-page-range legend { padding: 0 .4rem; font-size: .8rem; font-weight: 750; color: var(--ppp-text); }
      .ppp-page-range__numbers { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .7rem; }
      .ppp-page-range__numbers .ppp-field { width: min(180px, 40%); }
      .ppp-page-range__numbers .ppp-check { min-height: 2.75rem; padding: .4rem .2rem; }
      .ppp-page-range__arrow { padding-bottom: .7rem; color: var(--ppp-accent); }
      .ppp-page-range__bar { position: relative; height: 2.75rem; margin: .8rem .7rem .4rem; }
      .ppp-page-range__track { position: absolute; left: 0; right: 0; top: calc(50% - 3px); height: 6px; background: #555555; }
      .ppp-page-range__track span { position: absolute; left: var(--range-from, 0%); right: calc(100% - var(--range-to, 25%)); height: 100%; background: var(--ppp-accent); }
      #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%; min-height: 0;
        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 #f4f1e8; border-radius: 0 !important;
        background: #f0a739; box-shadow: none; 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 #f4f1e8;
        border-radius: 0 !important; background: #f0a739; box-shadow: none; cursor: ew-resize;
      }
      .ppp-page-range__bar input:focus-visible::-webkit-slider-thumb { outline: 2px solid var(--ppp-accent); outline-offset: 3px; }
      .ppp-page-range__bar input:focus-visible::-moz-range-thumb { outline: 2px solid var(--ppp-accent); outline-offset: 3px; }
      .ppp-page-range__summary { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .7rem; }
      .ppp-page-range__summary output { color: var(--ppp-accent); 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-discovery__status { display: block; font-size: .8rem; line-height: 1.65; margin-top: .9rem; padding-top: .8rem; border-top: 1px solid var(--ppp-border); color: var(--ppp-text); overflow-wrap: anywhere; }
      .ppp-index-count { color: var(--ppp-accent); white-space: nowrap; letter-spacing: .08em; font-size: .65rem; font-weight: 750; }
      .ppp-artist-results { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 310px), 1fr)); gap: .8rem; margin: 1rem 0; }
      .ppp-artist-card { display: flex; min-width: 0; flex-direction: column; align-items: stretch; overflow: hidden; border: 1px solid var(--ppp-border); background: var(--ppp-surface-2); color: var(--ppp-text); transition: border-color 120ms ease; }
      .ppp-artist-card:hover { border-color: var(--ppp-accent); }
      .ppp-panel .ppp-artist-card__profile { display: flex; align-items: flex-start; gap: .8rem; padding: 1rem; color: inherit; text-decoration: none; min-width: 0; flex: 1; }
      .ppp-panel .ppp-artist-card__profile:hover { color: inherit; background: #2c2c2c; }
      .ppp-artist-card__avatar { position: relative; flex: 0 0 3.6rem; height: 3.6rem; overflow: hidden; background: #383838; }
      .ppp-artist-card__initials { display: grid; place-items: center; position: absolute; inset: 0; color: var(--ppp-accent); font-weight: 800; font-size: 1.15rem; }
      .ppp-artist-card__icon { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: #383838; }
      .ppp-artist-card__icon.ppp-image-error { display: none; }
      .ppp-artist-card__content { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: .3rem; }
      .ppp-artist-card__top { min-width: 0; align-items: flex-start; flex-direction: column; gap: .35rem; }
      .ppp-artist-card__name { white-space: normal; overflow-wrap: anywhere; font-size: 1rem; font-weight: 750; line-height: 1.35; }
      .ppp-service-badge { flex: 0 0 auto; padding: .2rem .45rem; color: #ffffff; font-size: .62rem; line-height: 1.4; font-weight: 800; }
      .ppp-artist-card__meta { color: var(--ppp-muted); font-size: .72rem; line-height: 1.6; overflow-wrap: anywhere; }
      .ppp-panel .ppp-artist-card__evidence { display: flex; flex-direction: column; gap: .4rem; padding: .85rem 1rem; border-top: 1px solid var(--ppp-border); background: #202020; color: var(--ppp-accent); text-decoration: none; font-size: .76rem; line-height: 1.55; overflow-wrap: anywhere; }
      .ppp-panel .ppp-artist-card__evidence:hover { background: #303030; color: #ffda96; }
      .ppp-artist-card__evidence span { color: var(--ppp-muted); }
      .ppp-artist-nav { padding: 1rem 0 .3rem; margin-top: .8rem; justify-content: space-between; border-top: 1px solid var(--ppp-border); }
      .ppp-empty { grid-column: 1 / -1; padding: 1.5rem 1rem; margin: .85rem 0; text-align: center; border: 1px dashed var(--ppp-border); color: var(--ppp-muted); line-height: 1.6; }
      @keyframes ppp-progress { 0%, 100% { transform: scaleX(.35); opacity: .55; } 50% { transform: scaleX(1); opacity: 1; } }
      @media (max-width: 900px) {
        .ppp-grid--posts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
        .ppp-grid--artists, .ppp-discovery__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
        .ppp-grid--artists .ppp-field:nth-child(-n+2), .ppp-discovery__terms { grid-column: 1 / -1; }
        .ppp-panel__title-row { gap: .7rem; }
        #ppp-post-panel .ppp-post-main { grid-template-columns: minmax(0, 1fr); gap: .5rem; }
        #ppp-post-panel .ppp-post-main > .ppp-grid { grid-column: auto; }
      }
      @media (max-width: 560px) {
        .ppp-panel { width: calc(100% - .6rem); padding: .85rem; }
        .ppp-grid--posts, .ppp-grid--artists, .ppp-discovery__grid { grid-template-columns: 1fr; }
        .ppp-field--wide, .ppp-grid--artists .ppp-field:nth-child(-n+2), .ppp-discovery__terms { grid-column: auto; }
        .ppp-panel__title-row { flex-wrap: wrap; }
        .ppp-server-search, .ppp-results-row { align-items: stretch; flex-direction: column; }
        .ppp-server-search .ppp-button { width: 100%; }
        .ppp-batch-nav { width: 100%; }
        .ppp-batch-nav .ppp-button { flex: 1; }
        .ppp-toolbar .ppp-button, .ppp-stream-controls .ppp-button { flex: 1 1 145px; }
        .ppp-discovery, .ppp-page-range { padding: .75rem; }
        .ppp-discovery__heading { align-items: flex-start; flex-direction: column; }
        .ppp-discovery__actions .ppp-button { flex: 1 1 100%; }
        .ppp-page-range__numbers .ppp-field { flex: 1 1 100px; width: auto; }
        .ppp-page-range__numbers > .ppp-check { flex-basis: 100%; }
        .ppp-page-range__summary .ppp-button { width: 100%; }
        .ppp-artist-results { grid-template-columns: 1fr; }
        .ppp-artist-nav { gap: .5rem; }
        .ppp-artist-card__profile { padding: .85rem; }
        #ppp-post-panel { width: 100%; padding: .65rem; }
        #ppp-post-panel .ppp-panel__title-row { align-items: flex-start; }
        #ppp-post-panel .ppp-panel-header-actions { width: 100%; }
        #ppp-post-panel .ppp-grid { gap: .55rem; }
        #ppp-post-panel .ppp-field :is(input, select, textarea),
        #ppp-post-panel .ppp-button,
        #ppp-post-panel .ppp-services .ppp-check { min-height: 44px; }
        #ppp-post-panel .ppp-field textarea { height: 44px; }
        #ppp-post-panel .ppp-post-footer { align-items: stretch; gap: .55rem; }
        #ppp-post-panel .ppp-toolbar { flex-basis: 100%; }
        #ppp-post-panel .ppp-advanced > summary,
        #ppp-post-panel .ppp-help-details > summary { min-height: 44px; padding-block: .75rem; }
      }
      @media (prefers-reduced-motion: reduce) {
        .ppp-panel *, .ppp-post-card-tools * { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
        .ppp-status[data-tone="loading"]::before { animation: none; transform: none; }
      }
      @media (forced-colors: active) {
        .ppp-panel input[type="checkbox"], .ppp-post-card-tools input[type="checkbox"] { appearance: auto; -webkit-appearance: auto; }
        .ppp-panel input[type="checkbox"]::before, .ppp-post-card-tools input[type="checkbox"]::before { display: none; }
      }
    `;
    (document.head || document.documentElement).appendChild(style);
  }
})();