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).

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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