Scratch Studio Realtime Comments

Shows new Scratch studio comments without a manual reload.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name            Scratch Studio Realtime Comments
// @name:ja         Scratch スタジオ リアルタイムコメント
// @namespace       https://github.com/mokuzyy/scratch-studio-realtime-comments
// @version         0.1.0
// @description     Shows new Scratch studio comments without a manual reload.
// @description:ja  Scratch のスタジオコメント欄で、新規コメント/返信を手動リロードなしにリアルタイム表示します。
// @author          mokuzyy
// @license         MIT
// @match           https://scratch.mit.edu/studios/*
// @run-at          document-idle
// @grant           GM_xmlhttpRequest
// @grant           GM_getValue
// @grant           GM_setValue
// @grant           GM_addStyle
// @grant           GM_registerMenuCommand
// @grant           GM_addValueChangeListener
// @connect         api.scratch.mit.edu
// ==/UserScript==

(function () {
  "use strict";

  // Inject the extension stylesheet (manifest "css" replacement).
  GM_addStyle(".srtc-managed-top {\n  display: block;\n  width: 100%;\n}\n\n.srtc-comment-container {\n  animation: srtc-fade-in 180ms ease-out;\n}\n\n.srtc-comment .comment-content a {\n  overflow-wrap: anywhere;\n}\n\n/* Native avatars get their size from .avatar-wrapper (--avatar-size: 3rem),\n   which our synthetic nodes don't have. Pin the size so it matches Scratch. */\n.srtc-comment-avatar {\n  flex: 0 0 auto;\n  display: block;\n  width: 3rem;\n  height: 3rem;\n}\n\n.srtc-comment-avatar .avatar {\n  width: 3rem;\n  height: 3rem;\n  object-fit: cover;\n  border-radius: 4px;\n  box-shadow: 0 0 0 1px rgba(77, 151, 255, 0.25);\n}\n\n.srtc-reply-button {\n  appearance: none;\n  border: 0;\n  background: transparent;\n  padding: 0;\n  margin-left: 0.5rem;\n  color: #4c97ff;\n  font: inherit;\n  cursor: pointer;\n}\n\n.srtc-reply-button:hover {\n  text-decoration: underline;\n}\n\n.srtc-reply-button:focus-visible {\n  outline: 3px solid rgba(76, 151, 255, 0.45);\n  outline-offset: 2px;\n  border-radius: 4px;\n}\n\n.srtc-reply-compose {\n  box-sizing: border-box;\n  margin-top: 0.5rem;\n}\n\n.srtc-reply-compose[hidden] {\n  display: none;\n}\n\n.srtc-reply-compose textarea {\n  box-sizing: border-box;\n  width: 100%;\n  min-height: 5.25rem;\n  resize: vertical;\n  border: 1px solid rgba(0, 0, 0, 0.15);\n  border-radius: 6px;\n  padding: 0.5rem;\n  font: inherit;\n}\n\n.srtc-reply-actions {\n  display: flex;\n  align-items: center;\n  gap: 0.5rem;\n  margin-top: 0.4rem;\n}\n\n.srtc-reply-post:disabled {\n  cursor: wait;\n  opacity: 0.6;\n}\n\n.srtc-reply-cancel {\n  appearance: none;\n  border: 0;\n  background: transparent;\n  padding: 0.25rem 0.4rem;\n  color: #575e75;\n  font: inherit;\n  cursor: pointer;\n}\n\n.srtc-reply-error {\n  margin-top: 0.35rem;\n  color: #e5395d;\n  font-size: 0.8rem;\n}\n\n.srtc-reply-error[hidden] {\n  display: none;\n}\n\n.srtc-new-replies-badge {\n  display: inline-flex;\n  align-items: center;\n  margin-left: 0.75rem;\n  border: 1px solid rgba(76, 151, 255, 0.25);\n  border-radius: 999px;\n  background: rgba(76, 151, 255, 0.08);\n  padding: 0.125rem 0.45rem;\n  color: #575e75;\n  font-size: 0.72rem;\n  line-height: 1.1rem;\n  white-space: nowrap;\n}\n\n.srtc-new-replies-badge {\n  border-color: rgba(15, 189, 140, 0.35);\n  background: rgba(15, 189, 140, 0.12);\n  color: #0f8f6c;\n  font-weight: 700;\n}\n\n.srtc-status-button {\n  position: fixed;\n  right: 12px;\n  bottom: 12px;\n  z-index: 2147483000;\n  margin: 0;\n  border: 1px solid rgba(0, 0, 0, 0.12);\n  border-radius: 999px;\n  background: #ffffff;\n  padding: 0.25rem 0.55rem;\n  color: #575e75;\n  font-size: 0.75rem;\n  font-weight: 700;\n  line-height: 1rem;\n  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);\n  cursor: pointer;\n}\n\n.srtc-status-button[data-state=\"on\"] {\n  border-color: rgba(15, 189, 140, 0.4);\n  background: rgba(15, 189, 140, 0.12);\n  color: #0f8f6c;\n}\n\n.srtc-status-button[data-state=\"off\"] {\n  background: rgba(0, 0, 0, 0.06);\n  color: #575e75;\n}\n\n.srtc-status-button[data-state=\"error\"] {\n  border-color: rgba(255, 140, 26, 0.4);\n  background: rgba(255, 140, 26, 0.12);\n  color: #a55800;\n}\n\n.srtc-status-button:focus-visible {\n  outline: 3px solid rgba(76, 151, 255, 0.45);\n  outline-offset: 2px;\n}\n\n@media only screen and (max-width: 479px) {\n  .srtc-new-replies-badge {\n    margin-left: 0.35rem;\n    font-size: 0.68rem;\n  }\n}\n\n@keyframes srtc-fade-in {\n  from {\n    opacity: 0;\n    transform: translateY(-4px);\n  }\n\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n");

  // ---- Ported from src/background.js (CORS-free GET via GM_xmlhttpRequest) ----
  const API_ORIGIN = "https://api.scratch.mit.edu";
  function buildApiUrl(p) {
    if (typeof p !== "string" || !p.startsWith("/studios/")) {
      throw new Error("Unsupported Scratch API path.");
    }
    if (p.includes("://") || p.startsWith("//")) {
      throw new Error("Absolute URLs are not accepted.");
    }
    return new URL(p, API_ORIGIN).toString();
  }
  function gmFetchScratchApi(reqPath) {
    return new Promise(function (resolve) {
      let url;
      try { url = buildApiUrl(reqPath); }
      catch (e) { resolve({ ok: false, status: 0, error: e && e.message ? e.message : String(e) }); return; }
      GM_xmlhttpRequest({
        method: "GET",
        url: url,
        headers: { "Accept": "application/json" },
        anonymous: true,
        onload: function (res) {
          const text = res.responseText || "";
          let data = null;
          if (text) { try { data = JSON.parse(text); } catch (_e) { data = { raw: text }; } }
          const rh = String(res.responseHeaders || "");
          const cc = (rh.match(/^\s*cache-control\s*:\s*(.+?)\s*$/im) || [])[1] || "";
          const age = (rh.match(/^\s*age\s*:\s*(.+?)\s*$/im) || [])[1] || "";
          resolve({ ok: res.status >= 200 && res.status < 300, status: res.status, data: data, cacheControl: cc, age: age });
        },
        onerror: function () { resolve({ ok: false, status: 0, error: "network error" }); },
        ontimeout: function () { resolve({ ok: false, status: 0, error: "timeout" }); }
      });
    });
  }

  // ---- Minimal chrome.* shim so the unchanged core runs under a userscript ----
  const storageListeners = [];
  function fireChange(changes) {
    for (const fn of storageListeners) { try { fn(changes, "sync"); } catch (_e) {} }
  }
  const chrome = {
    runtime: {
      get lastError() { return null; },
      sendMessage: function (message, cb) {
        if (message && message.type === "scratch-api-fetch") {
          gmFetchScratchApi(message.path).then(cb).catch(function (e) {
            cb({ ok: false, status: 0, error: e && e.message ? e.message : String(e) });
          });
        }
      },
      onMessage: { addListener: function () {} }
    },
    storage: {
      sync: {
        get: function (defaults, cb) {
          const out = {};
          for (const k in defaults) out[k] = GM_getValue(k, defaults[k]);
          cb(out);
        },
        set: function (obj, cb) {
          const changes = {};
          for (const k in obj) { GM_setValue(k, obj[k]); changes[k] = { newValue: obj[k] }; }
          fireChange(changes);
          if (cb) cb();
        }
      },
      onChanged: { addListener: function (fn) { storageListeners.push(fn); } }
    }
  };

  // Cross-tab settings sync (other tabs / the menu in another tab).
  if (typeof GM_addValueChangeListener === "function") {
    ["enabled", "lowLoadMode"].forEach(function (key) {
      GM_addValueChangeListener(key, function (name, _old, newValue, remote) {
        if (remote) fireChange({ [name]: { newValue: newValue } });
      });
    });
  }

  // ===================== embedded core (verbatim) =====================

(function attachShared(global) {
  "use strict";

  const DEFAULT_SETTINGS = Object.freeze({
    enabled: true,
    lowLoadMode: false
  });

  // Top comments are polled fast; replies for open/changed threads on a budget.
  const TOP_COMMENT_LIMIT = 20;
  const REPLY_LIMIT = 25;
  // How often we re-fetch top comments (page 0) from the API.
  const TOP_REFRESH_MS = 1000;
  const LOW_LOAD_TOP_REFRESH_MS = 5000;
  // How often the reply scheduler re-fetches a thread's replies.
  const REPLY_REFRESH_MS = 1000;
  const LOW_LOAD_REPLY_REFRESH_MS = 60000;
  // How often the relative-time labels are re-rendered locally (no API calls).
  const REPLY_TICK_MS = 1000;
  // Number of top-comment pages to walk when first taking ownership of the list.
  const MAX_INITIAL_PAGES = 10;
  // Max simultaneous in-flight API requests (top pages + reply fetches).
  const API_CONCURRENCY = 6;
  // Cap on top-comment pages scanned per cycle for reply_count coverage.
  const MAX_COVERAGE_PAGES = 6;
  // Cap on reply pages walked in a single thread fetch (burst protection).
  const REPLY_MAX_PAGES = 8;

  // How many top-comment pages are needed to cover `threadCount` threads,
  // clamped to [1, maxPages]. Pure helper (unit-tested).
  function coveragePageCount(threadCount, limit, maxPages) {
    const perPage = limit > 0 ? limit : 1;
    const needed = Math.ceil((Number(threadCount) || 0) / perPage);
    return Math.min(Math.max(needed, 1), maxPages);
  }

  // U+00A0 non-breaking space, built from its code point to avoid stray bytes.
  const NBSP = String.fromCharCode(0xa0);

  function getStudioIdFromPath(pathname) {
    const match = String(pathname || "").match(/^\/studios\/(\d+)\/comments\/?$/);
    return match ? match[1] : null;
  }

  function isStudioCommentsPath(pathname) {
    return Boolean(getStudioIdFromPath(pathname));
  }

  function decodeHtmlEntities(value) {
    const raw = String(value || "");
    if (!raw || !/[&<>]/.test(raw)) return raw;

    const named = {
      amp: "&",
      lt: "<",
      gt: ">",
      quot: "\"",
      apos: "'",
      nbsp: NBSP,
      "#39": "'"
    };

    return raw.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (full, entity) => {
      const key = entity.toLowerCase();
      if (Object.prototype.hasOwnProperty.call(named, key)) return named[key];
      if (key[0] === "#") {
        const isHex = key[1] === "x";
        const number = Number.parseInt(key.slice(isHex ? 2 : 1), isHex ? 16 : 10);
        if (Number.isFinite(number)) {
          try {
            return String.fromCodePoint(number);
          } catch (_error) {
            return full;
          }
        }
      }
      return full;
    });
  }

  function normalizeText(value) {
    return decodeHtmlEntities(value)
      .split(NBSP).join(" ")
      .replace(/\s+/g, " ")
      .trim();
  }

  function normalizeComment(raw) {
    if (!raw || typeof raw !== "object") return null;
    const id = Number(raw.id);
    if (!Number.isFinite(id)) return null;

    const author = raw.author && typeof raw.author === "object" ? raw.author : {};
    return {
      id,
      parent_id: raw.parent_id === null || raw.parent_id === undefined || raw.parent_id === ""
        ? null
        : Number(raw.parent_id),
      commentee_id: raw.commentee_id === null || raw.commentee_id === undefined || raw.commentee_id === ""
        ? null
        : Number(raw.commentee_id),
      content: decodeHtmlEntities(raw.content || ""),
      datetime_created: String(raw.datetime_created || ""),
      datetime_modified: String(raw.datetime_modified || ""),
      visibility: String(raw.visibility || "visible"),
      author: {
        id: Number(author.id) || 0,
        username: String(author.username || ""),
        scratchteam: Boolean(author.scratchteam),
        image: String(author.image || "")
      },
      reply_count: Number(raw.reply_count) || 0
    };
  }

  function compareNewestFirst(a, b) {
    const aTime = Date.parse(a.datetime_created || "");
    const bTime = Date.parse(b.datetime_created || "");
    if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
      return bTime - aTime;
    }
    return Number(b.id || 0) - Number(a.id || 0);
  }

  function compareOldestFirst(a, b) {
    return compareNewestFirst(b, a);
  }

  // Pure list reconciliation used by the renderer and unit tests.
  // `known` is a Map<id, comment>; `incoming` is an array of comments.
  // Returns the comments to add, the comments whose content changed, and the
  // ids that disappeared. `incoming` is assumed to cover the same window as
  // `known` (e.g. page 0), so callers decide whether a removal is a real
  // deletion or just an id that paged out of the polled window.
  function computeListDelta(known, incoming) {
    const added = [];
    const updated = [];
    const seen = new Set();

    for (const comment of incoming) {
      if (!comment) continue;
      seen.add(comment.id);
      const previous = known.get(comment.id);
      if (!previous) {
        added.push(comment);
      } else if (
        previous.content !== comment.content ||
        previous.visibility !== comment.visibility ||
        previous.reply_count !== comment.reply_count
      ) {
        updated.push(comment);
      }
    }

    const removed = [];
    for (const id of known.keys()) {
      if (!seen.has(id)) removed.push(id);
    }

    return { added, updated, removed };
  }

  function formatRelativeTime(datetime, now, locale) {
    const timestamp = Date.parse(datetime || "");
    if (!Number.isFinite(timestamp)) return "";

    const current = Number.isFinite(now) ? now : Date.now();
    const deltaSeconds = (timestamp - current) / 1000;
    const abs = Math.abs(deltaSeconds);
    const units = [
      ["year", 31536000],
      ["month", 2592000],
      ["week", 604800],
      ["day", 86400],
      ["hour", 3600],
      ["minute", 60],
      ["second", 1]
    ];
    const unit = units.find(([, seconds]) => abs >= seconds) || units[units.length - 1];
    const amount = Math.floor(abs / unit[1]);
    const signedAmount = timestamp <= current ? -amount : amount;

    try {
      return new Intl.RelativeTimeFormat(locale || "en", { numeric: "always" }).format(signedAmount, unit[0]);
    } catch (_error) {
      return new Date(timestamp).toLocaleString();
    }
  }

  function formatAbsoluteTime(datetime, locale) {
    const timestamp = Date.parse(datetime || "");
    if (!Number.isFinite(timestamp)) return "";
    try {
      return new Date(timestamp).toLocaleString(locale || "en");
    } catch (_error) {
      return new Date(timestamp).toISOString();
    }
  }

  function createBackoff(options) {
    const initial = options && options.initial ? options.initial : 1000;
    const max = options && options.max ? options.max : 30000;
    let current = 0;

    return {
      fail() {
        current = current ? Math.min(current * 2, max) : initial;
        return current;
      },
      reset() {
        current = 0;
      },
      get current() {
        return current;
      }
    };
  }

  const api = {
    DEFAULT_SETTINGS,
    TOP_COMMENT_LIMIT,
    REPLY_LIMIT,
    TOP_REFRESH_MS,
    LOW_LOAD_TOP_REFRESH_MS,
    REPLY_REFRESH_MS,
    LOW_LOAD_REPLY_REFRESH_MS,
    REPLY_TICK_MS,
    MAX_INITIAL_PAGES,
    API_CONCURRENCY,
    MAX_COVERAGE_PAGES,
    REPLY_MAX_PAGES,
    coveragePageCount,
    getStudioIdFromPath,
    isStudioCommentsPath,
    decodeHtmlEntities,
    normalizeText,
    normalizeComment,
    compareNewestFirst,
    compareOldestFirst,
    computeListDelta,
    formatRelativeTime,
    formatAbsoluteTime,
    createBackoff
  };

  if (typeof module !== "undefined" && module.exports) {
    module.exports = api;
  } else {
    global.SRTCShared = api;
  }
})(typeof globalThis !== "undefined" ? globalThis : this);

(function attachContentScript() {
  "use strict";

  const Shared = globalThis.SRTCShared;
  if (!Shared) return;

  // The script runs on every /studios/* page (so it survives SPA tab navigation
  // into the comments tab); the studio id is resolved per cycle from the URL and
  // work only happens on a `/studios/<id>/comments` path (requirement ①).
  const LOCALE = (navigator && navigator.language) || "en";

  // Lightweight i18n for our on-page UI so it matches the locale of the native
  // Scratch UI it sits next to (instead of mixing English controls with
  // Japanese messages). Falls back to English for unknown locales.
  const STRINGS = {
    en: {
      reply: "reply", post: "Post", cancel: "Cancel", placeholder: "Write a reply…",
      needContent: "Please enter a message.", sendFailed: "Failed to send",
      needLogin: "You must be logged in (couldn't get a session token).",
      noStudio: "Couldn't determine the studio.",
      live: "● live", retrying: "● retrying", off: "○ off"
    },
    ja: {
      reply: "返信", post: "投稿する", cancel: "キャンセル", placeholder: "返信を書く…",
      needContent: "本文を入力してください。", sendFailed: "送信に失敗しました",
      needLogin: "ログインが必要です(セッションを取得できません)。",
      noStudio: "スタジオを特定できません。",
      live: "● ライブ", retrying: "● 再試行", off: "○ 停止"
    }
  };
  const T = STRINGS[String(LOCALE).slice(0, 2).toLowerCase()] || STRINGS.en;
  // Scratch comments have a 500-character limit.
  const MAX_COMMENT_LENGTH = 500;

  const state = {
    settings: Object.assign({}, Shared.DEFAULT_SETTINGS),
    running: false,
    studioId: null,
    listEl: null,
    seededListEl: null,
    knownTopIds: new Set(),
    // id -> thread record (see ensureThread)
    threads: new Map(),
    statusEl: null,
    statusState: "off",
    pollTimer: null,
    tickTimer: null,
    inFlight: false,
    cdnCached: false,
    rateLimited: false,
    nonce: 0,
    backoff: Shared.createBackoff({ initial: 1000, max: 30000 })
  };

  // ---------------------------------------------------------------------------
  // API access (delegated to the background service worker to avoid CORS).
  // ---------------------------------------------------------------------------
  function apiFetch(path) {
    return new Promise(resolve => {
      try {
        chrome.runtime.sendMessage({ type: "scratch-api-fetch", path }, response => {
          if (chrome.runtime.lastError) {
            resolve({ ok: false, status: 0, error: chrome.runtime.lastError.message });
            return;
          }
          resolve(response || { ok: false, status: 0, error: "empty response" });
        });
      } catch (error) {
        resolve({ ok: false, status: 0, error: error && error.message ? error.message : String(error) });
      }
    });
  }

  // Once we detect the API is CDN-cached, append a unique param to bust it so
  // polling reflects fresh data (decision: auto-detect via Cache-Control).
  function withCacheBust(path) {
    if (!state.cdnCached) return path;
    const sep = path.includes("?") ? "&" : "?";
    return `${path}${sep}_=${Date.now()}-${state.nonce++}`;
  }

  function noteCacheHeaders(response) {
    if (state.cdnCached || !response) return;
    const cc = String(response.cacheControl || "").toLowerCase();
    const maxAge = cc.match(/(?:s-maxage|max-age)=(\d+)/);
    const cached = (maxAge && Number(maxAge[1]) > 0) || (response.age && Number(response.age) > 0);
    if (cached) state.cdnCached = true;
  }

  async function fetchTopPage(offset) {
    const response = await apiFetch(withCacheBust(
      `/studios/${state.studioId}/comments?offset=${offset}&limit=${Shared.TOP_COMMENT_LIMIT}`
    ));
    noteCacheHeaders(response);
    return response;
  }

  async function fetchReplyPage(commentId, offset) {
    const response = await apiFetch(withCacheBust(
      `/studios/${state.studioId}/comments/${commentId}/replies?offset=${offset}&limit=${Shared.REPLY_LIMIT}`
    ));
    noteCacheHeaders(response);
    return response;
  }

  // Run `worker` over `items` with at most `concurrency` in flight at once.
  // Results are returned in input order (callers rely on index alignment).
  async function runPool(items, worker, concurrency) {
    const results = new Array(items.length);
    let next = 0;
    async function drain() {
      while (true) {
        const i = next;
        next += 1;
        if (i >= items.length) break;
        results[i] = await worker(items[i]);
      }
    }
    const runnerCount = Math.min(Math.max(1, concurrency), items.length);
    const runners = [];
    for (let i = 0; i < runnerCount; i += 1) runners.push(drain());
    await Promise.all(runners);
    return results;
  }

  // ---------------------------------------------------------------------------
  // Posting (replies). Mirrors scratch-www: GET /session/ for the token, then
  // POST to the studio comments proxy with X-Token + X-CSRFToken, same as the
  // site does from its own page JS (so CORS/credentials behave identically).
  // ---------------------------------------------------------------------------
  let cachedToken = null;
  let tokenFetchedAt = 0;

  async function getSessionToken() {
    if (cachedToken && Date.now() - tokenFetchedAt < 5 * 60 * 1000) return cachedToken;
    try {
      const response = await fetch("/session/", {
        credentials: "include",
        headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" }
      });
      if (!response.ok) return null;
      const data = await response.json();
      cachedToken = data && data.user && data.user.token ? data.user.token : null;
      tokenFetchedAt = Date.now();
      return cachedToken;
    } catch (_error) {
      return null;
    }
  }

  function getCsrfToken() {
    const match = document.cookie.match(/(?:^|;\s*)scratchcsrftoken=([^;]+)/);
    return match ? match[1] : "";
  }

  async function postReply(parentId, commenteeId, content) {
    if (!state.studioId) return { ok: false, error: T.noStudio };
    const token = await getSessionToken();
    if (!token) return { ok: false, error: T.needLogin };
    try {
      const response = await fetch(`https://api.scratch.mit.edu/proxy/comments/studio/${state.studioId}`, {
        method: "POST",
        credentials: "include",
        headers: {
          "Content-Type": "application/json",
          "X-Token": token,
          "X-CSRFToken": getCsrfToken()
        },
        body: JSON.stringify({
          content,
          parent_id: parentId || "",
          commentee_id: commenteeId || ""
        })
      });
      const text = await response.text();
      let data = null;
      if (text) {
        try { data = JSON.parse(text); } catch (_error) { data = { raw: text }; }
      }
      if (!response.ok) {
        // Token may be stale (re-login, expiry) — drop the cache so the next
        // attempt re-fetches a fresh one from /session/.
        if (response.status === 401 || response.status === 403) {
          cachedToken = null;
          tokenFetchedAt = 0;
        }
        const reason = (data && (data.message || data.error)) || `HTTP ${response.status}`;
        return { ok: false, status: response.status, error: reason, data };
      }
      return { ok: true, data };
    } catch (error) {
      return { ok: false, error: error && error.message ? error.message : String(error) };
    }
  }

  function normalizeList(data) {
    if (!Array.isArray(data)) return [];
    const out = [];
    for (const raw of data) {
      const comment = Shared.normalizeComment(raw);
      if (comment) out.push(comment);
    }
    return out;
  }

  // ---------------------------------------------------------------------------
  // DOM helpers.
  // ---------------------------------------------------------------------------
  function getScroller() {
    return document.scrollingElement || document.documentElement;
  }

  // The list element is the shared parent of the rendered .comment-container nodes.
  function acquireList() {
    const first = document.querySelector(".comment-container");
    const list = first ? first.parentElement : null;
    if (list && list !== state.seededListEl) {
      // A fresh list (first load or SPA navigation back to comments): re-baseline.
      resetForNewList(list);
    }
    state.listEl = list;
    return list;
  }

  function resetForNewList(list) {
    state.knownTopIds = new Set();
    state.threads = new Map();
    state.seededListEl = list;
    state.backoff.reset();
  }

  function nativeUsername(containerEl) {
    const link = containerEl.querySelector(".comment-body .username");
    return link ? Shared.normalizeText(link.textContent) : "";
  }

  function nativeContentText(containerEl) {
    const content = containerEl.querySelector(".comment-bubble .comment-content");
    return content ? Shared.normalizeText(content.textContent) : "";
  }

  function buildAnchorsFromText(text, target) {
    // Render plain text with safe http(s) links — never via innerHTML.
    const pattern = /(https?:\/\/[^\s<>"')]+)/g;
    let lastIndex = 0;
    let match;
    while ((match = pattern.exec(text)) !== null) {
      if (match.index > lastIndex) {
        target.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
      }
      const anchor = document.createElement("a");
      anchor.href = match[0];
      anchor.textContent = match[0];
      anchor.rel = "nofollow noopener";
      target.appendChild(anchor);
      lastIndex = match.index + match[0].length;
    }
    if (lastIndex < text.length) {
      target.appendChild(document.createTextNode(text.slice(lastIndex)));
    }
  }

  // Build a node that mirrors Scratch's native comment markup so the site CSS
  // styles it (requirement ②). `managedTop` marks a top-level synthetic node.
  function buildCommentNode(comment, opts) {
    opts = opts || {};
    const container = document.createElement("div");
    container.className = "comment-container srtc-comment-container";
    if (opts.managedTop) container.classList.add("srtc-managed-top");
    container.setAttribute("data-srtc-id", String(comment.id));

    const row = document.createElement("div");
    row.className = "flex-row comment srtc-comment";

    const username = comment.author.username || "";
    const avatarLink = document.createElement("a");
    avatarLink.href = username ? `/users/${encodeURIComponent(username)}/` : "#";
    avatarLink.className = "comment-avatar srtc-comment-avatar";
    if (comment.author.image) {
      const avatar = document.createElement("img");
      avatar.className = "avatar";
      avatar.src = comment.author.image;
      avatar.alt = username;
      avatarLink.appendChild(avatar);
    }
    row.appendChild(avatarLink);

    const body = document.createElement("div");
    body.className = "comment-body column";

    const userLink = document.createElement("a");
    userLink.className = "username";
    userLink.href = username ? `/users/${encodeURIComponent(username)}/` : "#";
    userLink.textContent = username;
    body.appendChild(userLink);

    const bubble = document.createElement("div");
    bubble.className = "comment-bubble";
    const content = document.createElement("div");
    content.className = "comment-content";
    buildAnchorsFromText(comment.content, content);
    bubble.appendChild(content);
    body.appendChild(bubble);

    const bottom = document.createElement("div");
    bottom.className = "flex-row comment-bottom-row";
    const time = document.createElement("span");
    time.className = "comment-time";
    time.setAttribute("data-srtc-created", comment.datetime_created);
    time.title = Shared.formatAbsoluteTime(comment.datetime_created, LOCALE);
    time.textContent = Shared.formatRelativeTime(comment.datetime_created, Date.now(), LOCALE);
    bottom.appendChild(time);
    body.appendChild(bottom);

    // Our synthetic nodes aren't in Scratch's React state, so the native reply
    // box can't open for them — attach our own. Studio replies are flat, so a
    // reply (even a reply-to-a-reply) always targets the TOP-level comment, with
    // commentee = the author being replied to.
    if (opts.reply) {
      attachReplyUi({
        body: body,
        bottom: bottom,
        topContainer: opts.reply.topContainer || container,
        parentId: opts.reply.parentId,
        commenteeId: comment.author.id
      });
    }

    row.appendChild(body);
    container.appendChild(row);
    return container;
  }

  function ensureSyntheticReplies(container) {
    let replies = container.querySelector(":scope > .replies");
    if (!replies) {
      replies = document.createElement("div");
      replies.className = "replies column srtc-synthetic-replies";
      container.appendChild(replies);
    }
    return replies;
  }

  // config: { body, bottom, topContainer, parentId, commenteeId }
  // parentId is always the TOP-level comment id; topContainer is that comment's
  // node (its `.replies` list is where the new reply is shown).
  function attachReplyUi(config) {
    const replyButton = document.createElement("button");
    replyButton.type = "button";
    replyButton.className = "comment-reply srtc-reply-button";
    replyButton.textContent = T.reply;
    config.bottom.appendChild(replyButton);

    const compose = document.createElement("div");
    compose.className = "srtc-reply-compose";
    compose.hidden = true;

    const textarea = document.createElement("textarea");
    textarea.placeholder = T.placeholder;
    textarea.maxLength = MAX_COMMENT_LENGTH;
    // No "@username" prefill — threading is handled by commentee_id in the POST.
    textarea.value = "";

    const errorRow = document.createElement("div");
    errorRow.className = "srtc-reply-error";
    errorRow.hidden = true;

    const actions = document.createElement("div");
    actions.className = "srtc-reply-actions";
    const postButton = document.createElement("button");
    postButton.type = "button";
    postButton.className = "button srtc-reply-post";
    postButton.textContent = T.post;
    const cancelButton = document.createElement("button");
    cancelButton.type = "button";
    cancelButton.className = "srtc-reply-cancel";
    cancelButton.textContent = T.cancel;
    actions.appendChild(postButton);
    actions.appendChild(cancelButton);

    compose.appendChild(textarea);
    compose.appendChild(errorRow);
    compose.appendChild(actions);
    config.body.appendChild(compose);

    replyButton.addEventListener("click", () => {
      compose.hidden = !compose.hidden;
      if (!compose.hidden) textarea.focus();
    });
    cancelButton.addEventListener("click", () => {
      compose.hidden = true;
      errorRow.hidden = true;
      textarea.value = "";
    });

    postButton.addEventListener("click", async () => {
      const content = textarea.value.trim();
      if (!content) {
        errorRow.hidden = false;
        errorRow.textContent = T.needContent;
        return;
      }
      postButton.disabled = true;
      errorRow.hidden = true;
      const result = await postReply(config.parentId, config.commenteeId, content);
      postButton.disabled = false;
      if (!result.ok) {
        errorRow.hidden = false;
        errorRow.textContent = `${T.sendFailed}: ${result.error}`;
        return;
      }
      // Show the new reply immediately under the top-level comment and record
      // its id so the poller won't re-insert it.
      const created = Shared.normalizeComment(result.data);
      const thread = state.threads.get(config.parentId);
      if (created) {
        ensureSyntheticReplies(config.topContainer).appendChild(
          buildCommentNode(created, {
            reply: { topContainer: config.topContainer, parentId: config.parentId }
          })
        );
        if (thread) {
          thread.knownReplyIds.add(created.id);
          thread.knownTotal += 1;
          thread.replyCount = Math.max(thread.replyCount, thread.knownTotal);
          clearBadge(thread);
        }
      } else if (thread) {
        // Couldn't parse the response; let the next cycle pick it up.
        thread.replyCountDirty = true;
      }
      compose.hidden = true;
      textarea.value = "";
    });
  }

  // ---------------------------------------------------------------------------
  // Thread bookkeeping (one record per visible top-level comment).
  // ---------------------------------------------------------------------------
  function ensureThread(id, el, synthetic) {
    let thread = state.threads.get(id);
    if (!thread) {
      thread = {
        id,
        el,
        synthetic: Boolean(synthetic),
        replyCount: 0,
        replyCountDirty: false,
        knownReplyIds: new Set(),
        // Number of replies already accounted for; the tail fetch starts here.
        knownTotal: 0,
        pendingBadge: 0
      };
      state.threads.set(id, thread);
    } else if (el) {
      thread.el = el;
    }
    return thread;
  }

  // Register a freshly-displayed thread and seed its reply baseline. Native
  // threads skip their existing replies (already rendered by Scratch); synthetic
  // threads start at 0 so we render all of their replies inline.
  function registerThread(id, el, synthetic, replyCount) {
    const fresh = !state.threads.has(id);
    const thread = ensureThread(id, el, synthetic);
    thread.replyCount = replyCount;
    if (fresh) thread.knownTotal = synthetic ? 0 : replyCount;
    return thread;
  }

  function repliesContainer(thread) {
    if (!thread.el || !thread.el.isConnected) return null;
    return thread.el.querySelector(":scope > .replies");
  }

  // True only when Scratch has hidden replies behind a "show more" toggle
  // (>3 replies). Small reply lists render inline, so they are NOT collapsed.
  function isThreadCollapsed(thread) {
    const replies = repliesContainer(thread);
    return Boolean(replies) && replies.classList.contains("collapsed");
  }

  function appendReplyNodes(thread, replies) {
    const container = repliesContainer(thread);
    if (!container) return false;
    for (const reply of replies) {
      // Give each synthetic reply its own reply form, targeting the top comment.
      container.appendChild(buildCommentNode(reply, {
        reply: { topContainer: thread.el, parentId: thread.id }
      }));
    }
    return true;
  }

  function bumpBadge(thread, increment) {
    thread.pendingBadge += increment;
    const bottom = thread.el && thread.el.querySelector(".comment-bottom-row");
    if (!bottom) return;
    let badge = bottom.querySelector(".srtc-new-replies-badge");
    if (!badge) {
      badge = document.createElement("span");
      badge.className = "srtc-new-replies-badge";
      bottom.appendChild(badge);
    }
    badge.textContent = `+${thread.pendingBadge} replies`;
  }

  function clearBadge(thread) {
    thread.pendingBadge = 0;
    const badge = thread.el && thread.el.querySelector(".srtc-new-replies-badge");
    if (badge) badge.remove();
  }

  // Map currently-displayed native top-level nodes by username+content so we can
  // attach a thread record to a comment the API returns.
  function buildNativeNodeIndex() {
    const index = new Map();
    const nodes = state.listEl.querySelectorAll(":scope > .comment-container:not(.srtc-managed-top)");
    for (const node of nodes) {
      const key = `${nativeUsername(node)} ${nativeContentText(node)}`;
      if (!index.has(key)) index.set(key, []);
      index.get(key).push(node);
    }
    return index;
  }

  function commentKey(comment) {
    return `${Shared.normalizeText(comment.author.username)} ${Shared.normalizeText(comment.content)}`;
  }

  // ---------------------------------------------------------------------------
  // Seeding: record what is already on the page without inserting anything.
  // ---------------------------------------------------------------------------
  async function seedFromApi() {
    const nativeIndex = buildNativeNodeIndex();
    let offset = 0;
    for (let page = 0; page < Shared.MAX_INITIAL_PAGES; page += 1) {
      const result = await fetchTopPage(offset);
      if (!result.ok) break;
      const comments = normalizeList(result.data);
      if (comments.length === 0) break;
      for (const comment of comments) {
        state.knownTopIds.add(comment.id);
        const bucket = nativeIndex.get(commentKey(comment));
        const node = bucket && bucket.length ? bucket.shift() : null;
        if (node) registerThread(comment.id, node, false, comment.reply_count);
      }
      if (comments.length < Shared.TOP_COMMENT_LIMIT) break;
      offset += Shared.TOP_COMMENT_LIMIT;
    }
  }

  // Process one fetched top page: (page 0 only) insert genuinely new comments,
  // and for every page diff reply_count and register newly-displayed natives.
  // Returns the list of brand-new comments to insert (page 0).
  function processTopPage(comments, isFirstPage, nativeIndex) {
    const fresh = [];
    for (const comment of comments) {
      const existing = state.threads.get(comment.id);
      if (existing) {
        // If React replaced this native node, re-bind to the current one so we
        // keep tracking its replies.
        if (!existing.synthetic && (!existing.el || !existing.el.isConnected)) {
          const bucket = nativeIndex.get(commentKey(comment));
          const node = bucket && bucket.length ? bucket.shift() : null;
          if (node) existing.el = node;
        }
        existing.replyCount = comment.reply_count;
        if (comment.reply_count > existing.knownTotal) {
          // Server has replies we haven't pulled yet.
          existing.replyCountDirty = true;
        } else if (comment.reply_count < existing.knownTotal) {
          // Replies were deleted — resync so detection keeps working.
          existing.knownTotal = comment.reply_count;
        }
        continue;
      }

      // Not yet tracked. If it's already shown as a native node (initial render
      // or "load more"), register it for reply tracking without inserting.
      const bucket = nativeIndex.get(commentKey(comment));
      const node = bucket && bucket.length ? bucket.shift() : null;
      if (node) {
        state.knownTopIds.add(comment.id);
        registerThread(comment.id, node, false, comment.reply_count);
        continue;
      }

      if (state.knownTopIds.has(comment.id)) continue;
      // Only page 0 can carry genuinely new comments (newest-first API).
      if (isFirstPage) fresh.push(comment);
    }
    return fresh;
  }

  // Find the top-level DOM node for a comment: our synthetic node (by id) or the
  // native node (by username+content). Only direct children of the list.
  function findTopNode(comment) {
    const idStr = String(comment.id);
    const key = commentKey(comment);
    let nativeMatch = null;
    for (const child of state.listEl.children) {
      if (!child.classList || !child.classList.contains("comment-container")) continue;
      if (child.classList.contains("srtc-managed-top")) {
        if (child.getAttribute("data-srtc-id") === idStr) return child;
      } else if (!nativeMatch && `${nativeUsername(child)} ${nativeContentText(child)}` === key) {
        nativeMatch = child;
      }
    }
    return nativeMatch;
  }

  function insertAtTop(node) {
    const anchor = state.listEl.querySelector(":scope > .comment-container");
    state.listEl.insertBefore(node, anchor);
  }

  // Reconcile the order of our synthetic top nodes to match the API (newest
  // first), inserting brand-new comments at the right spot. Native nodes are
  // React-owned, so we never move them — we only slot our own nodes between
  // them. This keeps order correct even when a native comment is posted after
  // we've inserted a (now older) synthetic one.
  function syncTopOrder(page0Comments, freshIds) {
    const scroller = getScroller();
    const beforeScroll = scroller.scrollTop;
    const atTop = beforeScroll <= 4;
    const beforeHeight = scroller.scrollHeight;
    let changed = false;

    let prevNode = null; // node of the nearest newer comment already placed
    for (const comment of page0Comments) { // newest-first
      let node = findTopNode(comment);
      if (!node && freshIds.has(comment.id)) {
        node = buildCommentNode(comment, { managedTop: true, reply: { parentId: comment.id } });
        state.knownTopIds.add(comment.id);
        // Synthetic node: knownTotal starts at 0 so all replies render inline.
        const thread = registerThread(comment.id, node, true, comment.reply_count);
        if (comment.reply_count > 0) thread.replyCountDirty = true;
        if (prevNode) prevNode.after(node);
        else insertAtTop(node);
        changed = true;
      } else if (node && node.classList.contains("srtc-managed-top")) {
        // Existing synthetic node: ensure it sits right after prevNode.
        const placed = prevNode
          ? node.previousElementSibling === prevNode
          : node === state.listEl.querySelector(":scope > .comment-container");
        if (!placed) {
          if (prevNode) prevNode.after(node);
          else insertAtTop(node);
          changed = true;
        }
      }
      if (node) prevNode = node;
    }

    // Requirement ②③: keep the reader's position fixed when content shifts above.
    if (changed && !atTop) {
      const added = scroller.scrollHeight - beforeHeight;
      if (added > 0) scroller.scrollTop = beforeScroll + added;
    }
  }

  // ---------------------------------------------------------------------------
  // Reply fetching: count-driven. Only called for threads whose reply_count
  // changed, so it just needs to pull the replies appended since knownTotal.
  // ---------------------------------------------------------------------------
  async function fetchThreadReplies(thread) {
    if (!thread.el || !thread.el.isConnected) return;
    thread.replyCountDirty = false;
    const limit = Shared.REPLY_LIMIT;
    const fresh = [];
    for (let page = 0; page < Shared.REPLY_MAX_PAGES; page += 1) {
      const result = await fetchReplyPage(thread.id, thread.knownTotal);
      if (!result.ok) {
        if (result.status === 429) state.rateLimited = true;
        return;
      }
      const replies = normalizeList(result.data);
      if (replies.length === 0) break;
      thread.knownTotal += replies.length;
      for (const reply of replies) {
        if (!thread.knownReplyIds.has(reply.id)) {
          thread.knownReplyIds.add(reply.id);
          fresh.push(reply);
        }
      }
      if (replies.length < limit) break;
    }
    if (!fresh.length) return;
    fresh.sort(Shared.compareOldestFirst);

    // Show inline unless the thread is explicitly collapsed (>3 replies behind a
    // "show more" toggle). Scratch renders small reply lists inline, so a new
    // reply there must appear without a reload. Only the collapsed case stays a
    // small "+N replies" hint so we never force-expand it (requirement ③).
    if (!isThreadCollapsed(thread)) {
      ensureSyntheticReplies(thread.el);
      if (appendReplyNodes(thread, fresh)) {
        clearBadge(thread);
        return;
      }
    }
    bumpBadge(thread, fresh.length);
  }

  // ---------------------------------------------------------------------------
  // Relative-time ticker: local-only, never touches the network.
  // ---------------------------------------------------------------------------
  function tickRelativeTimes() {
    const now = Date.now();
    const labels = document.querySelectorAll(".comment-time[data-srtc-created]");
    for (const label of labels) {
      const created = label.getAttribute("data-srtc-created");
      label.textContent = Shared.formatRelativeTime(created, now, LOCALE);
    }
  }

  // ---------------------------------------------------------------------------
  // Status pill (requirement ②: minimal, non-intrusive affordance).
  // ---------------------------------------------------------------------------
  function removeStatusButton() {
    if (state.statusEl) {
      state.statusEl.remove();
      state.statusEl = null;
    }
  }

  function ensureStatusButton() {
    if (state.statusEl && state.statusEl.isConnected) return;
    // Only show the pill on a comments page (the script also runs on other
    // studio tabs to survive SPA navigation).
    if (!Shared.getStudioIdFromPath(location.pathname)) return;
    // Keep this out of Scratch's React tree (especially the composer) so it can
    // never interfere with writing — a fixed pill on <body>.
    const button = document.createElement("button");
    button.type = "button";
    button.className = "srtc-status-button";
    button.title = "Realtime comments";
    button.addEventListener("click", () => {
      chrome.storage.sync.set({ enabled: !state.settings.enabled });
    });
    document.body.appendChild(button);
    state.statusEl = button;
    setStatus(state.statusState);
  }

  function setStatus(next) {
    // Only a successful cycle clears the backoff; errors must let it grow.
    if (next === "on") state.backoff.reset();
    state.statusState = next;
    if (!state.statusEl) return;
    state.statusEl.dataset.state = next;
    state.statusEl.textContent = next === "on" ? T.live : next === "error" ? T.retrying : T.off;
  }

  // ---------------------------------------------------------------------------
  // Scheduling: one cycle scans top pages (new comments + reply_count deltas),
  // then fetches replies only for the threads that changed.
  // ---------------------------------------------------------------------------
  // Minimum gap between cycles even if the work took ~a full interval, so we
  // never tight-loop the API.
  const MIN_POLL_GAP_MS = 100;

  function pollInterval() {
    return state.settings.lowLoadMode ? Shared.LOW_LOAD_TOP_REFRESH_MS : Shared.TOP_REFRESH_MS;
  }

  // Fixed-rate: measure the next delay from the cycle START, not from when the
  // awaited fetches finished, so the real cadence stays near the interval
  // instead of (interval + work time).
  function nextPollDelay(startedAt) {
    return Math.max(MIN_POLL_GAP_MS, pollInterval() - (Date.now() - startedAt));
  }

  function schedulePoll(delay) {
    clearTimeout(state.pollTimer);
    if (!state.running) return;
    state.pollTimer = setTimeout(pollCycle, delay);
  }

  async function scanTopPages() {
    // Cover every displayed top-level comment (native + synthetic), so threads
    // revealed by "load more" get tracked and their reply_count watched.
    const displayed = state.listEl.querySelectorAll(":scope > .comment-container").length;
    const pageCount = Shared.coveragePageCount(
      displayed, Shared.TOP_COMMENT_LIMIT, Shared.MAX_COVERAGE_PAGES
    );
    const offsets = [];
    for (let p = 0; p < pageCount; p += 1) offsets.push(p * Shared.TOP_COMMENT_LIMIT);

    const results = await runPool(offsets, fetchTopPage, Shared.API_CONCURRENCY);
    if (!results.length || !results[0].ok) return false;

    const nativeIndex = buildNativeNodeIndex();
    let page0Comments = [];
    const freshIds = new Set();
    for (let i = 0; i < results.length; i += 1) {
      const result = results[i];
      if (!result.ok) {
        if (result.status === 429) state.rateLimited = true;
        continue;
      }
      const comments = normalizeList(result.data);
      const isFirstPage = offsets[i] === 0;
      if (isFirstPage) page0Comments = comments;
      const fresh = processTopPage(comments, isFirstPage, nativeIndex);
      for (const comment of fresh) freshIds.add(comment.id);
    }
    // Place/insert synthetic nodes in API (newest-first) order.
    if (page0Comments.length) syncTopOrder(page0Comments, freshIds);
    return true;
  }

  async function pollCycle() {
    if (!state.running) return;
    const sid = Shared.getStudioIdFromPath(location.pathname);
    if (!sid) {
      // On a non-comments studio tab (or navigated away): idle but keep polling
      // the URL so we re-activate when the user opens the comments tab.
      removeStatusButton();
      schedulePoll(pollInterval());
      return;
    }
    if (document.hidden || state.inFlight) {
      schedulePoll(pollInterval());
      return;
    }
    if (sid !== state.studioId) {
      // First activation or switched to a different studio — start fresh.
      state.studioId = sid;
      state.threads = new Map();
      state.knownTopIds = new Set();
      state.seededListEl = null;
      state.listEl = null;
      state.backoff.reset();
    }
    if (!acquireList()) {
      schedulePoll(pollInterval());
      return;
    }
    ensureStatusButton();
    state.inFlight = true;
    state.rateLimited = false;
    const startedAt = Date.now();
    try {
      if (state.seededListEl === state.listEl && state.knownTopIds.size === 0) {
        await seedFromApi();
      }
      const ok = await scanTopPages();
      if (!ok) {
        setStatus("error");
        schedulePoll(state.backoff.fail());
        return;
      }
      // Count-driven: only fetch replies for threads whose reply_count grew.
      const due = [];
      for (const thread of state.threads.values()) {
        if (thread.replyCountDirty && thread.el && thread.el.isConnected) due.push(thread);
      }
      if (due.length) await runPool(due, fetchThreadReplies, Shared.API_CONCURRENCY);

      if (state.rateLimited) {
        setStatus("error");
        schedulePoll(state.backoff.fail());
      } else {
        setStatus("on");
        schedulePoll(nextPollDelay(startedAt));
      }
    } catch (_error) {
      setStatus("error");
      schedulePoll(state.backoff.fail());
    } finally {
      state.inFlight = false;
    }
  }

  function start() {
    if (state.running) return;
    state.running = true;
    ensureStatusButton();
    setStatus("on");
    if (!state.tickTimer) {
      state.tickTimer = setInterval(tickRelativeTimes, Shared.REPLY_TICK_MS);
    }
    schedulePoll(0);
  }

  function stop() {
    state.running = false;
    clearTimeout(state.pollTimer);
    setStatus("off");
  }

  // Catch up immediately when the tab becomes visible again.
  document.addEventListener("visibilitychange", () => {
    if (!document.hidden && state.running) schedulePoll(0);
  });

  // ---------------------------------------------------------------------------
  // Settings wiring.
  // ---------------------------------------------------------------------------
  function applySettings(settings) {
    state.settings = Object.assign({}, Shared.DEFAULT_SETTINGS, settings);
    ensureStatusButton();
    if (state.settings.enabled) start();
    else stop();
  }

  chrome.storage.sync.get(Shared.DEFAULT_SETTINGS, applySettings);

  chrome.storage.onChanged.addListener((changes, area) => {
    if (area !== "sync") return;
    const next = Object.assign({}, state.settings);
    if (changes.enabled) next.enabled = Boolean(changes.enabled.newValue);
    if (changes.lowLoadMode) next.lowLoadMode = Boolean(changes.lowLoadMode.newValue);
    applySettings(next);
  });
})();


  // ============== settings menu (replaces the extension popup) ==============
  function toggleSetting(key) {
    const defaults = (globalThis.SRTCShared && globalThis.SRTCShared.DEFAULT_SETTINGS) ||
      { enabled: true, lowLoadMode: false };
    chrome.storage.sync.get(defaults, function (s) {
      chrome.storage.sync.set({ [key]: !s[key] });
    });
  }
  if (typeof GM_registerMenuCommand === "function") {
    GM_registerMenuCommand("リアルタイム更新: ON/OFF を切替 (Toggle realtime)", function () { toggleSetting("enabled"); });
    GM_registerMenuCommand("低負荷モード: ON/OFF を切替 (Toggle low-load)", function () { toggleSetting("lowLoadMode"); });
  }
})();