ancientchan

4chan time machine. Replays archived 4chan boards in real time with era-correct UI. Visit a real 4chan board URL and travel back to a set date; posts stream in at the exact second they were originally posted. Data from FoolFuuka archives (desuarchive / 4plebs / archived.moe).

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

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

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

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

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

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

(I already have a user script manager, let me install it!)

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

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

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

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

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

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

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         ancientchan
// @namespace    4chan-wayback-machine
// @version      0.10.11
// @description  4chan time machine. Replays archived 4chan boards in real time with era-correct UI. Visit a real 4chan board URL and travel back to a set date; posts stream in at the exact second they were originally posted. Data from FoolFuuka archives (desuarchive / 4plebs / archived.moe).
// @author       relicofatime
// @match        *://boards.4chan.org/*
// @match        *://boards.4channel.org/*
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_registerMenuCommand
// @connect      desuarchive.org
// @connect      archive.4plebs.org
// @connect      archived.moe
// @connect      desu-usergeneratedcontent.xyz
// @connect      archive.org
// @connect      *.archive.org
// @connect      us.archive.org
// @connect      arch.b4k.dev
// @connect      arch.b4k.co
// @connect      arch-img.b4k.dev
// @connect      arch-img.b4k.co
// @connect      archive-media.palanq.win
// @connect      archive.palanq.win
// @connect      archive.alice.al
// @connect      eientei.xyz
// @connect      archiveofsins.com
// @connect      thebarchive.com
// @connect      img.4plebs.org
// @connect      i.4cdn.org
// @connect      images.4chan.org
// @connect      s.4cdn.org
// @connect      derpicdn.net
// @connect      e621.net
// @connect      static1.e621.net
// @connect      danbooru.donmai.us
// @connect      cdn.donmai.us
// @connect      *
// ==/UserScript==

/*  ──────────────────────────────────────────────────────────────────────────
    HOW IT WORKS  (v0.1 vertical slice)
    ----------------------------------------------------------------------------
    1. You navigate to a real board, e.g.  https://boards.4chan.org/g/
    2. This script blanks the live page and overlays an era-correct (2013) UI.
    3. It enumerates that board's threads for CONFIG.date via the archive SEARCH
       endpoint (one call per page, cached forever in GM storage).
    4. A replay clock starts at the first activity of that day and advances in
       real time (× CONFIG.speed). Threads appear on the index, and replies
       stream into open threads, at the exact moment they were posted.
    5. Images are fetched as blobs (GM_xmlhttpRequest) so 4chan's CSP can't
       block the archive CDN, then shown via blob: URLs.

    DESIGN NOTES
    - We only ever SEARCH a board-day once (cached). Thread JSON is fetched once
      per thread (cached). Opening a thread = 1 request that returns ALL replies.
    - A gentle background prefetcher grabs threads for OPs as they appear on the
      index, throttled, so clicking a thread is instant. Because replay runs in
      real time, the natural request rate is a trickle — we never flood anyone.
    - Caching is in GM storage (text/JSON only — tiny). Images are never stored;
      they lazy-load from the archive CDN on demand.

    TUNE ME ↓
    ────────────────────────────────────────────────────────────────────────── */

(function () {
  'use strict';

  // Live mode: show the real, present-day 4chan with the script inert. This
  // check MUST come before anything else — the early-hide CSS below blanks
  // the page expecting the overlay to replace it, and in live mode the
  // overlay never comes (that was the white-screen bug). The only footprints
  // are a small floating return button and a userscript-menu command.
  if (GM_getValue('oldchanLiveMode', false)) {
    const returnToReplay = () => {
      GM_setValue('oldchanLiveMode', false);
      location.reload();
    };
    try {
      GM_registerMenuCommand('ancientchan: return to the time machine', returnToReplay);
    } catch (e) { /* menu unavailable */ }
    const addReturnButton = () => {
      if (!document.body || document.getElementById('oldchan-return')) return;
      const b = document.createElement('button');
      b.id = 'oldchan-return';
      b.textContent = 'ancientchan';
      b.title = 'Return to the time machine';
      b.style.cssText = 'position:fixed;top:8px;right:8px;z-index:2147483647;' +
        'font:11px arial,helvetica,sans-serif;padding:3px 9px;cursor:pointer;' +
        'background:#fffdef;color:#800;border:1px solid #b7c5d9;border-radius:3px;opacity:.85;';
      b.addEventListener('mouseenter', () => { b.style.opacity = '1'; });
      b.addEventListener('mouseleave', () => { b.style.opacity = '.85'; });
      b.addEventListener('click', returnToReplay);
      document.body.append(b);
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', addReturnButton);
    else addReturnButton();
    return;
  }

  // We replace 4chan's page wholesale, so its own bundles (options/core/…) end up
  // running against a DOM they no longer recognise and throw — e.g. 4chan's
  // settings menu firing "e.target.closest is not a function". None of that code
  // is ours or needed by the replay, so swallow those uncaught errors to keep the
  // console clean. Our own script has a different filename and passes through.
  window.addEventListener('error', (e) => {
    const f = (e && e.filename) || '';
    if (/4cdn|options\/index|\/core\.|extension\.js/i.test(f)) e.preventDefault();
  }, true);

  // Hide 4chan's page immediately — before any DOM is parsed — so the 404
  // or live board never flashes. The full stylesheet follows in ensureStyles().
  // No class gate here: at document-start, <html> may not exist yet, so a
  // class-dependent rule would miss the window entirely.
  const _earlyHideCSS = 'html, body { visibility:hidden !important; background:#EEF2FF !important; } #wb-overlay { visibility:visible !important; }';
  try {
    GM_addStyle(_earlyHideCSS);
  } catch (e) {
    const s = document.createElement('style');
    s.textContent = _earlyHideCSS;
    (document.head || document.documentElement || document).append(s);
  }

  let _stylesInjected = false;
  const VALID_COLORS = ['yotsublue', 'yotsuba', 'tomorrow'];
  const VALID_DESIGNS = ['2012', '2005'];
  let activeColors = 'yotsublue';
  let activeDesign = '2012';
  const normColors = (c) => VALID_COLORS.includes(c) ? c : 'yotsublue';
  const normDesign = (d) => VALID_DESIGNS.includes(d) ? d : '2012';
  function applyTheme(c) {
    activeColors = normColors(c);
    const root = document.documentElement;
    if (!root) return;
    for (const v of VALID_COLORS) root.classList.toggle('wb-colors-' + v, activeColors === v);
  }
  function applyDesign(d) {
    activeDesign = normDesign(d);
    const root = document.documentElement;
    if (!root) return;
    for (const v of VALID_DESIGNS) root.classList.toggle('wb-design-' + v, activeDesign === v);
  }
  // Font rendering: '2005' = bitcrushed GDI look (binary alpha threshold),
  // '2012' = modern smooth antialiasing for people who find the crunch
  // illegible. Independent of the design dropdown.
  const VALID_FONTS = ['2005', '2012'];
  let activeFont = '2005';
  const normFont = (f) => VALID_FONTS.includes(f) ? f : '2005';
  function applyFont(f) {
    activeFont = normFont(f);
    const root = document.documentElement;
    if (!root) return;
    for (const v of VALID_FONTS) root.classList.toggle('wb-font-' + v, activeFont === v);
  }
  function ensureStyles() {
    if (_stylesInjected) return;
    const css = getCSS();
    try {
      GM_addStyle(css);
      _stylesInjected = true;
    } catch (e) { /* GM_addStyle unavailable */ }
    if (!_stylesInjected) {
      try {
        const s = document.createElement('style');
        s.textContent = css;
        (document.head || document.documentElement).append(s);
        _stylesInjected = true;
      } catch (e2) { /* fallback also failed */ }
    }
    if (document.documentElement) {
      document.documentElement.classList.add('wb-active');
      let saved = null;
      try { saved = JSON.parse(GM_getValue('settings', 'null')); } catch (e) { /* none yet */ }
      applyTheme(saved && saved.theme || saved && saved.colors);
      applyDesign(saved && saved.design);
      applyFont(saved && saved.font);
    }
  }
  ensureStyles();

  const CONFIG = {
    date: '2013-06-15',   // the day to replay (YYYY-MM-DD)
    startTime: new Date().toTimeString().slice(0, 5),   // default to current local time
    speed: 1,             // time multiplier (1 = true real time)
    prefetch: true,       // background-cache threads as their OPs appear
    prefetchDelayMs: 500,
    prefetchConcurrency: 1,
    catalogActivityMaxDays: 14,
    catalogActivityThreadTarget: 150,
    // Pages are 25 posts each, newest first — 20 pages samples the last ~500
    // posts of a day. 6 was too shallow: on a 4k-post/day board it only saw
    // the final hour, so threads whose last bump was mid-day never got
    // enumerated at all. Pages cache forever, so the depth is a one-time cost.
    catalogActivitySearchMaxPages: 20,
    catalogHydrateConcurrency: 1,
    catalogHydrateLimit: 150,
    catalogHydrateYieldMs: 200,
    catalogSyncUpdateEvery: 10,
    catalogTinyOpsThreshold: 12,
    catalogPageDelayMs: 150,
    catalogSearchMaxPages: 20,
    bumpLimit: 300,
    indexThreadsPerPage: 15, // era-correct: 4chan index pages held 15 threads
    mediaResolveConcurrency: 3,
    mediaMissCacheMs: 24 * 60 * 60 * 1000,
    mediaPersistentCache: true,
    mediaPersistentMaxBytes: 8 * 1024 * 1024,
    threadPersistentCache: true,
    localPostMaxImageBytes: 1024 * 1024,
    markArchiveOrgMedia: false, // ★ badge on images served from the archive.org rehost
    mediaDebug: false,
    cacheDebug: false
  };

  // ── Archive routing ──────────────────────────────────────────────────────
  // Maps each board to only the FoolFuuka-compatible archives that can serve it.
  // Media fallback candidates are derived from the same coverage table.
  const DESU = 'https://desuarchive.org';
  const PLEBS = 'https://archive.4plebs.org';
  const MOE = 'https://archived.moe';
  const B4K = 'https://arch.b4k.dev';
  const PALANQ = 'https://archive.palanq.win';
  const ALICE = 'https://archive.alice.al';
  const EIENTEI = 'https://eientei.xyz';
  const SINS = 'https://archiveofsins.com';
  const THEB = 'https://thebarchive.com';

  // Authoritative FoolFuuka-compatible board coverage, ordered by query preference. A
  // board is ONLY looked up on archives whose list includes it — no archive (not
  // even archived.moe) is queried for a board it doesn't actually host.
  const ARCHIVE_COVERAGE = [
    { base: DESU,    boards: ['a', 'aco', 'an', 'c', 'cgl', 'co', 'd', 'fit', 'g', 'his', 'int', 'k', 'm', 'mlp', 'mu', 'q', 'qa', 'r9k', 'tg', 'vr', 'wsg'] },
    { base: PLEBS,   boards: ['adv', 'f', 'hr', 'o', 'pol', 's4s', 'sp', 'trv', 'tv', 'x', 'mlpol'] },
    { base: B4K,     boards: ['g', 'mlp', 'v', 'vg', 'vm', 'vmg', 'vp', 'vrpg', 'vst'] },
    { base: PALANQ,  boards: ['bant', 'c', 'con', 'e', 'i', 'n', 'news', 'out', 'p', 'pw', 'qst', 'toy', 'vip', 'vp', 'vt', 'w', 'wg', 'wsr'] },
    { base: ALICE,   boards: ['c', 'vg'] },
    { base: EIENTEI, boards: ['3', 'i', 'sci', 'xs'] },
    { base: SINS,    boards: ['h', 'hc', 'hm', 'i', 'lgbt', 'r', 's', 'soc', 't', 'u'] },
    { base: THEB,    boards: ['b', 'bant'] },
    { base: MOE,     boards: ['3', 'a', 'aco', 'adv', 'an', 'b', 'bant', 'biz', 'c', 'cgl', 'ck', 'cm', 'co', 'd', 'diy', 'e', 'f', 'fa', 'fit', 'g', 'gd', 'gif', 'h', 'hc', 'his', 'hm', 'hr', 'i', 'ic', 'int', 'jp', 'k', 'lgbt', 'lit', 'm', 'mlp', 'mlpol', 'mu', 'n', 'news', 'o', 'out', 'p', 'po', 'pol', 'pw', 'q', 'qa', 'qst', 'r', 'r9k', 's', 's4s', 'sci', 'soc', 'sp', 't', 'tg', 'toy', 'trash', 'trv', 'tv', 'u', 'v', 'vg', 'vip', 'vm', 'vmg', 'vp', 'vr', 'vrpg', 'vst', 'vt', 'w', 'wg', 'wsg', 'wsr', 'x', 'xs', 'y'] }
  ];
  const SUPPORTED_BOARDS = Array.from(new Set(ARCHIVE_COVERAGE.flatMap((a) => a.boards))).sort();
  // Per-board overrides for hosts that are technically available but noisy or
  // worse as a first choice. /mlp/ has B4K and archived.moe coverage; keep Desu
  // as a fallback because it rate-limits heavily under replay load.
  const BOARD_ARCHIVE_PREFERENCE = {
    mlp: [B4K, MOE, DESU]
  };

  // Archives that adopted a board late hold none of its older history —
  // querying them for replay dates before they started wastes the opening
  // request of every single fetch on a guaranteed 404.
  const ARCHIVE_BOARD_SINCE = {
    [B4K]: { mlp: Date.UTC(2021, 0, 1) }
  };
  function archiveCoversReplayDate(base, board) {
    const since = ARCHIVE_BOARD_SINCE[base] && ARCHIVE_BOARD_SINCE[base][board];
    if (!since) return true;
    const d = replayDateMs();
    return !Number.isFinite(d) || d >= since;
  }
  // Every archive that hosts this board, best first. Falls back to archived.moe
  // for an unrecognised board rather than fanning out to everything blindly.
  function archivesForBoard(board) {
    const hosts = ARCHIVE_COVERAGE.filter((a) => a.boards.includes(board)).map((a) => a.base)
      .filter((base) => archiveCoversReplayDate(base, board));
    if (!hosts.length) return [MOE];
    const preferred = BOARD_ARCHIVE_PREFERENCE[board] || [];
    if (!preferred.length) return hosts;
    const front = preferred.filter((base) => hosts.includes(base));
    const rest = hosts.filter((base) => !front.includes(base));
    return [...front, ...rest];
  }
  // Some archives expose thread/post/media APIs for a board but disable the
  // path-style HTML search page used for date catalog enumeration.
  const HTML_SEARCH_DISABLED = {
    [B4K]: ['g', 'mlp']
  };
  function htmlSearchEnabled(base, board) {
    return !((HTML_SEARCH_DISABLED[base] || []).includes(board));
  }
  function searchArchivesForBoard(board) {
    const hosts = archivesForBoard(board).filter((base) => htmlSearchEnabled(base, board));
    return hosts.length ? hosts : archivesForBoard(board);
  }
  const archiveFor = (board) => archivesForBoard(board)[0];
  const archiveAPIsFor = (board) => archivesForBoard(board);
  // Media METADATA lookups ask the board's media authority first. For /mlp/
  // that's desuarchive: it serves full images and its thumb_link carries the
  // deduplicated preview path (reposts reuse the first upload's thumbnail
  // file, under a different timestamp than the post's own). archived.moe is
  // ahead of desu for text — but it keeps only thumbnails for /mlp/, so a
  // sequential first-answer-wins media query must not stop there.
  const BOARD_MEDIA_API_PREFERENCE = { mlp: [DESU, MOE, B4K] };
  // Real board thread capacity: 10 index pages x 15 threads = 150. Kept as
  // a per-board table in case a board with a genuinely different depth turns
  // up; entries here override the default.
  const BOARD_THREAD_CAPACITY = {};
  const DEFAULT_THREAD_CAPACITY = 150;
  function boardThreadCapacity(board = engine.board) {
    return BOARD_THREAD_CAPACITY[board] || DEFAULT_THREAD_CAPACITY;
  }
  function boardIndexPages(board = engine.board) {
    return Math.max(1, Math.ceil(boardThreadCapacity(board) / (CONFIG.indexThreadsPerPage || 18)));
  }
  function mediaAPIsFor(board) {
    const hosts = archivesForBoard(board);
    const preferred = BOARD_MEDIA_API_PREFERENCE[board];
    if (!preferred) return hosts;
    const front = preferred.filter((b) => hosts.includes(b));
    return [...front, ...hosts.filter((b) => !front.includes(b))];
  }
  // Thread fetches shard across archives by thread number: hydrating a
  // catalog is dozens of fetches, and splitting them halves the load each
  // host sees. Failover order is preserved — just the starting host rotates.
  const threadAPIsFor = (board, num) => {
    const hosts = archivesForBoard(board);
    if (hosts.length < 2 || num == null) return hosts;
    const i = (Number(String(num).slice(-4)) || 0) % hosts.length;
    return [...hosts.slice(i), ...hosts.slice(0, i)];
  };

  const BOARD_NAMES = {
    3: '3DCG',
    a: 'Anime & Manga', aco: 'Adult Cartoons', adv: 'Advice', an: 'Animals & Nature',
    b: 'Random', bant: 'International/Random', biz: 'Business & Finance',
    c: 'Anime/Cute', cgl: 'Cosplay & EGL', ck: 'Food & Cooking', cm: 'Cute/Male',
    co: 'Comics & Cartoons', con: 'Conventions', d: 'Hentai/Alternative',
    diy: 'Do-It-Yourself', e: 'Ecchi', f: 'Flash', fa: 'Fashion', fit: 'Fitness',
    g: 'Technology', gd: 'Graphic Design', gif: 'Adult GIF', h: 'Hentai',
    hc: 'Hardcore', his: 'History & Humanities', hm: 'Handsome Men', hr: 'High Resolution',
    i: 'Oekaki', ic: 'Artwork/Critique', int: 'International', jp: 'Otaku Culture',
    k: 'Weapons', lgbt: 'LGBT', lit: 'Literature', m: 'Mecha', mlp: 'Pony',
    mlpol: 'Pony Politically Incorrect', mu: 'Music', n: 'Transportation',
    news: 'Current News', o: 'Auto', out: 'Outdoors', p: 'Photography',
    po: 'Papercraft & Origami', pol: 'Politically Incorrect', pw: 'Professional Wrestling',
    q: '4chan Feedback', qa: 'Question & Answer', qst: 'Quests', r: 'Adult Requests',
    r9k: 'ROBOT9001', s: 'Sexy Beautiful Women', s4s: 'Shit 4chan Says',
    sci: 'Science & Math', soc: 'Cams & Meetups', sp: 'Sports', t: 'Torrents',
    tg: 'Traditional Games', toy: 'Toys', trash: 'Off-topic', trv: 'Travel',
    tv: 'Television & Film', u: 'Yuri', v: 'Video Games', vg: 'Video Game Generals',
    vip: 'Very Important Posts', vm: 'Video Games/Multiplayer', vmg: 'Video Games/Mobile',
    vp: 'Pokemon', vr: 'Retro Games', vrpg: 'Video Games/RPG', vst: 'Video Games/Strategy',
    vt: 'Virtual YouTubers', w: 'Anime/Wallpapers', wg: 'Wallpapers/General',
    wsg: 'Worksafe GIF', wsr: 'Worksafe Requests', x: 'Paranormal', xs: 'Extreme Sports',
    y: 'Yaoi'
  };

  // The real 4chan top board list, grouped exactly as 4chan bracketed it.
  const BOARD_NAV = [
    ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'gif', 'h', 'hr', 'k', 'm', 'o', 'p', 'r', 's', 't', 'u', 'v', 'vg', 'vm', 'vmg', 'vr', 'vrpg', 'vst', 'w', 'wg'],
    ['i', 'ic'],
    ['r9k', 's4s', 'vip', 'cm', 'hm', 'lgbt', 'y'],
    ['3', 'aco', 'adv', 'an', 'bant', 'biz', 'cgl', 'ck', 'co', 'diy', 'fa', 'fit', 'gd', 'hc', 'his', 'int', 'jp', 'lit', 'mlp', 'mu', 'n', 'news', 'out', 'po', 'pol', 'pw', 'qst', 'sci', 'soc', 'sp', 'tg', 'toy', 'trv', 'tv', 'vp', 'vt', 'wsg', 'wsr', 'x', 'xs']
  ];
  const NAV_BOARD_SET = new Set(BOARD_NAV.flat());
  const ARCHIVE_ONLY_BOARD_NAV = SUPPORTED_BOARDS.filter((b) => !NAV_BOARD_SET.has(b));
  const BOARD_NAV_GROUPS = ARCHIVE_ONLY_BOARD_NAV.length ? [...BOARD_NAV, ARCHIVE_ONLY_BOARD_NAV] : BOARD_NAV;

  // ── Tiny utilities ─────────────────────────────────────────────────────────
  const $ = (sel, root = document) => root.querySelector(sel);
  const el = (tag, props = {}, ...kids) => {
    const n = document.createElement(tag);
    for (const [k, v] of Object.entries(props)) {
      if (k === 'class') n.className = v;
      else if (k === 'html') n.innerHTML = v;
      else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v);
      else if (v != null) n.setAttribute(k, v);
    }
    for (const kid of kids) if (kid != null) n.append(kid);
    return n;
  };
  const pad = (n) => String(n).padStart(2, '0');
  const nextDay = (d) => {
    const [y, m, dd] = d.split('-').map(Number);
    const t = new Date(Date.UTC(y, m - 1, dd + 1));
    return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
  };
  const addDays = (d, days) => {
    const [y, m, dd] = d.split('-').map(Number);
    const t = new Date(Date.UTC(y, m - 1, dd + days));
    return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
  };
  const easternClock = (unixSec) =>
    new Date(unixSec * 1000).toLocaleString('en-US', {
      timeZone: 'America/New_York', weekday: 'short', year: 'numeric',
      month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit'
    });

  // 4chan-style post stamp, but with seconds (4chan only showed HH:MM):
  // M/D/YY(Ddd)HH:MM:SS in US Eastern. Reuses one formatter for speed.
  const _etFmt = new Intl.DateTimeFormat('en-US', {
    timeZone: 'America/New_York', year: '2-digit', month: '2-digit', day: '2-digit',
    weekday: 'short', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
  });
  function fourchanStamp(unixSec) {
    const parts = _etFmt.formatToParts(new Date(unixSec * 1000));
    const g = (t) => (parts.find((p) => p.type === t) || {}).value || '';
    let hh = g('hour'); if (hh === '24') hh = '00';
    // Replay stamps include seconds so posts arriving in the same minute are ordered visibly.
    return `${g('month')}/${g('day')}/${g('year')}(${g('weekday')})${hh}:${g('minute')}:${g('second')}`;
  }

  // ── Network (CORS-free via GM) ───────────────────────────────────────────
  function okStatus(status) {
    return !status || (status >= 200 && status < 300);
  }
  function statusError(r, url) {
    return new Error(`HTTP ${r.status || 0}: ${url}`);
  }
  // ── Rate-limit detection & backoff ─────────────────────────────────────
  // Archives (desuarchive especially) 429 bursts of API calls. The defense
  // has two layers: a per-host concurrency gate that keeps us from bursting
  // in the first place, and a shared per-host cooldown when the server
  // pushes back anyway. Status is shown in the control bar (#wb-ratelimit).
  // Generic 5xx responses are not rate limits (desuarchive often clears a
  // transient 503 on an immediate refresh) but they do get a short shared
  // pause so concurrent workers don't collectively hammer a struggling host.
  const _rateLimits = new Map(); // host -> { until }, true 429/explicit throttle only
  const _hostPauses = new Map();  // host -> { until }, short generic 5xx backoff, not a rate-limit verdict
  // Retrying a host that just said "slow down" is the worst response when
  // mirror archives exist — one retry, then throw so callers fail over.
  const RATE_LIMIT_MAX_RETRIES = 1;
  const TRANSIENT_STATUS_MAX_RETRIES = 2;
  const GM_GET_MAX_WAIT_MS = 35000; // total budget incl. cooldowns — fail over to the next archive rather than sleep forever

  // Per-host concurrency gate. Board loads fan out dozens of API calls; the
  // gate caps simultaneous in-flight requests per host so the burst that
  // trips the limiter never happens. Slots are held through cooldown sleeps
  // on purpose — that's what makes the backoff collective. archive.org
  // serves bulk downloads and tolerates more parallelism than the FoolFuuka
  // archives, whose limiters watch API traffic closely.
  function hostMaxConcurrent(host) {
    return /(^|\.)archive\.org$/i.test(host) ? 4 : 2;
  }

  // Per-host pacing: requests reserve evenly-spaced send slots (sync, so no
  // race between concurrent reservers). Bursts are what trip archive
  // limiters — but every host's budget is different and undocumented, so
  // spacing is LEARNED: widen sharply when a host pushes back (429), ease
  // slowly after sustained success, and persist the result across sessions
  // so a new visit doesn't have to re-trip the limiter to rediscover it.
  const HOST_SPACING_DEFAULTS = [
    [/(^|\.)desuarchive\.org$/i, 900],   // known strict
    [/(^|\.)archived\.moe$/i, 600],
    [/(^|\.)archive\.org$/i, 150]        // bulk host, no fussy limiter
  ];
  const HOST_SPACING_FALLBACK = 300;
  const HOST_SPACING_MIN = 250;
  const HOST_SPACING_MAX = 5000;
  const HOST_SPACING_TIGHTEN_EVERY = 25; // consecutive OKs before easing
  function hostSpacingDefault(host) {
    for (const [re, ms] of HOST_SPACING_DEFAULTS) if (re.test(host)) return ms;
    return HOST_SPACING_FALLBACK;
  }
  let _hostPacing = null; // host → { ms, okStreak }, lazy-loaded from GM storage
  function hostPacing(host) {
    if (!_hostPacing) {
      _hostPacing = new Map();
      try {
        const saved = JSON.parse(GM_getValue('hostPacing:v1', 'null')) || {};
        for (const h of Object.keys(saved)) {
          const ms = Number(saved[h]);
          if (ms > 0) _hostPacing.set(h, { ms: Math.min(HOST_SPACING_MAX, ms), okStreak: 0 });
        }
      } catch (e) { /* fresh start */ }
    }
    let p = _hostPacing.get(host);
    if (!p) { p = { ms: hostSpacingDefault(host), okStreak: 0 }; _hostPacing.set(host, p); }
    return p;
  }
  let _hostPacingSaveTimer = 0;
  function persistHostPacing() {
    if (_hostPacingSaveTimer) return;
    _hostPacingSaveTimer = setTimeout(() => {
      _hostPacingSaveTimer = 0;
      const out = {};
      for (const [h, p] of _hostPacing || []) {
        if (Math.round(p.ms) !== hostSpacingDefault(h)) out[h] = Math.round(p.ms);
      }
      try { GM_setValue('hostPacing:v1', JSON.stringify(out)); } catch (e) { /* storage unavailable */ }
    }, 2000);
  }
  function widenHostSpacing(host) {
    const p = hostPacing(host);
    p.ms = Math.min(HOST_SPACING_MAX, Math.max(p.ms * 1.8, hostSpacingDefault(host)));
    p.okStreak = 0;
    persistHostPacing();
  }
  function noteHostSuccess(host) {
    const p = hostPacing(host);
    if (++p.okStreak < HOST_SPACING_TIGHTEN_EVERY) return;
    p.okStreak = 0;
    // Ease toward (but never much below) the host's default — defaults
    // already encode "known strict"; learning may relax them somewhat.
    const floor = Math.max(HOST_SPACING_MIN, hostSpacingDefault(host) * 0.6);
    const next = Math.max(floor, p.ms * 0.93);
    if (Math.round(next) !== Math.round(p.ms)) { p.ms = next; persistHostPacing(); }
  }
  const _hostNextSlot = new Map(); // host → earliest allowed send time
  function reserveHostSlot(host) {
    const now = Date.now();
    const at = Math.max(now, _hostNextSlot.get(host) || 0);
    _hostNextSlot.set(host, at + hostPacing(host).ms);
    return at - now; // ms this caller must wait before sending
  }
  const _hostGates = new Map(); // host → { active, queue }
  function hostGateAcquire(host) {
    let g = _hostGates.get(host);
    if (!g) { g = { active: 0, queue: [] }; _hostGates.set(host, g); }
    if (g.active < hostMaxConcurrent(host)) { g.active++; return Promise.resolve(); }
    return new Promise((res) => g.queue.push(res));
  }
  function hostGateRelease(host) {
    const g = _hostGates.get(host);
    if (!g) return;
    const next = g.queue.shift();
    if (next) next();
    else g.active = Math.max(0, g.active - 1);
  }

  function responseHeader(headers, name) {
    const re = new RegExp('(?:^|\\n)' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ':\\s*([^\\r\\n]+)', 'i');
    const m = re.exec(headers || '');
    return m ? m[1].trim() : '';
  }
  function retryAfterMs(responseHeaders) {
    const v = responseHeader(responseHeaders, 'retry-after');
    if (!v) return 0;
    const seconds = Number(v);
    if (seconds > 0 && seconds <= 120) return seconds * 1000;
    const ts = Date.parse(v);
    const ms = ts - Date.now();
    return ms > 0 && ms <= 120000 ? ms : 0;
  }
  function rateLimitResponseReason(r) {
    if (r.status === 429) return 'http 429';
    // Cloudflare-style "you are being rate limited" pages come back 403.
    // 5xx pages mentioning rate limiting stay on the transient path — a
    // server error page quoting the words is not a limiter verdict.
    if (r.status !== 403) return '';
    const text = String(r.responseText || '').slice(0, 2048);
    const m = /\b(?:too many requests|rate[-\s]?limit(?:ed|ing)?|throttled)\b/i.exec(text);
    return m ? `http 403 body matched "${m[0].slice(0, 80)}"` : '';
  }
  function transientRetryMs(responseHeaders, attempt) {
    const backoff = Math.min(750 * Math.pow(2, attempt), 3000);
    const retryAfter = retryAfterMs(responseHeaders);
    return retryAfter ? Math.min(retryAfter, 3000) : backoff;
  }
  function isTransientStatus(status) {
    return status === 500 || status === 502 || status === 503 || status === 504;
  }

  function extendCooldown(map, host, ms) {
    const prev = map.get(host);
    const until = Date.now() + ms;
    if (!prev || prev.until < until) map.set(host, { until });
  }
  function noteRateLimit(url, responseHeaders, attempt) {
    // Server's Retry-After or exponential 5s/10s/20s/40s — and jitter ON TOP
    // either way, so the herd of parallel requests doesn't share one wake-up
    // instant and re-trip the limiter in lockstep.
    const base = retryAfterMs(responseHeaders) || Math.min(5000 * Math.pow(2, attempt), 60000);
    extendCooldown(_rateLimits, mediaHost(url), base + Math.floor(Math.random() * 4000));
    updateRateLimitUI();
  }
  // Cooldowns are never deleted on success — a lone request finishing cannot
  // vouch for a host other waiters just saw 429; entries simply lapse.
  function cooldownRemaining(map, host) {
    const rl = map.get(host);
    return rl ? Math.max(0, rl.until - Date.now()) : 0;
  }
  function rateLimitRemaining(host) { return cooldownRemaining(_rateLimits, host); }
  function hostPauseRemaining(host) { return cooldownRemaining(_hostPauses, host); }

  // Self-rearming countdown: re-queries the span every tick so it survives
  // renderShell rebuilding the bar, and stops on its own when no cooldown is
  // active (no interval handle to leak).
  let _rlUiArmed = false;
  function updateRateLimitUI() {
    let worst = null;
    for (const [host, rl] of _rateLimits) {
      if (rl.until > Date.now() && (!worst || rl.until > worst.until)) worst = { host, until: rl.until };
    }
    const span = $('#wb-ratelimit');
    if (span) {
      span.textContent = worst
        ? `rate limited by ${worst.host} - retrying in ${Math.max(1, Math.ceil((worst.until - Date.now()) / 1000))}s`
        : '';
    }
    if (worst && !_rlUiArmed) {
      _rlUiArmed = true;
      setTimeout(() => { _rlUiArmed = false; updateRateLimitUI(); }, 1000);
    }
  }

  async function gmGet(url, { timeout = 30000, json = false, maxWaitMs = GM_GET_MAX_WAIT_MS } = {}) {
    const host = mediaHost(url);
    const deadline = Date.now() + maxWaitMs;
    let rlAttempts = 0;
    let transientAttempts = 0;
    await hostGateAcquire(host);
    try {
      for (;;) {
        // Wait out any shared cooldown — with our own jitter so waiters
        // trickle back instead of stampeding — but never past this request's
        // budget: throwing early lets callers fail over to another archive.
        const rateLimitWait = rateLimitRemaining(host);
        const hostPauseWait = hostPauseRemaining(host);
        const cooldown = Math.max(rateLimitWait, hostPauseWait);
        if (cooldown > 0) {
          const wait = cooldown + Math.floor(Math.random() * 2500);
          if (Date.now() + wait > deadline) {
            throw new Error((rateLimitWait >= hostPauseWait ? 'Rate limited' : 'Host temporarily unavailable') + ': ' + url);
          }
          await sleep(wait);
          continue; // cooldown may have been extended while we slept
        }
        const spacing = reserveHostSlot(host);
        if (spacing > 0) await sleep(spacing);
        const r = await new Promise((resolve, reject) => {
          GM_xmlhttpRequest({
            method: 'GET', url, timeout,
            headers: json ? { 'Accept': 'application/json' } : undefined,
            onload: resolve,
            onerror: () => reject(new Error('Network error: ' + url)),
            ontimeout: () => reject(new Error('Timeout: ' + url))
          });
        });
        const rateLimitReason = rateLimitResponseReason(r);
        if (rateLimitReason) {
          mediaDebug('warn', 'api rate-limit detected', {
            source: mediaSourceKind(url),
            host,
            status: r.status || 0,
            reason: rateLimitReason,
            headers: mediaHeaderSummary(r.responseHeaders || ''),
            url
          });
          widenHostSpacing(host);
          if (rlAttempts >= RATE_LIMIT_MAX_RETRIES) throw statusError(r, url);
          noteRateLimit(url, r.responseHeaders, rlAttempts);
          rlAttempts++;
          continue;
        }
        if (isTransientStatus(r.status)) {
          // Short shared pause: every worker hitting this host backs off a
          // beat together instead of independently hammering a 503ing host.
          extendCooldown(_hostPauses, host, 1500 + Math.floor(Math.random() * 1000));
          if (transientAttempts >= TRANSIENT_STATUS_MAX_RETRIES) throw statusError(r, url);
          await sleep(transientRetryMs(r.responseHeaders, transientAttempts));
          transientAttempts++;
          continue;
        }
        if (!okStatus(r.status)) throw statusError(r, url);
        noteHostSuccess(host);
        if (!json) return r.responseText;
        try { return JSON.parse(r.responseText); }
        catch (e) { throw new Error('Bad JSON from ' + url); }
      }
    } finally {
      hostGateRelease(host);
    }
  }
  function gmJSON(url, timeout = 30000) { return gmGet(url, { timeout, json: true }); }
  function gmText(url) { return gmGet(url, { timeout: 40000 }); }
  const MEDIA_TIMEOUT_MS = 5000;
  const MEDIA_ARCHIVE_ORG_TIMEOUT_MS = 12000;
  const MEDIA_BATCH_SIZE = 8;
  const MLP_ARCHIVE_ORG_FIRST_CUTOFF_MS = Date.UTC(2015, 0, 1);
  const _hostFails = new Map();
  const HOST_FAIL_THRESHOLD = 4;
  const HOST_FAIL_WINDOW_MS = 60000;
  function mediaHost(url) { try { return new URL(url).host; } catch (e) { return ''; } }
  function archiveOrgMedia(url) {
    return /\b(?:web\.)?archive\.org\b/i.test(mediaHost(url));
  }
  function replayDateMs() {
    const m = String(CONFIG.date || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
    if (!m) return NaN;
    return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
  }
  function mlpArchiveOrgFirstRequired(board = engine.board) {
    const d = replayDateMs();
    return board === 'mlp' && Number.isFinite(d) && d < MLP_ARCHIVE_ORG_FIRST_CUTOFF_MS;
  }
  function mediaCandidateBatchSize() {
    return MEDIA_BATCH_SIZE;
  }
  function mediaResolveConcurrencyLimit() {
    return Math.max(1, CONFIG.mediaResolveConcurrency || 1);
  }
  function mediaFetchTimeout(url) {
    return archiveOrgMedia(url) ? MEDIA_ARCHIVE_ORG_TIMEOUT_MS : MEDIA_TIMEOUT_MS;
  }
  function trackHostFail(url) {
    // archive.org/Wayback is often slow for a specific object without the whole
    // host being down; don't let a few slow captures suppress all later attempts.
    if (archiveOrgMedia(url)) return;
    const h = mediaHost(url);
    if (!h) return;
    const now = Date.now();
    const fails = (_hostFails.get(h) || []).filter((t) => now - t < HOST_FAIL_WINDOW_MS);
    fails.push(now);
    _hostFails.set(h, fails);
  }
  function hostIsDown(url) {
    if (archiveOrgMedia(url)) return false;
    const h = mediaHost(url);
    if (!h) return false;
    const fails = _hostFails.get(h);
    return fails && fails.filter((t) => Date.now() - t < HOST_FAIL_WINDOW_MS).length >= HOST_FAIL_THRESHOLD;
  }
  // True while any host is rate-limited or marked down — a "this image
  // doesn't exist" verdict reached during a disturbance is not trustworthy
  // and must not be persisted.
  function networkDisturbed() {
    const now = Date.now();
    for (const rl of _rateLimits.values()) {
      if (rl.until > now) return true;
    }
    for (const pause of _hostPauses.values()) {
      if (pause.until > now) return true;
    }
    for (const fails of _hostFails.values()) {
      if (fails.filter((t) => now - t < HOST_FAIL_WINDOW_MS).length >= HOST_FAIL_THRESHOLD) return true;
    }
    return false;
  }
  const _blobCache = new Map();
  const _mediaDebugLog = [];
  const MEDIA_DEBUG_LOG_LIMIT = 500;
  function archiveOrgZipUrl(url) {
    return /^https?:\/\/archive\.org\/download\/4chan-mlp-archive-\d{4}-\d{2}\/\d{4}-\d{2}\.zip\//i.test(url);
  }
  function archiveOrgDirectFileUrl(url) {
    return /^https?:\/\/archive\.org\/download\/4chan-mlp-archive-(?:2012-05|2012-06)\/[^/]+$/i.test(url);
  }
  function mediaSourceKind(url) {
    if (archiveOrgZipUrl(url)) return 'archive.org zip';
    if (archiveOrgDirectFileUrl(url)) return 'archive.org direct';
    if (/^https?:\/\/web\.archive\.org\/web\/2id_\//i.test(url)) return 'wayback raw';
    if (/\barchive\.org\b/i.test(url)) return 'archive.org';
    if (/\bdesuarchive\.org\b|\bdesu-usergeneratedcontent\.xyz\b/i.test(url)) return 'desuarchive';
    if (/\b4plebs\.org\b|\bimg\.4plebs\.org\b/i.test(url)) return '4plebs';
    if (/\barchived\.moe\b/i.test(url)) return 'archived.moe';
    if (/\bi\.4cdn\.org\b|\bimages\.4chan\.org\b/i.test(url)) return '4chan original';
    return mediaHost(url);
  }
  function mediaHeaderSummary(headers) {
    const out = {};
    for (const name of ['content-type', 'content-length', 'content-encoding', 'location', 'server', 'x-archive-orig-content-type']) {
      const v = responseHeader(headers, name);
      if (v) out[name] = v;
    }
    return out;
  }
  function mediaResponseMeta(url, r) {
    const blob = r && r.response;
    return {
      source: mediaSourceKind(url),
      host: mediaHost(url),
      status: (r && r.status) || 0,
      finalUrl: (r && r.finalUrl) || '',
      size: (blob && blob.size) || 0,
      type: (blob && blob.type) || '',
      headers: mediaHeaderSummary((r && r.responseHeaders) || ''),
      url
    };
  }
  function mediaRejectReason(r, type) {
    if (!r) return 'no response';
    if (!(r.status >= 200 && r.status < 300)) return `HTTP ${r.status || 0}`;
    if (!r.response) return 'empty response object';
    if (!r.response.size) return 'empty blob';
    if (type && (type.startsWith('text') || type.includes('html'))) return `non-image content type ${type}`;
    return 'unknown rejection';
  }
  function blobTextSnippet(blob, max = 500) {
    return new Promise((resolve) => {
      if (!blob || !blob.slice || typeof FileReader !== 'function') { resolve(''); return; }
      const reader = new FileReader();
      reader.onload = () => resolve(String(reader.result || '').replace(/\s+/g, ' ').slice(0, max));
      reader.onerror = () => resolve('');
      try { reader.readAsText(blob.slice(0, max)); } catch (e) { resolve(''); }
    });
  }
  function logRejectedMediaResponse(url, r, reason) {
    // Diagnostics off (the default): skip building meta and reading blob
    // bodies — thousands of rejected candidates per board would pay for it.
    if (!CONFIG.mediaDebug) return;
    const meta = { ...mediaResponseMeta(url, r), reason };
    mediaDebug('warn', 'fetch rejected', meta);
    const blob = r && r.response;
    const type = (blob && blob.type) || '';
    if (blob && blob.size && (type.startsWith('text') || type.includes('html') || /archive\.org/i.test(url))) {
      blobTextSnippet(blob).then((snippet) => {
        if (snippet) mediaDebug('warn', 'fetch rejected body snippet', { ...meta, snippet });
      });
    }
  }
  function mediaDebug(level, msg, data = {}) {
    if (!CONFIG.mediaDebug) return;
    const entry = {
      ts: new Date().toISOString(),
      level,
      msg,
      data
    };
    _mediaDebugLog.push(entry);
    if (_mediaDebugLog.length > MEDIA_DEBUG_LOG_LIMIT) _mediaDebugLog.splice(0, _mediaDebugLog.length - MEDIA_DEBUG_LOG_LIMIT);
    try { window.oldchanMediaLog = _mediaDebugLog; } catch (e) { /* window unavailable */ }
    const fn = level === 'warn' ? console.warn : console.debug;
    try { fn.call(console, `[oldchan media] ${msg}`, data); } catch (e) { /* console unavailable */ }
  }
  try {
    window.oldchanMediaLog = _mediaDebugLog;
    window.oldchanMediaDiagnostics = {
      log: _mediaDebugLog,
      enable: () => { CONFIG.mediaDebug = true; saveSettings(); return 'oldchan media diagnostics enabled'; },
      disable: () => { CONFIG.mediaDebug = false; saveSettings(); return 'oldchan media diagnostics disabled'; },
      clear: () => { _mediaDebugLog.length = 0; return 'oldchan media diagnostics cleared'; }
    };
  } catch (e) { /* window unavailable */ }
  const MEDIA_CACHE_NAME = 'oldchan-media-v1';
  const IA_MLP_INDEX_CACHE_NAME = 'oldchan-ia-mlp-index-v1';
  const IA_MLP_INDEX_URL = 'https://archive.org/download/4chan-mlp-archive-index/md5-index.json';
  function mediaCacheAvailable() {
    return !!(CONFIG.mediaPersistentCache && 'caches' in window && window.caches);
  }
  function mediaCacheRequestUrl(url) {
    return `/__oldchan_media_cache__?u=${encodeURIComponent(url)}`;
  }
  let _mediaCacheHandle = null;
  function openMediaCache() {
    return _mediaCacheHandle || (_mediaCacheHandle = caches.open(MEDIA_CACHE_NAME));
  }
  let _iaMlpIndex = null;
  let _iaMlpIndexPromise = null;
  function archiveOrgIndexCacheAvailable() {
    return !!('caches' in window && window.caches);
  }
  async function loadArchiveOrgMlpIndex() {
    if (_iaMlpIndex) return _iaMlpIndex;
    if (_iaMlpIndexPromise) return _iaMlpIndexPromise;
    _iaMlpIndexPromise = (async () => {
      mediaDebug('debug', 'archive.org md5 index load start', { url: IA_MLP_INDEX_URL });
      let cache = null;
      if (archiveOrgIndexCacheAvailable()) {
        try {
          cache = await caches.open(IA_MLP_INDEX_CACHE_NAME);
          const cached = await cache.match(IA_MLP_INDEX_URL);
          if (cached) {
            const data = await cached.json();
            if (data && typeof data === 'object') {
              _iaMlpIndex = data;
              mediaDebug('debug', 'archive.org md5 index cache hit', { url: IA_MLP_INDEX_URL });
              return data;
            }
          }
        } catch (e) {
          mediaDebug('warn', 'archive.org md5 index cache read failed', { error: String(e && e.message || e) });
        }
      }
      const controller = typeof AbortController === 'function' ? new AbortController() : null;
      const timer = controller ? setTimeout(() => controller.abort(), 180000) : null;
      let text = '';
      try {
        try {
          const res = await fetch(IA_MLP_INDEX_URL, {
            mode: 'cors',
            credentials: 'omit',
            signal: controller && controller.signal
          });
          if (!res.ok) throw new Error(`HTTP ${res.status || 0}`);
          text = await res.text();
        } catch (e) {
          mediaDebug('warn', 'archive.org md5 index native fetch failed, trying GM', { error: String(e && e.message || e) });
          text = await gmGet(IA_MLP_INDEX_URL, { timeout: 180000, json: false, maxWaitMs: 210000 });
        }
      } finally {
        if (timer) clearTimeout(timer);
      }
      const data = JSON.parse(text);
      if (!data || typeof data !== 'object') throw new Error('index JSON was not an object');
      _iaMlpIndex = data;
      if (cache) {
        try {
          await cache.put(IA_MLP_INDEX_URL, new Response(text, { headers: { 'Content-Type': 'application/json' } }));
        } catch (e) {
          mediaDebug('warn', 'archive.org md5 index cache write failed', { error: String(e && e.message || e) });
        }
      }
      mediaDebug('debug', 'archive.org md5 index loaded', { url: IA_MLP_INDEX_URL, bytes: text.length });
      return data;
    })().catch((e) => {
      _iaMlpIndexPromise = null;
      mediaDebug('warn', 'archive.org md5 index load failed', { url: IA_MLP_INDEX_URL, error: String(e && e.message || e) });
      return null;
    });
    return _iaMlpIndexPromise;
  }
  async function cachedMediaBlobURL(url) {
    if (!mediaCacheAvailable()) return null;
    try {
      const cache = await openMediaCache();
      const res = await cache.match(mediaCacheRequestUrl(url));
      if (!res) return null;
      const blob = await res.blob();
      const t = blob.type || '';
      if (!blob.size || t.startsWith('text') || t.includes('html')) return null;
      mediaDebug('debug', 'persistent media cache hit', { host: mediaHost(url), size: blob.size, type: t, url });
      return URL.createObjectURL(blob);
    } catch (e) {
      mediaDebug('warn', 'persistent media cache read failed', { host: mediaHost(url), url, error: String(e && e.message || e) });
      return null;
    }
  }
  function storeMediaBlob(url, blob) {
    if (!mediaCacheAvailable() || !blob || !blob.size) return;
    const max = Math.max(0, CONFIG.mediaPersistentMaxBytes || 0);
    if (max && blob.size > max) return;
    openMediaCache().then((cache) => {
      const headers = {};
      if (blob.type) headers['Content-Type'] = blob.type;
      return cache.put(mediaCacheRequestUrl(url), new Response(blob, { headers }));
    }).then(() => {
      mediaDebug('debug', 'persistent media cached', { host: mediaHost(url), size: blob.size, type: blob.type, url });
    }, (e) => {
      mediaDebug('warn', 'persistent media cache write failed', { host: mediaHost(url), url, error: String(e && e.message || e) });
    });
  }
  // ── Persistent full-thread cache (browser Cache Storage, like media) ─────
  // GM storage has an 8MB budget, far too small for full thread JSON — but
  // Cache Storage holds gigabytes. Archived threads are immutable (they
  // ended years ago), so an old thread never needs re-fetching; only threads
  // with recent activity get a freshness window.
  const THREAD_CACHE_NAME = 'oldchan-threads-v1';
  const THREAD_IMMUTABLE_AGE_S = 30 * 86400; // last post older than this → thread can never change
  const THREAD_FRESH_MS = 3600 * 1000;       // recent threads: trust cache for 1h
  function threadCacheAvailable() {
    return !!(CONFIG.threadPersistentCache && 'caches' in window && window.caches);
  }
  function threadCacheRequestUrl(board, num) {
    return `/__oldchan_thread_cache__?b=${encodeURIComponent(board)}&n=${encodeURIComponent(num)}`;
  }
  let _threadCacheHandle = null;
  function openThreadCache() {
    return _threadCacheHandle || (_threadCacheHandle = caches.open(THREAD_CACHE_NAME));
  }
  function cachedThreadFromMemory(board, num) {
    if (board !== engine.board) return null;
    const posts = engine.threads.get(String(num));
    return posts && posts.length ? { posts, source: 'memory' } : null;
  }
  async function cachedThreadFull(board, num, opts = {}) {
    if (!threadCacheAvailable()) return null;
    try {
      const cache = await openThreadCache();
      const res = await cache.match(threadCacheRequestUrl(board, num));
      if (!res) return null;
      const data = await res.json();
      if (!data || !validThreadResult(data.result)) return null;
      const fresh = Date.now() - (data.cachedAt || 0) <= THREAD_FRESH_MS;
      // Degraded results (fetched while a better archive was unreachable)
      // are only trusted briefly — they must retry, not pin a bad copy.
      if (data.result.degraded && !fresh && !opts.allowDegraded) return null;
      const lastTs = Number(data.result.posts[data.result.posts.length - 1].ts) || 0;
      const threadAgeS = Date.now() / 1000 - lastTs;
      if (threadAgeS < THREAD_IMMUTABLE_AGE_S && !fresh && !opts.allowStale) return null;
      return {
        ...data.result,
        source: data.result.source || 'thread-cache',
        staleCache: !fresh,
        degraded: !!data.result.degraded
      };
    } catch (e) {
      return null;
    }
  }
  function storeThreadFull(board, num, result) {
    if (!threadCacheAvailable() || !validThreadResult(result)) return;
    // Defer the (potentially multi-MB) stringify off the hydration hot path.
    const write = () => {
      openThreadCache().then((cache) => cache.put(
        threadCacheRequestUrl(board, num),
        new Response(JSON.stringify({ cachedAt: Date.now(), result }),
          { headers: { 'Content-Type': 'application/json' } })
      )).catch(() => { /* quota or private mode — fetch path still works */ });
    };
    if (typeof window.requestIdleCallback === 'function') window.requestIdleCallback(write, { timeout: 4000 });
    else setTimeout(write, 250);
  }

  function gmBlobURL(url) {
    if (_blobCache.has(url)) {
      mediaDebug('debug', 'fetch promise cache hit', { source: mediaSourceKind(url), host: mediaHost(url), url });
      return _blobCache.get(url);
    }
    let skippedHostDown = false;
    const p = (async () => {
      // Persistent cache FIRST, host-health check second: a cached blob must
      // be served even while its host is rate limited or down — the bytes
      // are already local and don't need the host at all.
      const cached = await cachedMediaBlobURL(url);
      if (cached) return cached;
      if (hostIsDown(url)) {
        skippedHostDown = true;
        mediaDebug('warn', 'fetch skipped, host marked down', { source: mediaSourceKind(url), host: mediaHost(url), url });
        return null;
      }
      return new Promise((resolve) => {
        const timeout = mediaFetchTimeout(url);
        mediaDebug('debug', 'fetch start', { source: mediaSourceKind(url), host: mediaHost(url), timeout, url });
        GM_xmlhttpRequest({
          method: 'GET', url, responseType: 'blob', timeout,
          onload: (r) => {
            const t = (r.response && r.response.type) || '';
            const ok = r.status >= 200 && r.status < 300 && r.response && r.response.size > 0 &&
              !t.startsWith('text') && !t.includes('html');
            if (!ok) {
              logRejectedMediaResponse(url, r, mediaRejectReason(r, t));
              resolve(null);
              return;
            }
            if (CONFIG.mediaDebug) mediaDebug('debug', 'fetch ok', mediaResponseMeta(url, r));
            storeMediaBlob(url, r.response);
            resolve(URL.createObjectURL(r.response));
          },
          onerror: (e) => {
            trackHostFail(url);
            mediaDebug('warn', 'fetch network error', { source: mediaSourceKind(url), host: mediaHost(url), url, error: String(e && e.message || e || '') });
            resolve(null);
          },
          ontimeout: () => {
            trackHostFail(url);
            mediaDebug('warn', 'fetch timeout', { source: mediaSourceKind(url), host: mediaHost(url), timeout, url });
            resolve(null);
          }
        });
      });
    })();
    _blobCache.set(url, p);
    // A host-down skip is not a verdict on the URL — drop the memoized null
    // so the next attempt (after the cooldown) actually tries the network.
    p.then((v) => { if (!v && skippedHostDown) _blobCache.delete(url); }, () => {});
    return p;
  }

  const _mediaTaskQueue = [];
  let _mediaTaskActive = 0;
  function drainMediaTasks() {
    const max = mediaResolveConcurrencyLimit();
    while (_mediaTaskActive < max && _mediaTaskQueue.length) {
      const task = _mediaTaskQueue.shift();
      _mediaTaskActive++;
      Promise.resolve().then(task.run).then(task.resolve, task.reject).finally(() => {
        _mediaTaskActive--;
        drainMediaTasks();
      });
    }
  }
  function enqueueMediaTask(run) {
    return new Promise((resolve, reject) => {
      _mediaTaskQueue.push({ run, resolve, reject });
      drainMediaTasks();
    });
  }

  let _lazyMediaObserver = null;
  let _lazyMediaRoot = null;
  const _lazyMediaJobs = new WeakMap();
  function runLazyMediaJob(target) {
    const job = _lazyMediaJobs.get(target);
    if (!job || job.started) return;
    job.started = true;
    _lazyMediaJobs.delete(target);
    if (_lazyMediaObserver) {
      try { _lazyMediaObserver.unobserve(target); } catch (e) { /* observer already gone */ }
    }
    enqueueMediaTask(job.run);
  }
  function lazyMediaObserver() {
    const root = $('#wb-overlay') || null;
    if (_lazyMediaObserver && _lazyMediaRoot === root) return _lazyMediaObserver;
    if (_lazyMediaObserver) _lazyMediaObserver.disconnect();
    _lazyMediaRoot = root;
    _lazyMediaObserver = new IntersectionObserver((entries) => {
      for (const entry of entries) {
        if (entry.isIntersecting || entry.intersectionRatio > 0) runLazyMediaJob(entry.target);
      }
    }, { root, rootMargin: '1200px 0px', threshold: 0 });
    return _lazyMediaObserver;
  }
  function lazyMedia(target, run) {
    if (!target || !('IntersectionObserver' in window)) {
      enqueueMediaTask(run);
      return;
    }
    _lazyMediaJobs.set(target, { run, started: false });
    setTimeout(() => {
      if (!_lazyMediaJobs.has(target)) return;
      try { lazyMediaObserver().observe(target); }
      catch (e) { runLazyMediaJob(target); }
    }, 0);
  }
  function lazyResolvePostMedia(target, p, kind, onOk, onMiss) {
    lazyMedia(target, async () => {
      if (target && !target.isConnected) await sleep(0);
      if (target && !target.isConnected) return null;
      const r = await postMediaBlob(p, kind);
      if (target && !target.isConnected) return r;
      if (r) onOk(r);
      else if (onMiss) onMiss();
      return r;
    });
  }

  // Across FoolFuuka-compatible archives an image's path is identical except for the host:
  // /{board}/{image|thumb}/{tim[0:4]}/{tim[4:6]}/{file}. So if one host has lost
  // a file, try the same path only on archives that actually host the board.
  const uniq = (items) => {
    const seen = new Set();
    return items.filter((x) => {
      if (!x || seen.has(x)) return false;
      seen.add(x);
      return true;
    });
  };
  function absArchiveUrl(base, url) {
    if (!url) return '';
    if (/^\/\//.test(url)) return 'https:' + url;
    if (/^https?:\/\//i.test(url)) return url;
    return base ? base.replace(/\/$/, '') + '/' + String(url).replace(/^\//, '') : url;
  }
  function filenameFromUrl(url) {
    if (!url) return '';
    try {
      const u = new URL(absArchiveUrl('', url), location.href);
      return decodeURIComponent((u.pathname.split('/').pop() || '').replace(/\+/g, ' '));
    } catch (e) {
      return String(url).split(/[?#]/)[0].split('/').pop() || '';
    }
  }
  function mediaLabel(media) {
    return (media && (media.fname || media.mediaFilenameProcessed || media.mediaFilename ||
      filenameFromUrl(media.full) || filenameFromUrl(media.thumb))) || 'image';
  }

  const mediaFromApi = (m, base = '', board = '') => {
    if (!m) return null;
    const mediaLink = absArchiveUrl(base, m.media_link);
    const remoteMediaLink = absArchiveUrl(base, m.remote_media_link);
    const thumbLink = absArchiveUrl(base, m.thumb_link);
    const mediaFilename = m.media_filename || '';
    const mediaFilenameProcessed = m.media_filename_processed || '';
    const rawHash = validMediaHash(m.media_hash) ? m.media_hash : '';
    const safeHash = validMediaHash(m.safe_media_hash) ? m.safe_media_hash : '';
    return {
      thumb: thumbLink,
      full: mediaLink || remoteMediaLink,
      fname: mediaFilenameProcessed || mediaFilename ||
        filenameFromUrl(mediaLink || remoteMediaLink || thumbLink),
      meta: (m.media_w && m.media_h) ? `${m.media_w}x${m.media_h}` : '',
      hash: safeHash || normalizedHash(rawHash),
      rawHash,
      board,
      sourceBase: base,
      mediaId: m.media_id || '',
      spoiler: m.spoiler || '',
      mediaStatus: m.media_status || '',
      banned: m.banned || '',
      total: m.total || '',
      archiveMedia: m.media || '',
      mediaOrig: m.media_orig || '',
      previewOrig: m.preview_orig || '',
      previewOp: m.preview_op || '',
      previewReply: m.preview_reply || '',
      mediaFilename,
      mediaFilenameProcessed,
      mediaW: m.media_w || '',
      mediaH: m.media_h || '',
      mediaSize: m.media_size || '',
      previewW: m.preview_w || '',
      previewH: m.preview_h || '',
      mediaLink,
      remoteMediaLink,
      thumbLink
    };
  };

  function archiveMediaPathCandidates(base, board, kind, path, file) {
    const out = [];
    if (base === DESU) {
      out.push(`https://desu-usergeneratedcontent.xyz/${path}`);
    } else if (base === B4K) {
      out.push(`https://arch.b4k.dev/media/${path}`);
      out.push(`https://arch-img.b4k.dev/${path}`);
      out.push(`https://arch-img.b4k.co/${path}`);
    } else if (base === MOE) {
      out.push(`https://archived.moe/files/${path}`);
    } else if (base === PALANQ) {
      out.push(`https://archive-media.palanq.win/${path}`);
    } else if (base === ALICE) {
      out.push(`https://archive.alice.al/foolfuuka/boards/${path}`);
    } else if (base === PLEBS) {
      out.push(`https://img.4plebs.org/boards/${path}`);
      out.push(`https://archive.4plebs.org/boards/${path}`);
      out.push(`https://archive.4plebs.org/${path}`);
    } else if (base === SINS) {
      out.push(`https://archiveofsins.com/data/${path}`);
      out.push(`https://archiveofsins.com/${path}`);
    } else if (base === THEB) {
      out.push(`https://thebarchive.com/data/${path}`);
      out.push(`https://thebarchive.com/${path}`);
    } else if (base === EIENTEI) {
      out.push(`https://eientei.xyz/data/${path}`);
      out.push(`https://eientei.xyz/${path}`);
    }

    if (kind !== 'image') return out;
    if (base === DESU) {
      out.push(`https://desuarchive.org/${board}/redirect/${file}`);
      out.push(`https://desuarchive.org/${board}/image/${file}`);
    } else if (base === B4K) {
      out.push(`https://arch.b4k.dev/${board}/image/${file}`);
      out.push(`https://arch.b4k.co/${board}/image/${file}`);
      out.push(`https://arch-img.b4k.dev/${board}/image/${file}`);
      out.push(`https://arch-img.b4k.co/${board}/image/${file}`);
    } else if (base === MOE) {
      out.push(`https://archived.moe/${board}/redirect/${file}`);
      out.push(`https://archived.moe/${board}/image/${file}`);
    } else if (base === ALICE) {
      out.push(`https://archive.alice.al/${board}/redirect/${file}`);
    } else if (base === PALANQ) {
      out.push(`https://archive.palanq.win/${board}/redirect/${file}`);
    } else if (base === PLEBS) {
      out.push(`https://archive.4plebs.org/${board}/redirect/${file}`);
    } else if (base === SINS) {
      out.push(`https://archiveofsins.com/${board}/redirect/${file}`);
    } else if (base === THEB) {
      out.push(`https://thebarchive.com/${board}/redirect/${file}`);
    } else if (base === EIENTEI) {
      out.push(`https://eientei.xyz/${board}/redirect/${file}`);
    }
    return out;
  }

  function mediaPathCandidates(board, kind, a, b, file) {
    const path = `${board}/${kind}/${a}/${b}/${file}`;
    return uniq(archivesForBoard(board).flatMap((base) => (
      archiveMediaPathCandidates(base, board, kind, path, file)
    )));
  }
  function timPathParts(file) {
    const m = String(file || '').match(/^(\d{4})(\d{2})\d*\.[a-z0-9]+$/i);
    return m ? [m[1], m[2]] : null;
  }
  function mediaFilePathCandidates(board, kind, file) {
    const parts = timPathParts(filenameFromUrl(file));
    return parts ? mediaPathCandidates(board, kind, parts[0], parts[1], filenameFromUrl(file)) : [];
  }
  function thumbNameFromImage(file) {
    const f = filenameFromUrl(file);
    if (!f) return '';
    return f.replace(/\.[^.]+$/, 's.jpg');
  }
  function originalFourcdnCandidates(board, file) {
    const f = filenameFromUrl(file);
    return timPathParts(f) ? [
      `https://i.4cdn.org/${board}/${f}`,
      `https://images.4chan.org/${board}/${f}`,
      `http://images.4chan.org/${board}/${f}`
    ] : [];
  }
  // Wayback Machine: try every known original URL format through archive.org.
  // The /web/2id_/ prefix returns the raw file from the nearest snapshot.
  function waybackCandidates(board, file) {
    const f = filenameFromUrl(file);
    if (!f || !timPathParts(f)) return [];
    const stem = f.replace(/\.[^.]+$/, '');
    return [
      `https://web.archive.org/web/2id_/https://i.4cdn.org/${board}/${f}`,
      `https://web.archive.org/web/2id_/http://i.4cdn.org/${board}/${f}`,
      `https://web.archive.org/web/2id_/https://images.4chan.org/${board}/src/${f}`,
      `https://web.archive.org/web/2id_/http://images.4chan.org/${board}/src/${f}`,
      `https://web.archive.org/web/2id_/https://i.4cdn.org/${board}/${stem}s.jpg`,
    ];
  }
  function waybackThumbCandidates(board, file) {
    const f = filenameFromUrl(file);
    if (!f || !timPathParts(f)) return [];
    const stem = f.replace(/\.[^.]+$/, '');
    return [
      `https://web.archive.org/web/2id_/https://i.4cdn.org/${board}/${stem}s.jpg`,
      `https://web.archive.org/web/2id_/http://i.4cdn.org/${board}/${stem}s.jpg`,
      `https://web.archive.org/web/2id_/https://images.4chan.org/${board}/thumb/${stem}s.jpg`,
      `https://web.archive.org/web/2id_/http://images.4chan.org/${board}/thumb/${stem}s.jpg`,
    ];
  }
  // Extra archives not in the FoolFuuka routing table but that still serve images.
  const EXTRA_IMAGE_ARCHIVES = {
    warosu: { host: 'fuuka.warosu.org/data', boards: ['jp', 'vr', 'g', 'ck', 'lit', 'sci', 'tg', 'ic', 'cgl', 'fa'] },
    fireden: { host: 'boards.fireden.net/data', boards: ['a', 'cm', 'ic', 'sci', 'tg', 'v', 'vg', 'y'] },
    rbt: { host: 'rbt.asia/data', boards: ['g', 'mu', 'cgl'] },
  };
  function extraArchiveCandidates(board, kind, file) {
    const f = filenameFromUrl(file);
    const parts = timPathParts(f);
    if (!f || !parts) return [];
    const out = [];
    for (const arc of Object.values(EXTRA_IMAGE_ARCHIVES)) {
      if (!arc.boards.includes(board)) continue;
      out.push(`https://${arc.host}/${board}/${kind}/${parts[0]}/${parts[1]}/${f}`);
    }
    return out;
  }
  function imageCandidates(url) {
    if (!url) return [];
    const m = url.match(/^https?:\/\/[^/]+\/(?:(?:files|media|boards|data|foolfuuka\/boards)\/)?([a-z0-9]+)\/(image|thumb)\/(\d+)\/(\d+)\/([^/?#]+)$/i);
    if (m) return uniq([url, ...mediaPathCandidates(m[1], m[2], m[3], m[4], m[5])]);
    const flat = url.match(/^https?:\/\/[^/]+\/([a-z0-9]+)\/(?:image|redirect)\/([^/?#]+)$/i);
    const parts = flat && flat[2].match(/^(\d{4})(\d{2})/);
    if (flat && parts) return uniq([url, ...mediaPathCandidates(flat[1], 'image', parts[1], parts[2], flat[2])]);
    return [url]; // unknown layout (e.g. 4plebs)
  }
  function thumbCandidatesFromFull(url) {
    const m = url && url.match(/^https?:\/\/[^/]+\/(?:(?:files|media|boards|data|foolfuuka\/boards)\/)?([a-z0-9]+)\/image\/(\d+)\/(\d+)\/([^/?#]+)$/i);
    if (m) {
      const stem = m[4].replace(/\.[^.]+$/, '');
      return mediaPathCandidates(m[1], 'thumb', m[2], m[3], `${stem}s.jpg`);
    }
    const flat = url && url.match(/^https?:\/\/[^/]+\/([a-z0-9]+)\/(?:image|redirect)\/([^/?#]+)$/i);
    const parts = flat && flat[2].match(/^(\d{4})(\d{2})/);
    if (!flat || !parts) return [];
    const stem = flat[2].replace(/\.[^.]+$/, '');
    return mediaPathCandidates(flat[1], 'thumb', parts[1], parts[2], `${stem}s.jpg`);
  }

  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  function firstSuccess(promises) {
    const live = promises.filter(Boolean);
    if (!live.length) return Promise.resolve(null);
    return new Promise((resolve) => {
      let pending = live.length, done = false;
      live.forEach((p) => Promise.resolve(p).then((r) => {
        if (done) return;
        if (r) { done = true; resolve(r); }
        else if (--pending === 0) resolve(null);
      }, () => {
        if (!done && --pending === 0) resolve(null);
      }));
    });
  }
  async function firstBlobBatch(urls) {
    return firstSuccess(urls.map((u) => gmBlobURL(u).then((b) => b ? { blob: b, url: u } : null)));
  }

  // Resolve to the first candidate that actually loads. Candidates are normally
  // probed in small parallel batches so one dead host cannot stall the whole
  // chain. /mlp/ before 2015 is stricter: batch size 1, with archive.org tried
  // before any mirror image host.
  async function firstBlob(urls) {
    const unique = uniq(urls);
    if (!unique.length) {
      mediaDebug('debug', 'candidate list empty');
      return null;
    }
    mediaDebug('debug', 'candidate list', { count: unique.length, urls: unique });
    const batchSize = mediaCandidateBatchSize();
    for (let i = 0; i < unique.length; i += batchSize) {
      const batch = unique.slice(i, i + batchSize);
      mediaDebug('debug', 'candidate batch', { start: i, size: batch.length, urls: batch });
      const found = await firstBlobBatch(batch);
      if (found) {
        mediaDebug('debug', 'candidate selected', { url: found.url, batchStart: i });
        return found;
      }
    }
    mediaDebug('warn', 'all candidates failed', { count: unique.length, urls: unique });
    return null;
  }

  const _postMediaCache = new Map();
  async function postArchiveMedia(board, num) {
    const key = `${board}:${num}`;
    if (_postMediaCache.has(key)) return _postMediaCache.get(key);
    const p = (async () => {
      const readOne = async (base) => {
        const url = `${base}/_/api/chan/post/?board=${board}&num=${num}`;
        let data;
        try { data = await gmJSON(url, 8000); }
        catch (e) { mediaDebug('warn', 'post API failed', { board, num, base, url, error: String(e && e.message || e) }); return null; }
        return mediaFromApi(data && data.media, base, board);
      };
      const usable = (m) => m && (m.thumb || m.full || mediaHashes(m).length);
      // Sequential everywhere: the first archive with usable media answers
      // for all of them — fanning out to every mirror tripled the request
      // count for zero extra information.
      const found = [];
      for (const base of mediaAPIsFor(board)) {
        const m = await readOne(base);
        if (!usable(m)) continue;
        found.push(m);
        mediaDebug('debug', 'post API media sequential hit', { board, num, base, hash: firstMediaHash([m]) });
        break;
      }
      const media = found.filter(usable);
      mediaDebug(media.length ? 'debug' : 'warn', 'post API media results', { board, num, count: media.length, media });
      if (!media.length) _postMediaCache.delete(key);
      return media;
    })();
    _postMediaCache.set(key, p);
    return p;
  }
  // Last-resort metadata sweep: every archive's post API in parallel. Only
  // runs when everything the sequential lookup produced turned out dead —
  // each archive deduplicates previews independently, so a mirror can know
  // a working path the first archive doesn't.
  const _postMediaAllCache = new Map();
  async function postArchiveMediaAll(board, num) {
    const key = `${board}:${num}:all`;
    if (_postMediaAllCache.has(key)) return _postMediaAllCache.get(key);
    const p = (async () => {
      const found = await Promise.all(mediaAPIsFor(board).map(async (base) => {
        const url = `${base}/_/api/chan/post/?board=${board}&num=${num}`;
        try {
          const data = await gmJSON(url, 8000);
          return mediaFromApi(data && data.media, base, board);
        } catch (e) { return null; }
      }));
      const media = found.filter((m) => m && (m.thumb || m.full || mediaHashes(m).length));
      mediaDebug(media.length ? 'debug' : 'warn', 'post API all-archives sweep', { board, num, count: media.length });
      return media;
    })();
    _postMediaAllCache.set(key, p);
    return p;
  }

  function normalizedHash(h) {
    return h ? String(h).replace(/=+$/, '').replace(/\//g, '_').replace(/\+/g, '-') : '';
  }
  // Compact MD5 for verifying image blobs against archive media_hash.
  const md5Binary = (() => {
    const k = [], s = [7,12,17,22,5,9,14,20,4,11,16,23,6,10,15,21];
    for (let i = 0; i < 64; i++) k[i] = (Math.abs(Math.sin(i + 1)) * 0x100000000) >>> 0;
    const r = (n, c) => (n << c) | (n >>> (32 - c));
    return (buf) => {
      const bytes = new Uint8Array(buf);
      const len = bytes.length;
      const padded = new Uint8Array((((len + 8) >>> 6) + 1) << 6);
      padded.set(bytes);
      padded[len] = 0x80;
      const dv = new DataView(padded.buffer);
      dv.setUint32(padded.length - 8, (len * 8) >>> 0, true);
      dv.setUint32(padded.length - 4, (len * 8) / 0x100000000 >>> 0, true);
      let a0 = 0x67452301, b0 = 0xEFCDAB89, c0 = 0x98BADCFE, d0 = 0x10325476;
      for (let off = 0; off < padded.length; off += 64) {
        const m = [];
        for (let j = 0; j < 16; j++) m[j] = dv.getUint32(off + j * 4, true);
        let a = a0, b = b0, c = c0, d = d0;
        for (let i = 0; i < 64; i++) {
          let f, g;
          if (i < 16) { f = (b & c) | (~b & d); g = i; }
          else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) % 16; }
          else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; }
          else { f = c ^ (b | ~d); g = (7 * i) % 16; }
          const tmp = d; d = c; c = b;
          b = (b + r((a + f + k[i] + m[g]) >>> 0, s[(i >>> 4) * 4 + (i % 4)])) >>> 0;
          a = tmp;
        }
        a0 = (a0 + a) >>> 0; b0 = (b0 + b) >>> 0; c0 = (c0 + c) >>> 0; d0 = (d0 + d) >>> 0;
      }
      const out = new Uint8Array(16);
      [a0, b0, c0, d0].forEach((v, i) => {
        out[i * 4] = v & 0xFF; out[i * 4 + 1] = (v >>> 8) & 0xFF;
        out[i * 4 + 2] = (v >>> 16) & 0xFF; out[i * 4 + 3] = (v >>> 24) & 0xFF;
      });
      return out;
    };
  })();
  function md5Base64(buf) {
    const bytes = md5Binary(buf);
    let s = '';
    for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
    return btoa(s);
  }
  async function verifyBlobHash(blobUrl, expectedHash) {
    if (!expectedHash || !blobUrl) return true;
    try {
      const res = await fetch(blobUrl);
      const buf = await res.arrayBuffer();
      const actual = md5Base64(buf);
      const match = normalizedHash(actual) === normalizedHash(expectedHash);
      if (!match) mediaDebug('warn', 'hash mismatch', { expected: expectedHash, actual });
      return match;
    } catch (e) {
      // Fail CLOSED: an unreadable blob (e.g. a revoked object URL) must not
      // pass as verified — failing open let dead URLs render as images.
      mediaDebug('warn', 'hash verification unreadable, rejecting', { error: String(e && e.message || e) });
      return false;
    }
  }
  function mediaHashes(m) {
    return uniq([
      m && m.hash,
      m && m.rawHash,
      normalizedHash(m && m.rawHash),
      normalizedHash(m && m.hash)
    ].filter(validMediaHash));
  }
  function validMediaHash(hash) {
    const h = String(hash || '').trim();
    if (!h || /^\d+$/.test(h)) return false;
    const raw = h.replace(/-/g, '+').replace(/_/g, '/');
    const bare = raw.replace(/=+$/, '');
    return /^[A-Za-z0-9+/]{22}$/.test(bare) && (raw.length === 22 || /^[A-Za-z0-9+/]{22}==$/.test(raw));
  }
  function firstMediaHash(items) {
    for (const item of items || []) {
      for (const hash of mediaHashes(item)) return hash;
    }
    return '';
  }
  function cleanMediaName(n) {
    return filenameFromUrl(n).trim();
  }
  function canonicalMediaName(n) {
    return cleanMediaName(n).toLowerCase();
  }
  function mediaExtension(n) {
    const m = canonicalMediaName(n).match(/\.([a-z0-9]{1,8})$/i);
    return m ? m[1] : '';
  }
  function mediaStem(n) {
    return canonicalMediaName(n).replace(/\.[^.]*$/, '');
  }
  function isTimMediaName(n) {
    return /^\d{6,}\.[a-z0-9]{1,8}$/i.test(canonicalMediaName(n));
  }
  function commonMediaStem(stem) {
    return /^(?:image|default|download|file|untitled|unknown|thumbnail|thumb|preview|photo|picture|pic|img|screenshot|screen shot|noimage|no image|missing|blank|avatar|media)(?:[\s._-]*\(?\d{1,4}\)?)?$/i.test(stem);
  }
  function distinctiveMediaName(n) {
    const name = canonicalMediaName(n);
    const ext = mediaExtension(name);
    if (!name || !ext) return false;
    if (isTimMediaName(name)) return true;
    const stem = mediaStem(name).trim();
    const compact = stem.replace(/[\s._-]+/g, '');
    if (!compact || commonMediaStem(stem) || commonMediaStem(compact)) return false;
    if (/^(?:\d{1,5}|[a-f0-9]{1,7})$/i.test(compact)) return false;
    return compact.length >= 6;
  }
  function distinctiveMediaNames(m) {
    const seen = new Set();
    const out = [];
    for (const n of mediaNames(m).map(cleanMediaName).filter(Boolean)) {
      const key = canonicalMediaName(n);
      if (!distinctiveMediaName(n) || seen.has(key)) continue;
      seen.add(key);
      out.push(n);
    }
    return out;
  }
  function mediaDimensions(m) {
    const w = Number(m && m.mediaW);
    const h = Number(m && m.mediaH);
    if (w > 0 && h > 0) return `${w}x${h}`;
    const text = String((m && m.meta) || '');
    const d = text.match(/(?:^|[^\d])(\d{1,5})\s*x\s*(\d{1,5})(?:[^\d]|$)/i);
    return d ? `${Number(d[1])}x${Number(d[2])}` : '';
  }
  function mediaByteSize(m) {
    const exact = Number(m && m.mediaSize);
    if (exact > 0) return exact;
    const text = String((m && m.meta) || '');
    const s = text.match(/([\d.]+)\s*(bytes?|b|kib|kb|mib|mb|gib|gb)\b/i);
    if (!s) return 0;
    const n = Number(s[1]);
    if (!(n > 0)) return 0;
    const unit = s[2].toLowerCase();
    const mult = unit.startsWith('g') ? 1024 * 1024 * 1024 :
      unit.startsWith('m') ? 1024 * 1024 :
      unit.startsWith('k') ? 1024 : 1;
    return Math.round(n * mult);
  }
  function mediaSizesAgree(a, b) {
    const diff = Math.abs(a - b);
    return diff <= Math.max(2048, Math.round(Math.max(a, b) * 0.02));
  }
  function mediaMetadataAgrees(a, b) {
    const ad = mediaDimensions(a), bd = mediaDimensions(b);
    const as = mediaByteSize(a), bs = mediaByteSize(b);
    let checks = 0;
    if (ad && bd) {
      if (ad !== bd) return false;
      checks++;
    }
    if (as && bs) {
      if (!mediaSizesAgree(as, bs)) return false;
      checks++;
    }
    return checks > 0;
  }
  function mediaNameMatches(seed, candidate) {
    const candidateNames = new Set(mediaNames(candidate).map(canonicalMediaName).filter(Boolean));
    if (!candidateNames.size) return false;
    for (const seedName of mediaNames(seed)) {
      const name = canonicalMediaName(seedName);
      if (!name || !candidateNames.has(name)) continue;
      if (distinctiveMediaName(name)) return true;
      if (mediaMetadataAgrees(seed, candidate)) return true;
    }
    return false;
  }
  function mediaName(m) {
    return (m && (m.fname || m.mediaFilenameProcessed || m.mediaFilename ||
      filenameFromUrl(m.full) || filenameFromUrl(m.mediaLink) ||
      filenameFromUrl(m.remoteMediaLink) || filenameFromUrl(m.thumb))) || '';
  }
  function mediaNames(m) {
    return uniq([
      m && m.fname,
      m && m.mediaFilename,
      m && m.mediaFilenameProcessed,
      m && m.archiveMedia,
      m && m.mediaOrig,
      m && m.previewOrig,
      m && m.previewOp,
      m && m.previewReply,
      m && filenameFromUrl(m.full),
      m && filenameFromUrl(m.mediaLink),
      m && filenameFromUrl(m.remoteMediaLink),
      m && filenameFromUrl(m.thumb),
      m && filenameFromUrl(m.thumbLink)
    ].filter(Boolean));
  }
  function searchPosts(data) {
    if (!data || data.error) return [];
    if (Array.isArray(data.posts)) return data.posts;
    const out = [];
    for (const v of Object.values(data)) {
      if (v && Array.isArray(v.posts)) out.push(...v.posts);
      else if (v && v.media) out.push(v);
    }
    return out;
  }
  function matchingSearchMedia(data, base, seeds, board) {
    const hashes = new Set(seeds.flatMap(mediaHashes).map(normalizedHash).filter(Boolean));
    const out = [];
    for (const post of searchPosts(data)) {
      const m = mediaFromApi(post.media, base, board || (post.board && post.board.shortname) || '');
      if (!m || (!m.thumb && !m.full)) continue;
      const hashMatch = hashes.size && mediaHashes(m).some((h) => hashes.has(normalizedHash(h)));
      const nameMatch = seeds.some((seed) => mediaNameMatches(seed, m));
      if (hashMatch || nameMatch) out.push(m);
    }
    return out;
  }

  const _searchMediaCache = new Map();
  async function searchArchiveMedia(board, seeds) {
    // Query both the url-safe hash and the raw base64 md5: FoolFuuka matches the
    // exact stored hash, and which form an archive accepts varies, so we try both.
    const hashes = uniq(seeds.flatMap(mediaHashes).filter(Boolean));
    const names = uniq(seeds.flatMap(distinctiveMediaNames));
    if (!hashes.length && !names.length) return [];
    const key = `${board}:${hashes.slice(0, 2).join(',')}:${names.slice(0, 2).join(',')}`;
    if (_searchMediaCache.has(key)) return _searchMediaCache.get(key);
    const p = (async () => {
      const queries = [];
      for (const h of hashes.slice(0, 6)) queries.push(['image', h, false]);
      for (const n of names.slice(0, 8)) queries.push(['filename', n, false]);
      // Cross-board repost hunt (primary archive only, hash only): the same
      // bytes often resurfaced on a sibling board — /mlp/ images turn up on
      // /aco/, /trash/, /co/ — and a post-2014 repost is fully fetchable even
      // when the original-era copy is long dead. Filenames are too generic
      // to trust across boards; the hash match keeps this exact.
      if (hashes.length) queries.push(['image', hashes[0], true]);
      const found = await Promise.all(mediaAPIsFor(board).flatMap((base, baseIndex) => queries.map(async ([field, value, global]) => {
        if (global && baseIndex > 0) return [];
        const url = global
          ? `${base}/_/api/chan/search/?${field}=${encodeURIComponent(value)}`
          : `${base}/_/api/chan/search/?board=${board}&${field}=${encodeURIComponent(value)}`;
        let data;
        try { data = await gmJSON(url, 6000); }
        catch (e) { mediaDebug('warn', 'search API failed', { board, base, field, value, global, url, error: String(e && e.message || e) }); return []; }
        return matchingSearchMedia(data, base, seeds, board);
      })));
      const media = uniq(found.flat().filter((m) => m && (m.thumb || m.full)).map(JSON.stringify)).map(JSON.parse);
      mediaDebug(media.length ? 'debug' : 'warn', 'search API media results', { board, queries, count: media.length, media });
      if (!media.length) _searchMediaCache.delete(key);
      return media;
    })();
    _searchMediaCache.set(key, p);
    return p;
  }

  function addMediaUrlCandidates(out, url) {
    for (const u of imageCandidates(url)) out.push(u);
  }

  function mediaBoard(m) {
    return (m && m.board) || engine.board;
  }
  function mediaFullFiles(m) {
    return uniq([
      m && m.archiveMedia,
      m && filenameFromUrl(m.full),
      m && filenameFromUrl(m.mediaLink),
      m && filenameFromUrl(m.remoteMediaLink)
    ].filter(Boolean));
  }
  function mediaThumbFiles(m) {
    const fullThumbs = mediaFullFiles(m).map(thumbNameFromImage).filter(Boolean);
    return uniq([
      m && m.previewReply,
      m && m.previewOp,
      m && m.previewOrig,
      m && filenameFromUrl(m.thumb),
      m && filenameFromUrl(m.thumbLink),
      ...fullThumbs
    ].filter(Boolean));
  }
  // ── archive.org re-hosted /mlp/ images (heinessen, 2012-05 → 2014-11) ────
  // 635k full-size golden-era images re-hosted by month. Some early month
  // items expose loose files as well as the month zip; later populated months
  // are stored as one zip per month. archive.org serves them at:
  //   https://archive.org/download/4chan-mlp-archive-YYYY-MM/<file>          (loose months)
  //   https://archive.org/download/4chan-mlp-archive-YYYY-MM/YYYY-MM.zip/<file>
  // The companion md5-index.json maps FoolFuuka media_hash values to exact
  // archive paths. Use that for zip members; guessing missing zip members makes
  // archive.org's view_archive.php return noisy server-side unzip 503s.
  const IA_MLP_FIRST = Date.UTC(2012, 4, 1) / 1000;   // coverage start, 2012-05-01
  const IA_MLP_END = Date.UTC(2014, 11, 1) / 1000;    // coverage end (excl), 2014-12-01
  const IA_MLP_DIRECT_MONTHS = new Set(['2012-05', '2012-06']);
  function archiveOrgIndexHashKeys(hash) {
    const h = String(hash || '').trim();
    if (!validMediaHash(h)) return [];
    const raw = h.replace(/-/g, '+').replace(/_/g, '/');
    const bare = raw.replace(/=+$/, '');
    return uniq([bare, `${bare}==`]);
  }
  function archiveOrgIndexPathCandidates(path) {
    const m = String(path || '').match(/^(\d{4}-\d{2})\/([^/?#]+)$/);
    if (!m) return [];
    const ym = m[1], file = m[2];
    const directUrl = `https://archive.org/download/4chan-mlp-archive-${ym}/${file}`;
    const zipUrl = `https://archive.org/download/4chan-mlp-archive-${ym}/${ym}.zip/${file}`;
    return IA_MLP_DIRECT_MONTHS.has(ym) ? [directUrl, zipUrl] : [zipUrl];
  }
  async function archiveOrgIndexedMedia(board, media) {
    if (!mlpArchiveOrgFirstRequired(board) || !media || !media.length) return [];
    const hashKeys = uniq(media.flatMap(mediaHashes).flatMap(archiveOrgIndexHashKeys));
    if (!hashKeys.length) return [];
    const index = await loadArchiveOrgMlpIndex();
    if (!index) return [];
    const seenPaths = new Set();
    const out = [];
    for (const hash of hashKeys) {
      const path = index[hash];
      if (!path || seenPaths.has(path)) continue;
      const urls = archiveOrgIndexPathCandidates(path);
      if (!urls.length) continue;
      seenPaths.add(path);
      const file = filenameFromUrl(path);
      out.push({
        full: urls[0],
        fname: file,
        board,
        hash: normalizedHash(hash),
        rawHash: hash,
        sourceBase: 'archive.org-index',
        archiveOrgUrls: urls,
        archiveIndexPath: path
      });
      mediaDebug('debug', 'archive.org md5 index hit', { board, hash, path, urls });
    }
    if (!out.length) {
      mediaDebug('debug', 'archive.org md5 index miss', { board, hashCount: hashKeys.length, hashes: hashKeys.slice(0, 6) });
    }
    return out;
  }
  function archiveOrgDownloadCandidates(board, file) {
    if (!mlpArchiveOrgFirstRequired(board)) return [];
    const m = String(file || '').match(/^(\d{10,13})\.[a-z0-9]+$/i);
    if (!m) {
      mediaDebug('debug', 'archive.org candidate skipped', { board, file, reason: 'filename is not a 4chan timestamp media name' });
      return [];
    }
    const ts = Number(m[1].slice(0, 10));
    if (!(ts >= IA_MLP_FIRST && ts < IA_MLP_END)) {
      mediaDebug('debug', 'archive.org candidate skipped', {
        board, file, ts,
        reason: 'timestamp outside archive.org mlp rehost coverage',
        coverageStart: IA_MLP_FIRST,
        coverageEndExclusive: IA_MLP_END
      });
      return [];
    }
    const d = new Date(ts * 1000);
    const ym = `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}`;
    const urls = IA_MLP_DIRECT_MONTHS.has(ym)
      ? [`https://archive.org/download/4chan-mlp-archive-${ym}/${file}`]
      : [];
    if (!urls.length) {
      mediaDebug('debug', 'archive.org candidate skipped', {
        board, file, ts, ym,
        reason: 'zip member guesses require md5 index hit'
      });
      return [];
    }
    mediaDebug('debug', 'archive.org candidate added', {
      board, file, ts, ym,
      layout: 'direct guess',
      urls
    });
    return urls;
  }

  const MEDIA_URL_CAP = 24;
  function mediaUrlCandidates(m, kind) {
    if (!m) return [];
    const board = mediaBoard(m);
    const archiveOrgFirst = [], fast = [], slow = [];
    if (Array.isArray(m.archiveOrgUrls)) {
      for (const u of m.archiveOrgUrls) archiveOrgFirst.push(u);
    }
    if (kind === 'full') {
      for (const file of mediaFullFiles(m)) {
        for (const u of archiveOrgDownloadCandidates(board, file)) archiveOrgFirst.push(u);
      }
      for (const url of [m.full, m.mediaLink, m.remoteMediaLink]) addMediaUrlCandidates(fast, url);
      for (const file of mediaFullFiles(m)) {
        for (const u of mediaFilePathCandidates(board, 'image', file)) fast.push(u);
        for (const u of originalFourcdnCandidates(board, file)) fast.push(u);
        for (const u of extraArchiveCandidates(board, 'image', file)) slow.push(u);
        for (const u of waybackCandidates(board, file)) slow.push(u);
      }
      const candidates = uniq([...archiveOrgFirst, ...fast, ...slow]).slice(0, MEDIA_URL_CAP);
      if (board === 'mlp' || candidates.some((u) => /archive\.org/i.test(u))) {
        mediaDebug('debug', 'media URL candidates', {
          board,
          kind,
          count: candidates.length,
          archiveOrgFirst,
          archiveOrg: candidates.filter((u) => /archive\.org/i.test(u)),
          names: mediaNames(m),
          candidates
        });
      }
      return candidates;
    }

    for (const file of mediaFullFiles(m)) {
      for (const u of archiveOrgDownloadCandidates(board, file)) archiveOrgFirst.push(u);
    }
    for (const url of [m.thumb, m.thumbLink]) addMediaUrlCandidates(fast, url);
    for (const url of [m.full, m.mediaLink, m.remoteMediaLink]) {
      for (const u of thumbCandidatesFromFull(url)) fast.push(u);
    }
    for (const file of mediaThumbFiles(m)) {
      for (const u of mediaFilePathCandidates(board, 'thumb', file)) fast.push(u);
      for (const u of extraArchiveCandidates(board, 'thumb', file)) slow.push(u);
      for (const u of waybackThumbCandidates(board, file)) slow.push(u);
    }
    const candidates = uniq([...archiveOrgFirst, ...fast, ...slow]).slice(0, MEDIA_URL_CAP);
    if (board === 'mlp' || candidates.some((u) => /archive\.org/i.test(u))) {
      mediaDebug('debug', 'media URL candidates', {
        board,
        kind,
        count: candidates.length,
        archiveOrgFirst,
        archiveOrg: candidates.filter((u) => /archive\.org/i.test(u)),
        names: mediaNames(m),
        candidates
      });
    }
    return candidates;
  }

  function fullUrls(media) {
    const out = [];
    for (const m of media) for (const u of mediaUrlCandidates(m, 'full')) out.push(u);
    return uniq(out);
  }
  function thumbUrls(media) {
    const out = [];
    for (const m of media) for (const u of mediaUrlCandidates(m, 'thumb')) out.push(u);
    return uniq(out);
  }
  // A blob that failed verification must be evicted everywhere, not just
  // revoked: _blobCache would otherwise re-serve the dead object URL and the
  // persistent cache would re-serve the wrong bytes across sessions.
  function discardRejectedBlob(found) {
    URL.revokeObjectURL(found.blob);
    _blobCache.delete(found.url);
    if (mediaCacheAvailable()) {
      openMediaCache()
        .then((cache) => cache.delete(mediaCacheRequestUrl(found.url)))
        .catch(() => { /* best effort */ });
    }
  }
  async function firstVerifiedBlob(urls, expectedHash) {
    const unique = uniq(urls);
    mediaDebug('debug', 'verified candidate list', {
      count: unique.length,
      expectedHash,
      archiveOrg: unique.filter((u) => /archive\.org/i.test(u)),
      urls: unique
    });
    const batchSize = mediaCandidateBatchSize();
    for (let i = 0; i < unique.length; i += batchSize) {
      const batch = unique.slice(i, i + batchSize);
      mediaDebug('debug', 'verified candidate batch', { start: i, size: batch.length, expectedHash, urls: batch });
      const found = await firstBlobBatch(batch);
      if (!found) {
        mediaDebug('debug', 'verified candidate batch miss', { start: i, size: batch.length, expectedHash });
        continue;
      }
      if (!expectedHash || await verifyBlobHash(found.blob, expectedHash)) {
        mediaDebug('debug', 'verified candidate selected', { url: found.url, expectedHash });
        return found;
      }
      mediaDebug('warn', 'hash mismatch, skipping candidate', { url: found.url, expectedHash });
      discardRejectedBlob(found);
    }
    mediaDebug('warn', 'verified candidate list failed', { count: unique.length, expectedHash });
    return null;
  }
  async function firstFull(media, expectedHash) {
    const r = expectedHash
      ? await firstVerifiedBlob(fullUrls(media), expectedHash)
      : await firstBlob(fullUrls(media));
    return r ? { ...r, thumbFallback: false } : null;
  }
  async function firstThumb(media) {
    const r = await firstBlob(thumbUrls(media));
    return r ? { ...r, thumbFallback: true } : null;
  }
  function mediaUrlsMatching(urls, wantArchiveOrg) {
    return uniq(urls).filter((u) => archiveOrgMedia(u) === wantArchiveOrg);
  }
  async function firstFullMatching(media, expectedHash, wantArchiveOrg) {
    const urls = mediaUrlsMatching(fullUrls(media), wantArchiveOrg);
    const r = expectedHash ? await firstVerifiedBlob(urls, expectedHash) : await firstBlob(urls);
    return r ? { ...r, thumbFallback: false } : null;
  }
  async function firstThumbMatching(media, wantArchiveOrg) {
    const r = await firstBlob(mediaUrlsMatching(thumbUrls(media), wantArchiveOrg));
    return r ? { ...r, thumbFallback: true } : null;
  }
  async function firstArchiveOrgFull(board, media, expectedHash) {
    const indexed = await archiveOrgIndexedMedia(board, media);
    let r = await firstFull(indexed, expectedHash);
    if (r) return r;
    const urls = mediaUrlsMatching(fullUrls(media), true);
    const found = expectedHash ? await firstVerifiedBlob(urls, expectedHash) : await firstBlob(urls);
    return found ? { ...found, thumbFallback: false } : null;
  }
  const FOURCHAN_404_IMAGES = [
    'Angelguy.png',
    'Anonymous-2.jpg',
    'Anonymous-2.png',
    'Anonymous-3.jpg',
    'Anonymous-3.png',
    'Anonymous-4.png',
    'Anonymous-5.png',
    'Anonymous-6.png',
    'Anonymous-7.png',
    'Anonymous-8.png',
    'Anonymous.gif',
    'Anonymous.jpg',
    'Anonymous.png',
    'DanKim.gif',
    'Kobayen.png',
    'Ragathol.png',
    'anonymouse.png'
  ];
  const MLP_MISSING_IMAGE_PLACEHOLDERS = [
    'https://derpicdn.net/img/view/2025/4/21/3591172.png',
    'https://derpicdn.net/img/view/2014/7/22/681028.png',
    'https://derpicdn.net/img/2016/7/10/1197996/large.png',
    'https://derpicdn.net/img/2025/5/4/3599187/full.png',
    'https://derpicdn.net/img/view/2022/5/22/2870238.png',
    'https://derpicdn.net/img/view/2019/3/1/1974289.jpg',
    'https://derpicdn.net/img/view/2019/11/29/2208464.jpg'
  ];
  function stringHash(s) {
    let h = 2166136261;
    for (let i = 0; i < s.length; i++) {
      h ^= s.charCodeAt(i);
      h = Math.imul(h, 16777619);
    }
    return h >>> 0;
  }
  function fourChan404Url(seed) {
    const files = FOURCHAN_404_IMAGES;
    const file = files[stringHash(seed || String(Math.random())) % files.length];
    return `https://s.4cdn.org/image/error/404/404-${file}`;
  }
  function missingImagePlaceholderUrl(board, seed) {
    if (board === 'mlp') {
      const urls = MLP_MISSING_IMAGE_PLACEHOLDERS;
      return urls[stringHash(seed || String(Math.random())) % urls.length];
    }
    return fourChan404Url(seed);
  }
  async function missingImagePlaceholderBlob(board, seed) {
    const url = missingImagePlaceholderUrl(board, seed);
    const blob = await gmBlobURL(url);
    return blob ? { blob, url, placeholder: true } : null;
  }
  // Convert base64 MD5 (from archive media_hash) to hex for booru lookups.
  function md5Base64ToHex(b64) {
    if (!b64) return '';
    try {
      const raw = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
      let hex = '';
      for (let i = 0; i < raw.length; i++) hex += raw.charCodeAt(i).toString(16).padStart(2, '0');
      return hex;
    } catch (e) { return ''; }
  }
  // Last-resort: search booru sites by MD5 hash. Many 4chan images end up on
  // boorus with the original hash intact.
  const BOORU_BOARDS = new Set(['a', 'aco', 'c', 'cm', 'co', 'd', 'e', 'gif', 'h', 'ic', 'mlp', 'trash', 'u', 'w', 'wg', 'wsr', 'y']);
  // Exact-MD5 lookups on booru sites: the same bytes the post had, recovered
  // from wherever else they were uploaded. Order is board-aware — pony
  // content lives on e621, anime boards on the danbooru family.
  function booruMd5Endpoints(board) {
    const e621 = { name: 'e621', url: (hex) => `https://e621.net/posts.json?tags=md5%3A${hex}`,
      parse: (d) => d && d.posts && d.posts[0] && d.posts[0].file && d.posts[0].file.url };
    const danbooru = { name: 'danbooru', url: (hex) => `https://danbooru.donmai.us/posts.json?tags=md5%3A${hex}&limit=1`,
      parse: (d) => Array.isArray(d) && d[0] && (d[0].file_url || d[0].large_file_url) };
    const yandere = { name: 'yande.re', url: (hex) => `https://yande.re/post.json?tags=md5:${hex}&limit=1`,
      parse: (d) => Array.isArray(d) && d[0] && d[0].file_url };
    const konachan = { name: 'konachan', url: (hex) => `https://konachan.com/post.json?tags=md5:${hex}&limit=1`,
      parse: (d) => Array.isArray(d) && d[0] && d[0].file_url };
    const safebooru = { name: 'safebooru', url: (hex) => `https://safebooru.org/index.php?page=dapi&s=post&q=index&json=1&tags=md5:${hex}&limit=1`,
      parse: (d) => {
        const p = (Array.isArray(d) ? d : (d && d.post) || [])[0];
        if (!p) return null;
        return p.image ? `https://safebooru.org/images/${p.directory || ''}/${p.image}` : null;
      } };
    if (board === 'mlp') return [e621, danbooru, safebooru];
    if (board === 'aco' || board === 'trash' || board === 'gif' || board === 'd') return [e621, danbooru, yandere, konachan, safebooru];
    return [danbooru, yandere, konachan, safebooru, e621];
  }
  async function booruMd5Search(hash, board = engine.board) {
    const hex = md5Base64ToHex(hash);
    if (!hex || hex.length !== 32) return null;
    for (const ep of booruMd5Endpoints(board)) {
      try {
        const data = await gmJSON(ep.url(hex), 8000);
        const fileUrl = ep.parse(data);
        if (fileUrl) {
          mediaDebug('debug', 'booru md5 hit', { booru: ep.name, hex, fileUrl });
          const blob = await gmBlobURL(fileUrl);
          if (blob) return { blob, url: fileUrl, thumbFallback: false };
        }
      } catch (e) { /* booru unavailable, skip */ }
    }
    return null;
  }
  async function resolvePostMediaBlob(p, kind) {
    if (!p || !p.num) return null;
    const ctx = { board: engine.board, num: p.num, kind };
    const local = p.media ? [p.media] : [];
    const expectedHash = firstMediaHash(local);
    mediaDebug('debug', 'resolve start', {
      ...ctx,
      expectedHash,
      local: local.map((m) => ({ full: m && m.full, thumb: m && m.thumb, fname: m && m.fname, hash: m && m.hash }))
    });
    const apiP = postArchiveMedia(engine.board, p.num);

    if (kind === 'thumb') {
      const indexedLocal = await archiveOrgIndexedMedia(engine.board, local);
      let r = await firstFull(indexedLocal, expectedHash);
      if (r) {
        mediaDebug('debug', 'resolve selected thumb archive.org index local', { ...ctx, url: r.url });
        return { ...r, thumbFallback: false };
      }
      r = await firstBlob(thumbUrls(local));
      if (r) {
        mediaDebug('debug', 'resolve selected thumb local', { ...ctx, url: r.url });
        return { ...r, thumbFallback: false };
      }
      const api = await apiP;
      const apiHash = expectedHash || firstMediaHash(api);
      mediaDebug(api.length ? 'debug' : 'warn', 'resolve thumb post API candidates', { ...ctx, count: api.length });
      const indexedApi = await archiveOrgIndexedMedia(engine.board, [...local, ...api]);
      r = await firstFull(indexedApi, apiHash);
      if (r) {
        mediaDebug('debug', 'resolve selected thumb archive.org index post API', { ...ctx, url: r.url });
        return { ...r, thumbFallback: false };
      }
      r = await firstBlob(thumbUrls(api));
      if (r) {
        mediaDebug('debug', 'resolve selected thumb post API', { ...ctx, url: r.url });
        return { ...r, thumbFallback: false };
      }
      const searched = await searchArchiveMedia(engine.board, [...local, ...api]);
      mediaDebug(searched.length ? 'debug' : 'warn', 'resolve thumb search candidates', { ...ctx, count: searched.length });
      const indexedSearched = await archiveOrgIndexedMedia(engine.board, [...local, ...api, ...searched]);
      r = await firstFull(indexedSearched, apiHash || expectedHash);
      if (r) {
        mediaDebug('debug', 'resolve selected thumb archive.org index search', { ...ctx, url: r.url });
        return { ...r, thumbFallback: false };
      }
      r = await firstBlob(thumbUrls(searched));
      if (r) {
        mediaDebug('debug', 'resolve selected thumb search', { ...ctx, url: r.url });
        return { ...r, thumbFallback: false };
      }
      // Everything from the first archive is dead — sweep every archive's
      // post API for independently-deduplicated paths before giving up.
      const apiAll = await postArchiveMediaAll(engine.board, p.num);
      if (apiAll.length) {
        r = await firstBlob(thumbUrls(apiAll));
        if (r) {
          mediaDebug('debug', 'resolve selected thumb all-archives sweep', { ...ctx, url: r.url });
          return { ...r, thumbFallback: false };
        }
      }
      const fullHash = apiHash || expectedHash;
      r = fullHash
        ? await firstVerifiedBlob(fullUrls([...local, ...api, ...searched]), fullHash)
        : await firstBlob(fullUrls([...local, ...api, ...searched]));
      if (r) {
        mediaDebug('debug', 'resolve selected thumb full-fallback', { ...ctx, url: r.url });
        return { ...r, thumbFallback: false };
      }
      mediaDebug('warn', 'resolve miss', ctx);
      return null;
    }

    const fullHash = expectedHash;
    const indexedLocal = await archiveOrgIndexedMedia(engine.board, local);
    let r = await firstFull(indexedLocal, fullHash);
    if (r) {
      mediaDebug('debug', 'resolve selected full archive.org index local', { ...ctx, url: r.url });
      return r;
    }
    r = await firstFull(local, fullHash);
    if (r) {
      mediaDebug('debug', 'resolve selected full local', { ...ctx, url: r.url });
      return r;
    }
    const api = await apiP;
    const apiHash = fullHash || firstMediaHash(api);
    mediaDebug(api.length ? 'debug' : 'warn', 'resolve full post API candidates', { ...ctx, count: api.length });
    const indexedApi = await archiveOrgIndexedMedia(engine.board, [...local, ...api]);
    r = await firstFull(indexedApi, apiHash);
    if (r) {
      mediaDebug('debug', 'resolve selected full archive.org index post API', { ...ctx, url: r.url });
      return r;
    }
    r = await firstFull(api, apiHash);
    if (r) {
      mediaDebug('debug', 'resolve selected full post API', { ...ctx, url: r.url });
      return r;
    }
    const searched = await searchArchiveMedia(engine.board, [...local, ...api]);
    mediaDebug(searched.length ? 'debug' : 'warn', 'resolve full search candidates', { ...ctx, count: searched.length });
    const indexedSearched = await archiveOrgIndexedMedia(engine.board, [...local, ...api, ...searched]);
    r = await firstFull(indexedSearched, apiHash);
    if (r) {
      mediaDebug('debug', 'resolve selected full archive.org index search', { ...ctx, url: r.url });
      return r;
    }
    r = await firstFull(searched, apiHash);
    if (r) {
      mediaDebug('debug', 'resolve selected full search', { ...ctx, url: r.url });
      return r;
    }

    // Everything from the first archive is dead — sweep every archive's
    // post API for independently-deduplicated paths before falling back.
    const apiAll = await postArchiveMediaAll(engine.board, p.num);
    if (apiAll.length) {
      const indexedAll = await archiveOrgIndexedMedia(engine.board, [...local, ...api, ...apiAll]);
      r = await firstFull(indexedAll, apiHash || firstMediaHash(apiAll));
      if (r) {
        mediaDebug('debug', 'resolve selected full archive.org index all-archives', { ...ctx, url: r.url });
        return r;
      }
      r = await firstFull(apiAll, apiHash || firstMediaHash(apiAll));
      if (r) {
        mediaDebug('debug', 'resolve selected full all-archives sweep', { ...ctx, url: r.url });
        return r;
      }
    }

    const all = [...local, ...api, ...searched, ...apiAll];
    // Booru MD5 recovery comes BEFORE the thumb fallback: the exact original
    // bytes from another site beat a 125px thumbnail every time.
    if (BOORU_BOARDS.has(engine.board) && (apiHash || expectedHash)) {
      r = await booruMd5Search(apiHash || expectedHash);
      if (r) {
        mediaDebug('debug', 'resolve selected booru md5 match', { ...ctx, url: r.url });
        return r;
      }
    }
    r = await firstThumb(all);
    if (r) {
      mediaDebug('warn', 'resolve selected thumb fallback for full', { ...ctx, url: r.url });
      return r;
    }
    mediaDebug('warn', 'resolve miss', ctx);
    return null;
  }

  const _postBlobCache = new Map();
  const _postBlobResultCache = new Map();
  function postMediaCacheKey(p, kind) {
    return p && p.num ? `${engine.board}:${p.num}:${kind}` : '';
  }
  function cachedPostMediaResult(p, kind) {
    const key = postMediaCacheKey(p, kind);
    return key ? _postBlobResultCache.get(key) || null : null;
  }
  async function postMediaBlob(p, kind) {
    if (!p || !p.num) return null;
    const key = postMediaCacheKey(p, kind);
    if (p.media && p.media.localDataURL) {
      const r = { blob: p.media.localDataURL, url: p.media.localDataURL, local: true };
      if (key) _postBlobResultCache.set(key, r);
      return r;
    }
    if (_postBlobResultCache.has(key)) return _postBlobResultCache.get(key);
    if (_postBlobCache.has(key)) {
      mediaDebug('debug', 'blob cache hit', { board: engine.board, num: p.num, kind, key });
      return _postBlobCache.get(key);
    }
    const resolvedKey = mediaResolveCacheKey(engine.board, p.num, kind);
    const cachedResolved = cacheGet(resolvedKey);
    if (cachedResolved && cachedResolved.url) {
      mediaDebug('debug', 'cached resolved URL check', {
        board: engine.board,
        num: p.num,
        kind,
        resolvedKey,
        url: cachedResolved.url,
        thumbFallback: !!cachedResolved.thumbFallback
      });
      const cachedPromise = gmBlobURL(cachedResolved.url).then((blob) => {
        if (blob) {
          const r = {
            blob,
            url: cachedResolved.url,
            thumbFallback: !!cachedResolved.thumbFallback
          };
          mediaDebug('debug', 'cached resolved URL ok', {
            board: engine.board,
            num: p.num,
            kind,
            resolvedKey,
            url: cachedResolved.url
          });
          _postBlobResultCache.set(key, r);
          return r;
        }
        mediaDebug('warn', 'cached resolved URL failed, invalidating', {
          board: engine.board,
          num: p.num,
          kind,
          resolvedKey,
          url: cachedResolved.url
        });
        cacheDelete(resolvedKey);
        _postBlobCache.delete(key);
        _postBlobResultCache.delete(key);
        return postMediaBlob(p, kind);
      });
      _postBlobCache.set(key, cachedPromise);
      return cachedPromise;
    }
    if (cachedResolved && cachedResolved.miss && Date.now() - (cachedResolved.cachedAt || 0) < CONFIG.mediaMissCacheMs) {
      mediaDebug('debug', 'cached media miss', {
        board: engine.board,
        num: p.num,
        kind,
        resolvedKey,
        ageMs: Date.now() - (cachedResolved.cachedAt || 0)
      });
      return Promise.resolve(null);
    }
    if (cachedResolved && cachedResolved.miss) {
      mediaDebug('debug', 'expired cached media miss, retrying', {
        board: engine.board,
        num: p.num,
        kind,
        resolvedKey,
        ageMs: Date.now() - (cachedResolved.cachedAt || 0)
      });
      cacheDelete(resolvedKey);
    }
    const promise = resolvePostMediaBlob(p, kind).then((r) => {
      if (!r || (kind === 'full' && r.thumbFallback)) _postBlobCache.delete(key);
      if (r && !(kind === 'full' && r.thumbFallback)) {
        _postBlobResultCache.set(key, r);
      } else if (!r) {
        // Session miss memo: a resolve that found nothing must NOT re-run on
        // every repaint (each run is API calls + searches — the traffic that
        // gets us rate limited). Memoize null for this session; a reload or
        // the persistent miss entry below governs retrying later.
        _postBlobResultCache.set(key, null);
      } else {
        _postBlobResultCache.delete(key); // thumb fallback: retry for the full image later
      }
      if (r && r.url && !(kind === 'full' && r.thumbFallback)) {
        cacheSet(resolvedKey, {
          url: r.url,
          thumbFallback: !!r.thumbFallback,
          cachedAt: Date.now()
        });
      } else if (!r && !networkDisturbed()) {
        // Persist the miss only when the network was healthy — a null during
        // a rate-limit storm or host outage would blank the image for 24h.
        cacheSet(resolvedKey, { miss: true, cachedAt: Date.now() });
      }
      mediaDebug(r ? 'debug' : 'warn', r ? 'blob resolve ok' : 'blob resolve failed', {
        board: engine.board,
        num: p.num,
        kind,
        key,
        result: r && { url: r.url, thumbFallback: !!r.thumbFallback }
      });
      return r;
    });
    _postBlobCache.set(key, promise);
    return promise;
  }

  // ── Cache (GM storage, JSON only) ──────────────────────────────────────────
  function cacheDebug(level, msg, data = {}) {
    if (!CONFIG.cacheDebug) return;
    const fn = level === 'warn' ? console.warn : console.debug;
    try { fn.call(console, `[oldchan cache] ${msg}`, data); } catch (e) { /* console unavailable */ }
  }
  const cacheGet = (k) => {
    try { return JSON.parse(GM_getValue(k, 'null')); }
    catch (e) { cacheDebug('warn', 'cache read failed', { key: k, error: String(e && e.message || e) }); return null; }
  };
  function cacheKeys() {
    try { return typeof GM_listValues === 'function' ? GM_listValues() : []; }
    catch (e) { cacheDebug('warn', 'cache key listing failed', { error: String(e && e.message || e) }); return []; }
  }
  function cacheDelete(k) {
    try {
      GM_deleteValue(k);
      _storageBytes.delete(k);
      return true;
    } catch (e) { cacheDebug('warn', 'cache delete failed', { key: k, error: String(e && e.message || e) }); return false; }
  }
  const CACHE_MAX_BYTES = 8 * 1024 * 1024;
  const CACHE_HARD_CEILING = 48 * 1024 * 1024; // absolute max — 64MiB messaging limit is fatal
  const CACHE_PROTECTED_KEYS = new Set(['settings', 'clockAnchor', 'postIdentity:v1']);
  const _storageBytes = new Map();
  let _storageTotalBytes = 0;
  let _storageScanDone = false;
  function initStorageEstimate() {
    try {
      const keys = cacheKeys();
      let total = 0;
      for (const k of keys) {
        let sz = 0;
        try { sz = String(GM_getValue(k, '')).length; } catch (e) { sz = 500; }
        _storageBytes.set(k, sz);
        total += sz;
      }
      _storageTotalBytes = total;
      _storageScanDone = true;
    } catch (e) {
      _storageTotalBytes = CACHE_HARD_CEILING;
      _storageScanDone = false;
    }
  }
  // Eviction order when the budget fills: bulky re-derivable page caches go
  // first; media resolve results go LAST — each ~100-byte entry replaces an
  // entire multi-request image re-search, so sweeping them out (the old
  // oldest-first policy) made every refresh re-hunt images it had found.
  function pruneClassRank(k) {
    if (k.startsWith('idxp:') || k.startsWith('actp:')) return 0; // paged search HTML results
    if (k.startsWith('idx:') || k.startsWith('catalog:')) return 1; // day/catalog enumerations
    if (k.startsWith('thrs:')) return 2; // thread summaries (rebuilt from thread cache)
    if (k.startsWith('media:')) return 4; // resolve results — most expensive to recreate
    return 3;
  }
  function pruneStorage(exceptKey, targetBytes = CACHE_MAX_BYTES) {
    const keys = cacheKeys().filter((k) => !CACHE_PROTECTED_KEYS.has(k) && k !== exceptKey);
    if (!keys.length) return 0;
    let canRead = true;
    const scored = keys.map((k) => {
      let cachedAt = 0, size = _storageBytes.get(k) || 0;
      if (!size) {
        try {
          const raw = String(GM_getValue(k, ''));
          size = raw.length;
          _storageBytes.set(k, size);
          const m = raw.match(/"cachedAt"\s*:\s*(\d+)/);
          cachedAt = m ? Number(m[1]) : 0;
        } catch (e) { canRead = false; size = 500; }
      } else {
        try {
          const raw = String(GM_getValue(k, ''));
          const m = raw.match(/"cachedAt"\s*:\s*(\d+)/);
          cachedAt = m ? Number(m[1]) : 0;
        } catch (e) { canRead = false; }
      }
      return { key: k, cachedAt, size };
    }).sort((a, b) => pruneClassRank(a.key) - pruneClassRank(b.key) || a.cachedAt - b.cachedAt);
    if (!canRead) {
      let deleted = 0;
      for (const item of scored) { if (cacheDelete(item.key)) deleted++; }
      _storageTotalBytes = 0;
      return deleted;
    }
    let total = scored.reduce((s, e) => s + e.size, 0);
    _storageTotalBytes = total;
    let deleted = 0;
    for (const item of scored) {
      if (total <= targetBytes) break;
      if (cacheDelete(item.key)) { total -= item.size; deleted++; }
    }
    _storageTotalBytes = total;
    return deleted;
  }
  function cacheSet(k, v) {
    const text = JSON.stringify(v);
    const oldSize = _storageBytes.get(k) || 0;
    const newTotal = _storageTotalBytes - oldSize + text.length;
    if (newTotal > CACHE_HARD_CEILING) return false;
    if (newTotal > CACHE_MAX_BYTES) {
      pruneStorage(k, CACHE_MAX_BYTES * 0.6);
    }
    try {
      GM_setValue(k, text);
      _storageBytes.set(k, text.length);
      _storageTotalBytes = _storageTotalBytes - oldSize + text.length;
      return true;
    } catch (e) {
      cacheDebug('warn', 'cache write failed', { key: k, bytes: text.length, error: String(e && e.message || e) });
      return false;
    }
  }
  const indexCacheKey = (board, date) => `idx:v8:${board}:${date}`;
  // v10: v9 catalogs were enumerated with 6-page day sampling and wrongly
  // marked complete — they're missing mid-day-active threads.
  const catalogCacheKey = (board, date) =>
    `catalog:v10:${board}:${date}:active${Math.max(boardThreadCapacity(board), CONFIG.catalogActivityThreadTarget || 0)}:d${CONFIG.catalogActivityMaxDays}`;
  function cachedCatalogOps(board, date) {
    const cached = cacheGet(catalogCacheKey(board, date));
    const ops = Array.isArray(cached) ? cached : (cached && Array.isArray(cached.ops) ? cached.ops : null);
    return ops && ops.length ? mergeLocalCatalogOps(ops, board, date) : [];
  }
  const indexPageCacheKey = (board, date, base, page) =>
    `idxp:v1:${board}:${date}:${base.replace(/^https?:\/\//, '').replace(/[^a-z0-9]+/gi, '_')}:${page}`;
  const activityPageCacheKey = (board, date, base, page) =>
    `actp:v1:${board}:${date}:${base.replace(/^https?:\/\//, '').replace(/[^a-z0-9]+/gi, '_')}:${page}`;
  const threadCacheKey = (board, num) => `thr:v5:${board}:${num}`;
  const threadSummaryCacheKey = (board, num) => `thrs:v1:${board}:${num}`;
  // v12: archive.org-only mode removed — v11 entries hold misses recorded
  // while every non-archive.org source was deliberately disabled.
  const mediaResolveCacheKey = (board, num, kind) => `media:v13:${board}:${num}:${kind}`;
  const localPostCacheKey = (board) => `localposts:v1:${board}`;
  const postIdentityCacheKey = () => 'postIdentity:v1';

  const LOCAL_POST_NUM_BASE = 9000000000000;
  function emptyLocalPostStore() {
    return { nextNum: LOCAL_POST_NUM_BASE, posts: [] };
  }
  function validLocalPost(p) {
    return !!(p && p.num && p.threadNum && p.board && typeof p.ts === 'number');
  }
  function localPostStore(board = engine.board) {
    const store = cacheGet(localPostCacheKey(board)) || emptyLocalPostStore();
    const posts = Array.isArray(store.posts) ? store.posts.filter(validLocalPost) : [];
    return {
      nextNum: Math.max(LOCAL_POST_NUM_BASE, Number(store.nextNum) || LOCAL_POST_NUM_BASE),
      posts
    };
  }
  function saveLocalPostStore(board, store) {
    return cacheSet(localPostCacheKey(board), {
      nextNum: Math.max(LOCAL_POST_NUM_BASE, Number(store.nextNum) || LOCAL_POST_NUM_BASE),
      posts: Array.isArray(store.posts) ? store.posts.filter(validLocalPost) : []
    });
  }
  function nextLocalPostNum(store) {
    const used = new Set((store.posts || []).map((p) => String(p.num)));
    let n = Math.max(LOCAL_POST_NUM_BASE, Number(store.nextNum) || LOCAL_POST_NUM_BASE);
    while (used.has(String(n))) n++;
    store.nextNum = n + 1;
    return String(n);
  }
  function localPostsForBoard(board = engine.board) {
    return localPostStore(board).posts.slice().sort((a, b) => a.ts - b.ts || Number(a.num) - Number(b.num));
  }
  function localPostsForThread(board, num) {
    const threadNum = String(num);
    return localPostsForBoard(board).filter((p) => String(p.threadNum) === threadNum);
  }
  function localOpsForDate(board, date) {
    return localPostsForBoard(board).filter((p) => p.op && p.date === date);
  }
  function mergeLocalOps(ops, board, date) {
    return sortedUniqueOps([...(ops || []), ...localOpsForDate(board, date)]);
  }
  function mergeLocalCatalogOps(ops, board, date) {
    const all = [...(ops || [])];
    const snapshotTs = replayEndTs(date);
    all.push(...localPostsForBoard(board).filter((p) => p.op && p.ts <= snapshotTs));
    return sortedUniqueOps(all);
  }
  function mergeThreadPosts(archivePosts, localPosts) {
    const byNum = new Map();
    for (const p of archivePosts || []) if (p && p.num) byNum.set(String(p.num), p);
    for (const p of localPosts || []) if (p && p.num) byNum.set(String(p.num), p);
    return Array.from(byNum.values()).sort((a, b) =>
      (b.op ? 1 : 0) - (a.op ? 1 : 0) || a.ts - b.ts || Number(a.num) - Number(b.num));
  }
  function mergeLocalThreadResult(board, num, result) {
    const locals = localPostsForThread(board, num);
    if (!locals.length) return result;
    const archivePosts = validThreadResult(result) ? result.posts : [];
    const posts = mergeThreadPosts(archivePosts, locals);
    if (!posts.length || !posts[0].op) return result;
    return {
      ...(result && typeof result === 'object' ? result : {}),
      posts,
      source: result && result.source ? `${result.source}+local` : 'local'
    };
  }
  function summaryWithLocalPosts(board, op, summary) {
    if (!op || !op.num) return summary;
    if (summary && summary.localApplied) return summary;
    const locals = localPostsForThread(board, op.num);
    if (!locals.length) return summary;
    const localOp = locals.find((p) => p.op);
    if (localOp) {
      const localSummary = threadSummaryFromPosts(mergeThreadPosts(summary ? [op] : [], locals));
      return localSummary ? { ...localSummary, localApplied: true } : summary;
    }
    const replies = locals.filter((p) => !p.op);
    if (!replies.length) return summary;
    const base = summary || {
      num: String(op.num),
      opTs: op.ts,
      bump: op.ts,
      sticky: !!op.sticky,
      deleted: !!op.deleted,
      expiredTs: op.expiredTs || 0,
      lastTs: op.ts,
      replyCount: 0,
      imageCount: postHasMedia(op) ? 1 : 0,
      omittedImages: 0,
      cachedAt: Date.now()
    };
    let bump = base.bump;
    for (const p of replies) if (!isSagePost(p)) bump = Math.max(bump, p.ts);
    return {
      ...base,
      bump,
      lastTs: Math.max(base.lastTs || op.ts, ...replies.map((p) => p.ts)),
      replyCount: (base.replyCount || 0) + replies.length,
      imageCount: (base.imageCount || 0) + replies.filter(postHasMedia).length,
      omittedImages: (base.omittedImages || 0) + replies.filter(postHasMedia).length,
      localApplied: true,
      cachedAt: Date.now()
    };
  }
  function loadPostIdentity() {
    const id = cacheGet(postIdentityCacheKey()) || {};
    return {
      name: typeof id.name === 'string' ? id.name : '',
      email: typeof id.email === 'string' ? id.email : '',
      password: typeof id.password === 'string' ? id.password : ''
    };
  }
  function savePostIdentity(identity) {
    cacheSet(postIdentityCacheKey(), {
      name: identity.name || '',
      email: identity.email || '',
      password: identity.password || ''
    });
  }

  // ── Archive API ────────────────────────────────────────────────────────────
  // Enumerate a board's OPs for a date by parsing the HTML path-search page.
  // NOTE: the JSON search API (/_/api/chan/search) has broken date filtering on
  // these archives — it returns posts from every era stamped with the query
  // date. The website's own path-style search filters correctly, so we parse
  // that HTML instead. Verified: <time datetime> here == JSON API `timestamp`.
  function parseSearchOps(html) {
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const arts = doc.querySelectorAll('article.post.post_is_op');
    const ops = [];
    arts.forEach((a) => {
      const num = /^\d+$/.test(a.id) ? a.id : null;
      if (!num) return;
      const timeEl = a.querySelector('time[datetime]');
      const ts = timeEl ? Math.floor(Date.parse(timeEl.getAttribute('datetime')) / 1000) : NaN;
      if (!ts || isNaN(ts)) return;
      const titleEl = a.querySelector('.post_title');
      const nameEl = a.querySelector('.post_author');
      const tripEl = a.querySelector('.post_tripcode');
      const textEl = a.querySelector('.text');
      const thumbImg = a.querySelector('img.post_image');
      const fnEl = a.querySelector('a.post_file_filename');
      const metaEl = a.querySelector('.post_file_metadata');
      const thumb = thumbImg ? thumbImg.getAttribute('src') : null;
      ops.push({
        num, ts,
        title: titleEl ? titleEl.textContent.trim() : '',
        name: nameEl ? nameEl.textContent.trim() : 'Anonymous',
        trip: tripEl ? tripEl.textContent.trim() : '',
        email: '',
        sticky: false,
        locked: false,
        deleted: false,
        expiredTs: 0,
        comment: textEl ? textEl.innerHTML : '',
        preformatted: true,   // .text is already FoolFuuka-formatted HTML
        fourchan_date: timeEl ? (timeEl.getAttribute('title') || '').replace('4chan Time: ', '') : '',
        media: (thumb || fnEl) ? {
          thumb,
          full: fnEl ? fnEl.getAttribute('href') : null,
          fname: fnEl ? fnEl.textContent.trim() : '',
          meta: metaEl ? metaEl.textContent.trim() : ''
        } : null
      });
    });
    return ops;
  }

  function sortedUniqueOps(ops) {
    const seen = new Set();
    const out = [];
    for (const op of ops || []) {
      if (!op || !op.num || seen.has(op.num)) continue;
      seen.add(op.num);
      out.push(op);
    }
    return out.sort((a, b) => a.ts - b.ts || Number(a.num) - Number(b.num));
  }
  function threadNumFromSearchArticle(a) {
    if (!a) return '';
    if (a.classList && a.classList.contains('post_is_op') && /^\d+$/.test(a.id || '')) return String(a.id);
    const direct = a.getAttribute && a.getAttribute('data-thread-num');
    if (direct && /^\d+$/.test(direct)) return direct;
    const stub = a.previousElementSibling && a.previousElementSibling.getAttribute &&
      a.previousElementSibling.getAttribute('data-thread-num');
    if (stub && /^\d+$/.test(stub)) return stub;
    const link = a.querySelector('a[href*="/thread/"]');
    const href = link && link.getAttribute('href');
    const m = href && href.match(/\/thread\/(\d+)/);
    return m ? m[1] : '';
  }
  function parseSearchActivity(html) {
    const doc = new DOMParser().parseFromString(html, 'text/html');
    const arts = doc.querySelectorAll('article.post');
    const posts = [];
    arts.forEach((a) => {
      const num = /^\d+$/.test(a.id || '') ? String(a.id) : '';
      const threadNum = threadNumFromSearchArticle(a);
      if (!threadNum) return;
      const timeEl = a.querySelector('time[datetime]');
      const ts = timeEl ? Math.floor(Date.parse(timeEl.getAttribute('datetime')) / 1000) : NaN;
      if (!ts || isNaN(ts)) return;
      posts.push({ num, threadNum, ts });
    });
    return posts;
  }

  async function enumerateArchiveDay(board, date, base, opts = {}) {
    const end = nextDay(date);
    const ops = [];
    const seen = new Set();
    let fetched = false;
    let disabled = false;
    const maxPages = Math.max(1, CONFIG.catalogSearchMaxPages || 60);
    for (let page = 1; page <= maxPages; page++) {
      const url = `${base}/${board}/search/start/${date}/end/${end}/type/op/order/asc/page/${page}/`;
      const pageKey = indexPageCacheKey(board, date, base, page);
      const cachedPage = !opts.force && cacheGet(pageKey);
      let pageOps = null;
      let fromCache = false;
      if (cachedPage && Array.isArray(cachedPage.ops)) {
        pageOps = cachedPage.ops;
        fromCache = true;
        fetched = true;
      } else {
        let html;
        try { html = await gmText(url); }
        catch (e) { return { ops, ok: fetched, error: String(e && e.message || e), base }; }
        fetched = true;
        if (/Just a moment|Enable JavaScript and cookies|cdn-cgi\/challenge-platform/i.test(html)) {
          return { ops, ok: false, error: 'Cloudflare challenge', base };
        }
        if (/does not have search enabled/i.test(html)) {
          disabled = true;
          break;
        }
        pageOps = parseSearchOps(html);
        cacheSet(pageKey, { ops: pageOps, cachedAt: Date.now() });
      }
      if (!pageOps.length) break;
      let added = 0;
      for (const op of pageOps) {
        if (!op || !op.num || seen.has(op.num)) continue;
        seen.add(op.num);
        added++;
        ops.push(op);
      }
      if (added && opts.onProgress) opts.onProgress(sortedUniqueOps(ops), { board, date, base, page, fromCache });
      if (!added) break;
      if (!fromCache && CONFIG.catalogPageDelayMs) await sleep(CONFIG.catalogPageDelayMs); // be polite
    }
    return { ops: sortedUniqueOps(ops), ok: fetched && !disabled, disabled, base };
  }

  async function enumerateArchiveActivityDay(board, date, base, opts = {}) {
    const end = nextDay(date);
    const posts = [];
    const seenPosts = new Set();
    const seenThreads = new Set();
    const threads = [];
    let fetched = false;
    let disabled = false;
    const maxPages = Math.max(1, opts.maxPages || CONFIG.catalogActivitySearchMaxPages || 20);
    for (let page = 1; page <= maxPages; page++) {
      const url = `${base}/${board}/search/start/${date}/end/${end}/order/desc/page/${page}/`;
      const pageKey = activityPageCacheKey(board, date, base, page);
      const cachedPage = !opts.force && cacheGet(pageKey);
      let pagePosts = null;
      let fromCache = false;
      if (cachedPage && Array.isArray(cachedPage.posts)) {
        pagePosts = cachedPage.posts;
        fromCache = true;
        fetched = true;
      } else {
        let html;
        try { html = await gmText(url); }
        catch (e) { return { posts, threads, ok: fetched, error: String(e && e.message || e), base }; }
        fetched = true;
        if (/Just a moment|Enable JavaScript and cookies|cdn-cgi\/challenge-platform/i.test(html)) {
          return { posts, threads, ok: false, error: 'Cloudflare challenge', base };
        }
        if (/does not have search enabled/i.test(html)) {
          disabled = true;
          break;
        }
        pagePosts = parseSearchActivity(html);
        cacheSet(pageKey, { posts: pagePosts, cachedAt: Date.now() });
      }
      if (!pagePosts.length) break;
      let added = 0;
      for (const p of pagePosts) {
        const postKey = p.num || `${p.threadNum}:${p.ts}`;
        if (seenPosts.has(postKey)) continue;
        seenPosts.add(postKey);
        posts.push(p);
        added++;
        if (!seenThreads.has(p.threadNum)) {
          seenThreads.add(p.threadNum);
          threads.push(p.threadNum);
        }
      }
      if (added && opts.onProgress) opts.onProgress(posts, { board, date, base, page, fromCache });
      if (!added) break;
      if (!fromCache && CONFIG.catalogPageDelayMs) await sleep(CONFIG.catalogPageDelayMs);
    }
    return { posts, threads, ok: fetched && !disabled, disabled, base };
  }

  async function enumerateDay(board, date, opts = {}) {
    const key = indexCacheKey(board, date);
    const cached = cacheGet(key);
    const force = opts.force || tinyCatalogOps(cached) || (Array.isArray(cached) && !cached.length);
    if (cached && !force) return opts.includeLocal === false ? cached : mergeLocalOps(cached, board, date);

    const all = [];
    const seen = new Set();
    let searched = false;
    const mergeOps = (ops) => {
      let added = false;
      for (const op of ops || []) {
        if (!op || !op.num || seen.has(op.num)) continue;
        seen.add(op.num);
        all.push(op);
        added = true;
      }
      if (added && opts.onProgress) opts.onProgress(sortedUniqueOps(all), { board, date });
      return added;
    };

    let fullyEnumerated = false;
    for (const base of searchArchivesForBoard(board)) {
      const result = await enumerateArchiveDay(board, date, base, {
        force,
        onProgress: (partial) => { mergeOps(partial); }
      });
      if (result.disabled) {
        cacheDebug('warn', 'archive HTML search disabled', { board, date, base });
        continue;
      }
      if (!result.ok || result.error) {
        cacheDebug('warn', 'archive HTML search failed', { board, date, base, error: result.error });
        continue;
      }
      searched = true;
      fullyEnumerated = true;
      mergeOps(result.ops);
      // One archive's complete answer is the day's OP list — the mirrors
      // carry the same data, so asking them too just doubles the traffic
      // that gets us rate limited. They remain failover for errors above.
      break;
    }

    const ops = sortedUniqueOps(all);
    // Only persist complete enumerations. Caching a list truncated by a
    // mid-pagination throw (rate limit, outage) would pin a partial day
    // forever — the read path only re-scans empty or tiny lists.
    if (ops.length && fullyEnumerated) cacheSet(key, ops);
    return opts.includeLocal === false ? ops : mergeLocalOps(ops, board, date);
  }

  function tinyCatalogOps(ops) {
    return Array.isArray(ops) && ops.length > 0 && ops.length <= CONFIG.catalogTinyOpsThreshold;
  }

  async function enumerateCatalogCandidates(board, date, opts = {}) {
    const key = catalogCacheKey(board, date);
    const cached = cacheGet(key);
    const cachedOps = Array.isArray(cached) ? cached : (cached && Array.isArray(cached.ops) ? cached.ops : null);
    const endClock = replayEndTs(date);
    const targetClock = Math.min(Number(opts.atClock) || endClock, endClock);
    const target = catalogActiveCapacity();
    const all = [];
    const seen = new Set();
    const seenThreads = new Set();
    const maxDays = Math.max(1, CONFIG.catalogActivityMaxDays || 365);
    let visibleAtTarget = 0;
    let visibleAtEnd = 0;
    let scanDay = null;   // day the backward activity scan is currently on
    let scanOffset = 0;
    const mergeOp = (op) => {
      if (!op || !op.num || seen.has(String(op.num))) return false;
      seen.add(String(op.num));
      seenThreads.add(String(op.num));
      all.push(op);
      return true;
    };
    const currentOps = () => mergeLocalCatalogOps(sortedUniqueOps(all), board, date);
    const recount = () => {
      const ops = currentOps();
      visibleAtTarget = visibleCatalogStatesFromOps(ops, targetClock).length;
      visibleAtEnd = visibleCatalogStatesFromOps(ops, endClock, { browseCatalog: true }).length;
      return ops;
    };
    const emit = (reason = 'catalog') => {
      const ops = recount();
      if (opts.onProgress) opts.onProgress(ops, {
        board, date, reason, visibleAtTarget, visibleAtEnd, target,
        scanDay, scanOffset, maxDays
      });
      return ops;
    };

    if (cachedOps && !opts.force) {
      loadCachedThreadSummariesIntoMemory(board, cachedOps);
      for (const op of cachedOps) mergeOp(op);
      const ops = emit('cached catalog');
      if (cached && cached.complete && !tinyCatalogOps(cachedOps) && !opts.expand) return ops;
    }

    // Seed from neighboring dates' cached catalogs: the board barely changes
    // day to day, so a date next to one already browsed starts ~95% full for
    // free. The scan below then only has to fetch the new day's threads —
    // the whole catalog is enumerated from the network ONCE per era, ever.
    {
      let seeded = 0;
      for (const dOff of [-1, 1, -2, 2, -3, -4, -5, -6, -7]) {
        const nKey = catalogCacheKey(board, addDays(date, dOff));
        const nCached = cacheGet(nKey);
        const nOps = Array.isArray(nCached) ? nCached : (nCached && Array.isArray(nCached.ops) ? nCached.ops : null);
        if (!nOps) continue;
        for (const op of nOps) {
          if (op && op.num && op.ts <= endClock && !seen.has(String(op.num)) && mergeOp(op)) seeded++;
        }
      }
      if (seeded) {
        loadCachedThreadSummariesIntoMemory(board, sortedUniqueOps(all));
        cacheDebug('debug', 'seeded catalog from neighbor dates', { board, date, seeded });
        emit('neighbor catalog seed');
      }
    }

    // OPs gathered from per-day OP searches (cheap, paginated HTML that
    // caches forever) so activity threads don't each cost a full thread
    // fetch just to read their OP. Thread fetches remain the fallback for
    // threads created before the scan window (long-lived generals).
    const opByNum = new Map();
    const registerDayOps = (ops) => {
      for (const op of ops || []) if (op && op.num) opByNum.set(String(op.num), op);
    };
    // A provisional summary from search-page data: correct bump order and a
    // same-day lower bound on replies, painted immediately. Hydration
    // replaces it with exact counts when the thread itself arrives.
    const seedProvisionalSummary = (threadNum, op, bumpTs, dayPostCount) => {
      const key = String(threadNum);
      if (engine.threadSummaries.has(key)) return;
      const cached = cachedThreadSummary(board, key);
      if (cached) { rememberThreadSummary(key, cached); return; }
      rememberThreadSummary(key, {
        num: key,
        opTs: op.ts,
        bump: Math.max(op.ts, bumpTs || 0),
        sticky: !!op.sticky,
        deleted: false,
        expiredTs: 0,
        lastTs: Math.max(op.ts, bumpTs || 0),
        replyCount: Math.max(0, dayPostCount || 0),
        imageCount: postHasMedia(op) ? 1 : 0,
        omittedImages: 0,
        provisional: true,
        cachedAt: Date.now()
      });
    };

    try {
      const dayOps = await enumerateDay(board, date, { includeLocal: false });
      registerDayOps(dayOps);
      let added = false;
      for (const op of dayOps || []) if (mergeOp(op)) added = true;
      if (added) emit('selected day ops');
    } catch (e) {
      cacheDebug('warn', 'selected day OP scan failed', { board, date, error: String(e && e.message || e) });
    }

    const addThreadCandidate = async (threadNum, bumpTs, dayPostCount) => {
      if (!threadNum || seenThreads.has(String(threadNum))) return false;
      seenThreads.add(String(threadNum));
      const known = opByNum.get(String(threadNum));
      if (known) {
        if (known.ts > endClock || !mergeOp(known)) return false;
        seedProvisionalSummary(threadNum, known, bumpTs, dayPostCount);
        emit('activity thread');
        return true;
      }
      let result;
      try { result = await fetchThread(board, threadNum, { preferCache: true }); }
      catch (e) { return false; }
      if (!validThreadResult(result)) return false;
      const op = result.posts[0];
      if (!op || !op.num || op.ts > endClock || !mergeOp(op)) return false;
      emit('activity thread');
      return true;
    };

    let scannedDays = 0;
    let scanFailed = false;
    for (let offset = 0; offset < maxDays && (visibleAtTarget < target || visibleAtEnd < target); offset++) {
      scannedDays = offset + 1;
      const day = addDays(date, -offset);
      scanDay = day;
      scanOffset = offset;
      emit('scanning day'); // advance the loading note even on quiet days
      if (offset > 0) {
        // The day's OP search is cached forever and shared with direct
        // visits to that date — registering it here saves a thread fetch
        // per activity thread created that day.
        try { registerDayOps(await enumerateDay(board, day, { includeLocal: false })); }
        catch (e) { /* registry is an optimization; the scan continues */ }
      }
      for (const base of searchArchivesForBoard(board)) {
        const activity = await enumerateArchiveActivityDay(board, day, base, {
          force: opts.force || tinyCatalogOps(cachedOps)
        });
        if (activity.disabled) {
          cacheDebug('warn', 'archive activity search disabled', { board, day, base });
          continue;
        }
        if (!activity.ok) {
          scanFailed = true;
          cacheDebug('warn', 'archive activity search failed', { board, day, base, error: activity.error });
          continue;
        }
        const bumpByThread = new Map();
        const countByThread = new Map();
        for (const p of activity.posts || []) {
          if (!p || !p.threadNum || p.ts > endClock) continue;
          const k = String(p.threadNum);
          bumpByThread.set(k, Math.max(bumpByThread.get(k) || 0, p.ts));
          countByThread.set(k, (countByThread.get(k) || 0) + 1);
        }
        for (const threadNum of activity.threads) {
          await addThreadCandidate(threadNum, bumpByThread.get(String(threadNum)), countByThread.get(String(threadNum)));
          if (visibleAtTarget >= target && visibleAtEnd >= target) break;
        }
        // One archive's answer covers the day — mirrors hold the same data
        // and asking them too is how we got rate limited. They stay as
        // failover when this one errors.
        break;
      }
    }
    sortedUniqueOps(all);
    all.sort((a, b) => a.ts - b.ts || Number(a.num) - Number(b.num));
    recount();
    cacheSet(key, {
      ops: all,
      scannedAt: Date.now(),
      target,
      maxDays,
      visibleAtTarget,
      visibleAtEnd,
      complete: (visibleAtTarget >= target && visibleAtEnd >= target) || (!scanFailed && scannedDays >= maxDays)
    });
    return mergeLocalCatalogOps(all, board, date);
  }

  const _threadFetchCache = new Map();
  async function fetchThreadFresh(board, num) {
    let lastError = 'not found';
    // A network/rate-limit failure on an earlier (better) archive means the
    // result we eventually return may be a worse copy than what exists — mark
    // it degraded so the persistent cache retries it instead of pinning it.
    let sawFetchFailure = false;
    for (const base of threadAPIsFor(board, num)) {
      const url = `${base}/_/api/chan/thread/?board=${board}&num=${num}`;
      let data;
      try { data = await gmJSON(url, 12000); }
      catch (e) { lastError = 'fetch failed'; sawFetchFailure = true; continue; }
      if (!data || data.error || !data[num]) {
        lastError = data && data.error ? data.error : 'not found';
        continue;
      }

      const t = data[num];
      const norm = (p) => ({
        num: String(p.num), ts: Number(p.timestamp), op: p.op === '1' || p.op === 1,
        title: p.title || '', name: p.name || 'Anonymous', trip: p.trip || '',
        email: p.email || '',
        sticky: p.sticky === '1' || p.sticky === 1,
        locked: p.locked === '1' || p.locked === 1,
        deleted: p.deleted === '1' || p.deleted === 1,
        expiredTs: Number(p.timestamp_expired) || 0,
        comment: p.comment || '', fourchan_date: p.fourchan_date || '',
        media: mediaFromApi(p.media, base, board)
      });
      const posts = [norm(t.op)];
      const container = t.posts || {};
      for (const k of Object.keys(container)) posts.push(norm(container[k]));
      posts.sort((a, b) => a.ts - b.ts);
      return sawFetchFailure ? { posts, source: base, degraded: true } : { posts, source: base };
    }
    return { error: lastError };
  }

  function validThreadResult(result) {
    return !!(result && Array.isArray(result.posts) && result.posts.length);
  }
  function threadSummaryFromPosts(posts) {
    if (!Array.isArray(posts) || !posts.length) return null;
    const op = posts[0];
    const replies = posts.slice(1);
    let bump = op.ts;
    for (let i = 0; i < replies.length; i++) {
      if (i >= CONFIG.bumpLimit) break;
      if (!isSagePost(replies[i])) bump = replies[i].ts;
    }
    const omittedImages = replies.filter(postHasMedia).length;
    return {
      num: String(op.num),
      opTs: op.ts,
      bump,
      sticky: !!op.sticky,
      deleted: !!op.deleted,
      expiredTs: op.expiredTs || 0,
      lastTs: posts[posts.length - 1].ts,
      replyCount: replies.length,
      imageCount: posts.filter(postHasMedia).length,
      omittedImages,
      cachedAt: Date.now()
    };
  }
  function validThreadSummary(summary) {
    return !!(summary && summary.num && typeof summary.bump === 'number');
  }
  function rememberThreadSummary(num, summary) {
    if (!validThreadSummary(summary)) return false;
    engine.threadSummaries.set(String(num), summary);
    return true;
  }
  function cachedThreadSummary(board, num) {
    const summary = cacheGet(threadSummaryCacheKey(board, num));
    return validThreadSummary(summary) ? summary : null;
  }
  function cacheThreadSummary(board, num, result) {
    if (!validThreadResult(result)) return null;
    const summary = threadSummaryFromPosts(result.posts);
    if (!summary) return null;
    rememberThreadSummary(num, summary);
    cacheSet(threadSummaryCacheKey(board, num), summary);
    return summary;
  }
  function rememberThreadResult(num, result) {
    if (!validThreadResult(result)) return false;
    const key = String(num);
    engine.replyTimes.set(key, result.posts.map((p) => p.ts));
    engine.threads.set(key, result.posts);
    rememberThreadSummary(num, threadSummaryFromPosts(result.posts));
    return true;
  }
  function cacheThreadResult(board, num, result) {
    if (!validThreadResult(result)) return result;
    cacheThreadSummary(board, num, result);
    return result;
  }
  function loadCachedThreadSummariesIntoMemory(board, ops) {
    let loaded = 0;
    const keys = new Set(cacheKeys());
    for (const op of ops || []) {
      if (!op || !op.num || engine.threadSummaries.has(String(op.num))) continue;
      const cached = keys.size && keys.has(threadSummaryCacheKey(board, op.num)) ? cachedThreadSummary(board, op.num) : null;
      const summary = summaryWithLocalPosts(board, op, cached);
      if (rememberThreadSummary(op.num, summary)) loaded++;
    }
    if (loaded) cacheDebug('debug', 'loaded cached thread summaries into memory', { board, loaded, total: ops.length });
    return loaded;
  }
  function loadCachedThreadsIntoMemory() { return 0; }

  async function fetchThread(board, num, opts = {}) {
    const key = threadCacheKey(board, num);
    let result;
    if (opts.preferCache && !opts.force) {
      const memory = cachedThreadFromMemory(board, num);
      if (memory) {
        result = memory;
        cacheThreadResult(board, num, result);
        result = mergeLocalThreadResult(board, num, result);
        rememberThreadResult(num, result);
        return result;
      }
      const cached = await cachedThreadFull(board, num, { allowStale: true, allowDegraded: true });
      if (cached) {
        result = cached;
        cacheThreadResult(board, num, result);
        result = mergeLocalThreadResult(board, num, result);
        rememberThreadResult(num, result);
        return result;
      }
    }
    if (_threadFetchCache.has(key)) {
      result = await _threadFetchCache.get(key);
    } else {
      const pending = (async () => {
        if (!opts.force) {
          const memory = cachedThreadFromMemory(board, num);
          if (memory) return memory;
          const cached = await cachedThreadFull(board, num, {
            allowStale: !!opts.preferCache,
            allowDegraded: !!opts.preferCache
          });
          if (cached) return cached;
        }
        const fallback = !opts.force ? await cachedThreadFull(board, num, {
          allowStale: true,
          allowDegraded: true
        }) : null;
        let fresh;
        try {
          fresh = await fetchThreadFresh(board, num);
        } catch (e) {
          if (fallback) return {
            ...fallback,
            staleFallback: true,
            networkError: String(e && e.message || e || 'fetch failed')
          };
          throw e;
        }
        if (!validThreadResult(fresh) && fallback) return {
          ...fallback,
          staleFallback: true,
          networkError: fresh && fresh.error || 'fetch failed'
        };
        if (validThreadResult(fresh)) storeThreadFull(board, num, fresh);
        return fresh;
      })().finally(() => _threadFetchCache.delete(key));
      _threadFetchCache.set(key, pending);
      result = await pending;
    }
    cacheThreadResult(board, num, result);
    result = mergeLocalThreadResult(board, num, result);
    rememberThreadResult(num, result);
    return result;
  }

  // ── Comment formatting (era-correct: greentext + quotelinks) ────────────────
  function catalogHydrationQueue(ops) {
    return ops.slice().sort((a, b) => {
      const av = a.ts <= engine.clock, bv = b.ts <= engine.clock;
      if (av !== bv) return av ? -1 : 1;
      return av ? b.ts - a.ts : a.ts - b.ts;
    });
  }

  async function hydrateCatalog(board, ops) {
    const token = engine.catalogToken;
    const limit = Math.max(1, CONFIG.catalogHydrateLimit || ops.length || 1);
    const queue = catalogHydrationQueue(ops)
      .filter((op) => op && op.num && !engine.threads.has(String(op.num)) &&
        !engine.threadPermanentMiss.has(String(op.num)))
      .slice(0, limit);
    if (!queue.length) {
      engine.catalogHydrating = false;
      engine.catalogHydrateDone = 0;
      engine.catalogHydrateTotal = 0;
      updateCatalogSyncNoteOnly();
      return;
    }
    engine.catalogHydrating = true;
    engine.catalogHydrateDone = 0;
    engine.catalogHydrateTotal = queue.length;
    updateCatalogSyncNoteOnly();

    let next = 0;
    const noteEvery = Math.max(1, CONFIG.catalogSyncUpdateEvery || 1);
    const worker = async () => {
      while (token === engine.catalogToken && next < queue.length) {
        const op = queue[next++];
        try {
          const r = await fetchThread(board, op.num, { preferCache: true });
          // A definitive archive answer ("not found") is permanent — the
          // thread was never archived. Transient failures stay retryable.
          if (r && r.error && !/fetch failed|rate limit|timeout/i.test(String(r.error))) {
            engine.threadPermanentMiss.add(String(op.num));
          }
        }
        catch (e) { /* keep hydrating the rest */ }
        finally {
          engine.catalogHydrateDone++;
          if (engine.catalogHydrateDone % noteEvery === 0 || engine.catalogHydrateDone >= engine.catalogHydrateTotal) {
            updateCatalogSyncNoteOnly();
            if (engine.catalogView) scheduleBoardUpdate();
          }
          if (CONFIG.catalogHydrateYieldMs) await sleep(CONFIG.catalogHydrateYieldMs);
        }
      }
    };
    const n = Math.max(1, Math.min(CONFIG.catalogHydrateConcurrency || 1, queue.length || 1));
    await Promise.all(Array.from({ length: n }, worker));

    if (token === engine.catalogToken) {
      engine.catalogHydrating = false;
      updateCatalogSyncNoteOnly();
      scheduleBoardUpdate();
      // Threads that failed (or fell past the per-pass cap) get another pass
      // later — hydration keeps trying until everything reachable is in.
      const missing = ops.filter((op) => op && op.num &&
        !engine.threads.has(String(op.num)) &&
        !engine.threadPermanentMiss.has(String(op.num))).length;
      if (missing) {
        const wait = 45000 + Math.floor(Math.random() * 15000);
        setTimeout(() => {
          if (token === engine.catalogToken && !engine.catalogHydrating) hydrateCatalog(board, ops);
        }, wait);
      }
    }
  }

  function formatComment(raw) {
    if (!raw) return '';
    const esc = raw
      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
    const html = esc.split('\n').map((line) => {
      let html = line.replace(/&gt;&gt;(\d+)/g,
        (m, n) => `<a class="wb-quotelink" data-num="${n}" href="#p${n}">&gt;&gt;${n}</a>`);
      if (/^\s*&gt;/.test(html) && !/^\s*<a/.test(html.trimStart())) {
        html = `<span class="wb-quote">${html}</span>`;
      }
      return html;
    }).join('<br>');
    return html.replace(/\[spoiler\]([\s\S]*?)\[\/spoiler\]/gi, '<span class="wb-spoiler">$1</span>');
  }

  function quoteNumFromLink(a) {
    if (!a) return '';
    const dataNum = a.dataset && a.dataset.num;
    if (dataNum) return dataNum;
    const m = (a.textContent || '').match(/>>\s*(\d+)/);
    return m ? m[1] : '';
  }
  function annotateQuotelinks(scope, opNum, currentNum) {
    if (!scope) return;
    const op = String(opNum || '');
    const current = String(currentNum || '');
    scope.querySelectorAll('.wb-quotelink').forEach((a) => {
      const num = quoteNumFromLink(a);
      if (!num) return;
      a.dataset.num = num;
      a.setAttribute('href', '#p' + num);
      if (op && num === op && current !== op && !/\(OP\)/.test(a.textContent || '')) {
        a.append(document.createTextNode(' (OP)'));
      }
    });
  }

  // The HTML search page hands us comments already formatted by FoolFuuka.
  // Remap its greentext class to ours and make any backlinks inert (the posts
  // they point to aren't on the index), so previews don't navigate to the archive.
  function sanitizePreformatted(html) {
    if (!html) return '';
    return html
      .replace(/class="greentext"/g, 'class="wb-quote"')
      .replace(/class=(["'])spoiler\1/g, 'class="wb-spoiler"')
      .replace(/\[spoiler\]([\s\S]*?)\[\/spoiler\]/gi, '<span class="wb-spoiler">$1</span>')
      .replace(/<a\b[^>]*>/g, '<a class="wb-quotelink" href="javascript:void(0)">');
  }

  // ── Replay engine ───────────────────────────────────────────────────────────
  const engine = {
    board: 'g',
    openThread: null,     // num or null (index mode)
    realBanner: null,     // the live 4chan title banner node, relocated into our chrome
    titleBannerFile: '',
    catalogView: false,
    indexPage: 1,
    catalogSort: 'bump',
    ops: [],              // enumerated OPs for the day, sorted by ts
    thread: null,         // {posts:[...]} when a thread is open
    clock: 0,             // current replayed unix time (seconds)
    anchor: null,         // {wall, replay, speed, paused, date, startTime} — the persisted clock epoch
    threadClockOverride: null, // when browsing an off-date thread directly, reveal it fully without moving the global clock
    indexClock: 0,        // last explicit board-page refresh time
    catalogClock: 0,      // last explicit catalog refresh time
    speed: CONFIG.speed,
    paused: false,
    barHidden: false,     // dashboard hidden / minimized
    autoUpdate: true,     // auto-reveal new posts in the open thread
    shownOps: new Set(),  // OP nums already on the index
    shownPosts: new Set(),// post nums already rendered in open thread
    prefetchQueue: [],
    prefetching: false,
    timer: null,
    cards: new Map(),       // num -> { node, op, countEl, previewsEl, sig } for index cards
    catalogCards: new Map(),// num -> { node, sig } for catalog grid cards
    replyTimes: new Map(),  // num -> sorted [post ts...] for bump ordering
    threads: new Map(),     // num -> full posts[] (drives bump order + reply previews)
    threadSummaries: new Map(), // num -> compact persisted catalog state for stable reloads
    threadPermanentMiss: new Set(), // nums the archives definitively don't have — stop re-asking
    catalogHydrating: false,
    catalogHydrateDone: 0,
    catalogHydrateTotal: 0,
    catalogToken: 0,
    catalogLoadPending: null,
    _lastIndexSig: ''       // last rendered index order, to skip needless reflow
  };

  // ── Persistent replay clock ────────────────────────────────────────────────
  // The clock is anchored ONCE — a single (real wall time ⇄ replay time) pin
  // saved to storage — so it survives refreshes. At any instant the replay time
  // is a pure function of how much REAL time has elapsed since that pin, never
  // an accumulator, so reloading recomputes the exact same value. It only resets
  // when you explicitly start a new replay (Go / change the date).
  const CLOCK_KEY = 'clockAnchor';
  function loadAnchor() {
    const a = cacheGet(CLOCK_KEY);
    return (a && typeof a.wall === 'number' && typeof a.replay === 'number') ? a : null;
  }
  function saveAnchor(a) { cacheSet(CLOCK_KEY, a); }
  function clearAnchor() { engine.anchor = null; cacheDelete(CLOCK_KEY); }

  // Replay time right now, derived from the anchor (frozen while paused).
  function currentClock(a = engine.anchor, now = Date.now()) {
    if (!a) return engine.clock || 0;
    if (a.paused) return a.replay;
    return a.replay + ((now - a.wall) / 1000) * (a.speed || 1);
  }
  const _etDateFmt = new Intl.DateTimeFormat('en-US', {
    timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit'
  });
  // YYYY-MM-DD for a unix time, in 4chan's timezone (US Eastern).
  function etDateString(unixSec) {
    const parts = _etDateFmt.formatToParts(new Date(unixSec * 1000));
    const g = (t) => (parts.find((p) => p.type === t) || {}).value || '';
    return `${g('year')}-${g('month')}-${g('day')}`;
  }
  // 4chan's clock is US Eastern; +4h ≈ EDT→UTC in summer.
  function replayStartTs(date, startTime) {
    const [hh, mm] = (startTime || '12:00').split(':').map(Number);
    const [y, m, d] = date.split('-').map(Number);
    return Math.floor(Date.UTC(y, m - 1, d, hh + 4, mm || 0) / 1000);
  }
  function replayEndTs(date) {
    const [y, m, d] = date.split('-').map(Number);
    return Math.floor(Date.UTC(y, m - 1, d + 1, 4, 0, -1) / 1000);
  }
  // Pin a brand-new epoch: replay starts at `replayTs`, anchored to now.
  function anchorAt(replayTs, date, startTime, speed) {
    const a = { wall: Date.now(), replay: replayTs, speed: speed || 1, paused: false,
      board: engine.board, date, startTime: startTime || '12:00' };
    saveAnchor(a);
    return a;
  }
  function anchorEpoch(date, startTime, speed) {
    return anchorAt(replayStartTs(date, startTime), date, startTime, speed);
  }
  // Re-base the anchor to "now" (capturing the current replay time) so speed and
  // pause changes take effect going forward without rewriting the elapsed past.
  function reanchor(patch) {
    if (!engine.anchor) return; // no running epoch to re-base yet
    const now = Date.now();
    const cur = currentClock(engine.anchor, now);
    engine.anchor = { ...(engine.anchor || {}), wall: now, replay: cur, ...patch };
    if (typeof engine.anchor.speed !== 'number') engine.anchor.speed = engine.speed || 1;
    if (!engine.anchor.date) engine.anchor.date = CONFIG.date;
    if (!engine.anchor.startTime) engine.anchor.startTime = CONFIG.startTime;
    saveAnchor(engine.anchor);
    engine.clock = currentClock(engine.anchor, now);
  }
  // Resume the saved epoch if it's for the current date/time; otherwise start one.
  function ensureAnchor() {
    // ONE global clock epoch. ANY saved anchor is resumed — never reset by a
    // reload, a board switch, or opening a thread. The only thing that starts a
    // new epoch is an explicit "Go" (boot({ freshClock:true }) clears it first).
    const saved = loadAnchor();
    engine.anchor = saved || anchorEpoch(CONFIG.date, CONFIG.startTime, engine.speed);
    // The epoch is the source of truth for which 2013 instant we're at, so adopt
    // its origin date/time/speed/pause; the loaded board then matches the clock.
    if (engine.anchor.date) CONFIG.date = engine.anchor.date;
    if (engine.anchor.startTime) CONFIG.startTime = engine.anchor.startTime;
    engine.speed = engine.anchor.speed || engine.speed;
    engine.paused = !!engine.anchor.paused;
    engine.clock = currentClock();
  }
  function startTimer() {
    engine.clock = currentClock();
    if (engine.timer) clearInterval(engine.timer);
    engine.timer = setInterval(tick, 500);
    updateClockDisplay();
  }

  function tick() {
    engine.clock = currentClock();
    updateClockDisplay();
    // Keep the tab title ours even if a late 4chan script tries to reset it.
    if (engine.docTitle && document.title !== engine.docTitle) document.title = engine.docTitle;
    if (engine.openThread) {
      if (engine.autoUpdate) revealThreadPosts();
      refreshUpdateCount();
    }
  }

  // Show "[Update (N)]" when auto-update is off and posts are waiting.
  function refreshUpdateCount() {
    const u = $('#wb-update');
    if (!u || !engine.thread || !engine.thread.posts) return;
    let pending = 0;
    for (const p of engine.thread.posts) {
      if (p.ts <= engine.clock && !engine.shownPosts.has(p.num)) pending++;
    }
    u.textContent = pending ? `[Update (${pending})]` : '[Update]';
  }

  // ── Rendering ────────────────────────────────────────────────────────────────
  function insertQuote(num) {
    const box = $('#wb-post-comment');
    if (!box) return false;
    const quote = `>>${num}\n`;
    const start = typeof box.selectionStart === 'number' ? box.selectionStart : box.value.length;
    const end = typeof box.selectionEnd === 'number' ? box.selectionEnd : start;
    box.value = box.value.slice(0, start) + quote + box.value.slice(end);
    const pos = start + quote.length;
    try { box.setSelectionRange(pos, pos); } catch (e) { /* old textarea */ }
    box.focus();
    return true;
  }
  function renderPostNode(p, isOp, opts = {}) {
    const head = el('div', { class: 'wb-postinfo' });
    if (isOp && p.title) head.append(el('span', { class: 'wb-subject' }, p.title), document.createTextNode(' '));
    const nameSpan = el('span', { class: 'wb-name' }, p.name || 'Anonymous');
    head.append(nameSpan);
    if (p.trip) head.append(el('span', { class: 'wb-trip' }, ' ' + p.trip));
    head.append(document.createTextNode(' ' + fourchanStamp(p.ts) + ' '));
    const threadForPost = opts.opNum || (isOp && p.num) || '';
    head.append(el('a', {
      class: 'wb-no',
      href: '#p' + p.num,
      onclick: (e) => {
        if (!engine.openThread && threadForPost) {
          e.preventDefault();
          goThread(threadForPost);
          setTimeout(() => insertQuote(p.num), 200);
        } else if (insertQuote(p.num)) { e.preventDefault(); }
      }
    }, 'No.' + p.num));
    // 4chan's real sticky/closed icons, hotlinked from the same static host
    // the site itself used — not lookalikes.
    if (isOp && p.sticky) head.append(' ', el('img', {
      class: 'wb-threadicon', src: 'https://s.4cdn.org/image/sticky.gif', alt: 'Sticky', title: 'Sticky'
    }));
    if (isOp && p.locked) head.append(' ', el('img', {
      class: 'wb-threadicon', src: 'https://s.4cdn.org/image/closed.gif', alt: 'Closed', title: 'Closed'
    }));
    head.append(el('span', { class: 'wb-backlinks' }));

    const body = el('blockquote', { class: 'wb-comment',
      html: p.preformatted ? sanitizePreformatted(p.comment) : formatComment(p.comment) });
    annotateQuotelinks(body, opts.opNum || (isOp && p.num) || '', p.num);

    let fileInfo = null, imgWrap = null, loadInitialThumb = null;
    if (p.media && (p.media.thumb || p.media.full)) {
      const label = mediaLabel(p.media);
      fileInfo = el('div', { class: 'wb-fileinfo' });
      fileInfo.append(document.createTextNode(activeDesign === '2005' ? 'File : ' : 'File: '));
      const fileLink = el('a', { href: 'javascript:void(0)', target: '_blank' }, label);
      fileInfo.append(fileLink);
      if (p.media.meta) fileInfo.append(document.createTextNode(activeDesign === '2005' ? '-(' + p.media.meta + ')' : ' (' + p.media.meta + ')'));
      const loader = el('span', { class: 'wb-media-loader', title: 'Searching image', 'aria-label': 'Searching image' });
      fileInfo.append(loader);
      const stopLoading = () => { loader.remove(); };
      // Re-show the loader (used while fetching the full image after a click).
      const startLoading = () => { if (!loader.isConnected) fileInfo.append(loader); };
      const img = el('img', { class: 'wb-thumb', alt: p.media.fname || '' });
      img.hidden = true;
      const showMissingPlaceholder = async () => {
        if (img.dataset.placeholder === '1') return;
        stopLoading();
        fileInfo.classList.add('wb-media-unavailable');
        const placeholder = await missingImagePlaceholderBlob(engine.board, `${engine.board}:${p.num}:missing`);
        if (!placeholder || !img.isConnected) return;
        img.dataset.placeholder = '1';
        img.classList.add('wb-missing-placeholder');
        img.classList.remove('wb-expanded', 'wb-thumb-fallback');
        img.hidden = false;
        img.src = placeholder.blob;
        img.title = 'Missing archived image';
      };
      img.addEventListener('error', () => {
        mediaDebug('warn', 'display image failed', {
          board: engine.board,
          num: p.num,
          src: img.src && img.src.startsWith('blob:') ? 'blob:' : img.src
        });
        if (img.dataset.placeholder === '1') {
          img.hidden = true;
          return;
        }
        showMissingPlaceholder();
      });
      const useResolvedMedia = (r, linkKind = 'thumb') => {
        if (!r) return false;
        stopLoading();
        delete img.dataset.placeholder;
        img.classList.remove('wb-missing-placeholder');
        img.title = '';
        img.hidden = false;
        img.src = r.blob;
        // ★ badge for images served from the archive.org /mlp/ rehost —
        // visible only while the control-bar toggle is on.
        const fromIA = !!(r.url && (archiveOrgZipUrl(r.url) || archiveOrgDirectFileUrl(r.url)));
        const star = fileInfo.querySelector('.wb-ia-star');
        if (fromIA && !star) {
          fileInfo.append(el('span', { class: 'wb-ia-star', title: 'recovered from the archive.org rehost' }, ' ★'));
        } else if (!fromIA && star) {
          star.remove();
        }
        if (r.url && (linkKind === 'full' || fileLink.getAttribute('href') === 'javascript:void(0)')) {
          fileLink.href = r.url;
        }
        if (linkKind === 'full' && r.url && !r.thumbFallback) fileLink.dataset.fullResolved = '1';
        return true;
      };
      let fullPrefetch = null;
      const prefetchFull = (eager = false) => {
        if (fullPrefetch || img.dataset.placeholder === '1') return fullPrefetch;
        const cachedFull = cachedPostMediaResult(p, 'full');
        if (cachedFull) {
          fullPrefetch = Promise.resolve(cachedFull);
          return fullPrefetch;
        }
        if (fileLink.dataset.fullResolved === '1') return null;
        const run = () => postMediaBlob(p, 'full').catch(() => null);
        fullPrefetch = eager ? run() : enqueueMediaTask(run);
        return fullPrefetch;
      };
      const scheduleFullPrefetch = () => {
        const run = () => {
          if (img.isConnected && !img.hidden && img.dataset.placeholder !== '1') prefetchFull(false);
        };
        if ('requestIdleCallback' in window) window.requestIdleCallback(run, { timeout: 4000 });
        else setTimeout(run, 1500);
      };
      loadInitialThumb = (target) => {
        const cached = cachedPostMediaResult(p, 'thumb');
        if (cached) {
          useResolvedMedia(cached, 'thumb');
          scheduleFullPrefetch();
          return;
        }
        lazyResolvePostMedia(target, p, 'thumb',
          (r) => {
            useResolvedMedia(r, 'thumb');
            scheduleFullPrefetch();
          },
          () => { showMissingPlaceholder(); });
      };
      fileLink.addEventListener('click', async (e) => {
        if (fileLink.dataset.fullResolved === '1') return;
        e.preventDefault();
        const full = await prefetchFull(true);
        if (full && full.url) {
          useResolvedMedia(full, 'full');
          window.open(full.url, '_blank', 'noopener');
        } else if (fileLink.getAttribute('href') !== 'javascript:void(0)') {
          if (!full) { img.dataset.fullMissing = '1'; noteFullMissing(); }
          window.open(fileLink.href, '_blank', 'noopener');
        } else if (!full) {
          img.dataset.fullMissing = '1';
          noteFullMissing();
        }
      });
      // Click-to-expand works the same on the index and inside a thread.
      let expanded = false;
      // After a full-size hunt comes up empty, say so once next to the file
      // info and stop re-hunting — later clicks just toggle the enlarged
      // thumbnail, which is all that survives.
      const noteFullMissing = () => {
        if (fileInfo.querySelector('.wb-fullmissing')) return;
        fileInfo.append(el('span', { class: 'wb-fullmissing' },
          ' — full size lost, only the thumbnail survives'));
      };
      img.addEventListener('click', async (e) => {
        e.preventDefault();
        if (img.dataset.placeholder === '1') return;
        if (img.dataset.fullMissing === '1') {
          expanded = !expanded;
          img.classList.toggle('wb-expanded', expanded);
          img.classList.toggle('wb-thumb-fallback', expanded);
          return;
        }
        if (!expanded) {
          expanded = true;
          img.classList.add('wb-expanded', 'wb-thumb-fallback');
          // Spinner only while a bigger image is actually on its way. If it's
          // already cached it's instant; if the archive only has the thumbnail
          // there's nothing to wait for, so don't sit there saying "loading".
          const haveFull = !!cachedPostMediaResult(p, 'full');
          const fullExists = !!(p.media && p.media.full);
          if (!haveFull && fullExists && fileLink.dataset.fullResolved !== '1') startLoading();
          const full = await prefetchFull(true);
          stopLoading();
          if (expanded && full && img.isConnected && img.dataset.placeholder !== '1') {
            useResolvedMedia(full, 'full');
            img.classList.toggle('wb-thumb-fallback', full.thumbFallback);
          } else if (!full && img.isConnected && img.dataset.placeholder !== '1') {
            img.dataset.fullMissing = '1';
            noteFullMissing();
          }
        } else if (expanded) {
          expanded = false;
          img.classList.remove('wb-expanded', 'wb-thumb-fallback');
          const thumb = cachedPostMediaResult(p, 'thumb') || await postMediaBlob(p, 'thumb');
          if (!expanded && thumb) useResolvedMedia(thumb, 'thumb');
        }
      });
      imgWrap = img;
    }

    // 4chan post order: info line, then the file (File: text above a left-floated
    // thumb), then the comment that wraps around the thumb.
    const post = el('div', { class: isOp ? 'wb-op' : 'wb-reply', id: 'p' + p.num });
    post.append(head);
    if (fileInfo || imgWrap) {
      const fileDiv = el('div', { class: 'wb-file' });
      if (fileInfo) fileDiv.append(fileInfo);
      if (imgWrap) fileDiv.append(imgWrap);
      post.append(fileDiv);
      if (loadInitialThumb) loadInitialThumb(fileDiv);
    }
    post.append(body);
    wireQuotelinks(post);
    if (isOp) return post;
    // 4chan's reply "sideArrows": a >> marker floated in the post's left gutter.
    return el('div', { class: 'wb-postrow' }, el('span', { class: 'wb-arrows' }, '>>'), post);
  }

  // Jump to a quoted post and flash it the era-correct highlight, the way
  // clicking a >>quotelink did on 4chan. It stays highlighted until you click
  // another link (matching the native :target behaviour).
  function highlightPost(num) {
    document.querySelectorAll('.wb-highlight').forEach((n) => n.classList.remove('wb-highlight'));
    const target = document.getElementById('p' + num);
    if (!target) return false;
    target.classList.add('wb-highlight');
    const overlay = $('#wb-overlay');
    const center = () => target.scrollIntoView({ block: 'center' });
    center();
    // Blob images above the target keep loading and shift the layout — that's why
    // the first jump lands in the wrong place. Re-center while the page settles.
    let tries = 0;
    const settle = () => {
      if (++tries > 6) return;
      const r = target.getBoundingClientRect();
      const vh = overlay ? overlay.clientHeight : window.innerHeight;
      if (Math.abs(r.top + r.height / 2 - vh / 2) > 40) center();
      setTimeout(settle, 110);
    };
    setTimeout(settle, 110);
    return true;
  }
  function wireQuotelinks(scope) {
    scope.querySelectorAll('.wb-quotelink').forEach((a) => {
      if (!a.dataset.num) return;
      a.addEventListener('click', (e) => {
        e.preventDefault();
        highlightPost(a.dataset.num);
      });
    });
  }

  function resetIndexState() {
    engine.shownOps = new Set();
    engine.cards = new Map();
    engine.catalogCards = new Map();
    engine.replyTimes = new Map();
    engine.threads = new Map();
    engine.threadSummaries = new Map();
    engine.threadPermanentMiss = new Set();
    engine.catalogHydrating = false;
    engine.catalogHydrateDone = 0;
    engine.catalogHydrateTotal = 0;
    engine.catalogLoadPending = null;
    engine.catalogToken++;
    engine.indexClock = 0;
    engine.catalogClock = 0;
    engine._lastIndexSig = '';
  }

  function buildThreadCard(op) {
    const open = () => goThread(op.num);
    const post = renderPostNode(op, true, { opNum: op.num });
    // Authentic per-OP "[Reply]" link in the post info line — how you entered a
    // thread from the board index.
    const reply = el('a', { class: 'wb-replylink', href: `/${engine.board}/thread/${op.num}`,
      onclick: (e) => { e.preventDefault(); open(); } }, '[Reply]');
    const info = post.querySelector('.wb-postinfo');
    if (info) info.append(document.createTextNode(' '), reply);
    // The "N replies omitted. Click here to view." line, as 4chan's index read.
    const omitted = el('a', { class: 'wb-omitted', href: `/${engine.board}/thread/${op.num}`,
      onclick: (e) => { e.preventDefault(); open(); } }, '');
    // The last few replies preview below the OP (4chan showed ~3 on the index).
    const previews = el('div', { class: 'wb-previews' });
    const wrap = el('div', { class: 'wb-threadcard' }, post, omitted, previews, el('hr'));
    return { node: wrap, countEl: omitted, previewsEl: previews };
  }

  function postHasMedia(p) {
    return !!(p && p.media && (p.media.thumb || p.media.full));
  }
  function isSagePost(p) {
    return /\bsage\b/i.test((p && p.email) || '');
  }
  function omittedText(posts, images) {
    if (posts <= 0) return '';
    const p = `${posts} post${posts === 1 ? '' : 's'}`;
    const i = images > 0 ? ` and ${images} image${images === 1 ? '' : 's'}` : '';
    return `${p}${i} omitted. Click here to view.`;
  }
  const CATALOG_SORTS = ['bump', 'created', 'lastReply', 'replyCount'];
  function normCatalogSort(sort) {
    return CATALOG_SORTS.includes(sort) ? sort : 'bump';
  }
  function catalogActiveCapacity() {
    return Math.max(boardThreadCapacity(),
      CONFIG.catalogActivityThreadTarget || 0);
  }
  function compareCatalogStates(a, b, sort = 'bump') {
    const sticky = (b.sticky ? 1 : 0) - (a.sticky ? 1 : 0);
    if (sticky) return sticky;
    switch (normCatalogSort(sort)) {
      case 'created':
        return (b.creationTs || 0) - (a.creationTs || 0) || Number(b.num) - Number(a.num);
      case 'lastReply':
        return (b.lastReplyTs || 0) - (a.lastReplyTs || 0) || (b.bump || 0) - (a.bump || 0) || Number(b.num) - Number(a.num);
      case 'replyCount':
        return (b.replyCount || 0) - (a.replyCount || 0) || (b.bump || 0) - (a.bump || 0) || Number(b.num) - Number(a.num);
      case 'bump':
      default:
        return (b.bump || 0) - (a.bump || 0) || Number(b.num) - Number(a.num);
    }
  }
  function catalogState(op, atClock = engine.clock, opts = {}) {
    if (!op || op.ts > atClock) return null;
    const hydratedPosts = engine.threads.get(String(op.num));
    const hydrated = !!(hydratedPosts && hydratedPosts.length);
    const summary = hydrated ? null : summaryWithLocalPosts(engine.board, op, engine.threadSummaries.get(String(op.num)));
    const posts = hydrated ? hydratedPosts : [op];
    const browseCatalog = !!opts.browseCatalog;
    const useSummary = !hydrated && validThreadSummary(summary);
    const threadOp = useSummary ? {
      ...op,
      sticky: summary.sticky,
      deleted: summary.deleted,
      expiredTs: summary.expiredTs || 0
    } : (posts[0] || op);

    if ((hydrated || useSummary) &&
      (threadOp.deleted || (threadOp.expiredTs && threadOp.expiredTs <= atClock))) return null;
    if (useSummary) {
      return {
        op: threadOp,
        hydrated: false,
        summarized: true,
        bump: summary.bump,
        creationTs: threadOp.ts,
        lastReplyTs: summary.lastTs || summary.bump || threadOp.ts,
        replyCount: summary.replyCount || 0,
        sticky: !!threadOp.sticky,
        shown: [],
        omittedPosts: summary.replyCount || 0,
        omittedImages: summary.omittedImages || 0,
        imageCount: summary.imageCount || (postHasMedia(op) ? 1 : 0),
        sig: [
          summary.bump,
          'summary',
          summary.replyCount || 0,
          summary.omittedImages || 0,
          summary.imageCount || 0,
          's'
        ].join('|')
      };
    }
    const visible = posts.filter((p) => p.ts <= atClock);
    if (!visible.length) return null;

    const replies = visible.slice(1);
    let bump = threadOp.ts;
    for (let i = 0; i < replies.length; i++) {
      if (i >= CONFIG.bumpLimit) break;
      if (!isSagePost(replies[i])) bump = replies[i].ts;
    }

    const shown = replies.slice(-3);
    const shownNums = new Set(shown.map((p) => p.num));
    const omitted = replies.filter((p) => !shownNums.has(p.num));
    const imageCount = visible.filter(postHasMedia).length;
    const omittedImages = omitted.filter(postHasMedia).length;
    const lastReplyTs = replies.length ? replies[replies.length - 1].ts : threadOp.ts;

    return {
      op: threadOp,
      hydrated,
      bump,
      creationTs: threadOp.ts,
      lastReplyTs,
      replyCount: replies.length,
      sticky: !!threadOp.sticky,
      shown,
      omittedPosts: omitted.length,
      omittedImages,
      imageCount,
      sig: [
        bump,
        shown.map((p) => p.num).join(','),
        omitted.length,
        omittedImages,
        imageCount,
        hydrated ? 1 : 0
      ].join('|')
    };
  }
  function updateCatalogSyncNote(list) {
    let note = $('#wb-catalog-sync', list);
    if (!engine.catalogHydrating) {
      if (note) note.remove();
      return;
    }
    if (!note) {
      note = el('div', { id: 'wb-catalog-sync', class: 'wb-note' });
      list.prepend(note);
    }
    const left = Math.max(0, engine.catalogHydrateTotal - engine.catalogHydrateDone);
    note.textContent = `Syncing threads ${engine.catalogHydrateDone}/${engine.catalogHydrateTotal}` +
      ` (${left} left) — reply counts fill in as each thread arrives...`;
  }
  function updateCatalogSyncNoteOnly() {
    if (engine.openThread) return;
    const host = $('#wb-index') || $('#wb-catalog');
    if (host) updateCatalogSyncNote(host);
  }
  function updateThreadCard(card, state) {
    if (card.countEl) card.countEl.textContent = omittedText(state.omittedPosts, state.omittedImages);
    if (state.sig === card.sig) return;
    card.sig = state.sig;
    card.previewsEl.innerHTML = '';
    for (const r of state.shown) {
      card.previewsEl.append(el('div', { class: 'wb-previewrow' }, renderPostNode(r, false, { opNum: state.num })));
    }
  }
  function commentSummary(p, max = 180) {
    const tmp = document.createElement('div');
    tmp.innerHTML = p && p.preformatted ? sanitizePreformatted(p.comment) : formatComment((p && p.comment) || '');
    const text = tmp.textContent.replace(/\s+/g, ' ').trim();
    return text.length > max ? text.slice(0, max - 1) + '...' : text;
  }
  // Truncate a DOM subtree to `budget.n` chars of text, keeping element
  // boundaries intact so spoiler/greentext spans survive.
  function truncateNode(node, budget) {
    for (const child of Array.from(node.childNodes)) {
      if (budget.n <= 0) { child.remove(); continue; }
      if (child.nodeType === 3) {
        const t = child.textContent;
        if (t.length > budget.n) { child.textContent = t.slice(0, budget.n) + '…'; budget.n = 0; }
        else budget.n -= t.length;
      } else if (child.nodeType === 1) {
        truncateNode(child, budget);
      } else {
        child.remove();
      }
    }
  }
  // Like commentSummary but keeps the HTML, so catalog teasers render real
  // spoiler bars and greentext instead of leaking the text in the clear.
  function commentTeaserHTML(p, max = 180) {
    const tmp = document.createElement('div');
    tmp.innerHTML = p && p.preformatted ? sanitizePreformatted(p.comment) : formatComment((p && p.comment) || '');
    truncateNode(tmp, { n: max });
    return tmp.innerHTML;
  }
  function visibleCatalogStatesFromOps(ops, atClock = engine.clock, opts = {}) {
    const states = [];
    for (const op of ops || []) {
      if (op.ts > atClock) break;
      const state = catalogState(op, atClock, opts);
      if (state) states.push({ ...state, num: op.num });
    }
    // Natural archive turnover: a thread is active until enough other threads
    // bump ahead of it to push it past the board/catalog capacity. No arbitrary
    // age cutoff; month-long threads survive as long as their bump keeps them in.
    states.sort((a, b) => compareCatalogStates(a, b, 'bump'));
    const active = states.slice(0, catalogActiveCapacity());
    active.sort((a, b) => compareCatalogStates(a, b, opts.catalogSort || 'bump'));
    return active;
  }
  function visibleCatalogStates(atClock = engine.clock, opts = {}) {
    return visibleCatalogStatesFromOps(engine.ops, atClock, opts);
  }
  function clampIndexPage(page) {
    const n = Number(page) || 1;
    return Math.max(1, Math.min(boardIndexPages(), Math.floor(n)));
  }
  function indexPath(page = engine.indexPage) {
    const p = clampIndexPage(page);
    return p <= 1 ? `/${engine.board}/` : `/${engine.board}/${p}`;
  }
  function refreshIndexSnapshot() {
    engine.indexClock = engine.clock;
    engine._lastIndexSig = '';
    updateIndex();
  }
  function refreshCatalogSnapshot() {
    engine.catalogClock = replayEndTs(CONFIG.date);
    updateCatalog();
  }
  function refreshCatalogData() {
    refreshCatalogSnapshot();
    ensureCatalogViewOps({ expand: true });
  }
  function refreshCurrentBoardSnapshot() {
    if (engine.catalogView) refreshCatalogSnapshot();
    else refreshIndexSnapshot();
  }
  function buildCatalogCard(state) {
    const op = state.op;
    const open = () => goThread(state.num);
    const thumb = el('div', { class: 'wb-catalog-thumb' });
    if (postHasMedia(op)) {
      const loader = el('span', { class: 'wb-media-loader wb-catalog-loader', title: 'Searching image', 'aria-label': 'Searching image' });
      thumb.append(loader);
      const stopCatalogLoading = () => { loader.remove(); };
      const img = el('img', { alt: mediaLabel(op.media) });
      img.hidden = true;
      const showCatalogPlaceholder = async () => {
        if (img.dataset.placeholder === '1') return;
        stopCatalogLoading();
        const placeholder = await missingImagePlaceholderBlob(engine.board, `${engine.board}:${op.num}:catalog-missing`);
        if (!placeholder || !img.isConnected) {
          thumb.classList.add('wb-catalog-noimage');
          return;
        }
        img.dataset.placeholder = '1';
        img.classList.add('wb-missing-placeholder');
        img.hidden = false;
        img.src = placeholder.blob;
        img.title = 'Missing archived image';
        thumb.classList.remove('wb-catalog-noimage');
        thumb.classList.add('wb-catalog-missing');
      };
      img.addEventListener('error', () => {
        mediaDebug('warn', 'catalog display image failed', {
          board: engine.board,
          num: op.num,
          src: img.src && img.src.startsWith('blob:') ? 'blob:' : img.src
        });
        if (img.dataset.placeholder === '1') {
          img.hidden = true;
          thumb.classList.add('wb-catalog-noimage');
          return;
        }
        showCatalogPlaceholder();
      });
      const cached = cachedPostMediaResult(op, 'thumb');
      if (cached) {
        stopCatalogLoading();
        delete img.dataset.placeholder;
        img.classList.remove('wb-missing-placeholder');
        img.title = '';
        img.src = cached.blob;
        img.hidden = false;
      } else {
        lazyResolvePostMedia(thumb, op, 'thumb',
          (r) => {
            stopCatalogLoading();
            delete img.dataset.placeholder;
            img.classList.remove('wb-missing-placeholder');
            img.title = '';
            img.src = r.blob;
            img.hidden = false;
          },
          () => { showCatalogPlaceholder(); });
      }
      img.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); open(); });
      thumb.append(img);
    } else {
      thumb.classList.add('wb-catalog-noimage');
    }
    const meta = el('div', { class: 'wb-catalog-meta' }, `R: ${state.omittedPosts + state.shown.length} / I: ${state.imageCount}`);
    const title = op.title ? el('div', { class: 'wb-catalog-title' }, op.title) : null;
    const text = el('div', { class: 'wb-catalog-text', html: commentTeaserHTML(op) });
    const link = el('a', { class: 'wb-catalog-open', href: `/${engine.board}/thread/${state.num}`,
      onclick: (e) => { e.preventDefault(); open(); } }, `No.${state.num}`);
    const card = el('div', { class: 'wb-catalog-card', onclick: (e) => { e.preventDefault(); open(); } },
      thumb, meta, title, text, link);
    return { node: card, sig: `${state.sig}|${op.title}|${op.comment}` };
  }
  function updateCatalog() {
    const grid = $('#wb-catalog');
    if (!grid) return;
    updateCatalogSyncNote(grid);
    const states = visibleCatalogStates(engine.catalogClock || engine.clock, {
      browseCatalog: true,
      catalogSort: engine.catalogSort
    });
    const visibleNums = new Set(states.map((s) => s.num));

    let empty = $('#wb-catalog-empty', grid);
    if (!states.length) {
      if (!empty) {
        empty = el('div', { id: 'wb-catalog-empty', class: 'wb-note' });
        grid.append(empty);
      }
      empty.textContent = engine.ops.length ? 'No threads visible at this replay time.' : 'Loading catalog...';
    } else if (empty) {
      empty.remove();
    }
    // The loading note is owned by loadBoardOps — it shows live scan
    // progress while threads render and is removed when enumeration ends.

    for (const state of states) {
      let card = engine.catalogCards.get(state.num);
      const sig = `${state.sig}|${state.op.title}|${state.op.comment}`;
      if (!card || card.sig !== sig) {
        const built = buildCatalogCard(state);
        if (card) card.node.replaceWith(built.node);
        card = built;
        engine.catalogCards.set(state.num, card);
      }
      grid.append(card.node);
    }

    for (const [num, card] of engine.catalogCards) {
      if (visibleNums.has(num)) continue;
      card.node.remove();
      engine.catalogCards.delete(num);
    }
  }
  function updateBoardView() {
    if (engine.openThread) return;
    if (engine.catalogView) updateCatalog();
    else updateIndex();
  }
  let _boardUpdateScheduled = false;
  function scheduleBoardUpdate() {
    if (engine.openThread || _boardUpdateScheduled) return;
    _boardUpdateScheduled = true;
    const run = () => {
      _boardUpdateScheduled = false;
      updateBoardView();
    };
    if ('requestAnimationFrame' in window) requestAnimationFrame(run);
    else setTimeout(run, 0);
  }

  // Real 4chan orders the index by *bump*. We compute that order only when the
  // board page is explicitly refreshed, then slice the sorted list into pages.
  function updateIndex() {
    const list = $('#wb-index');
    if (!list) return;
    updateCatalogSyncNote(list);

    // Compose the board pages from the active-thread candidate set, then slice
    // it the way 4chan's numbered pages behaved: bump order first, fixed page
    // size next. Older OPs stay eligible when replies keep them alive.
    const clock = engine.indexClock || engine.clock;
    const allStates = visibleCatalogStates(clock, { browseCatalog: true });
    const page = clampIndexPage(engine.indexPage);
    engine.indexPage = page;
    const start = (page - 1) * CONFIG.indexThreadsPerPage;
    const states = allStates.slice(start, start + CONFIG.indexThreadsPerPage);
    for (const state of states) {
      const op = state.op;
      let card = engine.cards.get(state.num);
      if (!card) {
        const built = buildThreadCard(state.op || op);
        card = { node: built.node, op: state.op || op, countEl: built.countEl, previewsEl: built.previewsEl, sig: '' };
        engine.cards.set(state.num, card);
      }
      updateThreadCard(card, state);
      state.node = card.node;
    }

    const visibleNums = new Set(states.map((s) => s.num));
    for (const [num, card] of engine.cards) {
      if (visibleNums.has(num)) continue;
      card.node.remove();
      engine.cards.delete(num);
      engine.shownOps.delete(num);
    }

    let empty = $('#wb-index-empty', list);
    if (!states.length) {
      if (!empty) {
        empty = el('div', { id: 'wb-index-empty', class: 'wb-note' });
        list.append(empty);
      }
      empty.textContent = engine.ops.length ? `No threads on page ${page}.` : 'Loading threads...';
    } else if (empty) {
      empty.remove();
    }
    // The loading note is owned by loadBoardOps — it shows live scan
    // progress while threads render and is removed when enumeration ends.

    const sig = `${page}:full:${states.map((o) => o.num).join(',')}`;
    if (sig !== engine._lastIndexSig) {
      engine._lastIndexSig = sig;
      for (const o of states) list.append(o.node); // append() moves existing nodes
    }
  }

  function revealThreadPosts() {
    const wrap = $('#wb-thread-posts');
    if (!wrap || !engine.thread || engine.thread.error) return;
    const clk = engine.threadClockOverride || engine.clock;
    for (const p of engine.thread.posts) {
      if (p.ts > clk) break;
      if (engine.shownPosts.has(p.num)) continue;
      engine.shownPosts.add(p.num);
      // One malformed post must never blank the rest of the thread.
      try {
        const node = renderPostNode(p, p.op, { opNum: engine.thread && engine.thread.posts && engine.thread.posts[0] && engine.thread.posts[0].num });
        wrap.append(node);
        addBacklinksFor(p); // drop ">>this" onto every post this one quoted
      } catch (e) {
        mediaDebug('warn', 'post render failed', { num: p.num, error: String(e && e.message || e) });
      }
    }
  }

  // When a post quotes earlier posts (>>num), add a blue ">>thisNum" backlink
  // onto each quoted post, so you can see who replied to it and click across.
  function addBacklinksFor(p) {
    const quoted = new Set((String(p.comment).match(/>>(\d+)/g) || []).map((s) => s.slice(2)));
    for (const qnum of quoted) {
      const target = document.getElementById('p' + qnum);
      if (!target) continue;
      const bl = target.querySelector('.wb-backlinks');
      if (!bl || bl.querySelector(`a[data-num="${p.num}"]`)) continue;
      const a = el('a', { class: 'wb-quotelink wb-backlink', 'data-num': p.num, href: '#p' + p.num }, '>>' + p.num);
      a.addEventListener('click', (e) => {
        e.preventDefault();
        highlightPost(p.num);
      });
      bl.append(' ', a);
    }
  }

  function goThread(num) {
    // Push a real history entry so the browser Back button returns to the index
    // instead of leaving 4chan, then render the thread.
    engine.catalogView = false;
    history.pushState({ wb: 'thread', num }, '', `/${engine.board}/thread/${num}`);
    openThread(num);
  }

  async function openThread(num, opts = {}) {
    engine.openThread = num;
    engine.threadClockOverride = null; // default: this thread plays on the live global clock
    engine.catalogView = false;
    engine.shownPosts = new Set();
    engine.thread = { posts: [] };
    renderShell();
    const loading = $('#wb-thread-posts');
    if (loading) loading.append(el('div', { class: 'wb-note' }, 'Loading…'));
    // Keep trying until the thread arrives. Rate limits lift and outages
    // pass — a thread view must never die on its loading note waiting for a
    // manual Update click. Only navigating away stops the loop.
    let t = null;
    for (let attempt = 0; ; attempt++) {
      if (String(engine.openThread) !== String(num)) return; // navigated away
      try {
        t = await fetchThread(engine.board, num, { preferCache: true });
        // "fetch failed" is the all-archives-unreachable verdict — transient,
        // so retry. Real archive answers ("not found") render below.
        if (t && t.error && /fetch failed|rate limit|timeout/i.test(String(t.error))) {
          throw new Error(String(t.error));
        }
        break;
      } catch (e) {
        const wait = Math.min(45000, 4000 * Math.pow(1.6, attempt)) + Math.floor(Math.random() * 2000);
        const host = $('#wb-thread-posts');
        if (host) {
          host.innerHTML = '';
          host.append(el('div', { class: 'wb-note' },
            `Archives aren't answering (${String(e && e.message || e).slice(0, 100)}). Retrying in ${Math.round(wait / 1000)}s…`));
        }
        await sleep(wait);
      }
    }
    if (String(engine.openThread) !== String(num)) return;
    engine.thread = t;
    const host = $('#wb-thread-posts');
    if (host) host.innerHTML = '';
    if (t.error || !t.posts || !t.posts.length) {
      if (host) host.append(el('div', { class: 'wb-note' },
        `Thread not available in the archive (${t.error || 'empty'}).`));
      return;
    }
    // A thread URL carries no date. When we land on one directly:
    //  - if an epoch is already running (e.g. you just refreshed a thread you
    //    were watching), RESUME it — never reset the clock on reload;
    //  - only when there's no epoch yet (a freshly pasted thread link) do we pin
    //    a new epoch at the OP's own timestamp, so the thread plays from its top.
    if (opts.startFromOP) {
      const opTs = t.posts[0].ts;
      const opDate = etDateString(opTs);
      const existing = loadAnchor();
      if (existing) {
        // A clock is already running. It is GLOBAL and must never be reset by
        // opening a thread — resume it untouched.
        engine.anchor = existing;
        engine.speed = existing.speed || engine.speed;
        engine.paused = !!existing.paused;
        CONFIG.date = existing.date || opDate;
        // If this thread is from a different day than the running clock, show it
        // fully via a local view-clock instead of dragging the global clock to it.
        if (existing.date !== opDate) engine.threadClockOverride = replayEndTs(opDate);
      } else {
        // First-ever use via a pasted thread link: pin the one global epoch at
        // this thread's OP so it plays from the top.
        CONFIG.date = opDate;
        engine.anchor = anchorAt(opTs - 5, CONFIG.date, CONFIG.startTime, engine.speed);
        saveSettings();
      }
      const di = $('#wb-date'); if (di) di.value = CONFIG.date;
      startTimer();
      const token = engine.catalogToken;
      const board = engine.board;
      const date = CONFIG.date;
      const onProgress = (ops) => {
        if (token !== engine.catalogToken || board !== engine.board || date !== CONFIG.date || engine.openThread) return;
        engine.ops = ops;
        refreshCurrentBoardSnapshot();
      };
      enumerateCatalogCandidates(board, date, { atClock: engine.clock, onProgress }).then((ops) => {
        if (token !== engine.catalogToken || board !== engine.board || date !== CONFIG.date) return;
        engine.ops = ops;
        loadCachedThreadSummariesIntoMemory(board, ops);
        loadCachedThreadsIntoMemory(board, ops);
        if (!engine.openThread) refreshCurrentBoardSnapshot();
        hydrateCatalog(board, ops);
      }); // ready for "back to index"
    }
    // The catalog is an end-of-day snapshot, so it lists threads that start
    // later than the live replay clock. Opening one of those used to reveal
    // zero posts — an "empty thread" with no explanation. View it through an
    // end-of-day clock instead, the same way off-date threads are shown.
    if (!engine.threadClockOverride && t.posts[0] && t.posts[0].ts > engine.clock) {
      engine.threadClockOverride = replayEndTs(etDateString(t.posts[0].ts));
    }
    revealThreadPosts();
    updateTitle(); // now that the OP is loaded, use its subject in the tab
  }

  // Render the board index (no history change). Used on popstate / Back.
  function showIndexView(opts = {}) {
    engine.openThread = null;
    engine.threadClockOverride = null;
    engine.catalogView = false;
    engine.indexPage = clampIndexPage(opts.page || engine.indexPage || 1);
    engine.thread = null;
    engine.shownOps = new Set();
    engine.cards = new Map();
    engine._lastIndexSig = '';   // keep replyTimes/threads so fetched threads bump instantly
    renderShell();
    if (opts.refresh === false) updateIndex();
    else refreshIndexSnapshot();
  }

  function showCatalogView(opts = {}) {
    engine.openThread = null;
    engine.threadClockOverride = null;
    engine.catalogView = true;
    engine.thread = null;
    engine.catalogCards = new Map();
    renderShell();
    if (opts.refresh === false) updateCatalog();
    else refreshCatalogSnapshot();
    ensureCatalogViewOps();
  }

  async function ensureCatalogViewOps(opts = {}) {
    if (!engine.catalogView) return;
    const force = !!opts.force || tinyCatalogOps(engine.ops);
    if (engine.ops.length && !force && !opts.expand) return;
    if (engine.catalogLoadPending) return engine.catalogLoadPending;

    const token = engine.catalogToken;
    const board = engine.board;
    const date = CONFIG.date;
    engine.catalogLoadPending = (async () => {
      const ops = await enumerateCatalogCandidates(board, date, {
        force,
        atClock: replayEndTs(date),
        onProgress: (partial) => {
          if (token !== engine.catalogToken || board !== engine.board || date !== CONFIG.date || !engine.catalogView) return;
          engine.ops = partial;
          refreshCatalogSnapshot();
        }
      });
      if (token !== engine.catalogToken || board !== engine.board || date !== CONFIG.date || !engine.catalogView) return;
      engine.ops = ops;
      loadCachedThreadSummariesIntoMemory(board, ops);
      loadCachedThreadsIntoMemory(board, ops);
      refreshCatalogSnapshot();
      hydrateCatalog(board, ops);
    })().finally(() => {
      if (token === engine.catalogToken) engine.catalogLoadPending = null;
    });
    return engine.catalogLoadPending;
  }

  function goIndex(page = 1) {
    const p = clampIndexPage(page);
    history.pushState({ wb: 'index', page: p }, '', indexPath(p));
    showIndexView({ page: p, refresh: true });
  }

  function goCatalog() {
    history.pushState({ wb: 'catalog' }, '', `/${engine.board}/catalog`);
    showCatalogView({ refresh: true });
  }

  // [Return] / backing out of a thread just pops history; the popstate handler
  // re-renders the index. (boot seeds an index entry so this never leaves 4chan.)
  function backToIndex() { history.back(); }

  function readFileAsDataURL(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(String(reader.result || ''));
      reader.onerror = () => reject(new Error('Could not read file.'));
      reader.readAsDataURL(file);
    });
  }
  function imageDimensions(dataURL) {
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => resolve({ w: img.naturalWidth || 0, h: img.naturalHeight || 0 });
      img.onerror = () => resolve({ w: 0, h: 0 });
      img.src = dataURL;
    });
  }
  async function localMediaFromFile(file) {
    if (!file) return null;
    const okType = /^image\/(?:gif|jpe?g|png)$/i.test(file.type || '') ||
      /\.(?:gif|jpe?g|png)$/i.test(file.name || '');
    if (!okType) throw new Error('Supported file types are GIF, JPG, and PNG.');
    if (file.size > CONFIG.localPostMaxImageBytes) {
      throw new Error(`Maximum local file size is ${Math.floor(CONFIG.localPostMaxImageBytes / 1024)} KB.`);
    }
    const dataURL = await readFileAsDataURL(file);
    const dim = await imageDimensions(dataURL);
    const meta = dim.w && dim.h ? `${dim.w}x${dim.h}` : `${Math.max(1, Math.ceil(file.size / 1024))} KB`;
    return {
      thumb: dataURL,
      full: dataURL,
      localDataURL: dataURL,
      fname: file.name || 'upload',
      meta,
      mediaW: dim.w || '',
      mediaH: dim.h || '',
      mediaSize: file.size || ''
    };
  }
  function postClockForSubmit(threadNum, isReply) {
    let ts = Math.floor(engine.threadClockOverride || engine.clock || currentClock() || replayStartTs(CONFIG.date, CONFIG.startTime));
    if (isReply) {
      const posts = (engine.thread && String(engine.openThread) === String(threadNum) && engine.thread.posts) ||
        engine.threads.get(String(threadNum)) || [];
      const op = posts.find((p) => p && p.op) || posts[0];
      if (op && op.ts) ts = Math.max(ts, op.ts + 1);
    }
    return ts;
  }
  function absorbLocalPost(post) {
    if (!post || post.board !== engine.board) return;
    if (post.op) engine.ops = sortedUniqueOps([...engine.ops, post]);

    const threadNum = String(post.threadNum);
    const currentPosts = (engine.thread && String(engine.openThread) === threadNum && engine.thread.posts) ||
      engine.threads.get(threadNum) || [];
    const merged = mergeThreadPosts(currentPosts, [post]);
    if (merged.length && merged[0].op) {
      if (engine.thread && String(engine.openThread) === threadNum) engine.thread = { ...(engine.thread || {}), posts: merged };
      rememberThreadResult(threadNum, { posts: merged, source: 'local' });
    } else {
      const op = engine.ops.find((o) => String(o.num) === threadNum);
      const summary = summaryWithLocalPosts(engine.board, op, engine.threadSummaries.get(threadNum));
      rememberThreadSummary(threadNum, summary);
    }
    engine._lastIndexSig = '';
  }
  function setPostFormMessage(form, text, isError = false) {
    const msg = $('.wb-postform-msg', form);
    if (!msg) return;
    msg.textContent = text || '';
    msg.classList.toggle('wb-postform-error', !!isError);
  }
  async function handleLocalPostSubmit(e) {
    e.preventDefault();
    const form = e.currentTarget;
    const name = ($('#wb-post-name', form) || {}).value || '';
    const email = ($('#wb-post-email', form) || {}).value || '';
    const subject = ($('#wb-post-subject', form) || {}).value || '';
    const commentEl = $('#wb-post-comment', form);
    const fileEl = $('#wb-post-file', form);
    const password = ($('#wb-post-password', form) || {}).value || '';
    const comment = commentEl ? commentEl.value : '';
    const file = fileEl && fileEl.files && fileEl.files[0] ? fileEl.files[0] : null;
    const isReply = !!engine.openThread;

    if (!comment.trim() && !file) {
      setPostFormMessage(form, 'Error: Comment or file required.', true);
      return;
    }

    const submit = $('button[type="submit"]', form);
    if (submit) submit.disabled = true;
    setPostFormMessage(form, 'Posting...', false);
    try {
      const board = engine.board;
      const store = localPostStore(board);
      const num = nextLocalPostNum(store);
      const threadNum = isReply ? String(engine.openThread) : num;
      const ts = postClockForSubmit(threadNum, isReply);
      const media = await localMediaFromFile(file);
      const post = {
        board,
        num,
        threadNum,
        ts,
        date: etDateString(ts),
        op: !isReply,
        title: isReply ? '' : subject.trim(),
        name: name.trim() || 'Anonymous',
        trip: '',
        email: email.trim(),
        sticky: false,
        locked: false,
        deleted: false,
        expiredTs: 0,
        comment,
        preformatted: false,
        fourchan_date: fourchanStamp(ts),
        media,
        local: true
      };
      store.posts.push(post);
      if (!saveLocalPostStore(board, store)) throw new Error('Could not save local post.');
      savePostIdentity({ name, email, password });
      absorbLocalPost(post);

      if (commentEl) commentEl.value = '';
      const subjectEl = $('#wb-post-subject', form);
      if (subjectEl) subjectEl.value = '';
      if (fileEl) fileEl.value = '';
      setPostFormMessage(form, `Posted No.${num}`, false);

      if (post.op) {
        history.pushState({ wb: 'thread', num }, '', `/${board}/thread/${num}`);
        await openThread(num);
      } else if (engine.thread && String(engine.openThread) === threadNum) {
        engine.shownPosts.delete(num);
        revealThreadPosts();
        refreshUpdateCount();
        updateTitle();
      } else {
        scheduleBoardUpdate();
      }
    } catch (err) {
      setPostFormMessage(form, `Error: ${err && err.message ? err.message : err}`, true);
    } finally {
      if (submit) submit.disabled = false;
    }
  }

  // ── UI shell ────────────────────────────────────────────────────────────────
  function updateClockDisplay() {
    const c = $('#wb-clock');
    if (c) c.textContent = easternClock(engine.clock);
  }

  // The dead-thread URL we sit on makes the browser tab read "404 Not Found";
  // overwrite it with an era-correct 4chan title for whatever view is showing.
  function updateTitle() {
    const name = BOARD_NAMES[engine.board] || engine.board.toUpperCase();
    let label;
    if (engine.openThread) {
      const op = engine.thread && engine.thread.posts && engine.thread.posts[0];
      label = (op && (op.title || commentSummary(op, 60))) || name;
    } else if (engine.catalogView) {
      label = 'Catalog';
    } else {
      label = name;
    }
    const t = `/${engine.board}/ - ${label} - 4chan`;
    engine.docTitle = t;
    if (document.title !== t) document.title = t;
  }

  function saveSettings() {
    cacheSet('settings', {
      board: engine.board, date: CONFIG.date, startTime: CONFIG.startTime,
      speed: engine.speed, barHidden: engine.barHidden, autoUpdate: engine.autoUpdate,
      colors: activeColors, design: activeDesign, font: activeFont, catalogSort: engine.catalogSort,
      markArchiveOrgMedia: !!CONFIG.markArchiveOrgMedia,
      mediaDebug: !!CONFIG.mediaDebug, cacheDebug: !!CONFIG.cacheDebug
    });
  }

  function setBarHidden(v) {
    engine.barHidden = v;
    const root = $('#wb-overlay');
    if (root) root.classList.toggle('wb-min', v);
    saveSettings();
  }

  function renderControlBar() {
    const bar = el('div', { id: 'wb-bar' });

    const boardInp = el('input', { id: 'wb-board', value: engine.board, size: 3 });
    const dateInp = el('input', { id: 'wb-date', type: 'date', value: CONFIG.date });
    const timeInp = el('input', { id: 'wb-time', type: 'time', value: CONFIG.startTime });
    const go = el('button', { onclick: () => {
      engine.board = (boardInp.value || 'g').replace(/[^a-z0-9]/gi, '').toLowerCase();
      CONFIG.date = dateInp.value;
      CONFIG.startTime = timeInp.value || '12:00';
      saveSettings();
      boot({ freshClock: true }); // explicit (re)start → pin a new epoch from now
    } }, 'Go');

    const pause = el('button', { id: 'wb-pause', onclick: () => {
      engine.paused = !engine.paused;
      reanchor({ paused: engine.paused });
      pause.textContent = engine.paused ? 'Resume' : 'Pause';
    } }, engine.paused ? 'Resume' : 'Pause');

    const speedSel = el('select', { id: 'wb-speed', onchange: (e) => {
      engine.speed = Number(e.target.value);
      reanchor({ speed: engine.speed });
    } });
    for (const s of [1, 5, 30, 60, 300, 1800]) {
      const o = el('option', { value: s }, s + 'x');
      if (s === engine.speed) o.selected = true;
      speedSel.append(o);
    }

    const colorSel = el('select', { id: 'wb-color-sel', onchange: (e) => { applyTheme(e.target.value); saveSettings(); } });
    for (const [val, label] of [['yotsublue', 'Yotsuba B'], ['yotsuba', 'Yotsuba'], ['tomorrow', 'Tomorrow']]) {
      const o = el('option', { value: val }, label);
      if (val === activeColors) o.selected = true;
      colorSel.append(o);
    }
    const designSel = el('select', { id: 'wb-design-sel', onchange: (e) => { applyDesign(e.target.value); saveSettings(); } });
    for (const [val, label] of [['2012', '2012'], ['2005', '2005']]) {
      const o = el('option', { value: val }, label);
      if (val === activeDesign) o.selected = true;
      designSel.append(o);
    }
    const fontSel = el('select', { id: 'wb-font-sel', onchange: (e) => { applyFont(e.target.value); saveSettings(); } });
    for (const [val, label] of [['2005', '2005'], ['2012', '2012']]) {
      const o = el('option', { value: val }, label);
      if (val === activeFont) o.selected = true;
      fontSel.append(o);
    }
    const catalogSortSel = el('select', { id: 'wb-catalog-sort', onchange: (e) => {
      engine.catalogSort = normCatalogSort(e.target.value);
      saveSettings();
      if (engine.catalogView) updateCatalog();
    } });
    for (const [val, label] of [['bump', 'Bump order'], ['created', 'Creation date'], ['lastReply', 'Last reply'], ['replyCount', 'Reply count']]) {
      const o = el('option', { value: val }, label);
      if (val === normCatalogSort(engine.catalogSort)) o.selected = true;
      catalogSortSel.append(o);
    }

    const hide = el('button', { onclick: () => setBarHidden(true) }, 'Hide');
    const live = el('button', {
      id: 'wb-live',
      title: 'Leave the time machine: reload as the real, present-day board with the script fully disabled. Re-enable via the Violentmonkey menu ("return to the time machine").',
      onclick: () => {
        GM_setValue('oldchanLiveMode', true);
        location.href = `https://boards.4chan.org/${engine.board}/`;
      }
    }, 'Live');
    const iaStars = el('button', {
      id: 'wb-iastars',
      title: 'Mark images served from the archive.org /mlp/ rehost with a ★',
      onclick: () => {
        CONFIG.markArchiveOrgMedia = !CONFIG.markArchiveOrgMedia;
        saveSettings();
        applyIaStarMode();
        iaStars.classList.toggle('wb-on', CONFIG.markArchiveOrgMedia);
      },
      class: CONFIG.markArchiveOrgMedia ? 'wb-on' : ''
    }, '★');
    bar.append(
      el('label', {}, 'board /', boardInp, '/'),
      el('label', {}, ' date ', dateInp),
      el('label', {}, ' time ', timeInp),
      go,
      el('label', {}, ' speed ', speedSel),
      el('label', {}, ' colors ', colorSel),
      el('label', {}, ' design ', designSel),
      el('label', {}, ' font ', fontSel),
      el('label', {}, ' catalog ', catalogSortSel),
      pause, hide, live, iaStars,
      el('span', { id: 'wb-ratelimit' }, ''),
      el('span', { id: 'wb-clock' }, '')
    );
    return bar;
  }

  // Root-level class so the stars toggle without re-rendering anything; the
  // <html> element survives every renderShell rebuild.
  function applyIaStarMode() {
    try {
      document.documentElement.classList.toggle('wb-ia-stars', !!CONFIG.markArchiveOrgMedia);
    } catch (e) { /* document unavailable */ }
  }

  // Switch to another board's replay — keeps the running clock epoch (same date).
  function switchBoard(b) {
    b = String(b || '').replace(/[^a-z0-9]/gi, '').toLowerCase();
    if (!b) return;
    engine.board = b;
    engine.openThread = null;
    engine.catalogView = false;
    engine.indexPage = 1;
    saveSettings();
    history.pushState({ wb: 'index' }, '', `/${b}/`);
    boot();
  }

  const TITLE_BANNER_BASE = 'https://s.4cdn.org/image/title/';
  const DEFAULT_TITLE_BANNER = '61.gif';
  function normalizeTitleBannerFile(file) {
    file = String(file || '').trim().replace(/^.*\/image\/title\//, '').split(/[?#]/)[0];
    return /^[a-z0-9_.-]+\.(?:gif|png|jpe?g|webp)$/i.test(file) ? file : '';
  }
  function currentTitleBannerFile(node) {
    if (!node) return '';
    const fromData = normalizeTitleBannerFile(node.getAttribute('data-src'));
    if (fromData) return fromData;
    const img = node.querySelector('img');
    return img ? normalizeTitleBannerFile(img.getAttribute('src')) : '';
  }
  function titleBannerURL(file) {
    file = normalizeTitleBannerFile(file);
    return file ? TITLE_BANNER_BASE + file : '';
  }
  function setTitleBannerFile(file) {
    file = normalizeTitleBannerFile(file);
    if (!file || !engine.realBanner) return false;
    let img = engine.realBanner.querySelector('img');
    if (!img) {
      img = el('img', { alt: '4chan' });
      engine.realBanner.textContent = '';
      engine.realBanner.append(img);
    }
    engine.realBanner.setAttribute('data-src', file);
    img.src = titleBannerURL(file);
    engine.titleBannerFile = file;
    return true;
  }
  function titleBannerFromHTML(html) {
    try {
      const doc = new DOMParser().parseFromString(html, 'text/html');
      const node = doc.querySelector('#bannerCnt');
      const fromNode = currentTitleBannerFile(node);
      if (fromNode) return fromNode;
      const img = doc.querySelector('img[src*="/image/title/"]');
      return img ? normalizeTitleBannerFile(img.getAttribute('src')) : '';
    } catch (e) {
      return '';
    }
  }
  async function shuffleTitleBanner() {
    if (!engine.realBanner) return;
    const board = (engine.board || 'g').replace(/[^a-z0-9]/gi, '').toLowerCase() || 'g';
    const url = new URL(`/${board}/`, location.origin);
    url.searchParams.set('_oldchan_title', String(Date.now()));
    try {
      const html = await gmText(url.href);
      const file = titleBannerFromHTML(html);
      if (file) setTitleBannerFile(file);
    } catch (e) {
      // Keep the current title if the live board page cannot be fetched.
    }
  }
  function prepareTitleBanner() {
    if (!engine.realBanner) {
      engine.realBanner = el('div', { id: 'bannerCnt', class: 'title desktop', 'data-src': DEFAULT_TITLE_BANNER });
    }
    engine.realBanner.classList.add('wb-title-banner');
    const file = currentTitleBannerFile(engine.realBanner) || engine.titleBannerFile || DEFAULT_TITLE_BANNER;
    if (file) setTitleBannerFile(file);
    if (!engine.realBanner.__oldchanTitleClick) {
      engine.realBanner.__oldchanTitleClick = true;
      engine.realBanner.setAttribute('title', 'Click for another title');
      engine.realBanner.addEventListener('click', (e) => {
        e.preventDefault();
        shuffleTitleBanner();
      });
    }
    return engine.realBanner;
  }

  // The 4chan-style top chrome: the bracketed board list plus any archive-only
  // supported boards, the [Settings] [Search] [Mobile] [Home] links, and the
  // genuine rotating banner relocated from the live page.
  function renderChrome() {
    const chrome = el('div', { id: 'wb-chrome' });
    const nav = el('div', { id: 'wb-boardnav' });
    // Float the right-hand links first so they pin to the top-right corner while
    // the board list flows to their left and wraps full-width beneath.
    const mk = (label, fn) => el('a', { class: 'wb-boardlink', href: 'javascript:void(0)',
      onclick: (e) => { e.preventDefault(); fn(); } }, `[${label}]`);
    nav.append(el('span', { id: 'wb-navright' },
      mk('Settings', () => setBarHidden(!engine.barHidden)), ' ',
      mk('Search', () => { const s = $('#wb-board'); if (s) s.focus(); }), ' ',
      mk('Mobile', () => {}), ' ',
      mk('Home', () => goIndex(1))));
    for (const group of BOARD_NAV_GROUPS) {
      const span = el('span', { class: 'wb-boardgroup' });
      span.append('[ ');
      group.forEach((b, i) => {
        if (i) span.append(' / ');
        span.append(el('a', { class: 'wb-boardlink', href: `/${b}/`, title: BOARD_NAMES[b] || b,
          onclick: (e) => { e.preventDefault(); switchBoard(b); } }, b));
      });
      span.append(' ] ');
      nav.append(span);
    }
    chrome.append(nav);

    const banner = el('div', { id: 'wb-banner' });
    const titleBanner = prepareTitleBanner();
    if (titleBanner) banner.append(titleBanner);
    chrome.append(banner);
    return chrome;
  }

  function renderPostForm() {
    const identity = loadPostIdentity();
    const isReply = !!engine.openThread;
    const form = el('form', { id: 'wb-postform', onsubmit: handleLocalPostSubmit });
    const table = el('table', { class: 'wb-postform-table' });
    const row = (label, ...inputs) => {
      const tr = el('tr');
      tr.append(el('td', { class: 'wb-postform-label' }, label));
      const td = el('td', { class: 'wb-postform-input' });
      for (const inp of inputs) td.append(inp);
      tr.append(td);
      table.append(tr);
    };
    row('Name', el('input', { id: 'wb-post-name', name: 'name', type: 'text', size: 28, value: identity.name }));
    row('E-mail', el('input', { id: 'wb-post-email', name: 'email', type: 'text', size: 28, value: identity.email }));
    row('Subject',
      el('input', { id: 'wb-post-subject', name: 'sub', type: 'text', size: 24, value: '' }),
      document.createTextNode(' '),
      el('button', { type: 'submit' }, isReply ? 'Reply' : 'Submit'));
    row('Comment', el('textarea', { id: 'wb-post-comment', name: 'com', rows: 4, cols: 48 }));
    row('File', el('input', { id: 'wb-post-file', name: 'upfile', type: 'file', accept: 'image/gif,image/jpeg,image/png' }));
    row('Password',
      el('input', { id: 'wb-post-password', name: 'pwd', type: 'password', size: 8, value: identity.password }),
      document.createTextNode(' (for post deletion)'));
    const msgRow = el('tr');
    msgRow.append(el('td', { class: 'wb-postform-msg', colspan: 2 }, isReply ? `Reply to No.${engine.openThread}` : ''));
    table.append(msgRow);
    form.append(table);
    return form;
  }

  function renderRules() {
    const rules = el('div', { id: 'wb-rules' });
    const items = [
      'Supported file types are: GIF, JPG, PNG.',
      'Maximum file size allowed is 1024 KB.',
      'Images greater than 250x250 pixels will be thumbnailed.',
      'Read the rules and FAQ before posting.'
    ];
    for (const text of items) rules.append(el('div', { class: 'wb-rule-item' }, text));
    return rules;
  }

  function renderShell() {
    ensureStyles();
    let root = $('#wb-overlay');
    if (!root) {
      root = el('div', { id: 'wb-overlay' });
      (document.body || document.documentElement).append(root);
      // Force-hide 4chan's own content in case the stylesheet didn't load
      if (document.body) {
        for (const ch of document.body.children) {
          if (ch.id !== 'wb-overlay') ch.style.setProperty('display', 'none', 'important');
        }
      }
    }
    root.innerHTML = '';
    // Inline SVG filter: binary alpha threshold kills DirectWrite anti-aliasing,
    // giving text the crunchy bitmap look of old Windows GDI rendering.
    const svgNS = 'http://www.w3.org/2000/svg';
    const svg = document.createElementNS(svgNS, 'svg');
    svg.setAttribute('width', '0');
    svg.setAttribute('height', '0');
    svg.style.position = 'absolute';
    const filt = document.createElementNS(svgNS, 'filter');
    filt.setAttribute('id', 'wb-crunch');
    const ct = document.createElementNS(svgNS, 'feComponentTransfer');
    const fa = document.createElementNS(svgNS, 'feFuncA');
    fa.setAttribute('type', 'discrete');
    fa.setAttribute('tableValues', '0 1');
    ct.append(fa);
    filt.append(ct);
    svg.append(filt);
    root.append(svg);
    root.classList.toggle('wb-min', engine.barHidden);
    root.append(renderControlBar());
    root.append(el('div', { id: 'wb-restore', onclick: () => setBarHidden(false) }, 'show controls'));
    root.append(renderChrome());

    const title = el('div', { class: 'wb-boardtitle' },
      `/${engine.board}/ - ${BOARD_NAMES[engine.board] || engine.board.toUpperCase()}`);
    root.append(title);
    root.append(el('hr', { class: 'wb-titlerule' }));
    root.append(renderPostForm());
    root.append(renderRules());
    root.append(navBar('top'));

    if (engine.openThread) {
      root.append(el('div', { id: 'wb-thread-posts', class: 'wb-thread' }));
    } else if (engine.catalogView) {
      root.append(el('div', { id: 'wb-catalog', class: 'wb-catalog' }));
    } else {
      root.append(el('div', { id: 'wb-index', class: 'wb-index' }));
    }

    root.append(el('hr', { class: 'wb-titlerule' }));
    root.append(navBar('bottom'));
    updateClockDisplay();
    updateTitle();
  }

  function scrollOverlayToTop() {
    const root = $('#wb-overlay');
    if (!root) return;
    root.scrollTop = 0;
    root.scrollLeft = 0;
    requestAnimationFrame(() => {
      root.scrollTop = 0;
      root.scrollLeft = 0;
    });
  }

  // The [Return]/[Update]/[Top]/[Bottom] links 4chan put at the top and bottom
  // of every page. Top/Bottom scroll our overlay (the scroll container).
  function indexPageLinks() {
    const nodes = [];
    // Era-correct Previous/Next form buttons flanking the page list, as the
    // bottom of every real 4chan index page had.
    const btn = (label, page, enabled) => el('button', enabled
      ? { class: 'wb-pagebtn', onclick: (e) => { e.preventDefault(); goIndex(page); } }
      : { class: 'wb-pagebtn', disabled: 'disabled' }, label);
    const pages = boardIndexPages();
    nodes.push(btn('Previous', engine.indexPage - 1, engine.indexPage > 1), ' ');
    for (let i = 1; i <= pages; i++) {
      if (i === engine.indexPage) {
        nodes.push(el('span', { class: 'wb-pagecur' }, `[${i}]`));
      } else {
        nodes.push(el('a', {
          class: 'wb-navlink wb-pagelink',
          href: indexPath(i),
          onclick: (e) => { e.preventDefault(); goIndex(i); }
        }, `[${i}]`));
      }
      if (i < pages) nodes.push(' ');
    }
    nodes.push(' ', btn('Next', engine.indexPage + 1, engine.indexPage < pages));
    return nodes;
  }
  function navBar(position = 'top') {
    const overlay = () => $('#wb-overlay');
    const mk = (label, fn) => el('a', { class: 'wb-navlink', href: 'javascript:void(0)',
      onclick: (e) => { e.preventDefault(); fn(); } }, `[${label}]`);
    const top = () => { const o = overlay(); if (o) o.scrollTo({ top: 0 }); };
    const bottom = () => { const o = overlay(); if (o) o.scrollTo({ top: o.scrollHeight }); };
    const jump = position === 'bottom' ? mk('Top', top) : mk('Bottom', bottom);
    const bar = el('div', { class: 'wb-nav' });
    if (engine.openThread) {
      const update = el('a', { id: 'wb-update', class: 'wb-navlink', href: 'javascript:void(0)',
        onclick: (e) => { e.preventDefault(); revealThreadPosts(); } }, '[Update]');
      const auto = el('a', { id: 'wb-auto', class: 'wb-navlink', href: 'javascript:void(0)',
        onclick: (e) => {
          e.preventDefault();
          engine.autoUpdate = !engine.autoUpdate;
          saveSettings();
          auto.textContent = `[Auto-update: ${engine.autoUpdate ? 'on' : 'off'}]`;
          if (engine.autoUpdate) revealThreadPosts();
        } }, `[Auto-update: ${engine.autoUpdate ? 'on' : 'off'}]`);
      bar.append(mk('Return', backToIndex), ' ', mk('Index', goIndex), ' ', mk('Catalog', goCatalog), ' ', update, ' ', auto, ' ', jump);
    } else if (engine.catalogView) {
      bar.append(mk('Index', () => goIndex(1)), ' ', mk('Update', refreshCatalogData), ' ', jump);
    } else {
      bar.append(mk('Catalog', goCatalog), ' ', mk('Update', refreshIndexSnapshot), ' ', ...indexPageLinks(), ' ', jump);
    }
    return bar;
  }

  // ── Gentle background prefetch (stay ahead of the playhead) ─────────────────
  function enqueuePrefetch(num) {
    if (engine.threads.has(String(num)) || engine.prefetchQueue.includes(num)) return;
    engine.prefetchQueue.push(num);
    drainPrefetch();
  }
  async function drainPrefetch() {
    if (engine.prefetching) return;
    engine.prefetching = true;
    const worker = async () => {
      while (engine.prefetchQueue.length) {
        const num = engine.prefetchQueue.pop(); // newest-first: the threads on top fill in first
        const alreadyHydrated = engine.threads.has(String(num));
        await fetchThread(engine.board, num, { preferCache: true });
        if (!alreadyHydrated && CONFIG.prefetchDelayMs) await sleep(CONFIG.prefetchDelayMs);
      }
    };
    try {
      const n = Math.max(1, Math.min(CONFIG.prefetchConcurrency || 1, engine.prefetchQueue.length || 1));
      await Promise.all(Array.from({ length: n }, worker));
    } finally {
      engine.prefetching = false;
    }
    if (engine.prefetchQueue.length) drainPrefetch();
  }

  // ── Boot ────────────────────────────────────────────────────────────────────
  function parseURL() {
    const m = location.pathname.match(/^\/([a-z0-9]+)\/(?:(catalog)|thread\/(\d+)|(\d+))?\/?/i);
    return {
      board: m ? m[1] : null,
      catalog: !!(m && m[2]),
      thread: m && m[3] ? m[3] : null,
      page: m && m[4] ? clampIndexPage(m[4]) : 1
    };
  }

  async function boot(opts = {}) {
    resetIndexState();
    engine.shownPosts = new Set();
    const token = engine.catalogToken;

    // "Go" / an explicit date change starts a brand-new epoch; a plain refresh
    // must NOT — the persisted anchor keeps the clock synced to real time.
    if (opts.freshClock) clearAnchor();

    // Direct navigation or reload into a thread URL. 4chan 404s the long-dead
    // thread, but our script still runs on that page; we overlay the archived
    // thread and let it define the date + clock (the thread number is in the URL).
    if (engine.openThread) {
      // Seed an index entry beneath the thread so Back/Return returns to the
      // board instead of leaving 4chan.
      history.replaceState({ wb: 'index' }, '', `/${engine.board}/`);
      history.pushState({ wb: 'thread', num: engine.openThread }, '', `/${engine.board}/thread/${engine.openThread}`);
      renderShell();
      await openThread(engine.openThread, { startFromOP: true });
      return;
    }

    renderShell();
    const idx = $('#wb-index') || $('#wb-catalog');
    if (idx) idx.append(el('div', { id: 'wb-loading', class: 'wb-note' },
      `Loading /${engine.board}/ for ${CONFIG.date} — finding the day's threads…`));
    ensureAnchor();
    startTimer();

    const cachedOps = cachedCatalogOps(engine.board, CONFIG.date);
    if (cachedOps.length) {
      engine.ops = cachedOps;
      loadCachedThreadSummariesIntoMemory(engine.board, engine.ops);
      loadCachedThreadsIntoMemory(engine.board, engine.ops);
      refreshCurrentBoardSnapshot();
    }

    await loadBoardOps(token);
  }

  // Enumerate the board and keep retrying on failure — rate limits lift and
  // outages pass, so the index must never die on its loading note. Stops
  // only when the user navigates (token change) or opens a thread.
  async function loadBoardOps(token, attempt = 0) {
    // Live progress in the loading note: how many threads are in, how full
    // the board is, and which day the backward scan is on — so a slow cold
    // load reads as work happening instead of a dead page.
    const onProgress = (partialOps, meta = {}) => {
      if (token !== engine.catalogToken || engine.openThread) return;
      engine.ops = partialOps;
      refreshCurrentBoardSnapshot();
      const note = $('#wb-loading');
      if (!note) return;
      const bits = [`Loading /${engine.board}/ for ${CONFIG.date}`];
      bits.push(`${partialOps.length} thread${partialOps.length === 1 ? '' : 's'} found`);
      if (typeof meta.visibleAtEnd === 'number' && meta.target) {
        bits.push(`${Math.min(meta.visibleAtEnd, meta.target)}/${meta.target} board slots filled`);
      }
      if (meta.scanDay && meta.scanOffset > 0) {
        bits.push(`scanning ${meta.scanDay} for older active threads (day ${meta.scanOffset + 1} of up to ${meta.maxDays})`);
      }
      note.textContent = bits.join(' — ') + '…';
    };
    let ops = null;
    try {
      ops = await enumerateCatalogCandidates(engine.board, CONFIG.date, { atClock: engine.clock, onProgress });
    } catch (e) {
      cacheDebug('warn', 'board enumeration failed', { board: engine.board, date: CONFIG.date, attempt, error: String(e && e.message || e) });
    }
    if (token !== engine.catalogToken) return;

    if (ops && ops.length) {
      const note = $('#wb-loading');
      if (note) note.remove(); // enumeration done — the sync note takes over
      engine.ops = ops;
      loadCachedThreadSummariesIntoMemory(engine.board, engine.ops);
      loadCachedThreadsIntoMemory(engine.board, engine.ops);
      refreshCurrentBoardSnapshot();
      hydrateCatalog(engine.board, engine.ops);
      return;
    }

    const wait = Math.min(120000, 10000 * Math.pow(1.6, attempt)) + Math.floor(Math.random() * 5000);
    const host = $('#wb-index') || $('#wb-catalog');
    if (host) {
      let note = $('#wb-loading', host);
      if (!note) { note = el('div', { id: 'wb-loading', class: 'wb-note' }); host.append(note); }
      note.textContent = `No threads loaded for /${engine.board}/ on ${CONFIG.date} yet — ` +
        `the archives may be rate limiting or the board may have nothing archived that day. ` +
        `Retrying in ${Math.round(wait / 1000)}s…`;
    }
    setTimeout(() => {
      if (token === engine.catalogToken && !engine.openThread) loadBoardOps(token, attempt + 1);
    }, wait);
  }

  function init() {
    ensureStyles(); // in case the document-start injection ran before <head> existed
    try { initStorageEstimate(); } catch (e) { /* storage may be unreadable */ }
    try {
      for (const k of cacheKeys()) {
        if (k.startsWith('thr:')) cacheDelete(k); // legacy GM thread cache, now in Cache Storage
        else if (k.startsWith('media:') && !k.startsWith('media:v13:')) cacheDelete(k); // stale resolve versions
        else if (k.startsWith('catalog:') && !k.startsWith('catalog:v10:')) cacheDelete(k); // shallow-sampled catalogs
      }
    } catch (e) { /* best-effort cleanup */ }
    try { pruneStorage(null); } catch (e) { /* fallback prune */ }

    // Grab 4chan's genuine rotating banner before the page is hidden, so we can
    // show the real thing (with its own shuffle-on-click) inside our chrome.
    engine.realBanner = document.querySelector('#bannerCnt');

    const saved = cacheGet('settings');
    if (saved) {
      engine.board = saved.board || engine.board;
      CONFIG.date = saved.date || CONFIG.date;
      CONFIG.startTime = saved.startTime || CONFIG.startTime;
      engine.speed = saved.speed || engine.speed;
      engine.barHidden = !!saved.barHidden;
      if (typeof saved.autoUpdate === 'boolean') engine.autoUpdate = saved.autoUpdate;
      if (typeof saved.markArchiveOrgMedia === 'boolean') CONFIG.markArchiveOrgMedia = saved.markArchiveOrgMedia;
      if (typeof saved.mediaDebug === 'boolean') CONFIG.mediaDebug = saved.mediaDebug;
      if (typeof saved.cacheDebug === 'boolean') CONFIG.cacheDebug = saved.cacheDebug;
      engine.catalogSort = normCatalogSort(saved.catalogSort);
      applyTheme(saved.theme || saved.colors);
      applyDesign(saved.design);
      applyFont(saved.font);
    }
    applyIaStarMode();

    // Reflect the persisted clock epoch in the speed/pause controls before the
    // first render, so the bar matches the clock we're about to resume.
    const savedAnchor = loadAnchor();
    if (savedAnchor) {
      engine.speed = savedAnchor.speed || engine.speed;
      engine.paused = !!savedAnchor.paused;
    }

    const { board, catalog, thread, page } = parseURL();
    if (board) engine.board = board;
    engine.openThread = thread;
    engine.catalogView = !!catalog && !thread;
    engine.indexPage = page || 1;
    updateTitle(); // replace the 404 tab title as early as possible

    // Browser Back/Forward: re-render whichever view the URL now points at,
    // without pushing history again.
    window.addEventListener('popstate', () => {
      const cur = parseURL();
      if (cur.board) engine.board = cur.board;
      if (cur.thread) { engine.openThread = cur.thread; engine.catalogView = false; openThread(cur.thread); }
      else if (cur.catalog) showCatalogView({ refresh: true });
      else showIndexView({ page: cur.page || 1, refresh: true });
    });

    GM_registerMenuCommand('Replay this board on a different date', () => {
      const d = prompt('Replay date (YYYY-MM-DD):', CONFIG.date);
      if (d) { CONFIG.date = d; saveSettings(); boot({ freshClock: true }); }
    });
    GM_registerMenuCommand(`${CONFIG.mediaDebug ? 'Disable' : 'Enable'} image fetch diagnostics`, () => {
      CONFIG.mediaDebug = !CONFIG.mediaDebug;
      saveSettings();
      if (CONFIG.mediaDebug) {
        mediaDebug('debug', 'diagnostics enabled', {
          note: 'Image fetch diagnostics are now logging to the console and window.oldchanMediaLog.'
        });
      } else {
        try { console.info('[oldchan media] diagnostics disabled'); } catch (e) { /* console unavailable */ }
      }
    });
    GM_registerMenuCommand('Dump image fetch diagnostics', () => {
      try {
        console.table(_mediaDebugLog.map((e) => ({
          ts: e.ts,
          level: e.level,
          msg: e.msg,
          source: e.data && e.data.source || '',
          status: e.data && e.data.status || '',
          type: e.data && e.data.type || '',
          size: e.data && e.data.size || '',
          reason: e.data && e.data.reason || '',
          url: e.data && e.data.url || ''
        })));
        console.log('[oldchan media] raw diagnostics', _mediaDebugLog);
      } catch (e) { /* console unavailable */ }
    });
    GM_registerMenuCommand('Clear image fetch diagnostics', () => {
      _mediaDebugLog.length = 0;
      try { console.info('[oldchan media] diagnostics cleared'); } catch (e) { /* console unavailable */ }
    });
    GM_registerMenuCommand('Clear cached image fetches', () => {
      let deleted = 0;
      for (const k of cacheKeys()) {
        if (/^media:/.test(k) && cacheDelete(k)) deleted++;
      }
      _blobCache.clear();
      _postBlobCache.clear();
      _postBlobResultCache.clear();
      _postMediaCache.clear();
      _searchMediaCache.clear();
      _iaMlpIndex = null;
      _iaMlpIndexPromise = null;
      if (mediaCacheAvailable() || archiveOrgIndexCacheAvailable()) {
        _mediaCacheHandle = null; // stale after delete — reopen on next use
        Promise.all([
          mediaCacheAvailable() ? caches.delete(MEDIA_CACHE_NAME) : Promise.resolve(false),
          archiveOrgIndexCacheAvailable() ? caches.delete(IA_MLP_INDEX_CACHE_NAME) : Promise.resolve(false)
        ]).then(([mediaOk, indexOk]) => {
          try { console.info(`[oldchan media] cleared ${deleted} image resolution entries; persistent media cache deleted: ${mediaOk}; archive.org md5 index cache deleted: ${indexOk}`); } catch (e) { /* console unavailable */ }
        });
      } else {
        try { console.info(`[oldchan media] cleared ${deleted} image resolution entries`); } catch (e) { /* console unavailable */ }
      }
    });
    GM_registerMenuCommand('Clear cached threads', () => {
      if ('caches' in window && window.caches) {
        _threadCacheHandle = null; // stale after delete — reopen on next use
        caches.delete(THREAD_CACHE_NAME).then((ok) => {
          try { console.info(`[oldchan] persistent thread cache deleted: ${ok}`); } catch (e) { /* console unavailable */ }
        });
      }
    });

    // The clock is a pure function of wall time, but setInterval is throttled or
    // suspended in background tabs (and the OS may sleep). Recompute from the
    // anchor the instant we're visible/focused again, and when restored from the
    // back-forward cache — so the displayed time is never stale on return.
    const resyncClock = () => { if (engine.anchor) tick(); };
    document.addEventListener('visibilitychange', () => { if (!document.hidden) resyncClock(); });
    window.addEventListener('focus', resyncClock);
    window.addEventListener('pageshow', resyncClock);

    boot();
  }

  // run after DOM exists, but we overlay so we don't depend on 4chan's content
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }

  // ── Era-correct styling (Yotsuba B, the worksafe theme) ─────────────────────
  function getCSS() { return `
    /* Yotsuba B (worksafe blue) — the default palette. */
    html.wb-active {
      --wb-page-bg:#EEF2FF; --wb-text:#000000; --wb-link:#34345C; --wb-link-hover:#DD0000;
      --wb-rule:#B7C5D9; --wb-title:#AF0A0F; --wb-reply-bg:#D6DAF0; --wb-reply-border:#B7C5D9;
      --wb-name:#117743; --wb-trip:#117743; --wb-subject:#0F0C5D; --wb-no:#000000;
      --wb-quote:#789922; --wb-quotelink:#DD0000; --wb-hl-bg:#D6BAD0; --wb-hl-border:#BA9DBF;
      --wb-bar-bg:#D6DAF0; --wb-nav:#8899AA; --wb-dim:#707070; --wb-thumb-bg:#EEF2FF; --wb-arrows:#B7C5D9;
      --wb-form-label:#98ABD9;
    }
    /* Tomorrow — dark theme (real values from tomorrow.css). */
    html.wb-active.wb-colors-tomorrow {
      --wb-page-bg:#1d1f21; --wb-text:#c5c8c6; --wb-link:#81a2be; --wb-link-hover:#5F89AC;
      --wb-rule:#282a2e; --wb-title:#c5c8c6; --wb-reply-bg:#282a2e; --wb-reply-border:#282a2e;
      --wb-name:#c5c8c6; --wb-trip:#c5c8c6; --wb-subject:#b294bb; --wb-no:#c5c8c6;
      --wb-quote:#b5bd68; --wb-quotelink:#5F89AC; --wb-hl-bg:#1D1D21; --wb-hl-border:#111111;
      --wb-bar-bg:#282a2e; --wb-nav:#c5c8c6; --wb-dim:#707070; --wb-thumb-bg:#1d1f21; --wb-arrows:#c5c8c6;
      --wb-form-label:#383a3e;
    }
    /* Yotsuba — the original red/cream palette (genuine values from archived yotsuba.9.css). */
    html.wb-active.wb-colors-yotsuba {
      --wb-page-bg:#FFFFEE; --wb-text:#800000; --wb-link:#0000EE; --wb-link-hover:#DD0000;
      --wb-rule:#D9BFB7; --wb-title:#800000; --wb-reply-bg:#F0E0D6; --wb-reply-border:#D9BFB7;
      --wb-name:#117743; --wb-trip:#228854; --wb-subject:#CC1105; --wb-no:#800000;
      --wb-quote:#789922; --wb-quotelink:#000080; --wb-hl-bg:#F0C0B0; --wb-hl-border:#D99F91;
      --wb-bar-bg:#F0E0D6; --wb-nav:#BB8866; --wb-dim:#707070; --wb-thumb-bg:#FFFFEE; --wb-arrows:#D9BFB7;
      --wb-form-label:#EEAA88;
    }
    /* ═══ 2005 design ═══════════════════════════════════════════════════ */
    /* Post form */
    #wb-postform { display:block; text-align:center; margin:4px 0; }
    .wb-postform-table { margin:0 auto; border-spacing:1px; }
    .wb-postform-label { background:var(--wb-form-label); color:var(--wb-text); font-weight:700;
      padding:1px 5px; font-size:10pt; text-align:left; }
    .wb-postform-input { padding:1px; }
    .wb-postform-input input[type="text"],
    .wb-postform-input input[type="password"],
    .wb-postform-input textarea { border:1px solid #aaa; font-family:arial,helvetica,sans-serif; font-size:10pt; }
    .wb-postform-input input:focus,
    .wb-postform-input textarea:focus { border-color:#ea8; outline:none; }
    .wb-postform-msg { text-align:center; color:var(--wb-dim); font-size:9pt; padding:2px; }
    .wb-postform-error { color:#DD0000; font-weight:bold; }
    /* Rules section */
    #wb-rules { display:none; }
    html.wb-active.wb-design-2005 #wb-rules {
      display:block; text-align:center; margin:4px 0 2px; font-size:9pt; color:var(--wb-text); }
    .wb-rule-item::before { content:"\\2666  "; }
    .wb-rule-item { margin:1px 0; }
    /* Embossed XP-style form controls */
    html.wb-active.wb-design-2005 button,
    html.wb-active.wb-design-2005 #wb-bar button,
    html.wb-active.wb-design-2005 .wb-postform-input button {
      background:#ece9d8; border:2px outset #ece9d8; color:#000; padding:1px 8px; cursor:pointer; font-size:11px; }
    html.wb-active.wb-design-2005 button:active { border-style:inset; }
    html.wb-active.wb-design-2005 select,
    html.wb-active.wb-design-2005 #wb-bar select {
      background:#ece9d8; border:2px outset #ece9d8; color:#000; font-size:11px; padding:0 2px; }
    html.wb-active.wb-design-2005 input[type="text"],
    html.wb-active.wb-design-2005 input[type="date"],
    html.wb-active.wb-design-2005 input[type="time"],
    html.wb-active.wb-design-2005 input[type="password"],
    html.wb-active.wb-design-2005 textarea { border:2px inset #ece9d8; background:#fff; }
    html.wb-active.wb-design-2005 input[type="file"]::file-selector-button {
      background:#ece9d8; border:2px outset #ece9d8; color:#000; padding:1px 6px; cursor:pointer;
      font-size:11px; font-family:arial,helvetica,sans-serif; }
    html.wb-active.wb-design-2005 input[type="file"]::file-selector-button:active { border-style:inset; }
    /* Board title stays Tahoma (already default) */
    html.wb-active.wb-design-2005 .wb-boardtitle { font-family:Tahoma,Geneva,sans-serif; }
    /* Reply link is plain (no underline) in 2005 */
    html.wb-active.wb-design-2005 .wb-replylink { color:var(--wb-link); text-decoration:none; }
    html.wb-active.wb-design-2005 .wb-replylink:hover { color:var(--wb-link-hover); }
    html.wb-active, html.wb-active body { margin:0 !important; padding:0 !important; background:var(--wb-page-bg) !important; overflow:hidden !important; }
    html.wb-active body > *:not(#wb-overlay) { display:none !important; }
    #wb-overlay {
      position:fixed; inset:0; z-index:2147483646; overflow:auto;
      background:var(--wb-page-bg); color:var(--wb-text); text-align:left;
      font-family: arial, helvetica, sans-serif; font-size:10pt;
      /* kill anti-aliasing for the crunchy old-monitor look (effective on
         WebKit/Blink; on Windows the OS partly governs this) */
      -webkit-font-smoothing:none; font-smooth:never; text-rendering:optimizeSpeed;
    }
    /* Binary alpha threshold: snaps every text pixel to fully opaque or
       transparent, replicating old Windows GDI bitmap rendering. Applied to
       text containers only so images stay smooth. */
    html.wb-font-2005 :is(.wb-postinfo, .wb-comment, .wb-fileinfo, .wb-omitted,
    .wb-nav, .wb-boardtitle, .wb-note,
    #wb-bar, #wb-boardnav, #wb-postform, #wb-rules,
    .wb-catalog-meta, .wb-catalog-title, .wb-catalog-text) {
      filter: url(#wb-crunch);
    }
    #wb-overlay a, #wb-overlay a:visited { color:var(--wb-link); text-decoration:none; }
    #wb-overlay a:hover { color:var(--wb-link-hover); }
    #wb-overlay hr { border:none; border-top:1px solid var(--wb-rule); height:0; }

    /* thin utility strip — 4chan had no such bar, so keep it quiet and plain */
    #wb-bar {
      position:sticky; top:0; z-index:5; background:var(--wb-bar-bg); border-bottom:2px solid var(--wb-rule);
      padding:3px 5px; font-size:12px; color:var(--wb-text); display:flex; gap:8px; align-items:center; flex-wrap:wrap;
    }
    #wb-bar label { color:var(--wb-text); }
    #wb-bar input, #wb-bar select, #wb-bar button { font-size:12px; font-family:arial,helvetica,sans-serif; }
    #wb-bar #wb-clock { margin-left:auto; font-weight:bold; color:var(--wb-text); }
    #wb-bar #wb-ratelimit { color:#c00; font-weight:bold; }
    #wb-bar #wb-ratelimit:empty { display:none; }

    /* real 4chan top chrome: board list + nav links + relocated banner */
    #wb-chrome { padding:2px 0 0; }
    #wb-boardnav { font-size:9pt; line-height:1.5; padding:2px 5px 0; color:var(--wb-nav); overflow:hidden; }
    #wb-boardnav .wb-boardlink { color:var(--wb-link); text-decoration:none; }
    #wb-boardnav .wb-boardlink:hover { color:var(--wb-link-hover); text-decoration:underline; }
    #wb-navright { float:right; white-space:nowrap; }
    #wb-banner { text-align:center; margin:5px auto 0; clear:both; }
    #wb-banner #bannerCnt, #wb-banner .wb-title-banner { display:inline-block !important; cursor:pointer; }
    #wb-banner img { max-width:100%; height:auto; }

    .wb-boardtitle {
      text-align:center; font-family:Tahoma, Geneva, sans-serif; font-size:28px; font-weight:bold;
      color:var(--wb-title); letter-spacing:-2px; padding:6px 0 0;
    }
    .wb-titlerule { border:none; border-top:1px solid var(--wb-rule); margin:6px 5px; }
    .wb-nav { padding:2px 5px; font-size:9pt; color:var(--wb-nav); }
    .wb-navlink { color:var(--wb-link); text-decoration:underline; margin-right:6px; }
    .wb-navlink:hover { color:var(--wb-link-hover); }
    .wb-pagecur { color:var(--wb-text); font-weight:bold; margin-right:4px; }
    .wb-pagelink { margin-right:3px; }
    #wb-overlay.wb-min #wb-bar { display:none; }
    #wb-restore { display:none; position:fixed; top:4px; right:6px; z-index:7;
      background:var(--wb-bar-bg); border:1px solid var(--wb-rule); color:var(--wb-link); font-size:12px; padding:1px 6px; cursor:pointer; }
    #wb-overlay.wb-min #wb-restore { display:block; }

    .wb-index, .wb-thread { padding:4px 5px 8px; }
    .wb-threadcard { margin:0 0 6px; }
    /* Catalog: genuine Yotsuba Catalog (desuwa) layout, painted in 4chan theme colors.
       Centered inline-block grid, bordered thumbnails, no per-card boxes. */
    .wb-catalog {
      box-sizing:border-box; width:100%; margin:0 auto;
      padding:10px 4px 8px; text-align:center; position:relative;
      font:11px Arial, sans-serif; line-height:1.2;
    }
    .wb-catalog .wb-note { display:block; }
    .wb-catalog-card {
      display:inline-block; vertical-align:top; text-align:center;
      width:152px; margin:0 2px 7px; padding:2px 0 3px;
      word-wrap:break-word; overflow:hidden; max-height:300px; box-sizing:border-box;
      cursor:pointer;
    }
    .wb-catalog-thumb { display:inline-block; line-height:0; margin:0 0 2px; }
    .wb-catalog-thumb img {
      max-width:150px; max-height:150px; cursor:pointer; vertical-align:bottom;
      border:0; border-radius:0; box-shadow:none;
    }
    .wb-catalog-noimage {
      display:inline-flex; align-items:center; justify-content:center;
      width:150px; height:110px; background:var(--wb-thumb-bg);
      border:1px solid var(--wb-reply-border); margin:0 0 2px;
    }
    .wb-catalog-noimage::before { content:"No image"; color:var(--wb-dim); font-size:11px; }
    .wb-catalog-missing img, .wb-missing-placeholder { opacity:.82; filter:saturate(.85); }
    /* A little pixel hourglass: choppy old-Windows-cursor flip with sand that
       drops in discrete steps. The frame flips 180 each half-cycle; the two
       triangles (top drains, bottom fills) are the sand. */
    .wb-media-loader {
      display:inline-block; position:relative; width:9px; height:13px; margin-left:4px;
      color:var(--wb-dim); vertical-align:-2px; opacity:.8; box-sizing:border-box;
      border-top:1px solid currentColor; border-bottom:1px solid currentColor;
      animation:wb-hg-flip 1.3s linear infinite;
    }
    .wb-media-loader::before, .wb-media-loader::after {
      content:""; position:absolute; left:0; right:0; width:0; height:0; margin:auto;
      border-left:3.5px solid transparent; border-right:3.5px solid transparent;
    }
    .wb-media-loader::before { top:0; border-top:5px solid currentColor; animation:wb-hg-fill .65s steps(4) infinite alternate; }
    .wb-media-loader::after  { bottom:0; border-bottom:5px solid currentColor; animation:wb-hg-drain .65s steps(4) infinite alternate; }
    .wb-catalog-loader { width:10px; height:14px; margin-left:0; }
    /* The flip: hold upright, snap through an edge-on frame to 180, hold, snap back. */
    @keyframes wb-hg-flip {
      0%, 42%  { transform:rotate(0deg); }
      46%      { transform:rotate(90deg); }
      50%, 92% { transform:rotate(180deg); }
      96%      { transform:rotate(270deg); }
      100%     { transform:rotate(360deg); }
    }
    @keyframes wb-hg-drain { from { border-top-width:5px; }  to { border-top-width:0; } }
    @keyframes wb-hg-fill  { from { border-bottom-width:0; } to { border-bottom-width:5px; } }
    @media (prefers-reduced-motion: reduce) {
      .wb-media-loader, .wb-media-loader::before, .wb-media-loader::after { animation:none; }
    }
    .wb-catalog-meta { color:var(--wb-text); font-size:10px; line-height:11px; margin:1px 0; }
    .wb-catalog-open { display:none; }
    .wb-catalog-title { color:var(--wb-subject); font-weight:bold; font-size:11px; overflow-wrap:anywhere; display:block; }
    .wb-catalog-text { color:var(--wb-text); font-size:11px; overflow-wrap:anywhere; display:block; line-height:13px; margin-top:1px; }

    /* OP: no box, just contains its floated image (div.post{overflow:hidden}). */
    .wb-op { display:block; overflow:hidden; margin:4px 0; clear:both; }
    /* Reply: the light-purple box that shrink-wraps content (div.reply{display:table}). */
    .wb-postrow { display:block; clear:both; margin:4px 0; }
    .wb-arrows { float:left; margin:0 3px 0 2px; color:var(--wb-arrows); line-height:1.25; }
    .wb-reply {
      display:table; padding:2px; margin:0;
      background:var(--wb-reply-bg); border:1px solid var(--wb-reply-border); border-left:none; border-top:none;
    }
    .wb-reply:target, .wb-reply.wb-highlight {
      background:var(--wb-hl-bg); border:1px solid var(--wb-hl-border); border-left:none; border-top:none;
    }
    .wb-op.wb-highlight { background:var(--wb-hl-bg); }
    .wb-postinfo { display:block; width:100%; line-height:1.25; }
    .wb-name { color:var(--wb-name); font-weight:bold; }
    .wb-trip { color:var(--wb-trip); font-weight:normal; }
    .wb-subject { color:var(--wb-subject); font-weight:bold; }
    .wb-no { color:var(--wb-no); }
    .wb-backlinks { font-size:x-small; }
    .wb-backlink { margin-left:0; }
    .wb-replylink { color:var(--wb-link); text-decoration:underline; margin-left:4px; }
    /* The 40px blockquote indent is the browser default 4chan relied on. */
    .wb-comment { display:block; margin:1em 40px; line-height:1.25; word-wrap:break-word; overflow-wrap:break-word; }
    .wb-quote { color:var(--wb-quote); }
    .wb-spoiler { background:#000 !important; }
    .wb-spoiler, .wb-spoiler * { color:#000 !important; }
    .wb-spoiler:hover, .wb-spoiler:hover * { color:#fff !important; }
    .wb-quotelink { color:var(--wb-quotelink); text-decoration:underline; }
    .wb-omitted { display:block; color:var(--wb-dim); margin:2px 0 2px 20px; }
    .wb-threadicon { vertical-align:text-bottom; margin:0 1px; }
    .wb-pagebtn { font-size:11px; }
    .wb-fullmissing { color:var(--wb-dim); font-style:italic; font-size:11px; }
    .wb-ia-star { display:none; color:#fc0; text-shadow:0 0 1px #a80; cursor:default; }
    html.wb-ia-stars .wb-ia-star { display:inline; }
    #wb-iastars.wb-on { color:#fc0; }
    .wb-previews { }
    .wb-previewrow { margin:0; }
    .wb-file { display:block; }
    .wb-fileinfo { color:var(--wb-text); margin-right:10px; }
    .wb-reply .wb-fileinfo { margin-left:20px; }
    .wb-fileinfo.wb-media-unavailable::after { content:" [image unavailable]"; color:var(--wb-dim); }
    .wb-thumb { float:left; margin:3px 20px 5px 20px; cursor:pointer; border:none; position:relative; z-index:1; }
    .wb-thumb.wb-missing-placeholder { cursor:default; }
    .wb-op .wb-thumb { max-width:250px; max-height:250px; }
    .wb-reply .wb-thumb { max-width:125px; max-height:125px; }
    .wb-thumb.wb-expanded { max-width:90vw; max-height:none; }
    .wb-thumb.wb-expanded.wb-thumb-fallback { width:min(420px, 90vw); height:auto; image-rendering:auto; }
    .wb-threadcard hr, .wb-thread hr { clear:both; border:none; border-top:1px solid var(--wb-rule); margin:4px 0; }
    .wb-note { padding:8px 4px; color:var(--wb-dim); }
  `; }
})();