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

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

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

})();