Torn Set Trader

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();