Torn Set Trader

Track plushie & flower sets, missing items, profit calculator with MV% thresholds and lifetime trade tracking

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         Torn Set Trader
// @namespace    torn_set_trader
// @version      1.8.3
// @description  Track plushie & flower sets, missing items, profit calculator with MV% thresholds and lifetime trade tracking
// @author       TheOddSod (2640064)
// @match        https://www.torn.com/item.php*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// @run-at       document-idle
// ==/UserScript==

// Changelog:
// v1.8.3 — Full design system integration. All hardcoded hex values replaced
//           with --ocm-* CSS custom properties. Full 8-theme system added
//           (Default, Torn Classic, High Contrast, Deuteranopia, Protanopia,
//           Tritanopia, Low Vision, Light Mode). Theme selector in Settings
//           with live preview. Matches OC Manager design system exactly.
// v1.6.7 — Added Fring [3714844] credit in Settings (original concept author).
// v1.6.6 — Replaced browser confirm() dialogs with styled in-app modals.
// v1.6.5 — Record Exchange button dimmed and disabled when completeSets = 0.
// v1.6.4 — Renamed "Trade" → "Exchange" throughout UI and history log.
// v1.6.3 — MV Threshold repurposed: buy cost now uses threshold% of market
//           price as assumed buy price. Profit shows best-case scenario.
//           Settings label updated to "Buy Discount %".
// v1.6.2 — Fixed item doubling on refresh. Scraping now uses data-category
//           attribute to only count items for the relevant set type.
// v1.6.1 — Fixed item doubling: filter by visibility when scraping DOM.
// v1.6.0 — Switched inventory scraping to programmatically click Plushie/Flower
//           category tabs to force full render before scraping. Fixes missing
//           items caused by Torn's virtual scroll / lazy rendering.
// v1.5.5 — Removed all debug logs. Confirmed correct price parsing.
// v1.5.4 — Fixed itemmarket price path: listings are at itemmarket.listings[],
//           not itemmarket.item.listings[]. Prices now correct.
// v1.5.1 — Removed 30s cache guard — prices always re-fetched on refresh.
// v1.5.0 — Use listings[0].price (API returns listings sorted cheapest first).
// v1.4.9 — Added targeted debug logging for price diagnosis.
// v1.4.7 — Prices only re-fetched after 30s (Torn server-side cache window).
//           Footer shows price fetch timestamp.
// v1.4.6 — Fixed itemmarket URL to v2. MutationObserver for lazy DOM loading.
// v1.4.3 — Confirmed itemmarket/pointsmarket are v2-only (error 23 on v1).
// v1.4.2 — $ signs on all money values. Fixed-width item row columns.
// v1.4.1 — Removed debug logs. Label cleanup.
// v1.4.0 — Fixed itemmarket array/object parsing. Added v1 market_value fallback.
// v1.3.9 — Header badge shows total items to buy. Buy cost and sell value
//           calculations corrected. Record Trade uses completeSets.
// v1.3.8 — Switched API calls back to GM_xmlhttpRequest (CORS fix).
// v1.3.7 — Target sets defaults to 0 (auto = maxOwned per set type).
// v1.3.6 — Fixed all profit calculations: need/buyCost/sellValue/profit.
// v1.3.5 — Fixed -1 display for unowned items (Torn uses data-qty=-1).
// v1.3.4 — Fixed points price and item market prices showing 0.
// v1.3.3 — Fixed "apiFetch is not defined".
// v1.3.2 — Fixed "resolvedIds is not defined" — moved to module scope.
// v1.3.1 — Fixed DOM scraper selectors using actual item.php HTML structure.
// v1.3.0 — Switched to DOM scraping for inventory (Torn API endpoint removed).
// v1.2.1 — Switched all API calls to fetch() with full explicit URLs.
// v1.2.0 — Fixed all API endpoints to v2. Inventory deprecated on v1.
// v1.1.9 — Raw response logging to diagnose inventory API response structure.
// v1.1.8 — Improved error visibility. Clarified API key requirements.
// v1.1.7 — Force string keys throughout. Added targeted debug logging.
// v1.1.6 — Switched inventory & bazaar fetches to API v1.
// v1.1.4 — Item IDs resolved dynamically from torn/items API. Cached in GM storage.
// v1.1.3 — Corrected base points to 10 per set (11 was Museum Day active).
// v1.1.2 — Corrected all item IDs and names. Plushie set: 13 items. Flower
//           set: 11 items. Points corrected to 11 per set (not 3).
// v1.1.1 — Fixed tabs and settings panel. Event listeners re-attached after
//           every render. Fixed API parsing for v2 response structure.
// v1.0.0 — Initial release. Plushie & flower set tracker, profit calculator,
//           MV% threshold, Museum Day toggle, lifetime trade log, export/import.

(function () {
  'use strict';

  // ─── ITEM DATA ───────────────────────────────────────────────────────────────

  // Torn item IDs for each set piece — verified from Torn API item list & forum data.
  // Plushie set: 13 items → 11 points at museum (Museum Day: +10% = 12 pts)
  const PLUSHIE_SET = {
    name: 'Plushies',
    icon: '🧸',
    items: {
      186: 'Sheep Plushie',
      187: 'Teddy Bear Plushie',
      215: 'Kitten Plushie',
      258: 'Jaguar Plushie',
      266: 'Nessie Plushie',
      268: 'Red Fox Plushie',
      269: 'Monkey Plushie',
      270: 'Wolverine Plushie',
      273: 'Chamois Plushie',
      274: 'Panda Plushie',
      281: 'Lion Plushie',
      384: 'Camel Plushie',
      618: 'Stingray Plushie',
    },
  };

  // Flower set: 11 items → 11 points at museum (Museum Day: +10% = 12 pts)
  const FLOWER_SET = {
    name: 'Flowers',
    icon: '🌸',
    items: {
      260: 'Dahlia',
      263: 'Crocus',
      264: 'Orchid',
      267: 'Heather',
      271: 'Ceibo Flower',
      272: 'Edelweiss',
      276: 'Peony',
      277: 'Cherry Blossom',
      282: 'African Violet',
      385: 'Tribulus Omanense',
      617: 'Banana Orchid',
    },
  };

  // Base points per completed set trade (without any bonuses).
  // Museum Day (+10%) is handled separately via the MUSEUM_BONUS multiplier.
  const PLUSHIE_POINTS = 10;
  const FLOWER_POINTS  = 10;
  // Museum Day bonus: +10% points on exchange at the museum
  const MUSEUM_BONUS   = 1.10;

  // ─── STORAGE ─────────────────────────────────────────────────────────────────

  const S = {
    get: (k, d) => { const v = GM_getValue(k, null); return v === null ? d : v; },
    set: (k, v) => GM_setValue(k, v),
  };

  // ─── STATE ───────────────────────────────────────────────────────────────────

  // Persisted config — loaded once, saved on settings save
  const cfg = {
    apiKey:         S.get('tst_apiKey',        ''),
    pointsOverride: S.get('tst_pointsOver',    ''),   // empty = use API
    mvThreshold:    S.get('tst_mvThresh',      100),  // buy if price <= X% of MV
    targetSets:     S.get('tst_targetSets',    0),
    museumDay:      S.get('tst_museumDay',     false),
    showPlushies:   S.get('tst_showPlushies',  true),
    showFlowers:    S.get('tst_showFlowers',   true),
    autoRefresh:    S.get('tst_autoRefresh',   60),   // seconds; 0 = off
    theme:          S.get('tst_theme',         'default'),
    targetPrices:   S.get('tst_targetPrices',  {}),  // keyed by item ID, value = max buy price
  };

  // Runtime state — not persisted
  const rt = {
    inventory: {}, bazaar: {}, marketPrices: {},
    pointsPrice: 0, loading: false, lastRefresh: null,
    tab: 'tracker', timer: null, error: '',
  };

  let collapsed = S.get('tst_collapsed', false);

  // ─── ITEM ID RESOLUTION ──────────────────────────────────────────────────────

  // Cached name→ID map — populated from DOM on first render, persisted in GM storage
  let resolvedIds = S.get('tst_resolvedIds', null);

  const ALL_SET_NAMES = [
    ...Object.values(PLUSHIE_SET.items),
    ...Object.values(FLOWER_SET.items),
  ].map(n => n.toLowerCase());

  function itemId(hardcodedId, name) {
    if (!resolvedIds) return hardcodedId;
    return resolvedIds[name.toLowerCase()] || hardcodedId;
  }

  function resolvedSet(setDef) {
    const items = {};
    Object.entries(setDef.items).forEach(([hid, name]) => {
      items[itemId(hid, name)] = name;
    });
    return { ...setDef, items };
  }

  // ─── DOM INVENTORY SCRAPING ──────────────────────────────────────────────────
  // Torn's item page uses virtual scroll — not all items render on load.
  // Fix: programmatically click each set's category filter tab, wait for DOM
  // to settle, scrape quantities, then restore the original tab.
  // Category filter anchors use data-info attribute: "Plushie" / "Flower"

  function scrapeByCategory(category) {
    // Use data-category attribute on each li — Torn keeps all items in DOM always
    const inv = {};
    document.querySelectorAll(`ul.items-cont li[data-item][data-qty][data-category="${category}"]`).forEach(li => {
      const id  = String(li.dataset.item || '').trim();
      const qty = parseInt(li.dataset.qty) || 0;
      if (!id || id === '0' || qty <= 0) return;
      inv[id] = (inv[id] || 0) + qty;
    });
    return inv;
  }

  function clickCategoryAndWait(dataInfo) {
    return new Promise(resolve => {
      const anchor = document.querySelector(`a[data-info="${dataInfo}"]`);
      if (!anchor) { resolve({}); return; }
      anchor.click();
      // Wait for Torn to render all items in this category
      setTimeout(() => resolve(scrapeByCategory(dataInfo)), 600);
    });
  }

  async function readInventoryFromDOM() {
    // Remember what tab was active so we can restore it
    const activeAnchor = document.querySelector('ul.ui-tabs-nav li.ui-tabs-active a, ul.cat-wrap li.active a');
    const activeInfo   = activeAnchor?.dataset?.info || null;

    // Scrape plushies
    const plushieInv = await clickCategoryAndWait('Plushie');
    // Scrape flowers
    const flowerInv  = await clickCategoryAndWait('Flower');

    // Restore original tab
    if (activeInfo && activeInfo !== 'Flower') {
      const restoreAnchor = document.querySelector(`a[data-info="${activeInfo}"]`);
      if (restoreAnchor) restoreAnchor.click();
    }

    // Merge both
    const inv = { ...plushieInv };
    Object.entries(flowerInv).forEach(([id, qty]) => {
      inv[id] = (inv[id] || 0) + qty;
    });
    return inv;
  }

  // Resolve item IDs from DOM — call after clicking a category tab
  function resolveIdsFromDOM() {
    const map = {};
    document.querySelectorAll('ul.items-cont li[data-item]').forEach(li => {
      const id     = parseInt(li.dataset.item);
      const nameEl = li.querySelector('span.name');
      if (!nameEl || !id) return;
      const name = nameEl.textContent.trim().toLowerCase();
      if (ALL_SET_NAMES.includes(name)) map[name] = id;
    });
    if (Object.keys(map).length > 0) {
      resolvedIds = map;
      S.set('tst_resolvedIds', map);
    }
  }

  // ─── API ─────────────────────────────────────────────────────────────────────
  // Uses GM_xmlhttpRequest which bypasses CORS — required for Tampermonkey
  // scripts calling external domains. fetch() triggers CORS errors here.

  // market endpoints (itemmarket, pointsmarket) are v2-only (error 23 on v1)
  const API_BASE = 'https://api.torn.com/v2';

  function apiFetch(path) {
    const sep = path.includes('?') ? '&' : '?';
    const url = `${API_BASE}${path}${sep}key=${cfg.apiKey}&comment=TornSetTrader`;
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        onload(r) {
          try {
            const data = JSON.parse(r.responseText);
            if (data.error) reject(new Error(`API ${data.error.code}: ${data.error.error}`));
            else resolve(data);
          } catch (e) { reject(e); }
        },
        onerror() { reject(new Error('Network error')); },
      });
    });
  }

  async function fetchAll() {
    if (!cfg.apiKey) return;
    rt.loading = true;
    rt.error   = '';
    render();
    try {
      // Step 1: click Plushie + Flower category tabs to force full render, then scrape
      const inv = await readInventoryFromDOM();
      resolveIdsFromDOM();
      rt.inventory = inv;
      rt.bazaar    = {};

      // Step 2: market prices — fetch on every refresh
      const rPlushies = resolvedSet(PLUSHIE_SET);
      const rFlowers  = resolvedSet(FLOWER_SET);
      const allIds    = [...Object.keys(rPlushies.items), ...Object.keys(rFlowers.items)];

        async function fetchItemPrice(id) {
          return new Promise(resolve => {
            const url = `https://api.torn.com/v2/market/${id}?selections=itemmarket&key=${cfg.apiKey}&comment=TornSetTrader`;
            GM_xmlhttpRequest({
              method: 'GET', url,
              onload(r) {
                try {
                  const resp = JSON.parse(r.responseText);
                  if (resp.error) { resolve(0); return; }
                  // Structure: { itemmarket: { item: {...}, listings: [{price, amount}] } }
                  const listings = resp.itemmarket?.listings || [];
                  if (listings.length) {
                    const price = Number(listings[0].price ?? 0);
                    if (price > 0) { resolve(price); return; }
                  }
                } catch { /* fall through */ }
                resolve(0);
              },
              onerror() { resolve(0); },
            });
          });
        }

        const results = await Promise.allSettled(allIds.map(id => fetchItemPrice(id)));
        const prices  = {};
        results.forEach((r, i) => {
          if (r.status === 'fulfilled' && r.value > 0) prices[String(allIds[i])] = r.value;
        });

        // Fallback: v1 torn/items market_value if itemmarket returned nothing
        if (Object.keys(prices).length === 0) {
          try {
            const tornResp = await new Promise((res, rej) => {
              GM_xmlhttpRequest({
                method: 'GET',
                url: `https://api.torn.com/torn?selections=items&key=${cfg.apiKey}&comment=TornSetTrader`,
                onload: r => { try { res(JSON.parse(r.responseText)); } catch { rej(); } },
                onerror: rej,
              });
            });
            Object.entries(tornResp.items || {}).forEach(([id, item]) => {
              if (allIds.includes(String(id)) && item.market_value) prices[String(id)] = item.market_value;
            });
          } catch { /* ignore */ }
        }

        rt.marketPrices = prices;

      // Step 3: points market price
      const ptResp     = await apiFetch('/market?selections=pointsmarket');
      const pmListings = Array.isArray(ptResp.pointsmarket)
        ? ptResp.pointsmarket
        : Object.values(ptResp.pointsmarket || {});
      if (pmListings.length) {
        rt.pointsPrice = Math.min(...pmListings.map(l => Number(l.cost ?? l.price ?? 0)).filter(v => v > 0));
      }

    } catch (err) {
      rt.error = String(err);
      console.error('[TornSetTrader]', err);
    }
    rt.loading     = false;
    rt.lastRefresh = new Date();
    render();
  }

  function startAutoRefresh() {
    if (rt.timer) clearInterval(rt.timer);
    if (cfg.autoRefresh > 0) rt.timer = setInterval(fetchAll, cfg.autoRefresh * 1000);
  }

  // ─── CALCULATIONS ─────────────────────────────────────────────────────────────

  // Total owned across inventory + bazaar — all keys are strings
  function owned(id) {
    const k = String(id);
    return (rt.inventory[k] || 0) + (rt.bazaar[k]?.qty || 0);
  }

  function analyseSet(setDef, basePoints) {
    const rSet   = resolvedSet(setDef);
    const items  = Object.entries(rSet.items);

    const counts       = items.map(([id]) => owned(id));
    const maxSets      = Math.max(...counts);
    const completeSets = Math.min(...counts);

    // target: use user-specified value, or auto = maxSets (the most you could complete)
    const target = cfg.targetSets > 0 ? cfg.targetSets : maxSets;

    const ptPrice = cfg.pointsOverride ? parseFloat(cfg.pointsOverride) : rt.pointsPrice;
    const pts     = cfg.museumDay ? Math.floor(basePoints * MUSEUM_BONUS) : basePoints;

    const rows = items.map(([id, name]) => {
      const have        = owned(id);
      const need        = Math.max(0, target - have);
      const price       = rt.marketPrices[id] || 0;
      const targetPrice = price * (cfg.mvThreshold / 100);
      const maxBuy      = cfg.targetPrices[id] ? parseFloat(cfg.targetPrices[id]) : 0;
      const atTarget    = maxBuy > 0 && price > 0 && price <= maxBuy;
      return { id, name, have, need, price, targetPrice, atTarget, maxBuy };
    });

    const totalItemsNeeded = rows.reduce((s, r) => s + r.need, 0);
    // Buy cost at threshold% of market price — e.g. 90% means you assume buying at 10% discount
    const buyCost      = rows.reduce((s, r) => s + r.targetPrice * r.need, 0);
    // Full market buy cost (at 100%) for reference
    const buyCostFull  = rows.reduce((s, r) => s + r.price * r.need, 0);
    const sellValue    = target * pts * ptPrice;
    const currentValue = completeSets * pts * ptPrice;

    // Progress bar: items owned vs total items needed for full target
    const totalNeededFull = target * items.length;
    const totalOwned      = rows.reduce((s, r) => s + Math.min(r.have, target), 0);
    const pct             = totalNeededFull > 0 ? Math.min(100, (totalOwned / totalNeededFull) * 100) : 0;
    const barColour       = pct >= 66 ? 'var(--ocm-status-ok)' : pct >= 33 ? 'var(--ocm-status-warn)' : 'var(--ocm-status-crit)';

    return { completeSets, maxSets, target, rows, totalItemsNeeded, buyCost, buyCostFull, sellValue, currentValue, profit: sellValue - buyCost, pts, pct, totalOwned, totalNeededFull, barColour };
  }

  // ─── TRADE LOG ────────────────────────────────────────────────────────────────

  function recordTrade(type, sets, profitPerSet) {
    const log = S.get('tst_tradeLog', []);
    log.unshift({ date: new Date().toISOString(), type, sets, profit: profitPerSet * sets });
    S.set('tst_tradeLog',       log.slice(0, 500)); // cap at 500 entries
    S.set('tst_lifetimeProfit', S.get('tst_lifetimeProfit', 0) + profitPerSet * sets);
    S.set('tst_lifetimeSets',   S.get('tst_lifetimeSets',   0) + sets);
    render();
  }

  function exportData() {
    const data = {
      lifetimeProfit: S.get('tst_lifetimeProfit', 0),
      lifetimeSets:   S.get('tst_lifetimeSets',   0),
      tradeLog:       S.get('tst_tradeLog',        []),
      exported:       new Date().toISOString(),
    };
    const a    = document.createElement('a');
    a.href     = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
    a.download = `torn-set-trader-${Date.now()}.json`;
    a.click();
  }

  function importData(file) {
    const reader    = new FileReader();
    reader.onload   = e => {
      try {
        const d = JSON.parse(e.target.result);
        if (d.tradeLog) {
          S.set('tst_tradeLog',       d.tradeLog);
          S.set('tst_lifetimeProfit', d.lifetimeProfit || 0);
          S.set('tst_lifetimeSets',   d.lifetimeSets   || 0);
          render();
          alert('Import successful!');
        }
      } catch { alert('Invalid export file.'); }
    };
    reader.readAsText(file);
  }

  // ─── HELPERS ─────────────────────────────────────────────────────────────────

  function fmt(n) {
    if (n == null) return '—';
    const abs = Math.abs(n);
    if (abs >= 1e9) return (n / 1e9).toFixed(2) + 'b';
    if (abs >= 1e6) return (n / 1e6).toFixed(2) + 'm';
    if (abs >= 1e3) return (n / 1e3).toFixed(1) + 'k';
    return Math.round(n).toLocaleString();
  }

  // fmt with leading $ sign
  function fmtMoney(n) {
    if (n == null || n === 0) return '—';
    return '$' + fmt(n);
  }

  function fmtDate(iso) {
    const d = new Date(iso);
    return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) + ' ' +
           d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
  }

  // ─── STYLES ──────────────────────────────────────────────────────────────────

  GM_addStyle(`
    /* ── Root — CSS variables applied by applyTheme() ── */
    #tst-root {
      font-family: Arial, sans-serif;
      font-size: 13px;
      color: var(--ocm-text-primary);
      margin: 10px 0;
    }
    #tst-root * { box-sizing: border-box; }

    /* ── Header ── */
    #tst-header {
      display: flex;
      align-items: center;
      gap: 10px;
      background: var(--ocm-bg-header);
      padding: 8px 12px;
      border-radius: 6px 6px 0 0;
      border-bottom: 2px solid var(--ocm-border-accent);
      cursor: pointer;
      user-select: none;
    }
    #tst-header h2 { margin: 0; font-size: 15px; color: var(--ocm-accent); flex: 1; }
    #tst-header small { color: var(--ocm-text-label); font-size: 11px; }
    #tst-root.tst-collapsed #tst-header { border-radius: 6px; }
    #tst-root.tst-collapsed #tst-tabs,
    #tst-root.tst-collapsed #tst-body { display: none; }

    /* ── Tab bar ── */
    #tst-tabs {
      background: var(--ocm-bg-base);
      padding: 4px 12px 0;
      display: flex;
      gap: 2px;
      border-bottom: 1px solid var(--ocm-border-strip);
    }
    .tst-tab {
      padding: 4px 12px;
      font-size: 11px;
      font-weight: bold;
      color: var(--ocm-text-secondary);
      background: var(--ocm-bg-deep);
      border: 1px solid var(--ocm-border-strip);
      border-bottom: none;
      border-radius: 3px 3px 0 0;
      cursor: pointer;
      transition: background .15s, color .15s;
    }
    .tst-tab:hover { background: var(--ocm-border-strip); color: var(--ocm-text-primary); }
    .tst-tab.active { background: var(--ocm-bg-base); color: var(--ocm-accent); border-color: var(--ocm-border-input); }

    /* ── Body ── */
    #tst-body {
      background: var(--ocm-bg-base);
      border-radius: 0 0 6px 6px;
      padding: 10px;
    }

    /* ── Stats bar ── */
    #tst-stats-bar {
      display: flex;
      gap: 16px;
      background: var(--ocm-bg-input);
      padding: 6px 12px;
      flex-wrap: wrap;
      margin-bottom: 10px;
      border-radius: 3px;
    }
    .tst-stat { display: flex; flex-direction: column; }
    .tst-stat-label { font-size: 10px; color: var(--ocm-text-secondary); text-transform: uppercase; letter-spacing: .5px; }
    .tst-stat-value { font-size: 14px; font-weight: bold; color: var(--ocm-accent); }
    .tst-stat-value.green  { color: var(--ocm-status-ok); }
    .tst-stat-value.red    { color: var(--ocm-status-crit); }
    .tst-stat-value.blue   { color: var(--ocm-status-travel); }
    .tst-stat-value.orange { color: var(--ocm-status-warn); }
    .tst-stat-value.gold   { color: var(--ocm-status-respect); }

    /* ── Section title ── */
    .tst-section-title {
      color: var(--ocm-accent);
      font-size: 9px;
      font-weight: bold;
      text-transform: uppercase;
      letter-spacing: .5px;
      margin: 8px 0 4px;
      border-bottom: 1px solid var(--ocm-border-section);
      padding-bottom: 3px;
    }

    /* ── Set card ── */
    .tst-set-card {
      background: var(--ocm-bg-card);
      border: 1px solid var(--ocm-border-card);
      border-radius: 6px;
      margin-bottom: 8px;
      overflow: hidden;
    }
    .tst-set-card.ready   { border-color: var(--ocm-status-ok-border); }
    .tst-set-card.missing { border-color: var(--ocm-status-crit-border); }

    .tst-set-head {
      background: var(--ocm-bg-deep);
      padding: 6px 10px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      flex-wrap: wrap;
      gap: 6px;
      border-bottom: 1px solid var(--ocm-border-card);
    }
    .tst-set-name { font-size: 13px; font-weight: bold; color: var(--ocm-text-primary); display: flex; align-items: center; gap: 8px; }

    /* ── Badges ── */
    .tst-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: bold; }
    .tst-badge.ready   { background: var(--ocm-status-ok-bg);   color: var(--ocm-status-ok);   border: 1px solid var(--ocm-status-ok-border); }
    .tst-badge.missing { background: var(--ocm-status-crit-bg); color: var(--ocm-status-crit); border: 1px solid var(--ocm-status-crit-border); }
    .tst-badge.museum  { background: var(--ocm-phase-rec-bg);   color: var(--ocm-phase-rec-text); border: 1px solid var(--ocm-phase-rec-border); }

    .tst-set-meta { display: flex; gap: 14px; flex-wrap: wrap; }
    .tst-set-meta span { font-size: 10px; color: var(--ocm-text-label); }
    .tst-set-meta b { color: var(--ocm-text-card); }

    /* ── Item grid ── */
    .tst-items { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
    .tst-item {
      border-right: 1px solid var(--ocm-border-faint);
      border-bottom: 1px solid var(--ocm-border-faint);
      padding: 4px 8px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 6px;
      font-size: 11px;
    }
    .tst-item:hover { background: var(--ocm-bg-hover); }
    .tst-item-name  { color: var(--ocm-text-card); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
    .tst-item-r     { display: flex; align-items: center; gap: 5px; flex-shrink: 0; }
    .tst-item-qty   { font-size: 12px; font-weight: bold; min-width: 24px; text-align: right; }
    .tst-item-qty.ok  { color: var(--ocm-status-ok); }
    .tst-item-qty.low { color: var(--ocm-status-crit); }
    .tst-item-price   { font-size: 10px; color: var(--ocm-text-muted); min-width: 50px; text-align: right; text-decoration: none; }
    .tst-item-price:hover { color: var(--ocm-accent); text-decoration: underline; }
    .tst-item-price.target { color: var(--ocm-status-ok); font-weight: bold; }
    .tst-need-tag {
      background: var(--ocm-status-crit-bg);
      color: var(--ocm-status-crit);
      font-size: 9px;
      font-weight: bold;
      padding: 1px 5px;
      border-radius: 3px;
      min-width: 52px;
      text-align: center;
      white-space: nowrap;
    }

    /* ── Profit bar ── */
    .tst-profit-bar {
      background: var(--ocm-bg-deep);
      border-top: 1px solid var(--ocm-border-card);
      padding: 6px 10px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
      flex-wrap: wrap;
      font-size: 11px;
    }
    .tst-profit-nums { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
    .tst-pn span     { color: var(--ocm-text-label); margin-right: 3px; }
    .tst-pn b.orange { color: var(--ocm-status-warn); }
    .tst-pn b.blue   { color: var(--ocm-status-travel); }
    .tst-pn b.green  { color: var(--ocm-status-ok); }
    .tst-pn b.red    { color: var(--ocm-status-crit); }

    /* ── Buttons ── */
    .tst-btn {
      display: inline-flex;
      align-items: center;
      gap: 4px;
      padding: 4px 10px;
      font-size: 11px;
      font-weight: bold;
      border-radius: 4px;
      cursor: pointer;
      font-family: Arial, sans-serif;
      line-height: 1.4;
      transition: opacity .15s;
      white-space: nowrap;
      border: none;
    }
    .tst-btn:hover  { opacity: .85; }
    .tst-btn:active { opacity: .70; }
    .tst-btn-primary   { background: var(--ocm-accent-hover); color: #fff; }
    .tst-btn-primary:hover { background: var(--ocm-accent); opacity: 1; }
    .tst-btn-secondary { background: var(--ocm-bg-input); border: 1px solid var(--ocm-border-input); color: var(--ocm-text-secondary); }
    .tst-btn-secondary:hover { background: var(--ocm-bg-hover); color: var(--ocm-text-primary); opacity: 1; }
    .tst-btn-green { background: var(--ocm-status-ok-bg); border: 1px solid var(--ocm-status-ok-border); color: var(--ocm-status-ok); }
    .tst-btn-green:hover { background: var(--ocm-status-ok-border); color: #fff; opacity: 1; }
    .tst-btn-red   { background: var(--ocm-status-crit-bg); border: 1px solid var(--ocm-status-crit-border); color: var(--ocm-status-crit); }
    .tst-btn-red:hover { background: var(--ocm-status-crit-border); color: #fff; opacity: 1; }

    /* ── Footer ── */
    .tst-foot { margin-top: 8px; display: flex; align-items: center; justify-content: space-between; gap: 6px; flex-wrap: wrap; }
    .tst-refresh-info { font-size: 10px; color: var(--ocm-text-muted); }

    /* ── Settings form ── */
    .tst-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; margin-bottom: 10px; }
    .tst-form-full { grid-column: 1 / -1; }
    .tst-form-label {
      display: block;
      font-size: 9px;
      font-weight: bold;
      color: var(--ocm-text-muted);
      text-transform: uppercase;
      margin-bottom: 3px;
      letter-spacing: .5px;
    }
    .tst-form-input {
      width: 100%;
      background: var(--ocm-bg-deep);
      border: 1px solid var(--ocm-border-input);
      border-radius: 4px;
      color: var(--ocm-text-primary);
      padding: 5px 8px;
      font-size: 11px;
      font-family: Arial, sans-serif;
    }
    .tst-form-input::placeholder { color: var(--ocm-text-disabled); }
    .tst-form-input:focus { outline: none; border-color: var(--ocm-accent); }

    .tst-divider { height: 1px; background: var(--ocm-border-strip); margin: 10px 0; }

    .tst-toggle-row {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 6px;
      cursor: pointer;
      font-size: 11px;
      color: var(--ocm-text-card);
      user-select: none;
    }
    .tst-toggle-row input[type=checkbox] { accent-color: var(--ocm-accent); width: 13px; height: 13px; }

    .tst-api-note { font-size: 10px; color: var(--ocm-text-muted); margin-top: 4px; }
    .tst-api-note b { color: var(--ocm-status-warn); }

    /* ── History table ── */
    .tst-table { width: 100%; border-collapse: collapse; font-size: 11px; }
    .tst-table th {
      text-align: left;
      padding: 3px 6px;
      font-size: 10px;
      color: var(--ocm-text-disabled);
      font-weight: normal;
      text-transform: uppercase;
      letter-spacing: .5px;
      border-bottom: 1px solid var(--ocm-border-section);
    }
    .tst-table td { padding: 3px 6px; border-bottom: 1px solid var(--ocm-border-faint); color: var(--ocm-text-card); }
    .tst-table tr:hover td { background: var(--ocm-bg-hover); }

    .tst-empty { text-align: center; color: var(--ocm-text-muted); padding: 24px 0; font-size: 12px; }

    /* ── Modal ── */
    #tst-modal-overlay {
      position: fixed;
      top: 0; left: 0; right: 0; bottom: 0;
      background: rgba(0,0,0,.75);
      z-index: 999999;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    #tst-modal {
      position: relative;
      z-index: 1000000;
      background: var(--ocm-bg-card);
      border: 1px solid var(--ocm-border-card);
      border-top: 2px solid var(--ocm-border-accent);
      border-radius: 6px;
      padding: 16px 20px;
      min-width: 280px;
      max-width: 400px;
      font-family: Arial, sans-serif;
      box-shadow: 0 8px 32px rgba(0,0,0,.9);
    }
    .tst-modal-title { font-size: 14px; font-weight: bold; color: var(--ocm-accent); margin-bottom: 12px; }
    .tst-modal-body { margin-bottom: 16px; }
    .tst-modal-line { font-size: 12px; color: var(--ocm-text-card); margin-bottom: 6px; }
    .tst-modal-line b { color: var(--ocm-text-primary); }
    .tst-modal-line b.blue  { color: var(--ocm-status-travel); }
    .tst-modal-line b.green { color: var(--ocm-status-ok); }
    .tst-modal-footer { display: flex; justify-content: flex-end; gap: 8px; }

    /* ── Progress bar ── */
    .tst-progress-wrap {
      height: 5px;
      background: var(--ocm-bg-deep);
      border-radius: 0 0 6px 6px;
      overflow: hidden;
      cursor: default;
    }
    .tst-progress-bar {
      height: 100%;
      border-radius: 0 0 6px 6px;
      transition: width 0.4s ease;
    }

    /* ── Error banner ── */
    #tst-error {
      background: var(--ocm-status-crit-bg);
      border: 1px solid var(--ocm-status-crit-border);
      border-radius: 4px;
      padding: 6px 10px;
      font-size: 11px;
      color: var(--ocm-status-crit);
      margin-bottom: 8px;
    }
  `);


  // ─── THEME SYSTEM ────────────────────────────────────────────────────────────
  // Matches the OCM design system exactly. Theme is applied to #tst-root via
  // CSS custom properties so all var(--ocm-*) references update automatically.

  const THEMES = {
    default: {
      '--ocm-bg-deep':'#0f1a30','--ocm-bg-dark':'#111827','--ocm-bg-base':'#16213e',
      '--ocm-bg-card':'#1a1a2e','--ocm-bg-header':'#1a1a2e','--ocm-bg-hover':'#1e1e36',
      '--ocm-bg-input':'#0f3460','--ocm-bg-dropdown':'#0f1a30','--ocm-bg-row':'#111827',
      '--ocm-border-faint':'#111','--ocm-border-card':'#2a2a4a','--ocm-border-strip':'#1a2a4a',
      '--ocm-border-input':'#2a4a7a','--ocm-border-accent':'#e05a00','--ocm-border-section':'#333',
      '--ocm-accent':'#ff7700','--ocm-accent-hover':'#e05a00','--ocm-accent-dim':'#cc5500',
      '--ocm-text-primary':'#e0e0e0','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
      '--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#444',
      '--ocm-status-ok':'#44ee88','--ocm-status-warn':'#ffaa00','--ocm-status-crit':'#ff4444',
      '--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#2a1a00','--ocm-status-crit-bg':'#330a00',
      '--ocm-status-ok-border':'#006644','--ocm-status-warn-border':'#664400','--ocm-status-crit-border':'#882200',
      '--ocm-status-jail':'#ff8800','--ocm-status-hospital':'#ff4444','--ocm-status-travel':'#88aaff',
      '--ocm-status-abroad':'#aaddff','--ocm-status-blocked':'#dd44dd','--ocm-status-respect':'#ffcc44',
      '--ocm-phase-plan-bg':'#0d2a4a','--ocm-phase-plan-text':'#7aadff','--ocm-phase-plan-border':'#3a7acc',
      '--ocm-phase-rec-bg':'#2a1a00','--ocm-phase-rec-text':'#ffaa33','--ocm-phase-rec-border':'#cc7700',
      '--ocm-stuck-bg':'#2a0000','--ocm-stuck-border':'#ff2200',
      '--ocm-font-scale':'1',
    },
    torn: {
      '--ocm-bg-deep':'#111','--ocm-bg-dark':'#1a1a1a','--ocm-bg-base':'#222',
      '--ocm-bg-card':'#1c1c1c','--ocm-bg-header':'#1c1c1c','--ocm-bg-hover':'#2a2a2a',
      '--ocm-bg-input':'#2a2a2a','--ocm-bg-dropdown':'#111','--ocm-bg-row':'#1a1a1a',
      '--ocm-border-faint':'#2a2a2a','--ocm-border-card':'#3a3a3a','--ocm-border-strip':'#333',
      '--ocm-border-input':'#555','--ocm-border-accent':'#c03020','--ocm-border-section':'#444',
      '--ocm-accent':'#e04030','--ocm-accent-hover':'#c03020','--ocm-accent-dim':'#aa2010',
      '--ocm-text-primary':'#ddd','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
      '--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#333',
      '--ocm-status-ok':'#44ee88','--ocm-status-warn':'#ffaa00','--ocm-status-crit':'#ff4444',
      '--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#2a1a00','--ocm-status-crit-bg':'#330a00',
      '--ocm-status-ok-border':'#006644','--ocm-status-warn-border':'#664400','--ocm-status-crit-border':'#882200',
      '--ocm-status-jail':'#ff8800','--ocm-status-hospital':'#ff4444','--ocm-status-travel':'#88aaff',
      '--ocm-status-abroad':'#aaddff','--ocm-status-blocked':'#dd44dd','--ocm-status-respect':'#ffcc44',
      '--ocm-phase-plan-bg':'#1a2a1a','--ocm-phase-plan-text':'#88cc88','--ocm-phase-plan-border':'#4a8a4a',
      '--ocm-phase-rec-bg':'#2a1a00','--ocm-phase-rec-text':'#ffaa33','--ocm-phase-rec-border':'#cc7700',
      '--ocm-stuck-bg':'#2a0000','--ocm-stuck-border':'#cc2200',
      '--ocm-font-scale':'1',
    },
    highcontrast: {
      '--ocm-bg-deep':'#000','--ocm-bg-dark':'#000','--ocm-bg-base':'#000',
      '--ocm-bg-card':'#0a0a0a','--ocm-bg-header':'#000','--ocm-bg-hover':'#1a1a1a',
      '--ocm-bg-input':'#111','--ocm-bg-dropdown':'#000','--ocm-bg-row':'#000',
      '--ocm-border-faint':'#444','--ocm-border-card':'#fff','--ocm-border-strip':'#888',
      '--ocm-border-input':'#fff','--ocm-border-accent':'#ffff00','--ocm-border-section':'#888',
      '--ocm-accent':'#ffff00','--ocm-accent-hover':'#ffee00','--ocm-accent-dim':'#cccc00',
      '--ocm-text-primary':'#fff','--ocm-text-card':'#fff','--ocm-text-secondary':'#eee',
      '--ocm-text-label':'#ddd','--ocm-text-muted':'#bbb','--ocm-text-disabled':'#888','--ocm-text-dead':'#666',
      '--ocm-status-ok':'#00ff88','--ocm-status-warn':'#ffdd00','--ocm-status-crit':'#ff4444',
      '--ocm-status-ok-bg':'#003318','--ocm-status-warn-bg':'#332200','--ocm-status-crit-bg':'#330000',
      '--ocm-status-ok-border':'#00ff88','--ocm-status-warn-border':'#ffdd00','--ocm-status-crit-border':'#ff4444',
      '--ocm-status-jail':'#ffaa00','--ocm-status-hospital':'#ff6666','--ocm-status-travel':'#aaccff',
      '--ocm-status-abroad':'#ccddff','--ocm-status-blocked':'#ff88ff','--ocm-status-respect':'#ffff88',
      '--ocm-phase-plan-bg':'#001a33','--ocm-phase-plan-text':'#88ccff','--ocm-phase-plan-border':'#88ccff',
      '--ocm-phase-rec-bg':'#331a00','--ocm-phase-rec-text':'#ffcc44','--ocm-phase-rec-border':'#ffcc44',
      '--ocm-stuck-bg':'#330000','--ocm-stuck-border':'#ff4444',
      '--ocm-font-scale':'1',
    },
    deuteranopia: {
      '--ocm-bg-deep':'#0f1a30','--ocm-bg-dark':'#111827','--ocm-bg-base':'#16213e',
      '--ocm-bg-card':'#1a1a2e','--ocm-bg-header':'#1a1a2e','--ocm-bg-hover':'#1e1e36',
      '--ocm-bg-input':'#0f3460','--ocm-bg-dropdown':'#0f1a30','--ocm-bg-row':'#111827',
      '--ocm-border-faint':'#111','--ocm-border-card':'#2a2a4a','--ocm-border-strip':'#1a2a4a',
      '--ocm-border-input':'#2a4a7a','--ocm-border-accent':'#0088cc','--ocm-border-section':'#333',
      '--ocm-accent':'#0099ee','--ocm-accent-hover':'#0077cc','--ocm-accent-dim':'#005599',
      '--ocm-text-primary':'#e0e0e0','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
      '--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#444',
      '--ocm-status-ok':'#4499ff','--ocm-status-warn':'#ffcc00','--ocm-status-crit':'#ff6600',
      '--ocm-status-ok-bg':'#001833','--ocm-status-warn-bg':'#332a00','--ocm-status-crit-bg':'#331500',
      '--ocm-status-ok-border':'#2266cc','--ocm-status-warn-border':'#886600','--ocm-status-crit-border':'#994400',
      '--ocm-status-jail':'#ffcc00','--ocm-status-hospital':'#ff6600','--ocm-status-travel':'#88ccff',
      '--ocm-status-abroad':'#aaddff','--ocm-status-blocked':'#cc88ff','--ocm-status-respect':'#ffffff',
      '--ocm-phase-plan-bg':'#0d2a4a','--ocm-phase-plan-text':'#88ccff','--ocm-phase-plan-border':'#3a7acc',
      '--ocm-phase-rec-bg':'#2a1a00','--ocm-phase-rec-text':'#ffcc44','--ocm-phase-rec-border':'#aa8800',
      '--ocm-stuck-bg':'#2a1500','--ocm-stuck-border':'#ff6600',
      '--ocm-font-scale':'1',
    },
    protanopia: {
      '--ocm-bg-deep':'#0f1a30','--ocm-bg-dark':'#111827','--ocm-bg-base':'#16213e',
      '--ocm-bg-card':'#1a1a2e','--ocm-bg-header':'#1a1a2e','--ocm-bg-hover':'#1e1e36',
      '--ocm-bg-input':'#0f3460','--ocm-bg-dropdown':'#0f1a30','--ocm-bg-row':'#111827',
      '--ocm-border-faint':'#111','--ocm-border-card':'#2a2a4a','--ocm-border-strip':'#1a2a4a',
      '--ocm-border-input':'#2a4a7a','--ocm-border-accent':'#00aacc','--ocm-border-section':'#333',
      '--ocm-accent':'#00bbdd','--ocm-accent-hover':'#0099bb','--ocm-accent-dim':'#007799',
      '--ocm-text-primary':'#e0e0e0','--ocm-text-card':'#ccc','--ocm-text-secondary':'#aaa',
      '--ocm-text-label':'#888','--ocm-text-muted':'#666','--ocm-text-disabled':'#555','--ocm-text-dead':'#444',
      '--ocm-status-ok':'#00ddcc','--ocm-status-warn':'#ffcc00','--ocm-status-crit':'#ffffff',
      '--ocm-status-ok-bg':'#002a28','--ocm-status-warn-bg':'#332a00','--ocm-status-crit-bg':'#333333',
      '--ocm-status-ok-border':'#009988','--ocm-status-warn-border':'#886600','--ocm-status-crit-border':'#aaaaaa',
      '--ocm-status-jail':'#ffcc00','--ocm-status-hospital':'#ffffff','--ocm-status-travel':'#88ccff',
      '--ocm-status-abroad':'#aaddff','--ocm-status-blocked':'#cc88ff','--ocm-status-respect':'#ffff88',
      '--ocm-phase-plan-bg':'#0d2a4a','--ocm-phase-plan-text':'#88ccff','--ocm-phase-plan-border':'#3a7acc',
      '--ocm-phase-rec-bg':'#2a1a00','--ocm-phase-rec-text':'#ffcc44','--ocm-phase-rec-border':'#aa8800',
      '--ocm-stuck-bg':'#2a2a2a','--ocm-stuck-border':'#ffffff',
      '--ocm-font-scale':'1',
    },
    tritanopia: {
      '--ocm-bg-deep':'#1a0f1a','--ocm-bg-dark':'#1a111a','--ocm-bg-base':'#221522',
      '--ocm-bg-card':'#1e121e','--ocm-bg-header':'#1e121e','--ocm-bg-hover':'#281828',
      '--ocm-bg-input':'#2a0a2a','--ocm-bg-dropdown':'#1a0f1a','--ocm-bg-row':'#1a111a',
      '--ocm-border-faint':'#2a1a2a','--ocm-border-card':'#3a2a3a','--ocm-border-strip':'#2a1a2a',
      '--ocm-border-input':'#6a3a6a','--ocm-border-accent':'#cc0066','--ocm-border-section':'#442244',
      '--ocm-accent':'#ff1177','--ocm-accent-hover':'#cc0055','--ocm-accent-dim':'#990033',
      '--ocm-text-primary':'#e0e0e0','--ocm-text-card':'#ccc','--ocm-text-secondary':'#bbb',
      '--ocm-text-label':'#999','--ocm-text-muted':'#777','--ocm-text-disabled':'#555','--ocm-text-dead':'#444',
      '--ocm-status-ok':'#00ddaa','--ocm-status-warn':'#ff88cc','--ocm-status-crit':'#ff2255',
      '--ocm-status-ok-bg':'#002a22','--ocm-status-warn-bg':'#2a0a1a','--ocm-status-crit-bg':'#2a001a',
      '--ocm-status-ok-border':'#009977','--ocm-status-warn-border':'#884466','--ocm-status-crit-border':'#880033',
      '--ocm-status-jail':'#ff88cc','--ocm-status-hospital':'#ff2255','--ocm-status-travel':'#ff99dd',
      '--ocm-status-abroad':'#ffbbee','--ocm-status-blocked':'#aa44ff','--ocm-status-respect':'#ffffff',
      '--ocm-phase-plan-bg':'#1a0a2a','--ocm-phase-plan-text':'#ff99dd','--ocm-phase-plan-border':'#882266',
      '--ocm-phase-rec-bg':'#2a1a00','--ocm-phase-rec-text':'#ff88cc','--ocm-phase-rec-border':'#884455',
      '--ocm-stuck-bg':'#2a0011','--ocm-stuck-border':'#ff2255',
      '--ocm-font-scale':'1',
    },
    lowvision: {
      '--ocm-bg-deep':'#080d18','--ocm-bg-dark':'#0a1020','--ocm-bg-base':'#0d1628',
      '--ocm-bg-card':'#111828','--ocm-bg-header':'#111828','--ocm-bg-hover':'#161c30',
      '--ocm-bg-input':'#0a2a50','--ocm-bg-dropdown':'#080d18','--ocm-bg-row':'#0a1020',
      '--ocm-border-faint':'#333','--ocm-border-card':'#4a4a7a','--ocm-border-strip':'#2a3a6a',
      '--ocm-border-input':'#4a6a9a','--ocm-border-accent':'#ff8800','--ocm-border-section':'#555',
      '--ocm-accent':'#ff9900','--ocm-accent-hover':'#ff7700','--ocm-accent-dim':'#dd6600',
      '--ocm-text-primary':'#ffffff','--ocm-text-card':'#eee','--ocm-text-secondary':'#ccc',
      '--ocm-text-label':'#aaa','--ocm-text-muted':'#888','--ocm-text-disabled':'#666','--ocm-text-dead':'#555',
      '--ocm-status-ok':'#66ffaa','--ocm-status-warn':'#ffcc00','--ocm-status-crit':'#ff5555',
      '--ocm-status-ok-bg':'#003322','--ocm-status-warn-bg':'#332a00','--ocm-status-crit-bg':'#330a00',
      '--ocm-status-ok-border':'#33cc77','--ocm-status-warn-border':'#998800','--ocm-status-crit-border':'#cc2200',
      '--ocm-status-jail':'#ffaa00','--ocm-status-hospital':'#ff5555','--ocm-status-travel':'#99bbff',
      '--ocm-status-abroad':'#bbddff','--ocm-status-blocked':'#ee55ee','--ocm-status-respect':'#ffee55',
      '--ocm-phase-plan-bg':'#0a2040','--ocm-phase-plan-text':'#99ccff','--ocm-phase-plan-border':'#4488cc',
      '--ocm-phase-rec-bg':'#201400','--ocm-phase-rec-text':'#ffbb44','--ocm-phase-rec-border':'#cc8800',
      '--ocm-stuck-bg':'#200000','--ocm-stuck-border':'#ff3300',
      '--ocm-font-scale':'1.1',
    },
    light: {
      '--ocm-bg-deep':'#dde4f0','--ocm-bg-dark':'#e8eef8','--ocm-bg-base':'#eef2fa',
      '--ocm-bg-card':'#f4f6fc','--ocm-bg-header':'#f4f6fc','--ocm-bg-hover':'#e8ecf8',
      '--ocm-bg-input':'#dde4f0','--ocm-bg-dropdown':'#dde4f0','--ocm-bg-row':'#e8eef8',
      '--ocm-border-faint':'#ccd4e8','--ocm-border-card':'#b8c4dc','--ocm-border-strip':'#c8d4e8',
      '--ocm-border-input':'#9aaac8','--ocm-border-accent':'#cc5500','--ocm-border-section':'#b0bcd8',
      '--ocm-accent':'#cc5500','--ocm-accent-hover':'#aa4400','--ocm-accent-dim':'#993300',
      '--ocm-text-primary':'#1a1a2e','--ocm-text-card':'#222','--ocm-text-secondary':'#444',
      '--ocm-text-label':'#666','--ocm-text-muted':'#777','--ocm-text-disabled':'#999','--ocm-text-dead':'#aaa',
      '--ocm-status-ok':'#006622','--ocm-status-warn':'#885500','--ocm-status-crit':'#cc1111',
      '--ocm-status-ok-bg':'#d4f0dd','--ocm-status-warn-bg':'#fff0cc','--ocm-status-crit-bg':'#ffe0dd',
      '--ocm-status-ok-border':'#44aa66','--ocm-status-warn-border':'#cc8800','--ocm-status-crit-border':'#dd4444',
      '--ocm-status-jail':'#885500','--ocm-status-hospital':'#cc1111','--ocm-status-travel':'#2255aa',
      '--ocm-status-abroad':'#1144aa','--ocm-status-blocked':'#882288','--ocm-status-respect':'#775500',
      '--ocm-phase-plan-bg':'#d8e8f8','--ocm-phase-plan-text':'#1a4a8a','--ocm-phase-plan-border':'#3a7acc',
      '--ocm-phase-rec-bg':'#fff4dd','--ocm-phase-rec-text':'#774400','--ocm-phase-rec-border':'#cc8800',
      '--ocm-stuck-bg':'#ffe8e8','--ocm-stuck-border':'#cc1111',
      '--ocm-font-scale':'1',
    },
  };

  function applyTheme(key) {
    const theme = THEMES[key] || THEMES.default;
    const root  = document.getElementById('tst-root');
    if (!root) return;
    for (const [prop, val] of Object.entries(theme)) {
      if (prop.startsWith('--')) root.style.setProperty(prop, val);
    }
    root.style.fontSize = `${13 * parseFloat(theme['--ocm-font-scale'] || '1')}px`;
    S.set('tst_theme', key);
    cfg.theme = key;
  }

  // ─── HTML BUILDERS ────────────────────────────────────────────────────────────

  function buildTrackerTab() {
    if (!cfg.apiKey) {
      return `<div class="tst-empty">🔑 Enter your Torn API key in the Settings tab to get started.</div>`;
    }
    if (rt.loading) {
      return `<div class="tst-empty">Loading market data…</div>`;
    }

    const domItemCount = Object.keys(rt.inventory).length;
    const domNote = domItemCount === 0
      ? `<div id="tst-error">⚠ No inventory items found on this page. Make sure you are on <b>torn.com/item.php</b> with your items loaded, then hit Refresh.</div>`
      : '';

    const pa          = cfg.showPlushies ? analyseSet(PLUSHIE_SET, PLUSHIE_POINTS) : null;
    const fa          = cfg.showFlowers  ? analyseSet(FLOWER_SET,  FLOWER_POINTS)  : null;
    const totalProfit = (pa?.profit || 0) + (fa?.profit || 0);
    const ptsPrice    = cfg.pointsOverride ? parseFloat(cfg.pointsOverride) : rt.pointsPrice;

    return `
      ${rt.error ? `<div id="tst-error">⚠ ${rt.error}</div>` : ''}
      ${domNote}
      <div id="tst-stats-bar">
        <div class="tst-stat">
          <span class="tst-stat-label">Points price</span>
          <span class="tst-stat-value blue">${fmtMoney(ptsPrice)}${cfg.pointsOverride ? ' ✏' : ''}</span>
        </div>
        <div class="tst-stat">
          <span class="tst-stat-label">Buy discount</span>
          <span class="tst-stat-value orange">${cfg.mvThreshold}%</span>
        </div>
        <div class="tst-stat">
          <span class="tst-stat-label">Complete sets</span>
          <span class="tst-stat-value">${(pa?.completeSets || 0) + (fa?.completeSets || 0)}</span>
        </div>
        <div class="tst-stat">
          <span class="tst-stat-label">Combined profit</span>
          <span class="tst-stat-value ${totalProfit >= 0 ? 'green' : 'red'}">${totalProfit >= 0 ? '+' : ''}${fmtMoney(totalProfit)}</span>
        </div>
        ${cfg.museumDay ? `<div class="tst-stat"><span class="tst-stat-label">Mode</span><span class="tst-stat-value orange">🏛 Museum Day</span></div>` : ''}
      </div>
      ${pa ? buildSetCard(PLUSHIE_SET, pa) : ''}
      ${fa ? buildSetCard(FLOWER_SET,  fa) : ''}
      <div class="tst-foot">
        ${rt.lastRefresh ? `<span class="tst-refresh-info">Refreshed: ${rt.lastRefresh.toLocaleTimeString('en-GB')}</span>` : '<span></span>'}
        <button class="tst-btn tst-btn-primary" id="tst-btn-refresh">↻ Refresh now</button>
      </div>`;
  }

  function buildSetCard(setDef, a) {
    const allGood = a.totalItemsNeeded === 0;
    return `
      <div class="tst-set-card ${allGood ? 'ready' : 'missing'}">
        <div class="tst-set-head">
          <div class="tst-set-name">
            ${setDef.icon} ${setDef.name}
            <span class="tst-badge ${allGood ? 'ready' : 'missing'}">${allGood ? '✓ Ready' : `need ${a.totalItemsNeeded} items`}</span>
            ${cfg.museumDay ? '<span class="tst-badge museum">🏛 Museum Day</span>' : ''}
          </div>
          <div class="tst-set-meta">
            <span>Complete: <b>${a.completeSets}</b></span>
            <span>Target: <b>${a.target === a.maxSets ? `${a.target} (auto)` : a.target}</b></span>
            <span>Pts/set: <b>${a.pts}</b></span>
          </div>
        </div>
        <div class="tst-items">
          ${a.rows.map(r => `
            <div class="tst-item">
              <span class="tst-item-name" title="${r.name}">${r.name}</span>
              <div class="tst-item-r">
                ${r.need > 0 ? `<span class="tst-need-tag">need ${r.need}</span>` : `<span class="tst-need-tag" style="background:var(--ocm-status-ok-bg);color:var(--ocm-status-ok);border:1px solid var(--ocm-status-ok-border)">✓</span>`}
                <span class="tst-item-qty ${r.have >= a.target ? 'ok' : 'low'}">${Math.max(0, r.have)}</span>
                ${r.price > 0
                  ? `<a href="https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${r.id}&itemName=${encodeURIComponent(r.name)}&itemType=${encodeURIComponent(setDef.name === 'Plushies' ? 'Plushie' : 'Flower')}"
                       target="_blank"
                       class="tst-item-price${r.atTarget ? ' target' : ''}"
                       title="${r.maxBuy > 0 ? `Target: ${fmtMoney(r.maxBuy)}` : 'Open in Item Market'}"
                     >${fmtMoney(r.price)}${r.atTarget ? ' 🎯' : ''}</a>`
                  : `<span class="tst-item-price">—</span>`}
              </div>
            </div>`).join('')}
        </div>
        <div class="tst-profit-bar">
          <div class="tst-profit-nums">
            <div class="tst-pn">
              <span>Buy cost (${a.totalItemsNeeded} items${cfg.mvThreshold !== 100 ? ` @ ${cfg.mvThreshold}%` : ''}):</span>
              <b class="orange">${fmtMoney(a.buyCost)}</b>
              ${cfg.mvThreshold !== 100 ? `<span style="color:#555;font-size:9px;margin-left:4px">full: ${fmtMoney(a.buyCostFull)}</span>` : ''}
            </div>
            <div class="tst-pn"><span>Sell value (${a.target} sets × ${a.pts}pts):</span><b class="blue">${fmtMoney(a.sellValue)}</b></div>
            <div class="tst-pn"><span>Profit:</span><b class="${a.profit >= 0 ? 'green' : 'red'}">${a.profit >= 0 ? '+' : ''}${fmtMoney(a.profit)}</b></div>
          </div>
          <button class="tst-btn tst-btn-green tst-record-btn"
            data-type="${setDef.name}"
            data-sets="${a.completeSets}"
            data-profit="${a.currentValue}"
            ${a.completeSets === 0 ? 'disabled style="opacity:.35;cursor:not-allowed"' : ''}>
            ✓ Record Exchange
          </button>
        </div>
        <div class="tst-progress-wrap" title="${a.pct.toFixed(1)}% complete (${a.totalOwned.toLocaleString()} of ${a.totalNeededFull.toLocaleString()} items)">
          <div class="tst-progress-bar" style="width:${a.pct}%;background:${a.barColour}"></div>
        </div>
      </div>`;
  }

  function buildHistoryTab() {
    const log      = S.get('tst_tradeLog',       []);
    const lifePft  = S.get('tst_lifetimeProfit',  0);
    const lifeSets = S.get('tst_lifetimeSets',    0);
    return `
      <div id="tst-stats-bar">
        <div class="tst-stat">
          <span class="tst-stat-label">Lifetime profit</span>
          <span class="tst-stat-value green">+${fmt(lifePft)}</span>
        </div>
        <div class="tst-stat">
          <span class="tst-stat-label">Sets traded</span>
          <span class="tst-stat-value blue">${lifeSets}</span>
        </div>
        <div class="tst-stat">
          <span class="tst-stat-label">Avg per set</span>
          <span class="tst-stat-value gold">${lifeSets > 0 ? fmt(Math.round(lifePft / lifeSets)) : '—'}</span>
        </div>
        <div class="tst-stat">
          <span class="tst-stat-label">Log entries</span>
          <span class="tst-stat-value">${log.length}</span>
        </div>
      </div>
      <div class="tst-foot" style="margin-top:0;margin-bottom:8px">
        <div style="display:flex;gap:6px">
          <button class="tst-btn tst-btn-secondary" id="tst-btn-export">⬇ Export</button>
          <label class="tst-btn tst-btn-secondary" style="cursor:pointer">
            ⬆ Import
            <input type="file" accept=".json" id="tst-import-input" style="display:none">
          </label>
        </div>
        <button class="tst-btn tst-btn-red" id="tst-btn-clearlog">✕ Clear log</button>
      </div>
      ${log.length === 0
        ? `<div class="tst-empty">No exchanges recorded yet.<br>Use "Record Exchange" in the Tracker tab before heading to the museum.</div>`
        : `<table class="tst-table">
             <thead><tr><th>Date</th><th>Type</th><th>Sets</th><th>Profit</th></tr></thead>
             <tbody>
               ${log.slice(0, 100).map(e => `
                 <tr>
                   <td>${fmtDate(e.date)}</td>
                   <td>${e.type}</td>
                   <td>${e.sets}</td>
                   <td style="color:${e.profit >= 0 ? '#44ee88' : '#ff4444'};font-weight:bold">${e.profit >= 0 ? '+' : ''}${fmt(e.profit)}</td>
                 </tr>`).join('')}
             </tbody>
           </table>`}`;
  }

  function buildSettingsTab() {
    return `
      <div class="tst-section-title">Appearance</div>
      <div class="tst-form-grid">
        <div class="tst-form-full">
          <label class="tst-form-label">Theme</label>
          <select class="tst-form-input" id="tst-f-theme">
            <option value="default" ${cfg.theme==='default'?'selected':''}>Default (Dark Blue)</option>
            <option value="torn"    ${cfg.theme==='torn'?'selected':''}>Torn Classic</option>
            <option value="highcontrast" ${cfg.theme==='highcontrast'?'selected':''}>High Contrast (WCAG AAA)</option>
            <option value="deuteranopia" ${cfg.theme==='deuteranopia'?'selected':''}>Deuteranopia (Red/Green CB)</option>
            <option value="protanopia"   ${cfg.theme==='protanopia'?'selected':''}>Protanopia (Red Deficiency)</option>
            <option value="tritanopia"   ${cfg.theme==='tritanopia'?'selected':''}>Tritanopia (Blue/Yellow CB)</option>
            <option value="lowvision"    ${cfg.theme==='lowvision'?'selected':''}>Low Vision</option>
            <option value="light"        ${cfg.theme==='light'?'selected':''}>Light Mode</option>
          </select>
        </div>
      </div>
      <div class="tst-divider"></div>
      <div class="tst-section-title">API Access</div>
      <div class="tst-api-note" style="margin-bottom:8px">
        Requires <b>Minimal access</b> or higher (to read inventory &amp; bazaar) plus <b>Public access</b> (for market prices).<br>
        At <b>torn.com → Settings → API Keys</b>, create a key with at minimum: <b>Items</b> checked under Minimal access.
      </div>
      <div class="tst-form-grid">
        <div class="tst-form-full">
          <label class="tst-form-label">Torn API Key</label>
          <input class="tst-form-input" type="password" id="tst-f-apikey" value="${cfg.apiKey}" placeholder="Enter your Torn API key…" autocomplete="off">
        </div>
      </div>
      <div class="tst-section-title" style="margin-top:10px">Exchange Settings</div>
      <div class="tst-form-grid">
        <div>
          <label class="tst-form-label">Target Sets (0 = auto, uses max owned)</label>
          <input class="tst-form-input" type="number" id="tst-f-target" value="${cfg.targetSets}" min="0" max="999">
        </div>
        <div>
          <label class="tst-form-label">Buy Discount % (100 = full price, 90 = assume 10% off)</label>
          <input class="tst-form-input" type="number" id="tst-f-mvthresh" value="${cfg.mvThreshold}" min="1" max="100">
        </div>
        <div>
          <label class="tst-form-label">Points Price Override (blank = live API)</label>
          <input class="tst-form-input" type="number" id="tst-f-ptoverride" value="${cfg.pointsOverride}" placeholder="e.g. 50000">
        </div>
        <div>
          <label class="tst-form-label">Auto-Refresh (seconds, 0 = off)</label>
          <input class="tst-form-input" type="number" id="tst-f-autorefresh" value="${cfg.autoRefresh}" min="0" step="10">
        </div>
      </div>
      <div class="tst-divider"></div>
      <div class="tst-section-title">Display</div>
      <label class="tst-toggle-row">
        <input type="checkbox" id="tst-f-plushies" ${cfg.showPlushies ? 'checked' : ''}> Show Plushies 🧸
      </label>
      <label class="tst-toggle-row">
        <input type="checkbox" id="tst-f-flowers" ${cfg.showFlowers ? 'checked' : ''}> Show Flowers 🌸
      </label>
      <label class="tst-toggle-row">
        <input type="checkbox" id="tst-f-museum" ${cfg.museumDay ? 'checked' : ''}> Museum Day active (+10% points on exchange) 🏛
      </label>
      <div class="tst-divider"></div>
      <div class="tst-section-title">Target Prices</div>
      <div class="tst-api-note" style="margin-bottom:8px">Set the maximum price you're willing to pay per item. Price highlights 🎯 green on the tracker when at or below your target.</div>
      <div class="tst-section-title" style="margin-top:6px">🧸 Plushies</div>
      <div class="tst-form-grid">
        ${Object.entries(PLUSHIE_SET.items).map(([id, name]) => `
          <div>
            <label class="tst-form-label">${name}</label>
            <input class="tst-form-input" type="number" id="tst-tp-${id}" value="${cfg.targetPrices[id] || ''}" placeholder="No target">
          </div>`).join('')}
      </div>
      <div class="tst-section-title" style="margin-top:6px">🌸 Flowers</div>
      <div class="tst-form-grid">
        ${Object.entries(FLOWER_SET.items).map(([id, name]) => `
          <div>
            <label class="tst-form-label">${name}</label>
            <input class="tst-form-input" type="number" id="tst-tp-${id}" value="${cfg.targetPrices[id] || ''}" placeholder="No target">
          </div>`).join('')}
      </div>
      <div class="tst-divider"></div>
      <div class="tst-divider"></div>
      <button class="tst-btn tst-btn-primary" id="tst-btn-save">✓ Save Settings</button>
        💡 Original concept by <a href="https://www.torn.com/profiles.php?XID=3714844" target="_blank" style="color:#666;text-decoration:none">Fring [3714844]</a>
      </div>`;
  }

  // ─── MODAL ───────────────────────────────────────────────────────────────────

  function showModal({ title, lines, confirm: confirmLabel, confirmClass, onConfirm }) {
    const existing = document.getElementById('tst-modal-overlay');
    if (existing) existing.remove();

    const overlay = document.createElement('div');
    overlay.id = 'tst-modal-overlay';

    // Copy --ocm-* CSS variables from #tst-root so the modal inherits the theme
    const root = document.getElementById('tst-root');
    if (root) {
      const computed = getComputedStyle(root);
      const vars = ['--ocm-bg-card','--ocm-border-card','--ocm-border-accent',
        '--ocm-accent','--ocm-text-primary','--ocm-text-card',
        '--ocm-status-travel','--ocm-status-ok','--ocm-status-ok-bg',
        '--ocm-status-ok-border','--ocm-status-crit-bg','--ocm-status-crit-border','--ocm-status-crit'];
      vars.forEach(v => {
        const val = computed.getPropertyValue(v).trim();
        if (val) overlay.style.setProperty(v, val);
      });
    }
    overlay.innerHTML = `
      <div id="tst-modal">
        <div class="tst-modal-title">${title}</div>
        <div class="tst-modal-body">
          ${lines.map(l => `<div class="tst-modal-line">${l}</div>`).join('')}
        </div>
        <div class="tst-modal-footer">
          <button class="tst-btn tst-btn-secondary" id="tst-modal-cancel">Cancel</button>
          <button class="tst-btn ${confirmClass}" id="tst-modal-confirm">${confirmLabel}</button>
        </div>
      </div>`;

    document.body.appendChild(overlay);

    document.getElementById('tst-modal-cancel').addEventListener('click', () => overlay.remove());
    document.getElementById('tst-modal-confirm').addEventListener('click', () => {
      overlay.remove();
      onConfirm();
    });
    overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
  }

  // ─── RENDER ───────────────────────────────────────────────────────────────────

  function render() {
    const root = document.getElementById('tst-root');
    if (!root) return;

    const tabHTML =
      rt.tab === 'tracker'  ? buildTrackerTab()  :
      rt.tab === 'history'  ? buildHistoryTab()  :
      rt.tab === 'settings' ? buildSettingsTab() : '';

    root.className = collapsed ? 'tst-collapsed' : '';
    root.innerHTML = `
      <div id="tst-header">
        <h2>🎯 Set Trader <span style="font-size:10px;font-weight:normal;opacity:.5">v1.8.3</span></h2>
        ${rt.lastRefresh ? `<small>Updated: ${rt.lastRefresh.toLocaleTimeString('en-GB')}</small>` : ''}
        <button class="tst-btn tst-btn-secondary" id="tst-collapse-btn">${collapsed ? '▼' : '▲'}</button>
      </div>
      <div id="tst-tabs">
        <div class="tst-tab ${rt.tab === 'tracker'  ? 'active' : ''}" data-tab="tracker">Tracker</div>
        <div class="tst-tab ${rt.tab === 'history'  ? 'active' : ''}" data-tab="history">History</div>
        <div class="tst-tab ${rt.tab === 'settings' ? 'active' : ''}" data-tab="settings">Settings</div>
      </div>
      <div id="tst-body">${tabHTML}</div>`;

    bindEvents();
  }

  // ─── EVENT BINDING ────────────────────────────────────────────────────────────
  // Re-runs after every render() so listeners always point at current DOM nodes.

  function bindEvents() {
    const el = id => document.getElementById(id);

    // Collapse toggle (header click or dedicated button)
    el('tst-collapse-btn')?.addEventListener('click', e => {
      e.stopPropagation();
      collapsed = !collapsed;
      S.set('tst_collapsed', collapsed);
      render();
    });
    el('tst-header')?.addEventListener('click', () => {
      collapsed = !collapsed;
      S.set('tst_collapsed', collapsed);
      render();
    });

    // Tab switching — stop propagation so header click doesn't also fire
    document.querySelectorAll('#tst-root .tst-tab').forEach(tab => {
      tab.addEventListener('click', e => {
        e.stopPropagation();
        rt.tab = tab.dataset.tab;
        render();
      });
    });

    // ── Tracker ──────────────────────────────────────────────────────────────
    el('tst-btn-refresh')?.addEventListener('click', fetchAll);

    document.querySelectorAll('#tst-root .tst-record-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        if (btn.disabled) return;
        const { type, sets, profit } = btn.dataset;
        const n = parseInt(sets), p = parseFloat(profit);
        showModal({
          title: `Record ${type} Exchange`,
          lines: [
            `Sets exchanged: <b>${n}</b>`,
            `Points value: <b class="blue">${fmtMoney(p)}</b>`,
          ],
          confirm: 'Record Exchange',
          confirmClass: 'tst-btn-green',
          onConfirm: () => recordTrade(type, n, p / n),
        });
      });
    });

    // ── History ──────────────────────────────────────────────────────────────
    el('tst-btn-export')?.addEventListener('click', exportData);
    el('tst-import-input')?.addEventListener('change', e => {
      if (e.target.files[0]) importData(e.target.files[0]);
    });
    el('tst-btn-clearlog')?.addEventListener('click', () => {
      showModal({
        title: 'Clear Exchange History',
        lines: ['This will permanently delete all recorded exchanges and lifetime stats.'],
        confirm: 'Clear History',
        confirmClass: 'tst-btn-red',
        onConfirm: () => {
          S.set('tst_tradeLog',       []);
          S.set('tst_lifetimeProfit', 0);
          S.set('tst_lifetimeSets',   0);
          render();
        },
      });
    });

    // Theme live preview
    el('tst-f-theme')?.addEventListener('change', () => applyTheme(el('tst-f-theme').value));

    el('tst-btn-save')?.addEventListener('click', () => {
      cfg.apiKey         = el('tst-f-apikey').value.trim();
      cfg.targetSets     = Math.max(0, parseInt(el('tst-f-target').value) || 0);
      cfg.mvThreshold    = parseFloat(el('tst-f-mvthresh').value)              || 100;
      cfg.pointsOverride = el('tst-f-ptoverride').value.trim();
      cfg.autoRefresh    = Math.max(0, parseInt(el('tst-f-autorefresh').value) || 0);
      cfg.showPlushies   = el('tst-f-plushies').checked;
      cfg.showFlowers    = el('tst-f-flowers').checked;
      cfg.museumDay      = el('tst-f-museum').checked;
      cfg.theme          = el('tst-f-theme')?.value || 'default';

      // Save target prices for all set items
      const tp = {};
      [...Object.keys(PLUSHIE_SET.items), ...Object.keys(FLOWER_SET.items)].forEach(id => {
        const val = el(`tst-tp-${id}`)?.value.trim();
        if (val && parseFloat(val) > 0) tp[id] = parseFloat(val);
      });
      cfg.targetPrices = tp;
      S.set('tst_targetPrices', tp);

      S.set('tst_apiKey',        cfg.apiKey);
      S.set('tst_targetSets',    cfg.targetSets);
      S.set('tst_mvThresh',      cfg.mvThreshold);
      S.set('tst_pointsOver',    cfg.pointsOverride);
      S.set('tst_autoRefresh',   cfg.autoRefresh);
      S.set('tst_showPlushies',  cfg.showPlushies);
      S.set('tst_showFlowers',   cfg.showFlowers);
      S.set('tst_museumDay',     cfg.museumDay);
      // theme is already saved inside applyTheme()

      startAutoRefresh();
      rt.tab = 'tracker';
      render();
      if (cfg.apiKey) fetchAll();
    });
  }

  // ─── INIT ─────────────────────────────────────────────────────────────────────

  function init() {
    const root    = document.createElement('div');
    root.id       = 'tst-root';

    const anchor =
      document.querySelector('.content-title') ||
      document.querySelector('#mainContainer')  ||
      document.querySelector('.cont-gray')       ||
      document.body;

    if (anchor.parentNode) anchor.parentNode.insertBefore(root, anchor);
    else document.body.prepend(root);

    render();
    applyTheme(cfg.theme);
    if (cfg.apiKey) { fetchAll(); startAutoRefresh(); }
  }

  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
  else init();

})();