🌸🐻 Unified Stock Compact v4.4.1

Compact header: display-case (inv) + YATA stock (stk). Flowers / Plushies split, Xanax (SA). Color-coded stk, sets & missing to next set, flight suggestions. Refresh 45s. Narrow 250px layout to match Points Exporter visual width.

Від 12.10.2025. Дивіться остання версія.

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

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

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

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

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

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

(У мене вже є менеджер скриптів, дайте мені встановити його!)

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

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

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

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

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

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

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

// ==UserScript==
// @name         🌸🐻 Unified Stock Compact v4.4.1
// @namespace    http://tampermonkey.net/
// @version      4.4.1
// @description  Compact header: display-case (inv) + YATA stock (stk). Flowers / Plushies split, Xanax (SA). Color-coded stk, sets & missing to next set, flight suggestions. Refresh 45s. Narrow 250px layout to match Points Exporter visual width.
// @match        https://www.torn.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// @connect      yata.yt
// @run-at       document-end
// ==/UserScript==

(function(){
  'use strict';

  /* ---------- CONFIG ---------- */
  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
  const REFRESH_MS = 45 * 1000;
  const PANEL_ID = 'tm_uni_stock_compact_v4_4';
  const POINTS_PER_SET = 10;

  // color thresholds and class names (user requested mapping)
  const STK_ZERO = 'stock-zero';    // orange -> stk === 0
  const STK_LOW = 'stock-low';      // red    -> stk < 600 (but > 0)
  const STK_HIGH = 'stock-high';    // green  -> stk > 1000
  const STK_MID = 'stock-mid';      // neutral

  /* ---------- ITEMS, SHORT NAMES, ORDER & FLAGS ---------- */
  const SHORT_MAP = {
    "Dahlia":"Dahlia","Orchid":"Orchid","African Violet":"A.Violet","Cherry Blossom":"C.Blossom",
    "Peony":"Peony","Ceibo Flower":"Ceibo","Edelweiss":"Edelweiss","Crocus":"Crocus",
    "Heather":"Heather","Tribulus Omanense":"Tribulus","Banana Orchid":"Banana",
    "Sheep Plushie":"Sheep","Teddy Bear Plushie":"Teddy","Kitten Plushie":"Kitten",
    "Jaguar Plushie":"Jaguar","Wolverine Plushie":"Wolverine","Nessie Plushie":"Nessie",
    "Red Fox Plushie":"R.Fox","Monkey Plushie":"Monkey","Chamois Plushie":"Chamois",
    "Panda Plushie":"Panda","Lion Plushie":"Lion","Camel Plushie":"Camel","Stingray Plushie":"Stingray",
    "Xanax":"Xanax"
  };

  const FLOWERS_ORDER = [
    ["Dahlia",{code:'mex', flag:'🇲🇽'}],
    ["Orchid",{code:'haw', flag:'🏝️'}],
    ["African Violet",{code:'sou', flag:'🇿🇦'}],
    ["Cherry Blossom",{code:'jap', flag:'🇯🇵'}],
    ["Peony",{code:'chi', flag:'🇨🇳'}],
    ["Ceibo Flower",{code:'arg', flag:'🇦🇷'}],
    ["Edelweiss",{code:'swi', flag:'🇨🇭'}],
    ["Crocus",{code:'can', flag:'🇨🇦'}],
    ["Heather",{code:'uni', flag:'🇬🇧'}],
    ["Tribulus Omanense",{code:'uae', flag:'🇦🇪'}],
    ["Banana Orchid",{code:'cay', flag:'🇰🇾'}]
  ];

  const PLUSHIES_ORDER = [
    ["Sheep Plushie",{code:null, flag:'🏪'}],
    ["Teddy Bear Plushie",{code:null, flag:'🏪'}],
    ["Kitten Plushie",{code:null, flag:'🏪'}],
    ["Jaguar Plushie",{code:'mex', flag:'🇲🇽'}],
    ["Wolverine Plushie",{code:'can', flag:'🇨🇦'}],
    ["Nessie Plushie",{code:'uni', flag:'🇬🇧'}],
    ["Red Fox Plushie",{code:'uni', flag:'🇬🇧'}],
    ["Monkey Plushie",{code:'arg', flag:'🇦🇷'}],
    ["Chamois Plushie",{code:'swi', flag:'🇨🇭'}],
    ["Panda Plushie",{code:'chi', flag:'🇨🇳'}],
    ["Lion Plushie",{code:'sou', flag:'🇿🇦'}],
    ["Camel Plushie",{code:'uae', flag:'🇦🇪'}],
    ["Stingray Plushie",{code:'cay', flag:'🇰🇾'}]
  ];

  const SPECIAL_DRUG = "Xanax";
  const TRACKED_ORDER = [...FLOWERS_ORDER.map(x=>x[0]), ...PLUSHIES_ORDER.map(x=>x[0]), SPECIAL_DRUG];
  const COUNTRY_NAMES = { mex:'Mexico', can:'Canada', jap:'Japan', chi:'China', uni:'United Kingdom', arg:'Argentina', swi:'Switzerland', haw:'Hawaii', uae:'UAE', cay:'Cayman Islands', sou:'South Africa' };

  /* ---------- STYLES (adapted to 250px width like Points Exporter) ---------- */
  GM_addStyle(`
    /* container placed inline in header, narrow dropdown uses 250px */
    #${PANEL_ID} { display:inline-block; vertical-align:middle; margin:0 6px; position:relative; z-index:999999; }
    #${PANEL_ID} .compact { background: rgba(10,10,10,0.62); color:#eee; border-radius:6px; font-family:"DejaVu Sans Mono",monospace;
      font-size:12px; padding:5px 8px; display:flex; align-items:center; gap:8px; cursor:pointer; min-width:110px; max-width:250px; box-sizing:border-box; }
    #${PANEL_ID} .compact .title { font-weight:700; display:flex; gap:6px; align-items:center; overflow:hidden; text-overflow:ellipsis; }
    #${PANEL_ID} .compact .controls { display:flex; gap:6px; align-items:center; margin-left:auto; }
    #${PANEL_ID} .compact .btn { background:transparent; color:#eaeaea; border:1px solid rgba(255,255,255,0.06); padding:2px 6px; border-radius:4px; cursor:pointer; font-size:11px; }
    /* dropdown (fixed narrow 250px) */
    #${PANEL_ID} .dropdown { position:absolute; left:0; top:calc(100% + 8px); width:250px; min-width:250px; max-width:250px;
      background: rgba(11,11,11,0.96); color:#eaeaea; border:1px solid #222; border-radius:6px; box-shadow:0 12px 30px rgba(0,0,0,0.6);
      overflow:hidden; max-height:0; transition: max-height 320ms cubic-bezier(.2,.9,.3,1), opacity 200ms ease, transform 260ms cubic-bezier(.2,.9,.3,1);
      opacity:0; transform: translateY(-8px); font-size:11px; }
    #${PANEL_ID} .dropdown.open { max-height:60vh; opacity:1; transform:translateY(0); }
    #${PANEL_ID} .dropdown .head { display:flex; justify-content:space-between; align-items:center; padding:8px; gap:8px; border-bottom:1px solid rgba(255,255,255,0.03); }
    #${PANEL_ID} .points { color:#bfc9d6; font-weight:700; font-size:12px; }
    #${PANEL_ID} .list { max-height:calc(60vh - 110px); overflow:auto; padding:6px 8px; display:block; }
    #${PANEL_ID} .section-title { font-weight:700; font-size:12px; margin:6px 0 4px; color:#dfe7ff; }
    #${PANEL_ID} .row { display:flex; justify-content:space-between; gap:8px; padding:4px 0; border-bottom:1px solid rgba(255,255,255,0.02); align-items:center; white-space:nowrap; }
    #${PANEL_ID} .left { display:flex; align-items:center; gap:8px; min-width:0; overflow:hidden; }
    #${PANEL_ID} .dot { width:9px; height:9px; border-radius:50%; flex:0 0 9px; }
    /* color mapping: orange = zero, red = <600, green = >1000 */
    .${STK_HIGH} { background:#00c853 !important; }   /* green */
    .${STK_LOW}  { background:#ff1744 !important; }   /* red */
    .${STK_ZERO} { background:#ff9800 !important; }   /* orange */
    .${STK_MID}  { background:#9ea6b3 !important; }   /* neutral */
    #${PANEL_ID} .name { min-width:0; overflow:hidden; text-overflow:ellipsis; font-size:11px; }
    #${PANEL_ID} .meta { color:#bfc9d6; width:130px; text-align:right; flex:0 0 130px; font-size:11px; }
    #${PANEL_ID} .fly { color:#9ad0ff; font-weight:700; margin-top:8px; text-align:right; padding:6px 8px; white-space:normal; line-height:1.1; }
    #${PANEL_ID} .small { font-size:11px; color:#9ea6b3; margin-top:6px; padding:0 8px 8px; }
    @media (max-width:720px) {
      #${PANEL_ID} .dropdown { width:92vw; left:4vw; right:4vw; max-width:92vw; min-width:unset; }
      #${PANEL_ID} .meta { width:110px; flex:0 0 110px; }
    }
  `);

  /* ---------- Build panel DOM ---------- */
  function createPanelNode(){
    const root = document.createElement('div');
    root.id = PANEL_ID;
    root.innerHTML = `
      <div class="compact" title="Click to expand">
        <div class="title"><span id="${PANEL_ID}-icon">▼</span>&nbsp;🌸🐻 Unified Stock</div>
        <div class="controls">
          <button class="btn" id="${PANEL_ID}-refresh">Refresh</button>
          <button class="btn" id="${PANEL_ID}-setkey">Set Key</button>
        </div>
      </div>
      <div class="dropdown" id="${PANEL_ID}-dropdown" aria-hidden="true">
        <div class="head">
          <div class="points" id="${PANEL_ID}-points">Sets: - | Points: -</div>
          <div style="color:#9ea6b3;font-size:11px" id="${PANEL_ID}-updated">Updated: -</div>
        </div>
        <div class="list" id="${PANEL_ID}-list">
          <!-- sections inserted here -->
        </div>
        <div class="fly" id="${PANEL_ID}-fly"></div>
        <div class="small">Format: ShortName — (inv: X | stk: Y | CODE)</div>
      </div>
    `;
    // events
    root.querySelector('.compact').addEventListener('click', (e) => {
      if (e.target && (e.target.id === `${PANEL_ID}-refresh` || e.target.id === `${PANEL_ID}-setkey`)) return;
      toggleDropdown();
    });
    root.querySelector(`#${PANEL_ID}-refresh`).addEventListener('click', (ev) => { ev.stopPropagation(); refreshAll(true); });
    root.querySelector(`#${PANEL_ID}-setkey`).addEventListener('click', (ev) => { ev.stopPropagation(); askApiKey(); });
    return root;
  }

  let panelNode = createPanelNode();
  let dropdownOpen = false;

  function toggleDropdown(force){
    const dd = panelNode.querySelector(`#${PANEL_ID}-dropdown`);
    const icon = panelNode.querySelector(`#${PANEL_ID}-icon`);
    dropdownOpen = (typeof force === 'boolean') ? force : !dropdownOpen;
    if (dropdownOpen) { dd.classList.add('open'); dd.setAttribute('aria-hidden','false'); icon.textContent = '▲'; }
    else { dd.classList.remove('open'); dd.setAttribute('aria-hidden','true'); icon.textContent = '▼'; }
    GM_setValue(`${PANEL_ID}-collapsed`, !dropdownOpen);
  }

  /* ---------- Insert panel between header left and right groups (keep position) ---------- */
  function findHeaderGroups(){
    const left = document.querySelector('.header-links-left, .headerLinksLeft, .headerLeft, .leftLinks');
    const right = document.querySelector('.header-links-right, .headerLinksRight, .headerRight, .rightLinks, .hud, #topbar-right');
    return { left, right };
  }

  function insertPanel(){
    const { left, right } = findHeaderGroups();
    const old = document.getElementById(PANEL_ID);
    if (old) old.remove();
    if (left && right && left.parentNode === right.parentNode){
      left.parentNode.insertBefore(panelNode, right);
    } else {
      const header = document.querySelector('header, #header, .topbar, .header') || document.body;
      header.appendChild(panelNode);
    }
    // restore collapsed state
    const wasCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
    toggleDropdown(!wasCollapsed);
  }

  // reinsert if header gets re-rendered
  const headerObserver = new MutationObserver(() => {
    if (!document.body.contains(panelNode)) {
      panelNode = createPanelNode();
      insertPanel();
    }
  });
  headerObserver.observe(document.documentElement || document.body, { childList:true, subtree:true });

  insertPanel();

  /* ---------- Networking (GM_xmlhttpRequest wrapper) ---------- */
  function gmGetJson(url, timeout=14000){
    return new Promise((resolve,reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout,
        onload: res => {
          try {
            let d = res.response;
            if (!d && res.responseText) d = JSON.parse(res.responseText);
            resolve(d);
          } catch (e) { reject(e); }
        },
        onerror: err => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  /* ---------- Torn display + YATA fetchers ---------- */
  async function askApiKey(){
    const current = GM_getValue('tornAPIKey','');
    const k = prompt('Enter your Torn user API key (needs display permission):', current || '');
    if (k !== null) {
      GM_setValue('tornAPIKey', String(k).trim());
      await refreshAll(true);
    }
  }

  async function fetchDisplayCase(){
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return {};
    const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
    try {
      const data = await gmGetJson(url);
      if (!data || data.error) return {};
      const out = {};
      const entries = data.display ? (Array.isArray(data.display) ? data.display : Object.values(data.display)) : [];
      for (const e of entries) {
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 0) || 0;
        if (!name) continue;
        out[name] = (out[name] || 0) + qty;
      }
      return out;
    } catch (err) {
      console.warn('fetchDisplayCase', err);
      return {};
    }
  }

  async function fetchYata(){
    try {
      const data = await gmGetJson(YATA_URL);
      return data || null;
    } catch (err) {
      console.warn('fetchYata', err);
      return null;
    }
  }

  /* ---------- Logic: sets, missing-to-next, unify YATA stocks ---------- */
  function computeSetsAndMissing(displayMap, groupOrder){
    const counts = groupOrder.map(([name]) => Number(displayMap[name] || 0));
    const sets = counts.length ? Math.min(...counts) : 0;
    const need = counts.reduce((sum, c) => sum + Math.max(0, (sets + 1) - (c || 0)), 0);
    return { sets, need };
  }

  function buildYataMap(yataData){
    const map = {};
    if (!yataData || !yataData.stocks) return map;
    for (const [code, obj] of Object.entries(yataData.stocks)) {
      const arr = Array.isArray(obj.stocks) ? obj.stocks : [];
      const m = {};
      for (const it of arr) {
        if (!it || !it.name) continue;
        m[it.name] = Number(it.quantity ?? 0) || 0;
      }
      map[code] = m;
    }
    return map;
  }

  function sumTotalStockForItem(yataMap, itemName){
    let total = 0;
    for (const code of Object.keys(yataMap)) {
      total += Number(yataMap[code][itemName] || 0);
    }
    return total;
  }

  function bestCountryForItem(yataMap, itemName){
    let best = { code: null, qty: 0 };
    for (const [code, m] of Object.entries(yataMap)) {
      const q = Number(m[itemName] || 0);
      if (q > best.qty) { best = { code, qty: q }; }
    }
    return best;
  }

  /* ---------- Color class for stk ---------- */
  function stkClassForQty(q){
    if (q === 0) return STK_ZERO;        // orange
    if (q > 1000) return STK_HIGH;       // green
    if (q < 600) return STK_LOW;         // red (note: q>0 here)
    return STK_MID;                      // neutral
  }

  function getFlagByCode(code){
    if (!code) return '';
    const map = { mex:'🇲🇽', can:'🇨🇦', jap:'🇯🇵', chi:'🇨🇳', uni:'🇬🇧', arg:'🇦🇷', swi:'🇨🇭', haw:'🏝️', uae:'🇦🇪', cay:'🇰🇾', sou:'🇿🇦' };
    return map[code] || '';
  }

  /* ---------- Render function (compact & narrow) ---------- */
  function renderAll(displayMap, yataData){
    const yataMap = buildYataMap(yataData);

    const flowersInfo = computeSetsAndMissing(displayMap, FLOWERS_ORDER);
    const plushiesInfo = computeSetsAndMissing(displayMap, PLUSHIES_ORDER);

    const totalSets = (flowersInfo.sets || 0) + (plushiesInfo.sets || 0);
    const totalPoints = totalSets * POINTS_PER_SET;

    const pointsEl = panelNode.querySelector(`#${PANEL_ID}-points`);
    pointsEl.textContent = `Sets: ${totalSets} | Points: ${totalPoints}`;

    let html = '';

    // Flowers
    html += `<div class="section-title">Flowers — Missing to next set: ${flowersInfo.need}</div>`;
    for (const [name, meta] of FLOWERS_ORDER) {
      const inv = Number(displayMap[name] || 0);
      const stk = sumTotalStockForItem(yataMap, name);
      const best = bestCountryForItem(yataMap, name);
      const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
      const cls = stkClassForQty(stk);
      const short = SHORT_MAP[name] || name;
      html += `<div class="row"><div class="left"><div class="dot ${cls}"></div><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag || ''}</div></div>`;
    }

    // Plushies
    html += `<div class="section-title" style="margin-top:8px;">Plushies — Missing to next set: ${plushiesInfo.need}</div>`;
    for (const [name, meta] of PLUSHIES_ORDER) {
      const inv = Number(displayMap[name] || 0);
      const stk = sumTotalStockForItem(yataMap, name);
      const best = bestCountryForItem(yataMap, name);
      const codePart = best.code ? ` | ${best.code.toUpperCase()}` : '';
      const cls = stkClassForQty(stk);
      const short = SHORT_MAP[name] || name;
      html += `<div class="row"><div class="left"><div class="dot ${cls}"></div><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag || ''}</div></div>`;
    }

    // Xanax (S.A.)
    {
      const name = SPECIAL_DRUG;
      const inv = Number(displayMap[name] || 0);
      const stk = Number(yataMap['sou']?.[name] || 0);
      const cls = stkClassForQty(stk);
      const codePart = stk > 0 ? ` | SOU` : '';
      html += `<div class="section-title" style="margin-top:8px;">Drugs</div>`;
      html += `<div class="row"><div class="left"><div class="dot ${cls}"></div><div class="name">${escapeHtml(SHORT_MAP[name]||name)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) 🇿🇦</div></div>`;
    }

    const listEl = panelNode.querySelector(`#${PANEL_ID}-list`);
    listEl.innerHTML = html;

    // flight suggestions
    const bestFlower = findLowestAndBest(displayMap, yataMap, FLOWERS_ORDER);
    const bestPlush = findLowestAndBest(displayMap, yataMap, PLUSHIES_ORDER);
    const flyEl = panelNode.querySelector(`#${PANEL_ID}-fly`);
    let flyHtml = '';
    if (bestFlower) {
      if (bestFlower.bestCode && bestFlower.bestQty > 0) {
        flyHtml += `✈ Fly (flowers): ${(COUNTRY_NAMES[bestFlower.bestCode] || bestFlower.bestCode.toUpperCase())} ${getFlagByCode(bestFlower.bestCode)} — ${escapeHtml(SHORT_MAP[bestFlower.name]||bestFlower.name)}`;
      } else flyHtml += `✈ Fly (flowers): No stock abroad for ${escapeHtml(SHORT_MAP[bestFlower.name]||bestFlower.name)}`;
    }
    if (bestPlush) {
      if (flyHtml) flyHtml += '<br>';
      if (bestPlush.bestCode && bestPlush.bestQty > 0) flyHtml += `✈ Fly (plushies): ${(COUNTRY_NAMES[bestPlush.bestCode] || bestPlush.bestCode.toUpperCase())} ${getFlagByCode(bestPlush.bestCode)} — ${escapeHtml(SHORT_MAP[bestPlush.name]||bestPlush.name)}`;
      else flyHtml += `✈ Fly (plushies): No stock abroad for ${escapeHtml(SHORT_MAP[bestPlush.name]||bestPlush.name)}`;
    }
    flyEl.innerHTML = flyHtml;

    const upEl = panelNode.querySelector(`#${PANEL_ID}-updated`);
    upEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
  }

  // find lowest item in group by inv, tie-breaker by total stk
  function findLowestAndBest(displayMap, yataMap, groupOrder){
    let lowest = null;
    for (const [name] of groupOrder) {
      const inv = Number(displayMap[name] || 0);
      const totalStk = sumTotalStockForItem(yataMap, name);
      if (!lowest || inv < lowest.inv || (inv === lowest.inv && totalStk < lowest.totalStk)) {
        lowest = { name, inv, totalStk };
      }
    }
    if (!lowest) return null;
    const best = bestCountryForItem(yataMap, lowest.name);
    return { name: lowest.name, inv: lowest.inv, totalStk: lowest.totalStk, bestCode: best.code, bestQty: best.qty };
  }

  /* ---------- Utility ---------- */
  function escapeHtml(s){ if (s==null) return ''; return String(s).replace(/[&<>"']/g, m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }

  /* ---------- Master refresh ---------- */
  let timer = null;
  async function refreshAll(force=false){
    const pointsNode = panelNode.querySelector(`#${PANEL_ID}-points`);
    if (pointsNode) pointsNode.textContent = 'Updating...';
    try {
      const [displayMap, yataData] = await Promise.all([ fetchDisplayCase(), fetchYata() ]);
      renderAll(displayMap||{}, yataData||null);
    } catch (e) {
      console.warn('refreshAll', e);
      const listEl = panelNode.querySelector(`#${PANEL_ID}-list`);
      if (listEl) listEl.innerHTML = `<div style="color:#f88;padding:8px">Update failed</div>`;
    }
  }

  async function fetchDisplayCase(){
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return {};
    const url = `https://api.torn.com/user/?selections=display&key=${encodeURIComponent(key)}`;
    try {
      const data = await gmGetJson(url);
      if (!data || data.error) return {};
      const out = {};
      const entries = data.display ? (Array.isArray(data.display) ? data.display : Object.values(data.display)) : [];
      for (const e of entries) {
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 0) || 0;
        if (!name) continue;
        out[name] = (out[name] || 0) + qty;
      }
      return out;
    } catch (err) {
      console.warn('fetchDisplayCase err', err);
      return {};
    }
  }

  async function fetchYata(){
    try {
      const data = await gmGetJson(YATA_URL);
      return data || null;
    } catch (err) {
      console.warn('fetchYata err', err);
      return null;
    }
  }

  function gmGetJson(url, timeout=14000){
    return new Promise((resolve,reject) => {
      GM_xmlhttpRequest({
        method:'GET', url, timeout,
        onload: (res) => {
          try {
            let d = res.response;
            if (!d && res.responseText) d = JSON.parse(res.responseText);
            resolve(d);
          } catch (e) { reject(e); }
        },
        onerror: (err) => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  /* ---------- Init ---------- */
  if (!panelNode) panelNode = createPanelNode();
  insertPanel();

  // restore collapse state
  const storedCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
  toggleDropdown(!storedCollapsed);

  // initial refresh + polling
  refreshAll(true);
  if (timer) clearInterval(timer);
  timer = setInterval(() => refreshAll(false), REFRESH_MS);

  // cleanup
  window.addEventListener('beforeunload', () => {
    if (timer) clearInterval(timer);
    headerObserver.disconnect();
  });

  // highlight set-key if missing
  if (!GM_getValue('tornAPIKey', null)) {
    setTimeout(()=> {
      const btn = panelNode.querySelector(`#${PANEL_ID}-setkey`);
      if (btn) { btn.style.borderColor = '#89b7ff'; btn.style.boxShadow = '0 0 8px rgba(137,183,255,0.18)'; }
    }, 1500);
  }

})();