🌸🐻 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.

目前為 2025-10-12 提交的版本,檢視 最新版本

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

})();