🌸🐻 Unified Stock Compact v4.4

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.

As of 12.10.2025. See ბოლო ვერსია.

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.

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         🌸🐻 Unified Stock Compact v4.4
// @namespace    http://tampermonkey.net/
// @version      4.4
// @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.
// @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
  const STK_ZERO = 'stock-zero';    // orange
  const STK_LOW = 'stock-low';      // red (<600)
  const STK_HIGH = 'stock-high';    // green (>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"
  };

  // flowers (name, code, flag)
  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:'🇰🇾'}]
  ];

  // plushies (name, code, 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 (compact, narrow & sliding) ---------- */
  GM_addStyle(`
    #${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:360px; box-sizing:border-box; }
    #${PANEL_ID} .compact .title { font-weight:700; display:flex; gap:6px; align-items:center; }
    #${PANEL_ID} .compact .controls { display:flex; gap:6px; align-items:center; }
    #${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:12px; }
    /* dropdown (narrow) */
    #${PANEL_ID} .dropdown { position:absolute; left:0; top:calc(100% + 8px); width:320px; max-width:34vw; min-width:260px;
      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:40vh; 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(40vh - 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; }
    .${STK_HIGH} { background:#00c853; } .${STK_LOW} { background:#ff1744; }
    .${STK_ZERO} { background:#ff9800; } .${STK_MID} { background:#9ea6b3; }
    #${PANEL_ID} .name { min-width:0; overflow:hidden; text-overflow:ellipsis; font-size:11px; }
    #${PANEL_ID} .meta { color:#bfc9d6; width:150px; text-align:right; flex:0 0 150px; font-size:11px; }
    #${PANEL_ID} .fly { color:#9ad0ff; font-weight:700; margin-top:8px; text-align:right; padding:6px 8px; }
    #${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; }
      #${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 ---------- */
  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){
    // groupOrder: array of [name, meta]
    const counts = groupOrder.map(([name]) => Number(displayMap[name] || 0));
    const sets = counts.length ? Math.min(...counts) : 0;
    // missing to complete next set = sum max(0, (sets+1)-count)
    const need = counts.reduce((sum, c) => sum + Math.max(0, (sets + 1) - (c || 0)), 0);
    return { sets, need };
  }

  function buildYataMap(yataData){
    // returns { code: { itemName: qty, ... }, ... }
    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;
    if (q > 1000) return STK_HIGH;
    if (q < 600) return STK_LOW;
    return STK_MID;
  }

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

    // compute sets & missing
    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;

    // update top points (visible only when expanded)
    const pointsEl = panelNode.querySelector(`#${PANEL_ID}-points`);
    pointsEl.textContent = `Sets: ${totalSets} | Points: ${totalPoints}`;

    // prepare HTML for list: Flowers section, Plushies section, Xanax
    let html = '';

    // Flowers section title + missing line
    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 club = stkClassForQty(stk);
      const short = SHORT_MAP[name] || name;
      html += `<div class="row"><div class="left"><div class="dot ${club}"></div><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag || ''}</div></div>`;
    }

    // Plushies section title + missing line
    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 club = stkClassForQty(stk);
      const short = SHORT_MAP[name] || name;
      html += `<div class="row"><div class="left"><div class="dot ${club}"></div><div class="name">${escapeHtml(short)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) ${meta.flag || ''}</div></div>`;
    }

    // Xanax (SA) at bottom
    {
      const name = SPECIAL_DRUG;
      const inv = Number(displayMap[name] || 0);
      const stk = Number(yataMap['sou']?.[name] || 0);
      const club = 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 ${club}"></div><div class="name">${escapeHtml(SHORT_MAP[name]||name)}</div></div><div class="meta">(inv: ${inv} | stk: ${stk}${codePart}) 🇿🇦</div></div>`;
    }

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

    // flight suggestions: for flowers and plushies independently
    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 && 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 if (bestFlower) {
      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;

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

  // helper: find lowest-count item in group and best country
  function findLowestAndBest(displayMap, yataMap, groupOrder){
    let lowest = null;
    for (const [name] of groupOrder) {
      const inv = Number(displayMap[name] || 0);
      if (!lowest || inv < lowest.inv || (inv === lowest.inv && sumTotalStockForItem(yataMap,name) < lowest.totalStk)) {
        lowest = { name, inv, totalStk: sumTotalStockForItem(yataMap,name) };
      }
    }
    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: insert panel, restore state, start polling ---------- */
  // ensure panel present and inserted
  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();
  });

  // hint 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);
  }

})();