Torn Stock Analyzer

Floating panel for Torn City's stock market. Ranks all 35 stocks by ROI, Profit/Day, and Next BB Cost. Scrapes live prices, dividends, and owned shares directly from the page. Fetches item market prices via API. Works on desktop Tampermonkey and Torn PDA (mobile).

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

Advertisement:

// ==UserScript==
// @name         Torn Stock Analyzer
// @namespace    https://www.torn.com/
// @version      1.9.0
// @description  Floating panel for Torn City's stock market. Ranks all 35 stocks by ROI, Profit/Day, and Next BB Cost. Scrapes live prices, dividends, and owned shares directly from the page. Fetches item market prices via API. Works on desktop Tampermonkey and Torn PDA (mobile).
// @author       Chris_2025
// @license      MIT
// @match        https://www.torn.com/page.php?sid=stocks
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// @connect      www.torn.com
// @connect      query1.finance.yahoo.com
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ═══════════════════════════════════════════════════════════════
  // CONSTANTS
  // ═══════════════════════════════════════════════════════════════
  const SCRIPT_ID       = 'torn-stock-analyzer';
  const KEY_STORAGE     = `${SCRIPT_ID}-apikey`;
  const AUTO_REFRESH_MS = 60000;  // 60s — conservative, shares the 100 req/min limit
  const API_CALL_GAP_MS = 1200;   // space individual calls within a cycle
  let   lastApiCall     = 0;

  const API_KEY_PAGE  = 'https://www.torn.com/preferences.php#tab=api';
  // Deep-link straight into Torn's Custom Key Builder with every
  // selection this script could plausibly need pre-checked: item
  // market prices, the item catalog (to map names to IDs), and live
  // stock market + personal holdings data as a complete, future-proof
  // set in case any part of the DOM-scraping path ever needs an API
  // fallback. Format confirmed via Torn's community API docs:
  // #tab=api?step=addNewKey&title=X&SECTION=sel1,sel2
  const CUSTOM_KEY_LINK =
    'https://www.torn.com/preferences.php#tab=api?step=addNewKey' +
    '&title=Stock%20Analyzer' +
    '&torn=items,stocks' +
    '&market=itemmarket' +
    '&user=stocks';

  // ═══════════════════════════════════════════════════════════════
  // STOCK TABLE  —  23 Active + 12 Passive = 35 total
  //
  // dividend_kind:
  //   'item'    = tradeable item; item_id used for item market price lookup
  //   'cash'    = fixed cash payout; scraped live from page DOM
  //   'special' = non-tradeable in-game benefit (energy/happy/nerve/chance-cash)
  //   'perk'    = passive persistent bonus, no periodic payout
  // interval_days: 7 | 31 | null (perk)
  // item_name: used to match against the Item Market page scrape
  // ═══════════════════════════════════════════════════════════════
  const STOCK_DATA = {

    // ─── ACTIVE · 7-day item dividends ──────────────────────────────
    FHG:  { name: 'Feathery Hotels Group',      bb: 2000000,  type: 'active',  dividend_kind: 'item',    interval_days: 7,   item_name: 'Feathery Hotel Coupon',    perk_desc: '1× Feathery Hotel Coupon',       real_ticker: 'MAR',   real_name: 'Marriott Intl' },
    PRN:  { name: 'Performance Ribaldry',       bb: 1000000,  type: 'active',  dividend_kind: 'item',    interval_days: 7,   item_name: 'Erotic DVD',               perk_desc: '1× Erotic DVD',                  real_ticker: 'DIS',   real_name: 'Walt Disney' },
    BAG:  { name: "Big Al's Gun Shop",          bb: 3000000,  type: 'active',  dividend_kind: 'variable', interval_days: 7,   item_name: null,                       perk_desc: '1× Ammunition Pack (varies w/ equipped weapon, no fixed price)', real_ticker: 'RGR',   real_name: 'Sturm Ruger' },
    THS:  { name: 'Torn City Health Service',   bb: 150000,   type: 'active',  dividend_kind: 'item',    interval_days: 7,   item_name: 'Box of Medical Supplies',  perk_desc: '1× Box of Medical Supplies',     real_ticker: 'UNH',   real_name: 'UnitedHealth' },
    LAG:  { name: 'Legal Authorities Group',    bb: 750000,   type: 'active',  dividend_kind: 'item',    interval_days: 7,   item_name: "Lawyer's Business Card",   perk_desc: "1× Lawyer's Business Card",      real_ticker: 'EFX',   real_name: 'Equifax' },
    HRG:  { name: 'Home Retail Group',          bb: 500000,   type: 'active',  dividend_kind: 'variable', interval_days: 7,   item_name: null,                       perk_desc: 'Random property (Trailer→Private Island, 1/13 each, no fixed price)', real_ticker: 'HD',    real_name: 'Home Depot' },
    SYM:  { name: 'Symbiotic Ltd.',             bb: 500000,   type: 'active',  dividend_kind: 'item',    interval_days: 7,   item_name: 'Drug Pack',                perk_desc: '1× Drug Pack',                   real_ticker: 'LLY',   real_name: 'Eli Lilly' },
    LSC:  { name: 'Lucky Shot Casino',          bb: 100000,   type: 'active',  dividend_kind: 'item',    interval_days: 7,   item_name: 'Lottery Voucher',          perk_desc: '1× Lottery Voucher',             real_ticker: 'MGM',   real_name: 'MGM Resorts' },
    PTS:  { name: 'PointLess',                  bb: 500000,   type: 'active',  dividend_kind: 'special', interval_days: 7,   item_name: null,                       perk_desc: '100 Points per payout',          real_ticker: 'COIN',  real_name: 'Coinbase' },
    MUN:  { name: 'Munster Beverage Corp.',     bb: 5000000,  type: 'active',  dividend_kind: 'item',    interval_days: 7,   item_name: 'Six-Pack of Energy Drink', perk_desc: '1× Six-Pack of Energy Drink',    real_ticker: 'MNST',  real_name: 'Monster Beverage' },
    EWM:  { name: 'Eaglewood Mercenary',        bb: 2000000,  type: 'active',  dividend_kind: 'item',    interval_days: 7,   item_name: 'Box of Grenades',          perk_desc: '1× Box of Grenades',             real_ticker: 'LMT',   real_name: 'Lockheed Martin' },
    CBD:  { name: 'Herbal Releaf Co.',          bb: 1000000,  type: 'active',  dividend_kind: 'special', interval_days: 7,   item_name: null,                       perk_desc: '50 Nerve per payout',            real_ticker: 'TLRY',  real_name: 'Tilray Brands' },
    LOS:  { name: 'Lo Squalo Waste',            bb: 500000,   type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: '+25% Mission reward bonus',      real_ticker: 'WM',    real_name: 'Waste Management' },

    // ─── ACTIVE · 7-day special (non-tradeable) ─────────────────────
    EVL:  { name: 'Evil Ducks Candy Corp',      bb: 100000,   type: 'active',  dividend_kind: 'special', interval_days: 7,   item_name: null,                       perk_desc: '1,000 Happy per payout',         real_ticker: 'HSY',   real_name: 'Hershey' },
    MCS:  { name: 'Mc Smoogle Corp',            bb: 1750000,  type: 'active',  dividend_kind: 'special', interval_days: 7,   item_name: null,                       perk_desc: '100 Energy (max 10 blocks)',      real_ticker: 'GOOGL', real_name: 'Alphabet' },
    ASS:  { name: 'Alcoholics Synonymous',      bb: 3000000,  type: 'active',  dividend_kind: 'item',    interval_days: 7,   item_name: 'Six-Pack of Alcohol',      perk_desc: '1× Six-Pack of Alcohol',         real_ticker: 'DEO',   real_name: 'Diageo' },

    // ─── ACTIVE · 31-day cash dividends ─────────────────────────────
    TCT:  { name: 'The Torn City Times',        bb: 100000,   type: 'active',  dividend_kind: 'cash',    interval_days: 31,  item_name: null,                       perk_desc: '$1M cash every 31 days',         real_ticker: 'NYT',   real_name: 'New York Times' },
    IOU:  { name: 'Insured On Us',              bb: 3000000,  type: 'active',  dividend_kind: 'cash',    interval_days: 31,  item_name: null,                       perk_desc: '$12M cash every 31 days',        real_ticker: 'ALL',   real_name: 'Allstate' },
    TSB:  { name: 'Torn & Shanghai Banking',    bb: 4000000,  type: 'active',  dividend_kind: 'cash',    interval_days: 31,  item_name: null,                       perk_desc: '$50M cash every 31 days',        real_ticker: 'JPM',   real_name: 'JPMorgan Chase' },
    GRN:  { name: 'Grain',                      bb: 500000,   type: 'active',  dividend_kind: 'cash',    interval_days: 31,  item_name: null,                       perk_desc: '$8M cash every 31 days',         real_ticker: 'ADM',   real_name: 'Archer-Daniels' },
    TCC:  { name: 'Torn City Clothing',         bb: 350000,   type: 'active',  dividend_kind: 'variable', interval_days: 31,  item_name: null,                       perk_desc: '1× Clothing Cache (value varies $5M-$150M, no fixed price)', real_ticker: 'TPR',   real_name: 'Tapestry (Coach)' },
    TMI:  { name: 'TC Music Industries',        bb: 6000000,  type: 'active',  dividend_kind: 'cash',    interval_days: 31,  item_name: null,                       perk_desc: '$25M cash every 31 days',        real_ticker: 'LYV',   real_name: 'Live Nation' },
    TCI:  { name: 'Torn City Investments',      bb: 1500000,  type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: '+10% Bank interest bonus',       real_ticker: 'BRK-B', real_name: 'Berkshire Hathaway' },

    // ─── PASSIVE · persistent perks, no periodic payout ────────────
    WLT:  { name: 'Wind Lines Travel',          bb: 9000000,  type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: 'Free private jet travel',        real_ticker: 'UAL',   real_name: 'United Airlines' },
    WSU:  { name: 'West Side University',       bb: 1000000,  type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: '-10% Education time',            real_ticker: 'CHGG',  real_name: 'Chegg' },
    ELT:  { name: 'Empty Lunchbox Traders',     bb: 5000000,  type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: '-10% Home Upgrade cost',         real_ticker: 'LOW',   real_name: "Lowe's" },
    IIL:  { name: 'I Industries Ltd.',          bb: 1000000,  type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: '-50% Coding time',               real_ticker: 'PANW',  real_name: 'Palo Alto Networks' },
    TCM:  { name: 'Torn City Motors',           bb: 1000000,  type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: '+10% Racing Skill boost',        real_ticker: 'F',     real_name: 'Ford Motor' },
    CNC:  { name: 'Crude & Co',                 bb: 5000000,  type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: 'Oil rig discount & profit boost',real_ticker: 'XOM',   real_name: 'ExxonMobil' },
    YAZ:  { name: 'Yazoo',                      bb: 1000000,  type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: 'Free newspaper banners',         real_ticker: 'META',  real_name: 'Meta Platforms' },
    MSG:  { name: 'Messaging Inc.',             bb: 500000,   type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: 'Classified ad discount',         real_ticker: 'TWLO',  real_name: 'Twilio' },
    IST:  { name: 'International School TC',    bb: 100000,   type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: 'Free education courses',         real_ticker: 'STRA',  real_name: 'Strategic Education' },
    TGP:  { name: 'Tell Group Plc.',            bb: 500000,   type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: '+Company advertising boost',     real_ticker: 'MAN',   real_name: 'ManpowerGroup' },
    TCP:  { name: 'TC Media Productions',       bb: 1000000,  type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: '+Company sales boost',           real_ticker: 'NFLX',  real_name: 'Netflix' },
    SYS:  { name: 'Syscore MFG',                bb: 3000000,  type: 'passive', dividend_kind: 'perk',    interval_days: null,item_name: null,                       perk_desc: 'Protects company from hacks',    real_ticker: 'MSFT',  real_name: 'Microsoft' },
  };

  // ═══════════════════════════════════════════════════════════════
  // STYLES
  // ═══════════════════════════════════════════════════════════════
  const injectStyles = () => {
    if (document.getElementById(`${SCRIPT_ID}-styles`)) return;
    const style = document.createElement('style');
    style.id = `${SCRIPT_ID}-styles`;
    style.textContent = `
      @import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap');

      :root {
        --tsa-bg:       #0a0c10;
        --tsa-surface:  #111420;
        --tsa-surface2: #181c2a;
        --tsa-border:   #252a3d;
        --tsa-accent:   #00d4ff;
        --tsa-accent2:  #ff6b35;
        --tsa-green:    #00e676;
        --tsa-red:      #ff1744;
        --tsa-yellow:   #ffd740;
        --tsa-text:     #e8ecf4;
        --tsa-muted:    #5a6080;
        --tsa-real:     #4fc3f7;
        --tsa-passive:  #b39ddb;
        --tsa-glow:     0 0 20px rgba(0,212,255,0.15);
      }

      #tsa-root, #tsa-root * {
        box-sizing: border-box;
      }

      #tsa-root {
        font-family: 'Rajdhani', sans-serif;
        background: var(--tsa-bg);
        border: 1px solid var(--tsa-border);
        border-radius: 12px;
        overflow: hidden;
        box-shadow: 0 8px 32px rgba(0,0,0,0.5), var(--tsa-glow);
        color: var(--tsa-text);
        position: fixed;
        top: 80px;
        left: 80px;
        width: 960px;
        height: 640px;
        min-width: 360px;
        min-height: 220px;
        max-width: 96vw;
        max-height: 92vh;
        z-index: 999999;
        display: flex;
        flex-direction: column;
        resize: none; /* custom corner handle drives resize instead */
      }
      #tsa-root.tsa-collapsed {
        height: auto !important;
        min-height: 0;
      }
      #tsa-root.tsa-collapsed #tsa-tabs,
      #tsa-root.tsa-collapsed #tsa-content,
      #tsa-root.tsa-collapsed .tsa-resize-edge,
      #tsa-root.tsa-collapsed .tsa-resize-corner {
        display: none;
      }
      #tsa-root.tsa-dragging, #tsa-root.tsa-resizing {
        user-select: none;
        box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 1px var(--tsa-accent);
      }

      #tsa-header {
        background: linear-gradient(90deg, #0a0c10 0%, #0d1828 100%);
        border-bottom: 1px solid var(--tsa-border);
        padding: 10px 12px;
        display: flex;
        align-items: center;
        gap: 8px;
        cursor: grab;
        flex-shrink: 0;
        flex-wrap: wrap;
        touch-action: none; /* let our pointer handlers own all drag gestures here */
      }
      #tsa-header.tsa-grabbing { cursor: grabbing; }
      #tsa-header h2 {
        margin: 0;
        font-size: 16px;
        font-weight: 700;
        letter-spacing: 2px;
        text-transform: uppercase;
        color: var(--tsa-accent);
        text-shadow: 0 0 10px rgba(0,212,255,0.4);
        flex: 1 1 auto;
        min-width: 0;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      .tsa-version {
        font-family: 'Space Mono', monospace;
        font-size: 9px;
        color: var(--tsa-muted);
        background: var(--tsa-surface);
        padding: 2px 7px;
        border-radius: 3px;
        border: 1px solid var(--tsa-border);
        flex-shrink: 0;
      }
      .tsa-winbtn {
        width: 26px; height: 26px;
        display: flex; align-items: center; justify-content: center;
        background: var(--tsa-surface2);
        border: 1px solid var(--tsa-border);
        border-radius: 5px;
        color: var(--tsa-muted);
        cursor: pointer;
        font-size: 13px;
        transition: all 0.15s;
        flex-shrink: 0;
        touch-action: manipulation;
        order: 99; /* keep window-control buttons pinned to the end of the row */
      }
      .tsa-winbtn:hover { color: var(--tsa-text); border-color: var(--tsa-accent); }
      #tsa-reset-pos-btn { order: -1; } /* ...except reset, which stays first/leftmost */

      /* Narrow-viewport header: drop decorative pieces (version badge,
         "next refresh" label, fetch-status text) entirely rather than
         trying to shrink them further, and let the timer/refresh group
         wrap onto its own row below the title so the two window-control
         buttons (reset, collapse) always stay on the first row and
         reachable without any horizontal scrolling. JS adds/removes
         the .tsa-narrow class based on actual measured panel width
         (see applyResponsiveHeader), since CSS media queries only see
         the browser viewport, not this absolutely-positioned panel's
         own width. */
      #tsa-root.tsa-narrow .tsa-version,
      #tsa-root.tsa-narrow #tsa-timer-label,
      #tsa-root.tsa-narrow .tsa-fetch-status {
        display: none;
      }
      #tsa-root.tsa-narrow #tsa-header {
        gap: 6px;
      }
      #tsa-root.tsa-narrow #tsa-timer-wrap {
        flex: 1 1 100%;
        order: 100;
        justify-content: flex-end;
        gap: 6px;
      }
      #tsa-root.tsa-narrow #tsa-refresh-btn {
        padding: 5px 9px;
        font-size: 10px;
      }

      /* 8-direction resize handles: 4 edges (line along a whole side)
         plus 4 corners (small squares at each corner), all using
         touch-action:none so our pointer handlers get every move
         event instead of the browser intercepting them as a scroll. */
      .tsa-resize-edge, .tsa-resize-corner {
        position: absolute;
        z-index: 10;
        touch-action: none;
      }
      .tsa-resize-n, .tsa-resize-s { left: 14px; right: 14px; height: 10px; cursor: ns-resize; }
      .tsa-resize-n { top: -3px; }
      .tsa-resize-s { bottom: -3px; }
      .tsa-resize-e, .tsa-resize-w { top: 14px; bottom: 14px; width: 10px; cursor: ew-resize; }
      .tsa-resize-e { right: -3px; }
      .tsa-resize-w { left: -3px; }
      .tsa-resize-corner { width: 16px; height: 16px; }
      .tsa-resize-nw { top: -3px; left: -3px; cursor: nwse-resize; }
      .tsa-resize-ne { top: -3px; right: -3px; cursor: nesw-resize; }
      .tsa-resize-sw { bottom: -3px; left: -3px; cursor: nesw-resize; }
      .tsa-resize-se { bottom: -3px; right: -3px; cursor: nwse-resize; }
      .tsa-resize-se::before {
        content: '';
        position: absolute;
        bottom: 5px; right: 5px;
        width: 9px; height: 9px;
        border-right: 2px solid var(--tsa-muted);
        border-bottom: 2px solid var(--tsa-muted);
        border-radius: 0 0 2px 0;
      }
      .tsa-resize-se:hover::before { border-color: var(--tsa-accent); }

      #tsa-timer-wrap {
        display: flex;
        align-items: center;
        gap: 7px;
        font-family: 'Space Mono', monospace;
        font-size: 10px;
        color: var(--tsa-muted);
      }
      #tsa-timer-ring { position: relative; width: 26px; height: 26px; flex-shrink: 0; }
      #tsa-timer-ring svg { transform: rotate(-90deg); }
      #tsa-timer-ring .bg { fill: none; stroke: var(--tsa-border); stroke-width: 3; }
      #tsa-timer-ring .fg {
        fill: none; stroke: var(--tsa-accent); stroke-width: 3;
        stroke-linecap: round; stroke-dasharray: 69.1;
        transition: stroke-dashoffset 1s linear;
      }
      #tsa-timer-num {
        position: absolute; inset: 0;
        display: flex; align-items: center; justify-content: center;
        font-size: 7px; color: var(--tsa-accent); font-weight: 700;
      }
      #tsa-refresh-btn {
        padding: 5px 11px; border-radius: 5px; border: 1px solid var(--tsa-border);
        background: var(--tsa-surface2); color: var(--tsa-text); cursor: pointer;
        font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 11px;
        letter-spacing: 0.8px; text-transform: uppercase; transition: all 0.15s;
      }
      #tsa-refresh-btn:hover:not(:disabled) { border-color: var(--tsa-accent); color: var(--tsa-accent); }
      #tsa-refresh-btn:disabled { opacity: 0.4; cursor: not-allowed; }

      .tsa-fetch-status {
        font-family: 'Space Mono', monospace; font-size: 9px;
        color: var(--tsa-muted); white-space: nowrap;
      }

      #tsa-tabs {
        display: flex; background: var(--tsa-surface);
        border-bottom: 1px solid var(--tsa-border);
        overflow-x: auto; overflow-y: hidden;
        flex-shrink: 0;
        /* Let the browser/WebView handle horizontal pan gestures here
           directly (don't let our drag/resize pointer handlers compete
           for them), which is what makes swipe-to-scroll reliable on
           touch devices like Torn PDA. */
        touch-action: pan-x;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: thin;
        scrollbar-color: var(--tsa-border) transparent;
      }
      #tsa-tabs::-webkit-scrollbar { height: 4px; }
      #tsa-tabs::-webkit-scrollbar-thumb { background: var(--tsa-border); border-radius: 3px; }
      .tsa-tab {
        padding: 9px 18px; cursor: pointer; font-size: 11px; font-weight: 700;
        letter-spacing: 1px; text-transform: uppercase; color: var(--tsa-muted);
        border-bottom: 2px solid transparent; background: none;
        border-top: none; border-left: none; border-right: none;
        transition: all 0.15s; white-space: nowrap;
        flex-shrink: 0;
      }
      .tsa-tab:hover { color: var(--tsa-text); }
      .tsa-tab.active { color: var(--tsa-accent); border-bottom-color: var(--tsa-accent); }

      #tsa-content {
        padding: 16px 18px; overflow-y: auto; overflow-x: auto;
        scrollbar-width: thin; scrollbar-color: var(--tsa-border) transparent;
        flex: 1 1 auto;
        min-height: 0;
        touch-action: pan-x pan-y;
        -webkit-overflow-scrolling: touch;
      }
      #tsa-content::-webkit-scrollbar { width: 5px; height: 4px; }
      #tsa-content::-webkit-scrollbar-thumb { background: var(--tsa-border); border-radius: 3px; }


      .tsa-setup-card {
        background: var(--tsa-surface); border: 1px solid var(--tsa-border);
        border-radius: 8px; padding: 22px; max-width: 680px;
      }
      .tsa-setup-card h3 {
        margin: 0 0 6px; font-size: 16px; font-weight: 700;
        text-transform: uppercase; letter-spacing: 1px; color: var(--tsa-text);
      }
      .tsa-setup-card p { color: var(--tsa-muted); font-size: 12px; margin: 0 0 16px; line-height: 1.6; }
      .tsa-tos-table { width: 100%; border-collapse: collapse; margin: 0 0 16px; font-size: 10px; }
      .tsa-tos-table th {
        background: var(--tsa-surface2); color: var(--tsa-accent); font-weight: 700;
        text-transform: uppercase; letter-spacing: 0.5px; padding: 7px 8px;
        border: 1px solid var(--tsa-border); text-align: left; font-size: 9px;
      }
      .tsa-tos-table td { padding: 7px 8px; border: 1px solid var(--tsa-border); color: var(--tsa-text); vertical-align: top; }
      .tsa-tos-tag {
        background: var(--tsa-surface2); border: 1px solid var(--tsa-border);
        color: var(--tsa-green); padding: 2px 5px; border-radius: 3px;
        font-family: 'Space Mono', monospace; font-size: 8px;
        display: inline-block; margin: 1px 0; white-space: nowrap;
      }
      .tsa-input-row { display: flex; gap: 8px; margin-bottom: 10px; }
      .tsa-input-row input {
        flex: 1; background: var(--tsa-surface2); border: 1px solid var(--tsa-border);
        border-radius: 5px; color: var(--tsa-text); padding: 9px 12px;
        font-family: 'Space Mono', monospace; font-size: 12px; outline: none; transition: border-color 0.2s;
      }
      .tsa-input-row input:focus { border-color: var(--tsa-accent); }
      .tsa-input-row input::placeholder { color: var(--tsa-muted); }
      .tsa-btn {
        padding: 9px 15px; border-radius: 5px; border: none; cursor: pointer;
        font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 11px;
        letter-spacing: 1px; text-transform: uppercase; transition: all 0.15s; white-space: nowrap;
      }
      .tsa-btn-primary  { background: linear-gradient(135deg, var(--tsa-accent), #0056d6); color: #fff; }
      .tsa-btn-primary:hover { opacity: 0.85; transform: translateY(-1px); }
      .tsa-btn-secondary { background: var(--tsa-surface2); border: 1px solid var(--tsa-border); color: var(--tsa-text); }
      .tsa-btn-secondary:hover { border-color: var(--tsa-accent); color: var(--tsa-accent); }
      .tsa-btn-danger   { background: transparent; border: 1px solid var(--tsa-red); color: var(--tsa-red); }
      .tsa-btn-danger:hover { background: var(--tsa-red); color: #fff; }
      .tsa-key-links { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
      .tsa-key-links a {
        color: var(--tsa-accent); font-size: 11px; text-decoration: none;
        border: 1px solid var(--tsa-border); border-radius: 4px; padding: 4px 10px;
        transition: all 0.15s; font-weight: 600; letter-spacing: 0.5px;
      }
      .tsa-key-links a:hover { border-color: var(--tsa-accent); background: rgba(0,212,255,0.07); }
      .tsa-status-badge {
        display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px;
        border-radius: 20px; font-size: 10px; font-weight: 700;
        letter-spacing: 1px; text-transform: uppercase;
      }
      .tsa-status-ok  { background: rgba(0,230,118,0.1); border: 1px solid var(--tsa-green); color: var(--tsa-green); }
      .tsa-status-err { background: rgba(255,23,68,0.1);  border: 1px solid var(--tsa-red);   color: var(--tsa-red); }

      .tsa-cache-bar {
        display: flex; align-items: center; gap: 7px; font-size: 10px;
        color: var(--tsa-muted); margin-bottom: 12px;
        font-family: 'Space Mono', monospace; flex-wrap: wrap;
      }
      .tsa-cache-dot {
        width: 7px; height: 7px; border-radius: 50%; background: var(--tsa-green);
        box-shadow: 0 0 5px var(--tsa-green); animation: tsaPulse 2s infinite; flex-shrink: 0;
      }
      .tsa-cache-dot.stale { background: var(--tsa-yellow); box-shadow: 0 0 5px var(--tsa-yellow); }
      @keyframes tsaPulse { 0%,100%{opacity:1} 50%{opacity:0.3} }

      #tsa-best-buy {
        display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-bottom: 14px;
      }
      .tsa-best-card {
        background: var(--tsa-surface); border: 1px solid var(--tsa-border);
        border-radius: 8px; padding: 11px 14px; position: relative; overflow: hidden;
      }
      .tsa-best-card::before { content:''; position:absolute; top:0;left:0;right:0; height:2px; }
      .tsa-best-card.roi::before    { background: linear-gradient(90deg,var(--tsa-accent),transparent); }
      .tsa-best-card.profit::before { background: linear-gradient(90deg,var(--tsa-green),transparent); }
      .tsa-best-card.cost::before   { background: linear-gradient(90deg,var(--tsa-yellow),transparent); }
      .tsa-best-label  { font-size: 9px; color: var(--tsa-muted); text-transform: uppercase; letter-spacing: 1px; font-weight: 700; margin-bottom: 3px; }
      .tsa-best-ticker { font-size: 20px; font-weight: 700; color: var(--tsa-text); letter-spacing: 1px; }
      .tsa-best-val    { font-family: 'Space Mono', monospace; font-size: 11px; color: var(--tsa-green); }
      .tsa-best-name   { font-size: 9px; color: var(--tsa-muted); margin-top: 2px; }

      #tsa-controls {
        display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;
      }
      .tsa-filter-group { display: flex; align-items: center; gap: 12px; margin-left: auto; flex-wrap: wrap; }
      .tsa-filter-item { display: flex; align-items: center; gap: 5px; cursor: pointer; }
      .tsa-filter-item label {
        font-size: 11px; color: var(--tsa-muted); font-weight: 700;
        letter-spacing: 0.5px; text-transform: uppercase; cursor: pointer;
      }
      .tsa-filter-item input[type=checkbox] { accent-color: var(--tsa-accent); cursor: pointer; }

      .tsa-sortable-th { cursor: pointer; user-select: none; transition: color 0.15s, background 0.15s; }
      .tsa-sortable-th:hover { color: var(--tsa-accent) !important; background: rgba(0,212,255,0.05); }
      .tsa-sortable-th.sorted { color: var(--tsa-accent) !important; }
      .tsa-sort-arrow { margin-left: 4px; font-size: 8px; color: var(--tsa-accent); display: inline-block; }

      /* Stocks tab layout: cache bar / best-buy / controls stay fixed
         height at the top, the table fills whatever space remains and
         scrolls internally — so its horizontal scrollbar sits at a
         constant, always-reachable spot instead of trailing off after
         however many rows happen to be visible. */
      .tsa-stocks-layout {
        display: flex;
        flex-direction: column;
        height: 100%;
        min-height: 0;
      }
      .tsa-stocks-layout > .tsa-cache-bar,
      .tsa-stocks-layout > .tsa-warn,
      .tsa-stocks-layout > #tsa-best-buy,
      .tsa-stocks-layout > #tsa-controls {
        flex-shrink: 0;
      }
      .tsa-table-wrap {
        overflow: auto;
        border: 1px solid var(--tsa-border);
        border-radius: 8px;
        flex: 1 1 auto;
        min-height: 120px;
      }
      table.tsa-table { width: 100%; border-collapse: collapse; font-size: 11px; }
      table.tsa-table thead tr { background: var(--tsa-surface2); position: sticky; top: 0; z-index: 2; }
      table.tsa-table th {
        padding: 9px 10px; text-align: left; color: var(--tsa-muted); font-weight: 700;
        font-size: 9px; letter-spacing: 1px; text-transform: uppercase; white-space: nowrap;
        border-bottom: 1px solid var(--tsa-border); border-right: 1px solid var(--tsa-border);
      }
      table.tsa-table th:last-child { border-right: none; }
      table.tsa-table td {
        padding: 9px 10px; border-bottom: 1px solid rgba(37,42,61,0.5);
        border-right: 1px solid rgba(37,42,61,0.3); color: var(--tsa-text);
        vertical-align: middle; white-space: nowrap;
      }
      table.tsa-table td:last-child { border-right: none; }
      table.tsa-table tr:last-child td { border-bottom: none; }
      table.tsa-table tbody tr { transition: background 0.1s; }
      table.tsa-table tbody tr:hover { background: rgba(255,255,255,0.025); }
      table.tsa-table tbody tr.owned { background: rgba(0,212,255,0.04); }
      table.tsa-table tbody tr.passive-row { opacity: 0.75; }

      .tsa-ticker   { font-family: 'Space Mono', monospace; font-weight: 700; font-size: 12px; color: var(--tsa-text); letter-spacing: 1px; }
      .tsa-real-tkr { font-family: 'Space Mono', monospace; font-size: 9px; color: var(--tsa-real); margin-left: 4px; }
      .tsa-sname    { font-size: 9px; color: var(--tsa-muted); }

      .v-green   { color: var(--tsa-green);   font-family: 'Space Mono', monospace; font-size: 10px; }
      .v-red     { color: var(--tsa-red);     font-family: 'Space Mono', monospace; font-size: 10px; }
      .v-yellow  { color: var(--tsa-yellow);  font-family: 'Space Mono', monospace; font-size: 10px; }
      .v-blue    { color: var(--tsa-real);    font-family: 'Space Mono', monospace; font-size: 10px; }
      .v-mono    { font-family: 'Space Mono', monospace; font-size: 10px; }
      .v-passive { color: var(--tsa-passive); font-family: 'Space Mono', monospace; font-size: 10px; }

      .badge-own     { background: rgba(0,212,255,0.13); border: 1px solid var(--tsa-accent); color: var(--tsa-accent); font-size: 8px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding: 1px 4px; border-radius: 3px; margin-left: 3px; }
      .badge-passive { background: rgba(179,157,219,0.1); border: 1px solid rgba(179,157,219,0.3); color: var(--tsa-passive); font-size: 8px; font-weight: 700; text-transform: uppercase; padding: 1px 4px; border-radius: 3px; }
      .badge-7d      { background: rgba(0,230,118,0.1); border: 1px solid rgba(0,230,118,0.3); color: var(--tsa-green); font-size: 8px; font-weight: 700; padding: 1px 4px; border-radius: 3px; }
      .badge-31d     { background: rgba(255,107,53,0.1); border: 1px solid rgba(255,107,53,0.3); color: var(--tsa-accent2); font-size: 8px; font-weight: 700; padding: 1px 4px; border-radius: 3px; }

      .conf-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 3px; vertical-align: middle; }
      .conf-high   { background: var(--tsa-green); }
      .conf-medium { background: var(--tsa-yellow); }
      .conf-low    { background: var(--tsa-red); }

      .pred-grid { display: grid; grid-template-columns: repeat(5,48px); gap: 2px; margin-top: 3px; }
      .pred-cell { background: var(--tsa-surface2); border: 1px solid var(--tsa-border); border-radius: 3px; padding: 3px 2px; text-align: center; }
      .pred-label { font-size: 7px; color: var(--tsa-muted); text-transform: uppercase; }
      .pred-val   { font-family: 'Space Mono', monospace; font-size: 9px; }

      .tsa-pc { background: var(--tsa-surface); border: 1px solid var(--tsa-border); border-radius: 8px; padding: 14px; margin-bottom: 8px; }
      .tsa-pc h4 {
        margin: 0 0 10px; font-size: 12px; color: var(--tsa-text); font-weight: 700;
        text-transform: uppercase; letter-spacing: 1px; display: flex; align-items: center; gap: 6px;
      }
      .kv-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(120px,1fr)); gap: 8px; }
      .kv-label { color: var(--tsa-muted); font-size: 9px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 2px; }
      .kv-val   { font-size: 12px; }

      .tsa-loading { text-align: center; padding: 40px 20px; color: var(--tsa-muted); }
      .tsa-spinner {
        width: 30px; height: 30px; border: 3px solid var(--tsa-border);
        border-top-color: var(--tsa-accent); border-radius: 50%;
        animation: tsaSpin 0.8s linear infinite; margin: 0 auto 12px;
      }
      @keyframes tsaSpin { to { transform: rotate(360deg); } }
      .tsa-error {
        background: rgba(255,23,68,0.08); border: 1px solid rgba(255,23,68,0.3);
        border-radius: 6px; padding: 12px 15px; color: var(--tsa-red); font-size: 12px; margin-bottom: 12px;
      }
      .tsa-info {
        background: rgba(0,212,255,0.06); border: 1px solid rgba(0,212,255,0.2);
        border-radius: 6px; padding: 9px 13px; color: var(--tsa-accent);
        font-size: 12px; margin-bottom: 10px; line-height: 1.5;
      }
      .tsa-tipbadge { user-select: none; }

      #tsa-tooltip {
        position: fixed;
        z-index: 1000001;
        background: var(--tsa-surface2);
        border: 1px solid var(--tsa-accent);
        border-radius: 7px;
        padding: 9px 13px;
        font-family: 'Rajdhani', sans-serif;
        font-size: 13px;
        font-weight: 600;
        color: var(--tsa-text);
        max-width: 240px;
        box-shadow: 0 4px 20px rgba(0,0,0,0.5);
        pointer-events: none;
        opacity: 0;
        transition: opacity 0.15s;
      }
      #tsa-tooltip.visible { opacity: 1; pointer-events: auto; }

      .tsa-warn {
        background: rgba(255,215,64,0.06); border: 1px solid rgba(255,215,64,0.2);
        border-radius: 6px; padding: 7px 11px; color: var(--tsa-yellow);
        font-size: 11px; margin-bottom: 8px; line-height: 1.5;
      }

      .tsa-theme-grid {
        display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
        gap: 12px;
      }
      .tsa-theme-field { display: flex; flex-direction: column; gap: 5px; }
      .tsa-theme-field label {
        font-size: 11px; font-weight: 700; color: var(--tsa-text);
        text-transform: uppercase; letter-spacing: 0.5px;
      }
      .tsa-theme-swatch-row { display: flex; align-items: center; gap: 8px; }
      .tsa-theme-hint { font-size: 10px; color: var(--tsa-muted); }
      input[type="color"]#tsa-theme-bg,
      input[type="color"][id^="tsa-theme-"] {
        width: 38px; height: 28px; padding: 0; border: 1px solid var(--tsa-border);
        border-radius: 5px; background: var(--tsa-surface2); cursor: pointer;
        flex-shrink: 0;
      }
    `;
    document.head.appendChild(style);
  };

  // Defensive read: on Torn PDA, GM_getValue can return a Promise instead
  // of resolving synchronously. If so, treat the key as not-yet-loaded
  // (empty string) and pick it up asynchronously once it resolves.
  const readInitialApiKey = () => {
    try {
      const v = GM_getValue(KEY_STORAGE, '');
      if (v && typeof v.then === 'function') {
        v.then(resolved => {
          if (resolved) {
            state.apiKey = resolved;
            // Re-render and kick off a fetch now that we actually have a key
            if (document.getElementById('tsa-root')) {
              renderContent();
              fetchAll().then(startAutoRefresh);
            }
          }
        }).catch(() => {});
        return '';
      }
      return v || '';
    } catch (e) { return ''; }
  };

  // ═══════════════════════════════════════════════════════════════
  // STATE
  // ═══════════════════════════════════════════════════════════════
  let state = {
    apiKey:          '', // populated by readInitialApiKey() right after state is defined
    apiKeyValid:     false,
    sortBy:          'roi',
    sortDir:         'desc',
    showOwnedOnly:   false,
    showPassive:     true,
    tornStocks:      {},   // { TICKER: { current_price } } — built from page scrape
    pageStocks:      {},   // { TICKER: { price, shares, divText, cashDividend, isPassive, statusText } }
    userStocks:      {},   // { TICKER: { shares } } — built from page scrape
    itemPrices:      {},   // { item_name: price } — from Item Market page scrape
    realStockData:   {},   // { ticker: { pct_1d, pct_1w, ... } } — from Yahoo Finance
    lastFetch:       null,
    fetchError:      null,
    loading:         false,
    itemsLoading:    false,
    itemsError:      null,
    itemIdMap:       null,  // cached name→id map, fetched once per session
    scrapeDebug:     null,  // diagnostics from the last DOM scrape (see scrapePageStocks)
    activeTab:       'stocks',
    refreshTimer:    null,
    countdownSec:    60,
    countdownTimer:  null,
    fetchStep:       '',   // shown in header during fetch
  };
  state.apiKey = readInitialApiKey();

  // ═══════════════════════════════════════════════════════════════
  // UTILITIES
  // ═══════════════════════════════════════════════════════════════
  const fmt = {
    cash: v => {
      if (v == null || isNaN(v)) return '—';
      if (Math.abs(v) >= 1e9) return '$' + (v / 1e9).toFixed(2) + 'B';
      if (Math.abs(v) >= 1e6) return '$' + (v / 1e6).toFixed(2) + 'M';
      if (Math.abs(v) >= 1e3) return '$' + (v / 1e3).toFixed(1) + 'K';
      return '$' + v.toFixed(0);
    },
    pct: v => {
      if (v == null || isNaN(v)) return '—';
      return (v >= 0 ? '+' : '') + v.toFixed(2) + '%';
    },
    num: v => (v == null || isNaN(v)) ? '—' : Number(v).toLocaleString(),
    shares: v => {
      if (v == null || isNaN(v)) return '—';
      if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
      if (v >= 1e3) return (v / 1e3).toFixed(0) + 'K';
      return String(v);
    },
    days: v => {
      if (v == null || !isFinite(v) || v < 0) return '—';
      if (v > 3650) return '>10yr';
      if (v >= 365) return (v / 365).toFixed(1) + 'yr';
      if (v >= 30)  return (v / 30).toFixed(1)  + 'mo';
      return v.toFixed(0) + 'd';
    },
    ts: ts => {
      if (!ts) return 'never';
      return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
    },
  };

  const sleep = ms => new Promise(r => setTimeout(r, ms));

  // ═══════════════════════════════════════════════════════════════
  // API LAYER  (only used for key validation now)
  // ═══════════════════════════════════════════════════════════════
  const apiCall = (endpoint, params = {}) => new Promise((resolve, reject) => {
    const gap = Math.max(0, API_CALL_GAP_MS - (Date.now() - lastApiCall));
    setTimeout(() => {
      lastApiCall = Date.now();
      const qs  = new URLSearchParams({ key: state.apiKey, ...params }).toString();
      const url = `https://api.torn.com/${endpoint}?${qs}`;
      GM_xmlhttpRequest({
        method: 'GET', url,
        onload: r => {
          try {
            const d = JSON.parse(r.responseText);
            if (d.error) reject({ code: d.error.code, msg: d.error.error });
            else         resolve(d);
          } catch (e) { reject({ code: -1, msg: 'JSON parse error' }); }
        },
        onerror: () => reject({ code: -1, msg: 'Network error' }),
      });
    }, gap);
  });

  const validateKey = async key => {
    const saved = state.apiKey;
    state.apiKey = key;
    try {
      const d = await apiCall('key/', { selections: 'info' });
      return { valid: true, level: d.info?.access_level ?? '?' };
    } catch (e) {
      state.apiKey = saved;
      return { valid: false, code: e.code, msg: e.msg };
    }
  };

  // Actually exercises the two specific permissions this script needs
  // (torn→items and market→itemmarket) rather than just checking that
  // the key is valid in general — a key can be valid but still lack
  // either of these specific selections.
  const checkKeyPermissions = async key => {
    const saved = state.apiKey;
    state.apiKey = key;
    const result = {
      items: false, itemmarket: false, tornStocks: false, userStocks: false,
      itemsError: null, itemmarketError: null, tornStocksError: null, userStocksError: null,
    };

    try {
      await apiCall('v2/torn', { selections: 'items' });
      result.items = true;
    } catch (e) {
      result.itemsError = errorMsg(e.code);
    }

    // market/{id}/itemmarket needs a real item id to test against —
    // use 367 (Feathery Hotel Coupon), a stable, always-tradeable item.
    try {
      await apiCall('v2/market/367/itemmarket', {});
      result.itemmarket = true;
    } catch (e) {
      result.itemmarketError = errorMsg(e.code);
    }

    try {
      await apiCall('v2/torn', { selections: 'stocks' });
      result.tornStocks = true;
    } catch (e) {
      result.tornStocksError = errorMsg(e.code);
    }

    try {
      await apiCall('v2/user', { selections: 'stocks' });
      result.userStocks = true;
    } catch (e) {
      result.userStocksError = errorMsg(e.code);
    }

    state.apiKey = saved;
    return result;
  };

  const errorMsg = code => ({
    1:  'API key is empty.',
    2:  'Incorrect key — check you copied it correctly.',
    5:  'Rate limit hit (>100 req/min). Wait a moment.',
    7:  'Access denied — key lacks required permission.',
    8:  'IP temporarily banned. Wait before retrying.',
    9:  'Torn API is currently disabled.',
    10: 'Key owner is in federal jail.',
    13: 'Key disabled — owner offline for >7 days.',
    14: 'Daily read limit reached.',
    16: 'Key access level too low. Create a custom key.',
    18: 'API key has been paused by its owner.',
  }[code] || `Unknown error (code ${code}).`);

  // ═══════════════════════════════════════════════════════════════
  // ITEM MARKET PRICES  (via Torn API — requires 'market' permission)
  // Step 1: fetch torn→items once per session to build name→ID map
  //         (cached in state.itemIdMap so we don't refetch every cycle)
  // Step 2: fetch v2 market/{id}/itemmarket for each needed item,
  //         take the lowest listed price.
  // ═══════════════════════════════════════════════════════════════
  const buildItemIdMap = () => new Promise(resolve => {
    const url = `https://api.torn.com/v2/torn?selections=items&key=${state.apiKey}`;
    GM_xmlhttpRequest({
      method: 'GET', url,
      onload: r => {
        try {
          const d = JSON.parse(r.responseText);
          if (d.error) { resolve(null); return; }
          const items = d.items || {};
          const map = {};
          // v2 response shape: { items: [ {id, name, ...}, ... ] } or { items: {id: {...}} }
          const list = Array.isArray(items) ? items : Object.entries(items).map(([id, v]) => ({ id, ...v }));
          list.forEach(it => {
            if (it && it.name) map[it.name] = it.id;
          });
          resolve(map);
        } catch (e) { resolve(null); }
      },
      onerror: () => resolve(null),
    });
  });

  const fetchOneItemPrice = (itemId) => new Promise(resolve => {
    const url = `https://api.torn.com/v2/market/${itemId}/itemmarket?key=${state.apiKey}`;
    GM_xmlhttpRequest({
      method: 'GET', url,
      onload: r => {
        try {
          const d = JSON.parse(r.responseText);
          if (d.error) { resolve(null); return; }
          const listings = d.itemmarket?.listings || d.itemmarket || [];
          const prices = listings
            .map(l => l.price ?? l.cost)
            .filter(p => typeof p === 'number' && p > 0)
            .sort((a, b) => a - b);
          if (!prices.length) { resolve(null); return; }
          // Use the median of the 5 cheapest listings rather than the
          // single lowest — avoids skew from a stale or outlier listing
          // and better reflects what you'd realistically pay.
          const sample = prices.slice(0, 5);
          const mid = sample[Math.floor((sample.length - 1) / 2)];
          resolve(mid);
        } catch (e) { resolve(null); }
      },
      onerror: () => resolve(null),
    });
  });

  const fetchItemPrices = async () => {
    const itemNames = [...new Set(
      Object.values(STOCK_DATA)
        .filter(s => s.dividend_kind === 'item' && s.item_name)
        .map(s => s.item_name)
    )];
    if (!itemNames.length || !state.apiKey) return {};

    // Build (or reuse cached) name → item ID map
    if (!state.itemIdMap) {
      const map = await buildItemIdMap();
      if (!map) {
        state.itemsError = 'Could not fetch item list — check your key has "torn → items" access.';
        return {};
      }
      state.itemIdMap = map;
    }

    const results = {};
    let anyFailed = false;
    for (const name of itemNames) {
      const itemId = state.itemIdMap[name];
      if (!itemId) { anyFailed = true; continue; }
      const price = await fetchOneItemPrice(itemId);
      if (price != null) results[name] = price;
      else anyFailed = true;
      await sleep(API_CALL_GAP_MS); // respect rate limit, ~1 call/1.2s
    }

    if (anyFailed && Object.keys(results).length === 0) {
      state.itemsError = 'Could not fetch item market prices — check your key has "market → itemmarket" access enabled.';
    }
    return results;
  };

  // ═══════════════════════════════════════════════════════════════
  // REAL-WORLD STOCK FETCH  (Yahoo Finance, for predictions only)
  // ═══════════════════════════════════════════════════════════════
  const fetchRealStock = ticker => new Promise(resolve => {
    const url = `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?interval=1d&range=1y`;
    GM_xmlhttpRequest({
      method: 'GET', url,
      headers: { Accept: 'application/json' },
      onload: r => {
        try {
          const closes = JSON.parse(r.responseText).chart.result[0].indicators.quote[0].close;
          const n = closes.length;
          if (n < 2) { resolve(null); return; }
          const pct  = (a, b) => ((b - a) / a) * 100;
          const last = closes[n - 1];
          resolve({
            pct_1d: n >= 2  ? pct(closes[n - 2],  last) : null,
            pct_1w: n >= 6  ? pct(closes[n - 6],  last) : null,
            pct_2w: n >= 11 ? pct(closes[n - 11], last) : null,
            pct_1m: n >= 22 ? pct(closes[n - 22], last) : null,
            pct_1y: pct(closes[0], last),
          });
        } catch (e) { resolve(null); }
      },
      onerror: () => resolve(null),
    });
  });

  const fetchAllRealStocks = async () => {
    const tickers = [...new Set(Object.values(STOCK_DATA).map(s => s.real_ticker))];
    for (const t of tickers) {
      state.realStockData[t] = await fetchRealStock(t);
      await sleep(150);
    }
    renderContent();
  };

  // ═══════════════════════════════════════════════════════════════
  // DOM SCRAPER
  // Reads ALL stock data directly from the rendered page.
  // Called with a retry loop to handle React hydration delay.
  // ═══════════════════════════════════════════════════════════════
  const scrapePageStocks = () => {
    const result = {};
    const debug = {
      ulFound: 0, tickersFound: 0, pricesFromAriaLabel: 0,
      pricesFromDigitSpans: 0, pricesZero: 0, sampleRaw: null,
      sampleTickerHTML: null,
    };

    // ── Primary pass: per-stock <ul> elements ──────────────────────
    const ulList = document.querySelectorAll('ul.stock___CnywB');
    debug.ulFound = ulList.length;

    ulList.forEach(ul => {
      const acronymEl = ul.querySelector('.tt-acronym');

      // Ticker extraction — try every method from most to least reliable:
      // 1. data-acronym attribute via dataset (PC/standard)
      // 2. data-acronym attribute via getAttribute (in case dataset is
      //    not fully supported in PDA's WebView)
      // 3. Text content of .tt-acronym span (e.g. "(PRN) " → "PRN")
      // 4. aria-label on the nameTab li (e.g. "Stock: Performance
      //    Ribaldry" — extract ticker from the img alt text instead)
      // 5. The stock image src which contains the ticker in its filename
      let ticker = '';
      if (acronymEl) {
        ticker = (acronymEl.dataset?.acronym || '').trim()
          || (acronymEl.getAttribute('data-acronym') || '').trim()
          || (acronymEl.textContent || '').replace(/[^A-Z]/g, '').trim();
      }
      if (!ticker) {
        // Try extracting from the stock logo img src, e.g. "logos/PRN.svg"
        const img = ul.querySelector('img[src*="logos/"]');
        if (img) {
          const m = (img.getAttribute('src') || '').match(/logos\/([A-Z]+)\.svg/);
          if (m) ticker = m[1];
        }
      }
      if (!ticker) {
        // Capture what the nameTab actually looks like for one failing row
        if (!debug.sampleTickerHTML) {
          debug.sampleTickerHTML = (ul.querySelector('[data-name="nameTab"]')?.outerHTML || '(no nameTab)').slice(0, 600);
        }
        return;
      }

      // Validate ticker against our known stock list — skip anything
      // that isn't one of our 35 stocks to avoid false positives from
      // the fallback methods above.
      if (!STOCK_DATA[ticker]) return;

      debug.tickersFound++;

      // Price: prefer aria-label (full decimal, e.g. "$614.85"), but
      // some WebViews (observed on Torn PDA) strip aria-label entirely,
      // so fall back to reading the visible per-digit spans Torn always
      // renders regardless of accessibility attributes:
      //   <div class="price___..."><span class="number___...">6</span>...
      const priceLi    = ul.querySelector('[data-name="priceTab"]');
      const priceLabel = priceLi?.getAttribute('aria-label') || '';
      const priceMatch = priceLabel.match(/\$([0-9,]+(?:\.[0-9]+)?)/);
      let price = priceMatch ? parseFloat(priceMatch[1].replace(/,/g, '')) : 0;
      if (price) {
        debug.pricesFromAriaLabel++;
      } else {
        const digitSpans = priceLi?.querySelectorAll('[class*="number___"]');
        if (digitSpans && digitSpans.length) {
          const digitsText = Array.from(digitSpans).map(s => s.textContent).join('');
          const parsed = parseFloat(digitsText);
          if (!isNaN(parsed) && parsed > 0) { price = parsed; debug.pricesFromDigitSpans++; }
        }
      }
      if (!price) {
        debug.pricesZero++;
        if (!debug.sampleRaw) {
          // Capture one failing row's raw markup (truncated) so we can
          // see exactly what Torn PDA actually rendered here, without
          // needing another round-trip just to ask for it.
          debug.sampleRaw = {
            ticker,
            priceLiHTML: (priceLi?.outerHTML || '(no priceTab element found)').slice(0, 500),
            hasAriaLabel: priceLi?.hasAttribute('aria-label') || false,
          };
        }
      }

      // Owned shares from count span
      const ownedLi  = ul.querySelector('[data-name="ownedTab"]');
      const countEl  = ownedLi?.querySelector('.count___yJoKq');
      const countTxt = countEl?.textContent?.trim() || 'None';
      const shares   = countTxt === 'None' ? 0 : parseInt(countTxt.replace(/,/g, '')) || 0;

      // Dividend text and type: prefer aria-label, fall back to the
      // always-visible <p class="dividend___..."> / status text nodes
      // for the same WebView-stripped-attributes reason as price above.
      const divLi       = ul.querySelector('[data-name="dividendTab"]');
      const divLabel    = divLi?.getAttribute('aria-label') || '';
      const divMatch    = divLabel.match(/Dividend:\s*(.+?)\.\s*Status:/);
      let   divText     = divMatch ? divMatch[1].trim() : '';
      const statusMatch = divLabel.match(/Status:\s*(.+)$/);
      let   statusText  = statusMatch ? statusMatch[1].trim() : '';
      const isPassive   = !!divLi?.querySelector('[class*="passive___"]');

      if (!divText) {
        const divTextEl = divLi?.querySelector('[class*="dividend___"]');
        if (divTextEl) divText = divTextEl.textContent?.trim() || '';
      }
      if (!statusText) {
        const statusEl = divLi?.querySelector('[class*="active___"], [class*="passive___"]');
        if (statusEl) statusText = statusEl.textContent?.trim() || '';
      }

      // Parse cash dividend value from live page text e.g. "$8,000,000"
      let cashDividend = 0;
      if (!isPassive && divText.startsWith('$')) {
        const cm = divText.match(/\$([0-9,]+)/);
        if (cm) cashDividend = parseInt(cm[1].replace(/,/g, '')) || 0;
      }

      result[ticker] = {
        price, shares, divText, statusText, isPassive, cashDividend,
      };
    });

    result.__debug = debug;
    return result;
  };

  // Wait for the stocks DOM to be fully hydrated by React (retry up to N times)
  const scrapeWithRetry = async (maxAttempts = 5, delayMs = 800) => {
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      const data = scrapePageStocks();
      const tickers = Object.keys(data);
      // Consider it ready when we have ≥30 stocks with valid prices
      const validCount = tickers.filter(t => data[t].price > 0).length;
      if (validCount >= 30) return data;
      if (attempt < maxAttempts - 1) {
        state.fetchStep = `Waiting for page data… (${attempt + 1}/${maxAttempts})`;
        updateFetchStatus();
        await sleep(delayMs);
      }
    }
    // Return whatever we have after retries
    return scrapePageStocks();
  };

  const updateFetchStatus = () => {
    const el = document.getElementById('tsa-fetch-status');
    if (el) el.textContent = state.fetchStep;
  };

  // ═══════════════════════════════════════════════════════════════
  // MAIN DATA FETCH
  // Phase 1: Scrape page DOM (prices, shares, cash dividends, BB data)
  // Phase 2: Fetch item market prices in parallel (for item-dividend stocks)
  // Phase 3: Fetch Yahoo Finance data in background (predictions)
  // ═══════════════════════════════════════════════════════════════
  const fetchAll = async () => {
    if (!state.apiKey) return;
    state.loading    = true;
    state.fetchError = null;
    state.fetchStep  = 'Scraping page data…';
    renderContent();

    try {
      // ── Phase 1: DOM scrape with retry ────────────────────────────
      state.fetchStep = 'Reading stock prices from page…';
      updateFetchStatus();
      const pageData = await scrapeWithRetry(5, 700);
      state.scrapeDebug = pageData.__debug || null;
      delete pageData.__debug;
      state.pageStocks = pageData;

      // Build tornStocks and userStocks from scraped data
      state.tornStocks = {};
      state.userStocks = {};
      for (const [ticker, d] of Object.entries(pageData)) {
        state.tornStocks[ticker] = { current_price: d.price };
        if (d.shares > 0) {
          state.userStocks[ticker] = { shares: d.shares };
        }
      }

      state.lastFetch = Date.now();
      state.loading   = false;
      state.fetchStep = '';
      renderContent();

      // ── Phase 2: Item market prices (parallel, non-blocking) ──────
      // Requires API key with torn→items and market→itemmarket access.
      if (state.apiKey) {
        state.itemsLoading = true;
        state.itemsError   = null;
        state.fetchStep    = 'Fetching item market prices…';
        updateFetchStatus();
        const itemPrices = await fetchItemPrices();
        state.itemsLoading = false;
        state.fetchStep    = '';
        if (Object.keys(itemPrices).length > 0) {
          state.itemPrices = { ...state.itemPrices, ...itemPrices };
        }
        renderContent(); // re-render to show item dividend values
      }

      // ── Phase 3: Real-world stock predictions (background) ────────
      fetchAllRealStocks();

    } catch (e) {
      state.loading    = false;
      state.fetchStep  = '';
      state.fetchError = e.msg || String(e);
      renderContent();
    }
  };

  // ═══════════════════════════════════════════════════════════════
  // AUTO-REFRESH
  // ═══════════════════════════════════════════════════════════════
  const updateCountdownUI = () => {
    const ring = document.getElementById('tsa-timer-ring');
    const num  = document.getElementById('tsa-timer-num');
    if (!ring || !num) return;
    const fg       = ring.querySelector('.fg');
    const total    = 69.1;
    const fraction = state.countdownSec / 60;
    if (fg) fg.style.strokeDashoffset = total * (1 - fraction);
    num.textContent = state.countdownSec;
  };

  const startAutoRefresh = () => {
    stopAutoRefresh();
    state.countdownSec = 60;
    updateCountdownUI();
    state.countdownTimer = setInterval(() => {
      state.countdownSec = Math.max(0, state.countdownSec - 1);
      updateCountdownUI();
    }, 1000);
    state.refreshTimer = setInterval(() => {
      state.countdownSec = 60;
      fetchAll();
    }, AUTO_REFRESH_MS);
  };

  const stopAutoRefresh = () => {
    if (state.refreshTimer)   { clearInterval(state.refreshTimer);   state.refreshTimer   = null; }
    if (state.countdownTimer) { clearInterval(state.countdownTimer); state.countdownTimer = null; }
  };

  // ═══════════════════════════════════════════════════════════════
  // CALCULATION ENGINE
  // ═══════════════════════════════════════════════════════════════
  const calcStock = ticker => {
    const meta = STOCK_DATA[ticker];
    if (!meta) return null;

    const api       = state.tornStocks[ticker] || {};
    const owned     = state.userStocks[ticker];
    const pageStock = state.pageStocks[ticker] || {};

    const price = api.current_price || 0;

    // BB threshold and next-BB cost — calculated entirely from formula.
    //
    // IMPORTANT: Torn's "Buy X more shares for $Y to unlock the Nth
    // increment" tooltip text only renders in the DOM *after the user
    // clicks* the dividend status icon for that specific stock — it is
    // never present passively, so a background scrape can't read it.
    // We instead calculate it ourselves from data that's always present
    // in the static DOM: live price, live owned shares, and the base
    // BB threshold (meta.bb). Verified against a real in-game example
    // (PRN: 1,000,000 owned → "buy 2,000,000 more for the 2nd
    // increment" matches the cumulative-doubling formula below to
    // within a few dollars, the gap being normal price drift between
    // the moment of reading vs. the moment of the in-game tooltip).
    //
    // Increment N (1-indexed) requires a *cumulative* threshold of
    // bb * (2^N - 1) total shares held (1x, then +2x, then +4x, ...).
    const bb     = meta.bb;
    const bbCost = price * bb;

    let sharesOwned = 0, increments = 0;
    let cumulativeThreshold = 0, nextTierSize = bb;

    if (owned) {
      sharesOwned = owned.shares || 0;
      while (cumulativeThreshold + nextTierSize <= sharesOwned) {
        cumulativeThreshold += nextTierSize;
        increments++;
        nextTierSize *= 2;
      }
    }
    const sharesNeededForNext = (cumulativeThreshold + nextTierSize) - sharesOwned;
    const nextBBCost = sharesNeededForNext * price;

    // Dividend value
    let divValue = null;
    if (meta.dividend_kind === 'cash') {
      // Live cash amount from page DOM
      divValue = pageStock.cashDividend || null;
    } else if (meta.dividend_kind === 'item' && meta.item_name) {
      // Item market price (fetched separately)
      divValue = state.itemPrices[meta.item_name] || null;
    }
    // 'special' and 'perk' → divValue stays null (no tradeable value)

    const profitPerDay  = (divValue && meta.interval_days) ? divValue / meta.interval_days : null;
    const roi           = (profitPerDay && bbCost > 0) ? (profitPerDay * 365 / bbCost) * 100 : null;
    const breakEvenDays = (profitPerDay && profitPerDay > 0 && bbCost > 0) ? bbCost / profitPerDay : null;

    // Price predictions: mirror real-world % change onto Torn price
    const real = state.realStockData[meta.real_ticker] || null;
    const predictions = real ? {
      '1d': price * (1 + (real.pct_1d || 0) / 100),
      '1w': price * (1 + (real.pct_1w || 0) / 100),
      '2w': price * (1 + (real.pct_2w || 0) / 100),
      '1m': price * (1 + (real.pct_1m || 0) / 100),
      '1y': price * (1 + (real.pct_1y || 0) / 100),
    } : null;

    const confMap = {
      'BRK-B': 'high', 'JPM': 'high', 'MSFT': 'high',
      'NYT': 'medium', 'MAR': 'medium', 'UNH': 'medium',
      'HD': 'medium', 'GOOGL': 'medium', 'ADM': 'medium',
      'XOM': 'medium', 'MNST': 'medium', 'MAN': 'medium',
      'ALL': 'medium', 'EFX': 'medium', 'DEO': 'medium',
      'LMT': 'medium', 'WM': 'medium',
      'DIS': 'low', 'META': 'low', 'RGR': 'low', 'HSY': 'low',
      'MGM': 'low', 'COIN': 'low', 'UAL': 'low', 'TPR': 'low',
      'PANW': 'low', 'F': 'low', 'LYV': 'low', 'TLRY': 'low',
      'LOW': 'low', 'CHGG': 'low', 'TWLO': 'low', 'STRA': 'low',
      'NFLX': 'low',
    };
    const confidence = confMap[meta.real_ticker] || 'medium';

    let trend7d = null;
    if (Array.isArray(api.history) && api.history.length >= 2)
      trend7d = ((api.history[api.history.length - 1] - api.history[0]) / api.history[0]) * 100;

    return {
      ticker, meta, price, bb, bbCost, sharesOwned, increments,
      nextBBCost, divValue, profitPerDay, roi, breakEvenDays,
      predictions, confidence, trend7d,
      isOwned: sharesOwned > 0,
    };
  };

  // Value extractors for every sortable column, keyed by column id.
  // null/undefined values always sort to the bottom regardless of direction.
  const SORT_EXTRACTORS = {
    ticker:    s => s.ticker,
    type:      s => s.meta.type,
    price:     s => s.price,
    trend:     s => s.trend7d,
    bbCost:    s => s.bbCost,
    nextBB:    s => s.nextBBCost,
    dividend:  s => s.divValue,
    interval:  s => s.meta.interval_days,
    profit:    s => s.profitPerDay,
    roi:       s => s.roi,
    breakeven: s => s.breakEvenDays,
  };

  const getSortedStocks = () => {
    let rows = Object.keys(STOCK_DATA).map(t => calcStock(t)).filter(Boolean);
    if (state.showOwnedOnly) rows = rows.filter(s => s.isOwned);
    if (!state.showPassive)  rows = rows.filter(s => s.meta.type !== 'passive');

    const extractor = SORT_EXTRACTORS[state.sortBy] || SORT_EXTRACTORS.roi;
    rows.sort((a, b) => {
      let va = extractor(a), vb = extractor(b);
      const aNull = va == null, bNull = vb == null;
      if (aNull && bNull) return 0;
      if (aNull) return 1;   // nulls always last
      if (bNull) return -1;
      if (typeof va === 'string') {
        return state.sortDir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
      }
      return state.sortDir === 'asc' ? va - vb : vb - va;
    });
    return rows;
  };

  // ═══════════════════════════════════════════════════════════════
  // RENDER — SETUP TAB
  // ═══════════════════════════════════════════════════════════════
  const renderSetupTab = () => `
    <div class="tsa-setup-card">
      <h3>🔑 API Key Setup</h3>
      <p>
        Your key is currently used for <strong>key validation</strong> and fetching
        <strong>item market prices</strong> for item-dividend stocks (PRN, FHG, etc.).
        All stock prices, shares, and cash dividends are scraped directly from the
        page you're already viewing — no extra API calls needed for that today.<br><br>
        The custom key below also requests <code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">torn → stocks</code> and
        <code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">user → stocks</code> as a safety net covering everything the script could plausibly need,
        even though they aren't actively used yet.<br><br>
        Item market prices are fetched once per session.
        Predictions use Yahoo Finance (no Torn API).
      </p>

      <table class="tsa-tos-table">
        <thead>
          <tr>
            <th>Data Storage</th><th>Data Sharing</th><th>Purpose of Use</th>
            <th>Key Storage & Sharing</th><th>Key Access Level</th>
          </tr>
        </thead>
        <tbody>
          <tr>
            <td><span class="tsa-tos-tag">Only locally</span></td>
            <td><span class="tsa-tos-tag">Nobody</span></td>
            <td>
              <span class="tsa-tos-tag">Non-malicious statistical analysis</span><br>
              <span class="tsa-tos-tag">Public community tools</span>
            </td>
            <td><span class="tsa-tos-tag">Stored locally / Not shared</span></td>
            <td><span class="tsa-tos-tag">Custom: torn→items, torn→stocks, market→itemmarket, user→stocks</span></td>
          </tr>
        </tbody>
      </table>

      <div class="tsa-input-row">
        <input type="password" id="tsa-key-input"
               placeholder="Paste your 16-character API key…"
               value="${state.apiKey || ''}">
        <button class="tsa-btn tsa-btn-primary" id="tsa-key-save">Validate & Save</button>
      </div>

      ${state.apiKey ? `<span class="tsa-status-badge tsa-status-ok">✓ Key saved</span>` : ''}
      <div id="tsa-key-error" class="tsa-error" style="display:none;margin-top:10px;"></div>

      <div class="tsa-key-links">
        <a href="${CUSTOM_KEY_LINK}" target="_blank">⚡ Create Custom Key (pre-filled)</a>
        <a href="${API_KEY_PAGE}" target="_blank">⚙ Open API Keys Page</a>
        <button class="tsa-btn tsa-btn-secondary" id="tsa-check-perms" ${state.apiKey ? '' : 'disabled'}>🔍 Check Permissions</button>
        ${state.apiKey ? `<button class="tsa-btn tsa-btn-danger" id="tsa-key-clear">Clear Key</button>` : ''}
      </div>
      <div id="tsa-perm-results"></div>
      <div style="margin-top:14px;background:var(--tsa-surface2);border:1px solid var(--tsa-border);border-radius:6px;padding:12px 14px;font-size:11px;line-height:1.7;color:var(--tsa-muted);">
        <span style="color:var(--tsa-accent);font-weight:700;text-transform:uppercase;letter-spacing:1px;font-size:10px;">Fastest way: use the pre-filled link</span><br>
        1. Click <strong style="color:var(--tsa-text)">⚡ Create Custom Key (pre-filled)</strong> above —
        it opens Torn's key builder with <code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">torn → items</code>,
        <code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">torn → stocks</code>,
        <code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">market → itemmarket</code>, and
        <code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">user → stocks</code> already checked<br>
        2. Confirm the name/selections look right, then click <strong style="color:var(--tsa-text)">Create</strong><br>
        3. Copy the generated key and paste it above<br>
        4. Click <strong style="color:var(--tsa-text)">🔍 Check Permissions</strong> to confirm all four selections actually work — a key can save successfully but still be missing one of them<br><br>
        <span style="color:var(--tsa-muted);font-size:10px;">Manual route: Open API Keys Page → Create New Key → Custom access level → enable the same four selections yourself.</span>
      </div>
    </div>
  `;

  // ═══════════════════════════════════════════════════════════════
  // RENDER — STOCKS TAB
  // ═══════════════════════════════════════════════════════════════
  const renderStocksTab = () => {
    if (!state.apiKey)
      return `<div class="tsa-info">⚠ Set your API key in the <strong>Setup</strong> tab first.</div>`;
    if (state.loading)
      return `<div class="tsa-loading"><div class="tsa-spinner"></div>${state.fetchStep || 'Fetching stock data…'}</div>`;
    if (state.fetchError)
      return `<div class="tsa-error">❌ ${state.fetchError}</div>
              <button class="tsa-btn tsa-btn-primary" id="tsa-retry">Retry Now</button>`;
    if (!state.lastFetch)
      return `<div class="tsa-info">Ready — click <strong>⟳ Refresh</strong> to load, or wait for auto-refresh.</div>`;

    const stocks  = getSortedStocks();
    const actives = stocks.filter(s => s.meta.type === 'active');
    const byROI   = actives.filter(s => s.roi != null).sort((a, b) => b.roi - a.roi)[0];
    const byProfit= actives.filter(s => s.profitPerDay != null).sort((a, b) => b.profitPerDay - a.profitPerDay)[0];
    const byCost  = stocks.filter(s => s.nextBBCost > 0).sort((a, b) => a.nextBBCost - b.nextBBCost)[0];

    const cacheAge = Math.floor((Date.now() - state.lastFetch) / 1000);
    const isStale  = cacheAge > 90;

    const itemCount    = Object.values(STOCK_DATA).filter(s => s.dividend_kind === 'item' && s.item_name).length;
    const itemsFetched = Object.keys(state.itemPrices).length;

    // Self-diagnosing panel: if most stocks came back with a zero
    // price, show exactly what the scraper found instead of just
    // silently displaying zeros — this is what would otherwise need
    // a manual dev-tools round trip to figure out (especially awkward
    // on Torn PDA, which has no accessible dev console).
    const dbg = state.scrapeDebug;
    const zeroPriceCount = stocks.filter(s => !s.price).length;
    const showDiagnostics = dbg && zeroPriceCount > stocks.length * 0.5;
    const diagnosticsHTML = showDiagnostics ? `
      <div class="tsa-warn" style="white-space:normal;">
        ⚠ <strong>Most prices came back as $0</strong> — diagnostics from the last scrape:<br>
        <span style="font-family:'Space Mono',monospace;font-size:10px;display:block;margin-top:6px;line-height:1.6;">
          Stock rows found in page: ${dbg.ulFound}<br>
          Tickers matched: ${dbg.tickersFound}<br>
          Prices read from aria-label: ${dbg.pricesFromAriaLabel}<br>
          Prices read from visible digits (fallback): ${dbg.pricesFromDigitSpans}<br>
          Prices still $0 after both methods: ${dbg.pricesZero}
        </span>
        ${dbg.sampleRaw ? `
        <details style="margin-top:8px;">
          <summary style="cursor:pointer;color:var(--tsa-accent);font-size:10px;">Show raw markup for a failing stock (${dbg.sampleRaw.ticker})</summary>
          <div style="font-family:'Space Mono',monospace;font-size:9px;color:var(--tsa-muted);margin-top:6px;word-break:break-all;background:var(--tsa-bg);padding:8px;border-radius:4px;max-height:160px;overflow:auto;">
            has aria-label: ${dbg.sampleRaw.hasAriaLabel}<br><br>
            ${dbg.sampleRaw.priceLiHTML.replace(/</g, '&lt;')}
          </div>
        </details>` : ''}
        ${dbg.sampleTickerHTML ? `
        <details style="margin-top:8px;">
          <summary style="cursor:pointer;color:var(--tsa-accent);font-size:10px;">Show nameTab markup (ticker extraction failed)</summary>
          <div style="font-family:'Space Mono',monospace;font-size:9px;color:var(--tsa-muted);margin-top:6px;word-break:break-all;background:var(--tsa-bg);padding:8px;border-radius:4px;max-height:160px;overflow:auto;">
            ${dbg.sampleTickerHTML.replace(/</g, '&lt;')}
          </div>
        </details>` : ''}
      </div>` : '';

    return `
      <div class="tsa-stocks-layout">
      ${diagnosticsHTML}
      <div class="tsa-cache-bar">
        <span class="tsa-cache-dot ${isStale ? 'stale' : ''}"></span>
        Fetched ${fmt.ts(state.lastFetch)} · ${cacheAge}s ago
        ${isStale ? '· <span style="color:var(--tsa-yellow)">May be stale</span>' : '· Fresh'}
        <span style="margin-left:auto;font-size:9px;color:var(--tsa-muted)" id="tsa-fetch-status">
          ${state.itemsLoading ? `Loading item prices…` :
            itemsFetched > 0 ? `Item prices: ${itemsFetched}/${itemCount} loaded` :
            `Item prices: loading on first refresh`}
        </span>
      </div>

      ${state.itemsLoading ? `<div class="tsa-warn">⏳ Fetching item market prices — dividend values for item stocks will update shortly.</div>` : ''}
      ${state.itemsError ? `<div class="tsa-warn">⚠ ${state.itemsError}</div>` : ''}

      ${byROI || byProfit || byCost ? `
      <div id="tsa-best-buy">
        ${byROI ? `<div class="tsa-best-card roi">
          <div class="tsa-best-label">🏆 Best ROI</div>
          <div class="tsa-best-ticker">${byROI.ticker}</div>
          <div class="tsa-best-val">${fmt.pct(byROI.roi)}/yr</div>
          <div class="tsa-best-name">${byROI.meta.name}</div>
        </div>` : ''}
        ${byProfit ? `<div class="tsa-best-card profit">
          <div class="tsa-best-label">💰 Best Profit/Day</div>
          <div class="tsa-best-ticker">${byProfit.ticker}</div>
          <div class="tsa-best-val">${fmt.cash(byProfit.profitPerDay)}/day</div>
          <div class="tsa-best-name">${byProfit.meta.name}</div>
        </div>` : ''}
        ${byCost ? `<div class="tsa-best-card cost">
          <div class="tsa-best-label">💸 Cheapest Next BB</div>
          <div class="tsa-best-ticker">${byCost.ticker}</div>
          <div class="tsa-best-val">${fmt.cash(byCost.nextBBCost)}</div>
          <div class="tsa-best-name">${byCost.meta.name}</div>
        </div>` : ''}
      </div>` : ''}

      <div id="tsa-controls">
        <div class="tsa-filter-group" style="margin-left:0;">
          <div class="tsa-filter-item">
            <input type="checkbox" id="tsa-owned-only" ${state.showOwnedOnly ? 'checked' : ''}>
            <label for="tsa-owned-only">Owned Only</label>
          </div>
          <div class="tsa-filter-item">
            <input type="checkbox" id="tsa-show-passive" ${state.showPassive ? 'checked' : ''}>
            <label for="tsa-show-passive">Show Passive</label>
          </div>
        </div>
      </div>

      <div class="tsa-table-wrap">
        <table class="tsa-table">
          <thead>
            <tr>
              ${renderSortableTh('ticker',    'Stock')}
              ${renderSortableTh('type',      'Type')}
              ${renderSortableTh('price',     'Price')}
              ${renderSortableTh('trend',     '7d Trend')}
              ${renderSortableTh('bbCost',    '1 BB Cost')}
              ${renderSortableTh('nextBB',    'Next BB Cost')}
              ${renderSortableTh('dividend',  'Dividend')}
              ${renderSortableTh('interval',  'Interval')}
              ${renderSortableTh('profit',    'Profit/Day')}
              ${renderSortableTh('roi',       'ROI/yr')}
              ${renderSortableTh('breakeven', 'Break-Even')}
              <th>Predictions (mirrored)</th>
            </tr>
          </thead>
          <tbody>
            ${stocks.map(s => renderRow(s)).join('')}
          </tbody>
        </table>
      </div>
      </div>
    `;
  };

  // Renders a clickable, sortable column header with a direction triangle.
  // Triangle points up (▲) when sorted ascending (small→large), down (▼) when descending.
  const renderSortableTh = (key, label) => {
    const isActive = state.sortBy === key;
    const arrow = isActive
      ? (state.sortDir === 'asc' ? '▲' : '▼')
      : '';
    return `<th class="tsa-sortable-th ${isActive ? 'sorted' : ''}" data-sortkey="${key}">
      <span>${label}</span>${arrow ? `<span class="tsa-sort-arrow">${arrow}</span>` : ''}
    </th>`;
  };

  const renderRow = s => {
    const m = s.meta;
    const intervalBadge =
      m.interval_days === 7  ? `<span class="badge-7d">7d</span>` :
      m.interval_days === 31 ? `<span class="badge-31d">31d</span>` :
                               `<span class="badge-passive">perk</span>`;

    const trendCls = s.trend7d == null ? '' : s.trend7d >= 0 ? 'v-green' : 'v-red';

    let divCell;
    if (s.divValue != null) {
      divCell = `<span class="v-green">${fmt.cash(s.divValue)}</span>`;
    } else if (m.dividend_kind === 'item') {
      divCell = `<span style="color:var(--tsa-muted);font-size:9px">${state.itemsLoading ? 'Loading…' : '—'}</span>`;
    } else if (m.dividend_kind === 'variable') {
      divCell = `<span class="v-yellow tsa-tipbadge" style="font-size:9px;cursor:pointer;" data-tip="${m.perk_desc.replace(/"/g,'&quot;')}">Variable ⓘ</span>`;
    } else if (m.dividend_kind === 'special') {
      divCell = `<span class="v-passive tsa-tipbadge" style="cursor:pointer;" data-tip="${m.perk_desc.replace(/"/g,'&quot;')}">Special ⓘ</span>`;
    } else if (m.dividend_kind === 'perk') {
      divCell = `<span class="v-passive tsa-tipbadge" style="cursor:pointer;" data-tip="${m.perk_desc.replace(/"/g,'&quot;')}">Perk ⓘ</span>`;
    } else {
      divCell = '—';
    }

    let predCell;
    const realLoaded = Object.keys(state.realStockData).length > 0;
    if (!realLoaded) {
      predCell = `<span style="color:var(--tsa-muted);font-size:9px">Fetching…</span>`;
    } else if (s.predictions) {
      const p = s.predictions, cp = s.price;
      const pCls = v => v > cp ? 'v-green' : v < cp ? 'v-red' : 'v-mono';
      predCell = `
        <div style="font-size:8px;color:var(--tsa-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px;">
          <span class="conf-dot conf-${s.confidence}"></span>${s.confidence} confidence
        </div>
        <div class="pred-grid">
          ${['1d','1w','2w','1m','1y'].map(k => `
            <div class="pred-cell">
              <div class="pred-label">${k}</div>
              <div class="pred-val ${pCls(p[k])}">${fmt.cash(p[k])}</div>
            </div>`).join('')}
        </div>`;
    } else {
      predCell = `<span style="color:var(--tsa-muted);font-size:9px">No data</span>`;
    }

    return `
      <tr class="${s.isOwned ? 'owned' : ''}${m.type === 'passive' ? ' passive-row' : ''}">
        <td>
          <div>
            <span class="tsa-ticker">${s.ticker}</span>
            <span class="tsa-real-tkr" title="${m.real_name} (${m.real_ticker})">≈${m.real_ticker}</span>
            ${s.isOwned ? `<span class="badge-own">×${s.increments}</span>` : ''}
          </div>
          <div class="tsa-sname">${m.name}</div>
        </td>
        <td>${m.type === 'passive'
          ? `<span class="badge-passive">Passive</span>`
          : `<span class="badge-7d" style="color:var(--tsa-accent);background:rgba(0,212,255,0.08);border-color:rgba(0,212,255,0.3)">Active</span>`}</td>
        <td><span class="v-mono">${fmt.cash(s.price)}</span></td>
        <td><span class="${trendCls}">${s.trend7d != null ? fmt.pct(s.trend7d) : '—'}</span></td>
        <td>
          <span class="v-mono">${fmt.cash(s.bbCost)}</span><br>
          <span style="font-size:9px;color:var(--tsa-muted)">${fmt.shares(s.bb)} shares</span>
        </td>
        <td><span class="${s.isOwned ? 'v-blue' : 'v-mono'}">${fmt.cash(s.nextBBCost)}</span></td>
        <td>${divCell}</td>
        <td>${intervalBadge}</td>
        <td>${s.profitPerDay != null ? `<span class="v-green">${fmt.cash(s.profitPerDay)}</span>` : '—'}</td>
        <td>${s.roi != null ? `<span class="${s.roi >= 15 ? 'v-green' : s.roi >= 5 ? 'v-yellow' : 'v-red'}">${fmt.pct(s.roi)}</span>` : '—'}</td>
        <td><span class="v-mono">${fmt.days(s.breakEvenDays)}</span></td>
        <td>${predCell}</td>
      </tr>`;
  };

  // ═══════════════════════════════════════════════════════════════
  // RENDER — PORTFOLIO TAB
  // ═══════════════════════════════════════════════════════════════
  const renderPortfolioTab = () => {
    if (!state.apiKey)    return `<div class="tsa-info">⚠ Set your API key in the Setup tab first.</div>`;
    if (!state.lastFetch) return `<div class="tsa-info">Load stock data first (click ⟳ Refresh or wait for auto-refresh).</div>`;

    const owned = Object.keys(STOCK_DATA)
      .map(t => calcStock(t))
      .filter(s => s && s.isOwned);

    if (!owned.length)
      return `<div class="tsa-info">You don't appear to own any benefit blocks yet.</div>`;

    const totalValue  = owned.reduce((a, s) => a + (s.price * s.sharesOwned), 0);
    const totalProfit = owned.filter(s => s.profitPerDay)
      .reduce((a, s) => a + s.profitPerDay * s.increments, 0);

    return `
      <div class="tsa-pc">
        <h4>📊 Portfolio Summary</h4>
        <div class="kv-grid">
          <div>
            <div class="kv-label">Stocks Held</div>
            <div class="kv-val">${owned.length}</div>
          </div>
          <div>
            <div class="kv-label">Total Value</div>
            <div class="kv-val v-mono">${fmt.cash(totalValue)}</div>
          </div>
          <div>
            <div class="kv-label">Est. Daily Income</div>
            <div class="kv-val v-green">${fmt.cash(totalProfit)}</div>
          </div>
          <div>
            <div class="kv-label">Est. Annual Income</div>
            <div class="kv-val v-green">${fmt.cash(totalProfit * 365)}</div>
          </div>
        </div>
      </div>

      ${owned.map(s => {
        const m = s.meta;
        const ownedValue = s.price * s.sharesOwned;
        return `
        <div class="tsa-pc">
          <h4>
            <span class="tsa-ticker">${s.ticker}</span>
            <span class="tsa-real-tkr">≈${m.real_ticker}</span>
            <span class="badge-own">×${s.increments} increment${s.increments !== 1 ? 's' : ''}</span>
          </h4>
          <div class="kv-grid">
            <div>
              <div class="kv-label">Shares Owned</div>
              <div class="kv-val v-mono">${fmt.num(s.sharesOwned)}</div>
            </div>
            <div>
              <div class="kv-label">Holding Value</div>
              <div class="kv-val v-mono">${fmt.cash(ownedValue)}</div>
            </div>
            <div>
              <div class="kv-label">Current Price</div>
              <div class="kv-val v-mono">${fmt.cash(s.price)}</div>
            </div>
            <div>
              <div class="kv-label">Dividend Per BB</div>
              <div class="kv-val ${s.divValue ? 'v-green' : 'v-passive'}">
                ${s.divValue ? fmt.cash(s.divValue) : m.perk_desc}
              </div>
            </div>
            <div>
              <div class="kv-label">Interval</div>
              <div class="kv-val v-mono">${m.interval_days ? m.interval_days + 'd' : 'Perk'}</div>
            </div>
            <div>
              <div class="kv-label">Profit/Day ×${s.increments}</div>
              <div class="kv-val v-green">${s.profitPerDay ? fmt.cash(s.profitPerDay * s.increments) : '—'}</div>
            </div>
            <div>
              <div class="kv-label">ROI / yr</div>
              <div class="kv-val ${s.roi >= 15 ? 'v-green' : s.roi >= 5 ? 'v-yellow' : 'v-mono'}">
                ${s.roi != null ? fmt.pct(s.roi) : '—'}
              </div>
            </div>
            <div>
              <div class="kv-label">Break-Even</div>
              <div class="kv-val v-mono">${fmt.days(s.breakEvenDays)}</div>
            </div>
            <div>
              <div class="kv-label">Next BB Cost</div>
              <div class="kv-val v-blue">${fmt.cash(s.nextBBCost)}</div>
            </div>
          </div>
          ${s.predictions ? `
          <div style="margin-top:10px;">
            <div style="font-size:9px;color:var(--tsa-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:5px;">
              <span class="conf-dot conf-${s.confidence}"></span>
              Predictions mirrored from ${m.real_name} (${s.confidence} confidence)
            </div>
            <div class="pred-grid">
              ${['1d','1w','2w','1m','1y'].map(k => {
                const v = s.predictions[k];
                const cls = v > s.price ? 'v-green' : v < s.price ? 'v-red' : 'v-mono';
                return `<div class="pred-cell">
                  <div class="pred-label">${k}</div>
                  <div class="pred-val ${cls}">${fmt.cash(v)}</div>
                </div>`;
              }).join('')}
            </div>
          </div>` : ''}
        </div>`;
      }).join('')}
    `;
  };

  // ═══════════════════════════════════════════════════════════════
  // MASTER RENDER
  // ═══════════════════════════════════════════════════════════════
  // ═══════════════════════════════════════════════════════════════
  // RENDER — APPEARANCE TAB
  // ═══════════════════════════════════════════════════════════════
  const APPEARANCE_FIELDS = [
    { key: 'bg',         label: 'Background',        hint: 'Main panel background' },
    { key: 'surface',    label: 'Surface',            hint: 'Cards, tables, inputs' },
    { key: 'surface2',   label: 'Surface (alt)',      hint: 'Table header row, tabs bar' },
    { key: 'border',     label: 'Border',             hint: 'Dividing lines, outlines' },
    { key: 'accent',     label: 'Accent',              hint: 'Highlights, active tab, links' },
    { key: 'text',       label: 'Text',                hint: 'Main body text color' },
    { key: 'muted',      label: 'Muted Text',          hint: 'Labels, secondary text' },
    { key: 'headerFrom', label: 'Header Gradient (start)', hint: 'Top-left of header bar' },
    { key: 'headerTo',   label: 'Header Gradient (end)',   hint: 'Top-right of header bar' },
  ];

  const renderAppearanceTab = () => {
    const theme = loadTheme();
    return `
      <div class="tsa-setup-card" style="max-width:720px;">
        <h3>🎨 Appearance</h3>
        <p>Customize colors, header gradient, and text size. Changes apply instantly and are saved automatically.</p>

        <div class="tsa-theme-grid">
          ${APPEARANCE_FIELDS.map(f => `
            <div class="tsa-theme-field">
              <label for="tsa-theme-${f.key}">${f.label}</label>
              <div class="tsa-theme-swatch-row">
                <input type="color" id="tsa-theme-${f.key}" data-themekey="${f.key}" value="${theme[f.key]}">
                <span class="tsa-theme-hint">${f.hint}</span>
              </div>
            </div>`).join('')}
        </div>

        <div class="tsa-theme-field" style="margin-top:14px;">
          <label for="tsa-theme-fontsize">Font Size — <span id="tsa-fontsize-val">${theme.fontSize}%</span></label>
          <input type="range" id="tsa-theme-fontsize" min="80" max="130" step="5" value="${theme.fontSize}" style="width:100%;accent-color:var(--tsa-accent);">
        </div>

        <div style="display:flex;gap:8px;margin-top:18px;">
          <button class="tsa-btn tsa-btn-secondary" id="tsa-theme-reset">Reset to Default</button>
        </div>
      </div>
    `;
  };

  const renderContent = () => {
    const content = document.getElementById('tsa-content');
    if (!content) return;
    const map = { stocks: renderStocksTab, portfolio: renderPortfolioTab, appearance: renderAppearanceTab, setup: renderSetupTab };
    content.innerHTML = (map[state.activeTab] || renderStocksTab)();
    bindContentEvents();
  };

  // ═══════════════════════════════════════════════════════════════
  // EVENT BINDING
  // ═══════════════════════════════════════════════════════════════
  const bindContentEvents = () => {
    // Setup: save key
    document.getElementById('tsa-key-save')?.addEventListener('click', async () => {
      const input = document.getElementById('tsa-key-input');
      const key   = (input?.value || '').trim();
      if (!key) return;
      const btn = document.getElementById('tsa-key-save');
      btn.textContent = 'Validating…';
      btn.disabled    = true;
      const res = await validateKey(key);
      if (res.valid) {
        safeSetValue(KEY_STORAGE, key);
        state.apiKey      = key;
        state.apiKeyValid = true;
        renderContent();
        fetchAll().then(startAutoRefresh);
      } else {
        const err = document.getElementById('tsa-key-error');
        if (err) { err.style.display = 'block'; err.textContent = `❌ ${errorMsg(res.code)}`; }
        btn.textContent = 'Validate & Save';
        btn.disabled    = false;
      }
    });

    // Setup: clear key
    document.getElementById('tsa-key-clear')?.addEventListener('click', () => {
      let proceed = true;
      try { proceed = confirm('Clear saved API key and stop auto-refresh?'); } catch (e) { /* some WebViews block confirm(); proceed without it */ }
      if (!proceed) return;
      safeSetValue(KEY_STORAGE, '');
      state.apiKey      = '';
      state.apiKeyValid = false;
      stopAutoRefresh();
      renderContent();
    });

    // Setup: actually test both required permissions against the API
    document.getElementById('tsa-check-perms')?.addEventListener('click', async () => {
      const btn = document.getElementById('tsa-check-perms');
      const out = document.getElementById('tsa-perm-results');
      if (!btn || !out || !state.apiKey) return;
      btn.disabled = true;
      btn.textContent = 'Checking…';
      out.innerHTML = `<div class="tsa-info">Testing all required permissions…</div>`;

      const result = await checkKeyPermissions(state.apiKey);
      const allOk = result.items && result.itemmarket && result.tornStocks && result.userStocks;

      const row = (ok, label, err) => `
        <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
          <span class="tsa-status-badge ${ok ? 'tsa-status-ok' : 'tsa-status-err'}">${ok ? '✓ OK' : '✗ MISSING'}</span>
          <span style="font-family:'Space Mono',monospace;font-size:11px;color:var(--tsa-text);">${label}</span>
          ${!ok && err ? `<span style="font-size:10px;color:var(--tsa-muted);">— ${err}</span>` : ''}
        </div>`;

      out.innerHTML = `
        <div style="margin:10px 0;">
          ${row(result.items, 'torn → items', result.itemsError)}
          ${row(result.itemmarket, 'market → itemmarket', result.itemmarketError)}
          ${row(result.tornStocks, 'torn → stocks', result.tornStocksError)}
          ${row(result.userStocks, 'user → stocks', result.userStocksError)}
          ${allOk
            ? `<div class="tsa-info" style="margin-top:6px;">All permissions are working.</div>`
            : `<div class="tsa-warn" style="margin-top:6px;">⚠ One or more permissions are missing. Use the pre-filled link above to create a key with all four enabled, or edit your existing key's Custom selections on the API Keys page.</div>`}
        </div>`;

      btn.disabled = false;
      btn.textContent = '🔍 Check Permissions';
    });

    // Sortable column headers: click toggles direction if already active,
    // otherwise switches to that column defaulting to descending.
    document.querySelectorAll('.tsa-sortable-th').forEach(th =>
      th.addEventListener('click', () => {
        const key = th.dataset.sortkey;
        if (state.sortBy === key) {
          state.sortDir = state.sortDir === 'desc' ? 'asc' : 'desc';
        } else {
          state.sortBy  = key;
          state.sortDir = 'desc';
        }
        renderContent();
      })
    );

    // Filters
    document.getElementById('tsa-owned-only')?.addEventListener('change', e => {
      state.showOwnedOnly = e.target.checked; renderContent();
    });
    document.getElementById('tsa-show-passive')?.addEventListener('change', e => {
      state.showPassive = e.target.checked; renderContent();
    });

    // Retry button
    document.getElementById('tsa-retry')?.addEventListener('click', () => {
      fetchAll().then(startAutoRefresh);
    });

    // Appearance: color pickers — live-apply on every change, no debounce
    // needed since color inputs fire 'input' continuously while dragging.
    document.querySelectorAll('input[type="color"][data-themekey]').forEach(input => {
      input.addEventListener('input', () => {
        const theme = loadTheme();
        theme[input.dataset.themekey] = input.value;
        saveTheme(theme);
        applyTheme(theme);
      });
    });

    // Appearance: font size slider
    document.getElementById('tsa-theme-fontsize')?.addEventListener('input', e => {
      const theme = loadTheme();
      theme.fontSize = parseInt(e.target.value, 10);
      saveTheme(theme);
      applyTheme(theme);
      const label = document.getElementById('tsa-fontsize-val');
      if (label) label.textContent = theme.fontSize + '%';
    });

    // Appearance: reset to default
    document.getElementById('tsa-theme-reset')?.addEventListener('click', () => {
      saveTheme({ ...DEFAULT_THEME });
      applyTheme(DEFAULT_THEME);
      renderContent(); // re-render so color inputs show reset values
    });
  };

  // ═══════════════════════════════════════════════════════════════
  // BUILD UI
  // ═══════════════════════════════════════════════════════════════
  // ═══════════════════════════════════════════════════════════════
  // WINDOW GEOMETRY  —  persisted across sessions via GM_setValue
  // ═══════════════════════════════════════════════════════════════
  // ═══════════════════════════════════════════════════════════════
  // STORAGE COMPATIBILITY SHIM
  // Torn PDA's GM_getValue/GM_setValue are a compatibility layer, not
  // the real Tampermonkey implementation, and the project's own docs
  // warn they "will not behave the same" — in practice this can mean
  // GM_getValue returns a Promise instead of a value synchronously.
  // Since geometry/theme are read synchronously all over this script,
  // we keep an in-memory cache seeded on startup (handling both sync
  // and Promise-returning GM_getValue), and every read/write goes
  // through that cache first so behavior stays consistent regardless
  // of which GM implementation is actually running underneath.
  // ═══════════════════════════════════════════════════════════════
  const storageCache = {};

  const safeGetValue = (key, fallback) => {
    if (key in storageCache) return storageCache[key];
    try {
      const result = GM_getValue(key, fallback);
      if (result && typeof result.then === 'function') {
        // Promise-based GM_getValue (e.g. Torn PDA) — can't resolve
        // synchronously, so seed the cache async for next time and
        // return the fallback for this call.
        result.then(v => { storageCache[key] = v; }).catch(() => {});
        storageCache[key] = fallback;
        return fallback;
      }
      storageCache[key] = result;
      return result;
    } catch (e) {
      return fallback;
    }
  };

  const safeSetValue = (key, value) => {
    storageCache[key] = value; // update cache immediately regardless of backend
    try {
      const result = GM_setValue(key, value);
      if (result && typeof result.catch === 'function') result.catch(() => {});
    } catch (e) { /* ignore */ }
  };

  const GEOM_STORAGE = `${SCRIPT_ID}-geometry`;

  // The default geometry used when nothing is saved yet. Computed at
  // call time (not a fixed constant) so a first-ever load on a phone
  // screen starts at a sane size immediately, rather than starting at
  // a 960×640 desktop box and relying on clampGeom to shrink it after
  // the fact — which works, but only once something actually triggers
  // a re-check (see reclampNow in buildUI).
  const getDefaultGeom = () => {
    const w = window.innerWidth, h = window.innerHeight;
    if (w < 480) {
      const width  = Math.min(360, w - 16);
      const height = Math.min(560, h - 80);
      return { top: 70, left: Math.max(8, (w - width) / 2), width, height, collapsed: false };
    }
    return { top: 120, left: 80, width: 960, height: 640, collapsed: false };
  };

  const loadGeometry = () => {
    const fallback = getDefaultGeom();
    try {
      const saved = safeGetValue(GEOM_STORAGE, null);
      if (!saved) return fallback;
      const g = JSON.parse(saved);
      return { ...fallback, ...g };
    } catch (e) { return fallback; }
  };

  const saveGeometry = (g) => {
    try { safeSetValue(GEOM_STORAGE, JSON.stringify(g)); } catch (e) { /* ignore */ }
  };

  const clampGeom = (g) => {
    // On narrow viewports (phones inside Torn PDA), the desktop-sized
    // minimums (360px wide, 200px visible margin) can still push the
    // panel mostly off-screen since they assume far more horizontal
    // room than a phone provides. Scale the floors down on small
    // screens so the panel is always reachable.
    const isNarrow   = window.innerWidth < 480;
    const minW       = isNarrow ? Math.min(280, window.innerWidth - 16) : 360;
    const minH       = isNarrow ? 220 : 220;
    const visMargin  = isNarrow ? 24 : 200; // min px of panel that must stay reachable horizontally

    const maxLeft = Math.max(0, window.innerWidth  - visMargin);
    const maxTop  = Math.max(0, window.innerHeight - 80);
    return {
      ...g,
      left:   Math.min(Math.max(0, g.left), maxLeft),
      top:    Math.min(Math.max(0, g.top),  maxTop),
      width:  Math.min(Math.max(minW, g.width),  window.innerWidth  * 0.98),
      height: Math.min(Math.max(minH, g.height), window.innerHeight * 0.92),
    };
  };

  // ═══════════════════════════════════════════════════════════════
  // THEME / APPEARANCE  —  persisted across sessions via GM_setValue
  // Every value here maps 1:1 to a CSS variable already used
  // throughout the stylesheet, so applying a theme is just setting
  // inline custom properties on #tsa-root (which override the
  // :root defaults thanks to normal CSS cascade/specificity rules).
  // ═══════════════════════════════════════════════════════════════
  const THEME_STORAGE = `${SCRIPT_ID}-theme`;
  const DEFAULT_THEME = {
    bg:        '#0a0c10',
    surface:   '#111420',
    surface2:  '#181c2a',
    border:    '#252a3d',
    accent:    '#00d4ff',
    text:      '#e8ecf4',
    muted:     '#5a6080',
    headerFrom:'#0a0c10',
    headerTo:  '#0d1828',
    fontSize:  100, // percentage scale applied to #tsa-root
  };

  const loadTheme = () => {
    try {
      const saved = safeGetValue(THEME_STORAGE, null);
      if (!saved) return { ...DEFAULT_THEME };
      return { ...DEFAULT_THEME, ...JSON.parse(saved) };
    } catch (e) { return { ...DEFAULT_THEME }; }
  };

  const saveTheme = (t) => {
    try { safeSetValue(THEME_STORAGE, JSON.stringify(t)); } catch (e) { /* ignore */ }
  };

  // Apply theme values as inline CSS custom properties + font scale
  // on #tsa-root. Inline styles on the element beat the :root block
  // in the stylesheet, so every component picks this up automatically.
  const applyTheme = (theme) => {
    const root = document.getElementById('tsa-root');
    if (!root) return;
    root.style.setProperty('--tsa-bg',       theme.bg);
    root.style.setProperty('--tsa-surface',  theme.surface);
    root.style.setProperty('--tsa-surface2', theme.surface2);
    root.style.setProperty('--tsa-border',   theme.border);
    root.style.setProperty('--tsa-accent',   theme.accent);
    root.style.setProperty('--tsa-text',     theme.text);
    root.style.setProperty('--tsa-muted',    theme.muted);
    root.style.setProperty('font-size', theme.fontSize + '%');
    const header = root.querySelector('#tsa-header');
    if (header) {
      header.style.background = `linear-gradient(90deg, ${theme.headerFrom} 0%, ${theme.headerTo} 100%)`;
    }
  };

  const buildUI = () => {
    if (document.getElementById('tsa-root')) return;

    const geom = clampGeom(loadGeometry());

    const root = document.createElement('div');
    root.id = 'tsa-root';
    root.style.top    = geom.top    + 'px';
    root.style.left   = geom.left   + 'px';
    root.style.width  = geom.width  + 'px';
    root.style.height = geom.height + 'px';
    if (geom.collapsed) root.classList.add('tsa-collapsed');

    root.innerHTML = `
      <div id="tsa-header">
        <button class="tsa-winbtn" id="tsa-reset-pos-btn" title="Reset position to center">⌖</button>
        <h2>📈 Stock Analyzer</h2>
        <span class="tsa-version">v1.9.0</span>
        <div id="tsa-timer-wrap">
          <div id="tsa-timer-ring">
            <svg width="26" height="26" viewBox="0 0 26 26">
              <circle class="bg" cx="13" cy="13" r="11"/>
              <circle class="fg" cx="13" cy="13" r="11"/>
            </svg>
            <div id="tsa-timer-num">60</div>
          </div>
          <span id="tsa-timer-label">next refresh</span>
          <span class="tsa-fetch-status" id="tsa-fetch-status"></span>
          <button id="tsa-refresh-btn">⟳ Refresh</button>
        </div>
        <button class="tsa-winbtn" id="tsa-collapse-btn" title="Collapse/Expand">${geom.collapsed ? '▢' : '—'}</button>
      </div>

      <div id="tsa-tabs">
        <button class="tsa-tab active" data-tab="stocks">Stocks</button>
        <button class="tsa-tab"        data-tab="portfolio">My Portfolio</button>
        <button class="tsa-tab"        data-tab="appearance">Appearance</button>
        <button class="tsa-tab"        data-tab="setup">Setup / API Key</button>
      </div>

      <div id="tsa-content"></div>
      <div class="tsa-resize-edge tsa-resize-n"  data-dir="n"></div>
      <div class="tsa-resize-edge tsa-resize-s"  data-dir="s"></div>
      <div class="tsa-resize-edge tsa-resize-e"  data-dir="e"></div>
      <div class="tsa-resize-edge tsa-resize-w"  data-dir="w"></div>
      <div class="tsa-resize-corner tsa-resize-nw" data-dir="nw"></div>
      <div class="tsa-resize-corner tsa-resize-ne" data-dir="ne"></div>
      <div class="tsa-resize-corner tsa-resize-sw" data-dir="sw"></div>
      <div class="tsa-resize-corner tsa-resize-se" data-dir="se" title="Drag to resize"></div>
    `;

    document.body.appendChild(root);
    applyTheme(loadTheme());

    // ── Tap/click tooltip for Special/Perk/Variable dividend badges ──
    // Native `title` attributes only show on hover (desktop only).
    // This shared overlay works for both mouse and touch so PDA users
    // can tap any ⓘ badge and see the description inline.
    const tip = document.createElement('div');
    tip.id = 'tsa-tooltip';
    document.body.appendChild(tip);

    const showTip = (el, text) => {
      tip.textContent = text;
      tip.classList.add('visible');
      const rect = el.getBoundingClientRect();
      // Position above the element, clamped to screen edges
      let left = rect.left + rect.width / 2 - 120;
      let top  = rect.top - 10;
      left = Math.min(Math.max(8, left), window.innerWidth - 248);
      if (top < 60) top = rect.bottom + 8; // flip below if too close to top
      tip.style.left = left + 'px';
      tip.style.top  = (top - tip.offsetHeight || top - 40) + 'px';
    };

    const hideTip = () => tip.classList.remove('visible');

    // Delegate from document so it catches dynamically-rendered badges
    document.addEventListener('click', e => {
      const badge = e.target.closest('.tsa-tipbadge');
      if (badge) {
        e.stopPropagation();
        if (tip.classList.contains('visible') && tip._srcEl === badge) {
          hideTip();
        } else {
          tip._srcEl = badge;
          showTip(badge, badge.dataset.tip || '');
        }
      } else {
        hideTip();
      }
    }, true);

    // Also wire mouse hover for desktop (keeps the classic feel)
    document.addEventListener('mouseover', e => {
      const badge = e.target.closest('.tsa-tipbadge');
      if (badge) { tip._srcEl = badge; showTip(badge, badge.dataset.tip || ''); }
    });
    document.addEventListener('mouseout', e => {
      if (e.target.closest?.('.tsa-tipbadge')) hideTip();
    });

    // ── Tab switching ──────────────────────────────────────────────
    root.querySelectorAll('.tsa-tab').forEach(tab => {
      tab.addEventListener('click', () => {
        root.querySelectorAll('.tsa-tab').forEach(t => t.classList.remove('active'));
        tab.classList.add('active');
        state.activeTab = tab.dataset.tab;
        renderContent();
      });
    });

    // ── Manual refresh ─────────────────────────────────────────────
    root.querySelector('#tsa-refresh-btn').addEventListener('click', () => {
      const btn = root.querySelector('#tsa-refresh-btn');
      btn.disabled = true;
      state.countdownSec = 60;
      fetchAll().then(() => {
        startAutoRefresh();
        btn.disabled = false;
      });
    });

    // ── Collapse / expand ──────────────────────────────────────────
    const collapseBtn = root.querySelector('#tsa-collapse-btn');
    collapseBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      const nowCollapsed = !root.classList.contains('tsa-collapsed');
      root.classList.toggle('tsa-collapsed', nowCollapsed);
      collapseBtn.textContent = nowCollapsed ? '▢' : '—';
      const g = loadGeometry();
      g.collapsed = nowCollapsed;
      saveGeometry(g);
    });

    // ── Reset position (recovery if panel drifts off-screen) ───────
    root.querySelector('#tsa-reset-pos-btn').addEventListener('click', (e) => {
      e.stopPropagation();
      const w = Math.min(960, window.innerWidth  * 0.92);
      const h = Math.min(640, window.innerHeight * 0.85);
      const g = clampGeom({
        top:  Math.max(20, (window.innerHeight - h) / 2),
        left: Math.max(10, (window.innerWidth  - w) / 2),
        width: w, height: h,
        collapsed: root.classList.contains('tsa-collapsed'),
      });
      root.style.top    = g.top    + 'px';
      root.style.left   = g.left   + 'px';
      root.style.width  = g.width  + 'px';
      root.style.height = g.height + 'px';
      saveGeometry(g);
    });

    // ── Unified drag + resize via Pointer Events ────────────────────
    // Pointer Events fire for mouse, touch, and pen through one API,
    // which is what makes this work identically on desktop Tampermonkey
    // and inside Torn PDA's mobile WebView (plain mouse-only listeners
    // don't reliably receive sustained touch-drag gestures there).
    const header = root.querySelector('#tsa-header');
    let dragState = null;
    let resizeState = null;

    const isNarrowViewport = window.innerWidth < 480;
    const MIN_W = isNarrowViewport ? Math.min(280, window.innerWidth - 16) : 360;
    const MIN_H = isNarrowViewport ? 220 : 220;

    const onPointerDown = (e, mode, dir) => {
      // Ignore drags starting on interactive controls inside the header
      if (mode === 'drag' && e.target.closest('button, input, a')) return;
      const rect = root.getBoundingClientRect();
      const point = { x: e.clientX, y: e.clientY };

      if (mode === 'drag') {
        dragState = { startX: point.x, startY: point.y, origLeft: rect.left, origTop: rect.top };
        header.classList.add('tsa-grabbing');
        root.classList.add('tsa-dragging');
      } else {
        resizeState = {
          dir,
          startX: point.x, startY: point.y,
          origLeft: rect.left, origTop: rect.top,
          origW: rect.width, origH: rect.height,
        };
        root.classList.add('tsa-resizing');
      }

      // Capture the pointer so we keep receiving move/up events even if
      // the finger/cursor leaves the original element's bounds.
      if (e.target.setPointerCapture && e.pointerId != null) {
        try { e.target.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
      }
      e.preventDefault();
    };

    const onPointerMove = (e) => {
      if (dragState) {
        const dx = e.clientX - dragState.startX;
        const dy = e.clientY - dragState.startY;
        // Require at least 40px of the panel to remain reachable on
        // every edge — stricter than before so it can never fully
        // escape the viewport, which matters most on small mobile
        // screens where there's no easy way to "grab a sliver" back.
        const minVisible = 40;
        let newLeft = dragState.origLeft + dx;
        let newTop  = dragState.origTop  + dy;
        newLeft = Math.min(Math.max(minVisible - root.offsetWidth, newLeft), window.innerWidth  - minVisible);
        newTop  = Math.min(Math.max(0, newTop), window.innerHeight - minVisible);
        root.style.left = newLeft + 'px';
        root.style.top  = newTop  + 'px';
      } else if (resizeState) {
        const dx = e.clientX - resizeState.startX;
        const dy = e.clientY - resizeState.startY;
        const { dir, origLeft, origTop, origW, origH } = resizeState;
        let newLeft = origLeft, newTop = origTop, newW = origW, newH = origH;

        if (dir.includes('e')) newW = Math.min(Math.max(MIN_W, origW + dx), window.innerWidth  * 0.98 - origLeft);
        if (dir.includes('s')) newH = Math.min(Math.max(MIN_H, origH + dy), window.innerHeight * 0.98 - origTop);
        if (dir.includes('w')) {
          newW = Math.min(Math.max(MIN_W, origW - dx), origLeft + origW);
          newLeft = origLeft + origW - newW;
        }
        if (dir.includes('n')) {
          newH = Math.min(Math.max(MIN_H, origH - dy), origTop + origH);
          newTop = origTop + origH - newH;
        }

        root.style.left   = newLeft + 'px';
        root.style.top    = newTop  + 'px';
        root.style.width  = newW    + 'px';
        root.style.height = newH    + 'px';
      }
    };

    const onPointerUp = () => {
      if (dragState) {
        dragState = null;
        header.classList.remove('tsa-grabbing');
        root.classList.remove('tsa-dragging');
        const rect = root.getBoundingClientRect();
        const g = loadGeometry();
        g.left = rect.left; g.top = rect.top;
        saveGeometry(g);
      }
      if (resizeState) {
        resizeState = null;
        root.classList.remove('tsa-resizing');
        const rect = root.getBoundingClientRect();
        const g = loadGeometry();
        g.left = rect.left; g.top = rect.top;
        g.width = rect.width; g.height = rect.height;
        saveGeometry(g);
      }
    };

    header.addEventListener('pointerdown', (e) => onPointerDown(e, 'drag'));
    root.querySelectorAll('.tsa-resize-edge, .tsa-resize-corner').forEach(handle => {
      handle.addEventListener('pointerdown', (e) => onPointerDown(e, 'resize', handle.dataset.dir));
    });
    document.addEventListener('pointermove', onPointerMove);
    document.addEventListener('pointerup', onPointerUp);
    document.addEventListener('pointercancel', onPointerUp);

    // Re-measures the panel's actual current box, re-clamps it against
    // the real (already-known) viewport size, and re-applies + saves
    // if anything had to change. Called proactively below — not just
    // on a 'resize' event — because the bug this fixes isn't the
    // viewport changing size, it's the *initial* geometry being wrong
    // (e.g. a stale/default desktop width on a phone screen) and
    // nothing ever re-checking it afterward.
    const reclampNow = () => {
      const rect = root.getBoundingClientRect();
      const g = clampGeom({
        top: rect.top, left: rect.left, width: rect.width, height: rect.height,
        collapsed: root.classList.contains('tsa-collapsed'),
      });
      root.style.top    = g.top    + 'px';
      root.style.left   = g.left   + 'px';
      root.style.width  = g.width  + 'px';
      root.style.height = g.height + 'px';
      saveGeometry(g);
    };

    // Keep window on-screen if the browser window itself is resized
    window.addEventListener('resize', reclampNow);

    // Run once right after mount (catches a too-wide initial geometry
    // immediately) and once more shortly after (catches the case where
    // GM_getValue's storage Promise — on Torn PDA — resolves slightly
    // after this initial synchronous build and would otherwise leave
    // a stale, unclamped geometry sitting on screen).
    reclampNow();
    setTimeout(reclampNow, 400);

    // ── Responsive header ───────────────────────────────────────────
    // The header has too many fixed-width pieces (title, version
    // badge, countdown ring, refresh button, reset button, collapse
    // button) to fit on one line once the panel itself is narrower
    // than roughly 460px — which is the normal case on Torn PDA. A
    // CSS media query can't help here since it only sees the browser
    // viewport, not this absolutely-positioned panel's own width
    // (the panel can be narrow even on a wide desktop browser, and
    // vice versa). ResizeObserver watches the panel's actual box
    // and toggles a class that hides decorative pieces (version
    // badge, "next refresh" label, fetch-status text) and wraps the
    // timer/refresh group onto its own row, so the title bar's two
    // window-control buttons always stay reachable on the first row.
    const NARROW_HEADER_THRESHOLD = 460;
    if (typeof ResizeObserver !== 'undefined') {
      const ro = new ResizeObserver(entries => {
        for (const entry of entries) {
          const w = entry.contentRect.width;
          root.classList.toggle('tsa-narrow', w < NARROW_HEADER_THRESHOLD);
        }
      });
      ro.observe(root);
    } else {
      // Fallback for environments without ResizeObserver: check once
      // now and again on every window resize / reclamp pass.
      const checkNarrow = () => {
        root.classList.toggle('tsa-narrow', root.getBoundingClientRect().width < NARROW_HEADER_THRESHOLD);
      };
      checkNarrow();
      window.addEventListener('resize', checkNarrow);
      setTimeout(checkNarrow, 400);
    }

    renderContent();
  };

  // ═══════════════════════════════════════════════════════════════
  // INIT
  // ═══════════════════════════════════════════════════════════════
  const init = () => {
    injectStyles();
    buildUI();

    if (state.apiKey) {
      fetchAll().then(startAutoRefresh);
    } else {
      state.activeTab = 'setup';
      renderContent();
    }
  };

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    // Give the Torn React app a moment to hydrate before we scrape
    setTimeout(init, 500);
  }

})();