Torn Stock Advisor

Advises which stock blocks to buy next. Features daily briefing, portfolio score, payout calendar, recommendations, swap advisor, holdings and full rankings. Mobile/PDA optimised.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Torn Stock Advisor
// @namespace    torn_stock_advisor
// @version      3.1.6
// @description  Advises which stock blocks to buy next. Features daily briefing, portfolio score, payout calendar, recommendations, swap advisor, holdings and full rankings. Mobile/PDA optimised.
// @author       TheOddSod (2640064)
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @connect      api.torn.com
// ==/UserScript==

// ─── Changelog ───────────────────────────────────────────────────────────────
// v3.1.6 — Data: THS (Torn City Health Service) b1Threshold corrected from
//           450,000 back to 150,000 shares.
// v3.1.5 — Fix: briefing now lists all upcoming payouts grouped by day.
//           just the first one. e.g. "FHG, SYM, EWM pay out tomorrow — worth
//           waiting to collect before selling any of these shares." Previously
//           only soonPayouts[0] was mentioned. Priority actions also updated
//           to list all upcoming tickers up to 3, with overflow count.
// v3.1.4 — Fix: briefing now mentions both ready-to-collect and upcoming
//           instead of either/or. Previously if any stocks were ready now, all
//           upcoming payouts (e.g. THS paying tomorrow) were silently skipped.
//           Priority actions list similarly updated — both "collect now" and
//           "wait for upcoming" actions appear when both conditions exist.
// v3.1.3 — Polish: briefing upcoming payout wording improved.
//           for before selling anything" to "worth waiting to collect before
//           selling any of these shares" — clearer and more specific.
// v3.1.2 — Hotfix: ReferenceError "availCash is not defined" in calcPortfolio
//           Score — added getAvailableCash() call at top of function. Variable
//           was used for affordableRows filter but never declared in scope.
//         — Polish: config strip "Budget" renamed to "Cash" for clarity. Shows
//           the actual cash amount when set, or "Not set (set in Config)" as a
//           gentle nudge when unset.
// v3.1.1 — Fix: Optimisation score no longer contaminated by unaffordable
//           blocks. bestCashGain previously took the highest dailyValue from
//           ALL scoreable rows regardless of cost, so MUN B1 at $2.72B was
//           dragging the score down even when no user could afford it with
//           $0 cash. bestCashGain now only considers blocks affordable with
//           available cash; if cash is unset it contributes 0 (leaving only
//           the feasibility-checked swap score as the benchmark).
// v3.1.0 — Polish: Optimisation metric now shows a "set cash in Config for
//           accuracy" note when available cash is unset ($0). Without a cash
//           figure the swap advisor can only see sell-based moves, so the best
//           available improvement may overstate what's actually needed. The
//           same note appears in the briefing swap suggestion and portfolio
//           score insight when cash is unset. Swap logic itself unchanged —
//           buildSwaps already correctly filters to feasible moves only.
// v3.0.9 — Fix: payout calendar "Est. incoming" maths corrected.
//           daysLeft instead of totalDaily × interval (the actual cycle value).
//           e.g. TMI $806.5k/day on 31d cycle = $25M payout, not $4.8M.
//         — Polish: calendar rows now use CSS grid with fixed-width columns so
//           ticker, name, countdown and payout value all align cleanly. Added
//           "Later" group label above stocks beyond 7 days. Payout column now
//           shows the actual per-cycle value (e.g. "$25M · 1 block · every 31d")
//           rather than /day which was confusing in this context.
// v3.0.8 — Hotfix: ReferenceError "bestAvailableNetGain is not defined".
//           added to renderPortfolioScore destructuring. Was returned from
//           calcPortfolioScore but not destructured before use in metric card.
// v3.0.7 — Hotfix: ReferenceError "totalInvested is not defined".
//           was removed from Factor 1 refactor but still needed by Factor 2
//           (idle capital denominator). Restored as a local const.
// v3.0.6 — Fix: ROI efficiency factor in portfolio score replaced with a fairer
//           "optimisation" measure: actualDaily / (actualDaily + bestAvailableNetGain).
//           The old formula compared against a theoretical maximum (all capital
//           in the best single stock) which unfairly penalised diversification
//           and 31-day cash stocks. The new formula answers "how close are you
//           to making your best available move?" — 100% means nothing better
//           exists; lower means there's a swap worth considering. Metric card
//           label updated to "Optimisation" with the best available gain shown.
// v3.0.5 — Fix: PTS (PointLess) payoutType changed from 'cash' to 'points'.
//           Previously it was swept up by cash stock filters, appearing in the
//           "unresolved cash stocks" warning and DOM scrape logic. Now has its
//           own scoring branch: qty points × live points market price, with
//           user config override available. payoutQty set to 100 (confirmed).
//           Config placeholder now shows "auto (live points price)". Badge
//           and excludeMap updated to handle the new type correctly.
// v3.0.4 — Fix: portfolio score and briefing "unresolved cash stocks" check
//           now uses the same three-tier value resolution as scoring (user
//           override → DOM → STOCK_DATA default). Previously the check used
//           getPayoutOverride(t, 0) which ignored payoutCashValue defaults,
//           causing stocks like CNC and TSB to be flagged as unresolved even
//           when their defaults were in use.
//         — Polish: cash stock payout field in config now shows the default
//           value as placeholder text (e.g. "default $80M") instead of
//           "auto (from stocks page)" when a default is set in STOCK_DATA.
// v3.0.3 — Fix: cash dividend stocks now correctly use their default
//           payoutCashValue when the DOM scrape hasn't run yet or returns
//           nothing. Previously domDividends[ticker]||0 passed 0 as the
//           getPayoutOverride default, silently ignoring payoutCashValue.
//           Priority order is now: user config override → DOM-read value
//           → STOCK_DATA default (GRN=$4M, TCT=$1M, TMI=$25M etc.).
// v3.0.2 — Data: default payoutCashValue set for all six cash dividend stocks.
//           GRN=$4M, TCT=$1M, TMI=$25M, IOU=$12M, TSB=$50M, CNC=$80M per block.
//           These are used as fallbacks when the DOM hasn't been read yet.
//         — Fix: briefing unresolved cash stocks warning now shows which tickers
//           are missing values in parentheses, e.g. "(GRN, TCT)" so users know
//           exactly which stocks to check.
// v3.0.1 — Fix: passives excluded from briefing collect actions.
//           collect" and upcoming payouts in the briefing. They have no
//           collectable payout so should never appear as collect actions.
//           renderCalendar already filtered them; buildBriefing now does too.
// v3.0.0 — MAJOR: Three new sections added.
//           Briefing: rule-based natural language daily summary covering
//           portfolio state, urgent payouts, partial drag, best swap or cash
//           buy, and projected score delta if advice is followed.
//           Portfolio score: weighted 0-100 score (ROI efficiency 50%, idle
//           capital 30%, data completeness 20%) mapped to A+ through F with
//           +/- graduations. Shows current grade, projected grade after
//           recommended actions, and plain-English insight.
//           Payout calendar: all held stocks with timing data in countdown
//           order, colour-coded ready/soon/later, with estimated 7-day incoming.
//           Mobile/PDA layout: auto-detects <=480px, all sections collapsed by
//           default except briefing (state persisted), compact holdings list,
//           full rankings table replaced with dot+ticker+score compact list.
//           "Ahead after: X days" replaces "Payback: Xd" throughout swap advisor.
// v2.5.11 — Polish: Swap Advisor layout overhauled for readability. Cards now
//            have more padding, a clear dividing line between the action row
//            and detail row, and consistent font sizing. Group headers use a
//            bottom border for separation. Group dividers have more vertical
//            space. Chain cards use a distinct tsa-swap-chain class. Top-up
//            partial cards match the new layout. Daily loss now shown in red.
// v2.5.10 — Fix: "Hide from recommendations" now works correctly when available
//            cash is set to 0 or left blank. Previously budget=0 set budgetMax
//            to Infinity so nothing was ever flagged as over-budget, meaning
//            hide mode had no effect. Now when budget=0 and mode is hide, all
//            unaffordable blocks are hidden as expected.
// v2.5.9  — Fix: sell-based swap groups (full sell, sell-down, partial sell,
//           combined sell) no longer suggest targets already affordable with
//           available cash alone. Previously if you had $10B cash, every sell
//           group would surface targets you could simply buy directly, making
//           the sell appear necessary when it wasn't. Those targets are now
//           exclusively handled by the Tier 0 Use cash group.
// v2.5.8 — Fix: Swap Advisor Use cash group no longer shows duplicate "↳ or"
//           alternatives after the chain. Previously all Tier 0 targets were
//           rendered as independent alternatives below the chain, repeating
//           blocks already covered. Now only the primary buy renders; the chain
//           handles all follow-on purchases from leftover funds.
// v2.5.7 — Feature: Swap Advisor now chains follow-on purchases from leftover
//           funds after each primary buy. After the best buy in a group, all
//           remaining cash is spent greedily on the next-best immediately
//           actionable blocks, shown as "↳ then buy" entries. Virtual holdings
//           are tracked so buying B1 unlocks B2 as a follow-on candidate in the
//           same chain. Chaining only applies to the first (best) option per
//           group, not the "or" alternatives.
// v2.5.6 — Fix: Swap Advisor Tier 0 (Use cash) now only surfaces immediately
//           actionable blocks — the direct next block for each stock or a
//           partial already in progress. Previously showed later blocks (e.g.
//           MUN B2) as independent buy options even when the prerequisite block
//           wasn't held, which was both impossible and misleading.
//           Tier 0 now also simulates chained sequential purchases: after
//           picking the best-ROI affordable block, remaining cash is reduced
//           and the next best affordable block is evaluated, so a sequence like
//           "buy B1 then B2" is surfaced correctly rather than shown as
//           alternatives.
// v2.5.5 — Fix: available cash field now correctly returns 0 when set to 0.
//           left blank. The legacy savings_invest migration fallback caused the
//           old value to persist silently after clearing the field.
//         — Hotfix: ReferenceError "cashFromPartial is not defined" — variable
//           was renamed cashPartial in scope but the availCashUsed calculation
//           still referenced the old name. Swap Advisor now loads correctly.
// v2.5.3 — Fix: Swap Advisor cash pill now shows the actual shortfall covered
//           by available cash (costToComplete - cashReleased), not the full
//           available cash amount. Combined sell and partial sell blocks were
//           both using Math.min(availCash, costToComplete) which overcounted
//           when the sell proceeds already covered most of the target cost.
//         — Feature: Swap Advisor rows are now numbered (#1, #2, #3...) in the
//           group header so rows are easy to reference.
// v2.5.2 — Merge: combines all improvements from GF v2.5.0 and today's session.
//           From v2.5.0: tier priority system in Swap Advisor (Tier 0 = Use cash,
//           Tier 1 = Sell partial, Tier 2 = Sell block); explicit "Use cash" swap
//           entries when available cash alone covers a target; primary sort by
//           netDailyGain DESC (tier as tiebreaker only); combined-sell lower-bound
//           guard updated to account for available cash; "+$X cash" pill on cards
//           where cash bridges the gap; "Use cash" / "Sell partial" / "Sell block"
//           footer legend.
//           From today: single getAvailableCash() field drives everything; better
//           empty-state message when budget hide filter active; sell-down accuracy
//           fix; "Complete your partials" top-up section; partial block ROI uses
//           remaining cost not full block cost.
// v2.5.1 — Fix: recommendation cards empty state now explains why when the
//           budget hide filter is active. Previously showed "No scoreable blocks
//           — check config or API key" even when blocks existed but were all
//           over budget. Now shows "All N blocks are over your $Xm budget" with
//           guidance on how to adjust the filter.
// v2.4.5 — Polish: "Available to invest" and "Budget" config fields merged into
//           a single "Available cash" field. One number now drives everything:
//           recommendation affordability filter, swap advisor math, savings plan
//           progress tracking, and top-up partial detection. Legacy savings_invest
//           value migrated automatically on first load. Config label updated with
//           plain-English description of what the field affects.
// v2.4.4 — Fix: partial block ROI now uses costToComplete (shares still needed
//           x price) as the scoring denominator instead of the full block cost.
//           Previously a 90%-complete partial was ranked as if buying from
//           scratch, significantly underrating it.
//         — Fix: sell-down in Swap Advisor now correctly releases only the top
//           block's shares, landing at the highest complete block held. Previously
//           used the wrong threshold when excess shares existed above a block.
//         — Fix: partial positions now appear as sell candidates in Swap Advisor.
//           Selling a partial loses no daily value so these show payback Immediate.
//         — Fix: available cash (budget field) factored into all swap calculations.
//           Cash in hand reduces how much a sell must cover; each swap card shows
//           how much of your cash contributed alongside the sell proceeds.
//         — Feature: "Complete your partials" section at the top of Swap Advisor
//           shows partials affordable with available cash, with ROI on remaining
//           spend and shares still needed.
// v2.4.3 — Fix: config migration now clears ALL per-stock override keys on
//           version bump (itemqty, itemname, units, b1, maxblocks, payout) so
//           corrected STOCK_DATA defaults always take effect. Previously only
//           payout and legacy thresh keys were cleared, causing itemqty_THS=2
//           to survive the v2.4.2 bump and override the corrected qty of 1.
// v2.4.2 — Fix: THS (Torn City Health Service) corrected — b1Threshold 150,000
//           -> 450,000; payoutQty 2 -> 1 (1x Box of Medical Supplies per block).
// v2.4.1 — Fix: ASS (Alcoholics Synonymous) corrected — b1Threshold 3,000,000
//           -> 1,000,000; payoutQty 2 -> 1 (1x Six-Pack of Alcohol per block).
//         — Fix: LSC (Lucky Shot Casino) corrected — b1Threshold 1,500,000 ->
//           500,000; payoutQty 2 -> 1 (1x Lottery Voucher per block).
//         — Feature: item name validator. The item name field in config now
//           shows an inline status indicator on blur: green tick with item ID
//           if found in cache, red cross if not found, or a "Save & refresh"
//           hint when the item ID cache has not yet been populated. Checked
//           against the locally cached item map — no extra API call required.
// v2.4.0 — Feature: full theme system. All hardcoded hex values replaced with
//           --tsa-* CSS custom properties. Eight themes added matching the OCM
//           design system: Default (Dark Blue), Torn Classic, High Contrast
//           (WCAG AAA), Deuteranopia, Protanopia, Tritanopia, Low Vision, and
//           Light Mode. Theme selector added to config panel; preference saved
//           to GM storage and applied on load.
//         — Feature: dynamic block generation. Blocks are no longer hardcoded
//           to 5 per stock. STOCK_DATA now stores b1Threshold (the first block
//           share count) and maxBlocks (defaults to heldBlocks + 4, user-
//           configurable). All higher block thresholds are derived automatically
//           using the universal doubling rule: threshold[n] = (2^n - 1) * b1.
//           Config per-stock simplified to B1 threshold + max blocks inputs.
//         — Feature: n+2 visibility rule. The scoring engine and rankings table
//           only show a stock's blocks up to heldBlocks + 2, keeping
//           recommendations focused. Held blocks always shown. maxBlocks config
//           allows extending visibility for power users.
//         — Feature: payout config improvements. Item stocks now expose a
//           quantity field and item name field in config so users can correct
//           payout descriptions if market lookup fails. Nerve/energy/happy
//           stocks expose their units-per-block value in config.
// v2.3.4 — Data: full threshold audit — all 35 stocks confirmed correct against
//           the universal doubling rule threshold[n] = (2^n - 1) * b1.
//           GRN confirmed $4M/block (DOM shows $12M total / 3 held blocks).
//           CONFIG_VERSION bumped to clear stale manually-entered overrides.
// v2.3.3 — Removed: sidebar widget stripped out entirely.
// v2.3.2 — Fix: removed ALL hardcoded cash dividend values. Cash dividends now
//           read live from DOM dividend column (total / heldBlocks = per-block).
// v2.3.1 — Fix: CONFIG_VERSION bump to clear stale payout overrides.
//         — Fix: card label renamed to "This block earns:".
// v2.3.0 — Fix: various sidebar data/crash fixes (sidebar later removed).
// v2.2.1 — Fix: sidebar payout list iteration fix.
// v2.1.0 — Feature: sidebar widget (later removed in v2.3.3).
// v2.0.0 — MAJOR: Savings Plan feature.
// v1.7.4 — Feature: swap advisor payout timing.
// v1.7.3 — Feature: disclaimer footer.
// v1.6.4 — Feature: PTS live points market pricing.
// v1.6.1 — Feature: never-sell list in config.
// v1.4.0 — Feature: budget filter.
// v1.0.0 — Initial release.
// ─────────────────────────────────────────────────────────────────────────────

(function () {
  'use strict';

  if (window._tsaLoaded) return;
  window._tsaLoaded = true;

  const PREFIX   = 'tsa_';
  const API_BASE = 'https://api.torn.com/v2';
  const SCRIPT   = 'TornStockAdvisor';

  // Bumped to 2.4.0: dynamic block system replaces hardcoded thresholds.
  // All stored per-stock threshold overrides cleared; b1/maxBlocks take over.
  const CONFIG_VERSION = '3.1.6';

  let tickerToId = {};
  let lastRows   = [];
  let lastPrices = {};

  // ─── Theme system ─────────────────────────────────────────────────────────────
  // All colours are CSS custom properties on #tsa-root.
  // Never use hardcoded hex in CSS or inline styles — always var(--tsa-*).

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

  function applyTheme(themeKey) {
    const theme = THEMES[themeKey] || THEMES.default;
    const root  = document.getElementById('tsa-root');
    if (!root) return;
    for (const [prop, value] of Object.entries(theme)) {
      if (prop.startsWith('--')) root.style.setProperty(prop, value);
    }
    root.style.fontSize = `${13 * parseFloat(theme['--tsa-font-scale'] || '1')}px`;
    save('theme', themeKey);
  }

  // ─── Master stock data ────────────────────────────────────────────────────────
  //
  // THRESHOLD RULE (universal, confirmed v2.3.4):
  //   threshold[n] = (2^n - 1) * b1Threshold
  //   Generated dynamically — only b1Threshold is stored here.
  //   maxBlocks defaults to heldBlocks + 4 (user-configurable via GM storage).
  //
  // INTERVALS:
  //   7d:  FHG SYM PRN EWM THS LAG BAG MUN PTS EVL MCS CBD ASS LSC
  //   31d: GRN TCT TMI IOU TSB CNC HRG TCC
  //   0:   TCP TCM TGP IIL TCI WLT SYS ELT MSG WSU LOS YAZ IST (passive)
  //
  // PAYOUT TYPES:
  //   cash    — DOM-read or user override ($/block/interval)
  //   item    — qty * live market price (payoutItemName used for ID resolution)
  //   energy  — payoutUnits energy/block/interval
  //   nerve   — payoutUnits nerve/block/interval
  //   happy   — 1000 happy/block/interval
  //   other   — user sets $ value in config (e.g. HRG property)
  //   passive — no active payout

  const STOCK_DATA = [
    // ── 7-day item stocks ─────────────────────────────────────────────────────
    { ticker:'FHG', stockId:7,  name:'Feathery Hotels Group',
      payoutType:'item', payoutInterval:7,
      payoutItemName:'Feathery Hotel Coupon', payoutItemId:367, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Feathery Hotel Coupon per block, every 7 days',
      b1Threshold:2000000 },
    { ticker:'SYM', stockId:2,  name:'Symbiotic Ltd.',
      payoutType:'item', payoutInterval:7,
      payoutItemName:'Drug Pack', payoutItemId:370, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Drug Pack per block, every 7 days',
      b1Threshold:500000 },
    { ticker:'PRN', stockId:21, name:'Performance Ribaldry',
      payoutType:'item', payoutInterval:7,
      payoutItemName:'Erotic DVD', payoutItemId:null, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Erotic DVD per block, every 7 days',
      b1Threshold:1000000 },
    { ticker:'EWM', stockId:10, name:'Eaglewood Mercenary',
      payoutType:'item', payoutInterval:7,
      payoutItemName:'Box of Grenades', payoutItemId:null, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Box of Grenades per block, every 7 days',
      b1Threshold:1000000 },
    { ticker:'THS', stockId:20, name:'Torn City Health Service',
      payoutType:'item', payoutInterval:7,
      payoutItemName:'Box of Medical Supplies', payoutItemId:null, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Box of Medical Supplies per block, every 7 days',
      b1Threshold:150000 },
    { ticker:'LAG', stockId:17, name:'Legal Authorities Group',
      payoutType:'item', payoutInterval:7,
      payoutItemName:"Lawyer's Business Card", payoutItemId:368, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:"1x Lawyer's Business Card per block, every 7 days",
      b1Threshold:750000 },
    { ticker:'BAG', stockId:27, name:"Big Al's Gun Shop",
      payoutType:'item', payoutInterval:7,
      payoutItemName:'Ammunition Pack', payoutItemId:null, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Ammunition Pack per block, every 7 days',
      b1Threshold:3000000 },
    { ticker:'MUN', stockId:12, name:'Munster Beverage Corp.',
      payoutType:'item', payoutInterval:7,
      payoutItemName:'Six-Pack of Energy Drink', payoutItemId:null, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Six-Pack of Energy Drink per block, every 7 days',
      b1Threshold:5000000 },
    { ticker:'ASS', stockId:24, name:'Alcoholics Synonymous',
      payoutType:'item', payoutInterval:7,
      payoutItemName:'Six-Pack of Alcohol', payoutItemId:null, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Six-Pack of Alcohol per block, every 7 days',
      b1Threshold:1000000 },
    { ticker:'LSC', stockId:6,  name:'Lucky Shot Casino',
      payoutType:'item', payoutInterval:7,
      payoutItemName:'Lottery Voucher', payoutItemId:null, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Lottery Voucher per block, every 7 days',
      b1Threshold:500000 },
    { ticker:'TCC', stockId:35, name:'Torn City Clothing',
      payoutType:'item', payoutInterval:31,
      payoutItemName:'Clothing Cache', payoutItemId:null, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Clothing Cache per block, every 31 days',
      b1Threshold:7500000 },
    // ── 7-day non-item active stocks ──────────────────────────────────────────
    { ticker:'PTS', stockId:22, name:'PointLess',
      payoutType:'points', payoutInterval:7,
      payoutItemName:null, payoutItemId:null, payoutQty:100,
      payoutCashValue:0,
      payoutDesc:'100 points per block, every 7 days (valued at live points market price)',
      b1Threshold:10000000 },
    { ticker:'EVL', stockId:26, name:'Evil Ducks Candy Corp',
      payoutType:'happy', payoutInterval:7,
      payoutItemName:null, payoutItemId:null, payoutQty:1,
      payoutCashValue:0, payoutUnits:1000,
      payoutDesc:'1000 happiness per block, every 7 days',
      b1Threshold:100000 },
    { ticker:'MCS', stockId:23, name:'Mc Smoogle Corp',
      payoutType:'energy', payoutInterval:7,
      payoutItemName:null, payoutItemId:null, payoutQty:1,
      payoutCashValue:0, payoutUnits:200,
      payoutDesc:'200 energy per block, every 7 days',
      b1Threshold:350000 },
    { ticker:'CBD', stockId:18, name:'Herbal Releaf Co.',
      payoutType:'nerve', payoutInterval:7,
      payoutItemName:null, payoutItemId:null, payoutQty:1,
      payoutCashValue:0, payoutUnits:50,
      payoutDesc:'50 nerve per block, every 7 days',
      b1Threshold:350000 },
    // ── 31-day cash dividend stocks ───────────────────────────────────────────
    // payoutCashValue always 0 — read live from DOM (total/heldBlocks=per-block)
    { ticker:'GRN', stockId:16, name:'Grain',
      payoutType:'cash', payoutInterval:31,
      payoutItemName:null, payoutItemId:null, payoutQty:1,
      payoutCashValue:4000000,
      // Default: $4M/block (confirmed v2.3.4). Override in config if different.
      payoutDesc:'Cash dividend per block, every 31 days — default $4M, override in config',
      b1Threshold:500000 },
    { ticker:'TCT', stockId:9,  name:'The Torn City Times',
      payoutType:'cash', payoutInterval:31,
      payoutItemName:null, payoutItemId:null, payoutQty:1,
      payoutCashValue:1000000,
      payoutDesc:'Cash dividend per block, every 31 days — default $1M, override in config',
      b1Threshold:100000 },
    { ticker:'TMI', stockId:5,  name:'TC Music Industries',
      payoutType:'cash', payoutInterval:31,
      payoutItemName:null, payoutItemId:null, payoutQty:1,
      payoutCashValue:25000000,
      payoutDesc:'Cash dividend per block, every 31 days — default $25M, override in config',
      b1Threshold:6000000 },
    { ticker:'IOU', stockId:14, name:'Insured On Us',
      payoutType:'cash', payoutInterval:31,
      payoutItemName:null, payoutItemId:null, payoutQty:1,
      payoutCashValue:12000000,
      payoutDesc:'Cash dividend per block, every 31 days (+ lawsuit chance) — default $12M, override in config',
      b1Threshold:3000000 },
    { ticker:'TSB', stockId:1,  name:'Torn & Shanghai Banking',
      payoutType:'cash', payoutInterval:31,
      payoutItemName:null, payoutItemId:null, payoutQty:1,
      payoutCashValue:50000000,
      payoutDesc:'Cash dividend per block, every 31 days — default $50M, override in config',
      b1Threshold:3000000 },
    { ticker:'CNC', stockId:34, name:'Crude & Co',
      payoutType:'cash', payoutInterval:31,
      payoutItemName:null, payoutItemId:null, payoutQty:1,
      payoutCashValue:80000000,
      payoutDesc:'Cash dividend per block, every 31 days — default $80M, override in config',
      b1Threshold:7500000 },
    { ticker:'HRG', stockId:8,  name:'Home Retail Group',
      payoutType:'other', payoutInterval:31,
      payoutItemName:null, payoutItemId:null, payoutQty:1,
      payoutCashValue:0,
      payoutDesc:'1x Random Property per block, every 31 days — set $ value in config',
      b1Threshold:10000000 },
    // ── Passive stocks ────────────────────────────────────────────────────────
    { ticker:'TCP', stockId:13, name:'TC Media Productions',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'Company sales boost (passive)',      b1Threshold:1000000 },
    { ticker:'TCM', stockId:4,  name:'Torn City Motors',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'10% racing skill gain boost (passive)', b1Threshold:1000000 },
    { ticker:'TGP', stockId:19, name:'Tell Group Plc.',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'Company advertising boost (passive)', b1Threshold:2500000 },
    { ticker:'IIL', stockId:25, name:'I Industries Ltd.',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'50% coding time reduction (passive)', b1Threshold:1000000 },
    { ticker:'TCI', stockId:15, name:'Torn City Investments',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'10% bank interest bonus (passive — hold 7 days before banking)', b1Threshold:1500000 },
    { ticker:'WLT', stockId:11, name:'Wind Lines Travel',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'Private jet access (passive)',       b1Threshold:9000000 },
    { ticker:'SYS', stockId:3,  name:'Syscore MFG',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'Advanced firewall (passive)',        b1Threshold:3000000 },
    { ticker:'ELT', stockId:28, name:'Empty Lunchbox Traders',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'10% home upgrade discount (passive)', b1Threshold:5000000 },
    { ticker:'MSG', stockId:29, name:'Messaging Inc.',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'Free classified advertising (passive)', b1Threshold:300000 },
    { ticker:'WSU', stockId:31, name:'West Side University',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'10% course time reduction (passive)', b1Threshold:1000000 },
    { ticker:'LOS', stockId:32, name:'Lo Squalo Waste',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'25% mission reward bonus (passive)', b1Threshold:7500000 },
    { ticker:'YAZ', stockId:33, name:'Yazoo',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'Free banner advertising (passive)',  b1Threshold:1000000 },
    { ticker:'IST', stockId:30, name:'International School TC',
      payoutType:'passive', payoutInterval:0,
      payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
      payoutDesc:'Free education courses (passive)',   b1Threshold:100000 },
  ];

  // ─── Dynamic block generation ─────────────────────────────────────────────────
  // Returns array of {incr, threshold} using the universal doubling rule.
  // threshold[n] = (2^n - 1) * b1
  // maxBlocks is read from GM storage (default: heldBlocks + 4, min 5).
  function getB1(ticker) {
    const stored = parseInt(load(`b1_${ticker}`, ''), 10);
    const stock  = STOCK_DATA.find(s => s.ticker === ticker);
    return (!isNaN(stored) && stored > 0) ? stored : (stock?.b1Threshold || 0);
  }
  function getMaxBlocks(ticker, heldBlocks = 0) {
    const stored = parseInt(load(`maxblocks_${ticker}`, ''), 10);
    const def    = Math.max(5, heldBlocks + 4);
    return (!isNaN(stored) && stored > 0) ? stored : def;
  }
  function buildIncrements(ticker, heldBlocks = 0) {
    const b1  = getB1(ticker);
    const max = getMaxBlocks(ticker, heldBlocks);
    if (!b1 || !max) return [];
    const increments = [];
    for (let n = 1; n <= max; n++) {
      increments.push({ incr: n, threshold: (Math.pow(2, n) - 1) * b1 });
    }
    return increments;
  }

  // ─── Config migration ─────────────────────────────────────────────────────────
  (function migrateConfig() {
    const stored = GM_getValue(PREFIX + 'cfg_version', '');
    if (stored === CONFIG_VERSION) return;
    console.log(`[TSA] Config ${stored} -> ${CONFIG_VERSION}: clearing all per-stock overrides`);
    // Clear every per-stock key so corrected STOCK_DATA defaults take effect.
    // This covers payout, b1, maxblocks, itemqty, itemname, units, and legacy thresh keys.
    for (const stock of STOCK_DATA) {
      ['payout','b1','maxblocks','itemqty','itemname','units'].forEach(k => {
        GM_setValue(PREFIX + `${k}_${stock.ticker}`, null);
      });
      for (let n = 1; n <= 10; n++) {
        GM_setValue(PREFIX + `thresh_${stock.ticker}_${n}`, null);
      }
    }
    GM_setValue(PREFIX + 'cfg_version', CONFIG_VERSION);
  })();

  // ─── CSS ──────────────────────────────────────────────────────────────────────
  // ALL colours use var(--tsa-*) tokens. No hardcoded hex anywhere.
  GM_addStyle(`
    #tsa-root * { box-sizing: border-box; margin: 0; padding: 0; }
    #tsa-root {
      font-family: Arial, sans-serif; font-size: 13px;
      color: var(--tsa-text-primary);
      background: var(--tsa-bg-base);
      border-radius: 6px; margin: 12px 0; overflow: hidden;
    }

    /* Header */
    #tsa-header {
      background: var(--tsa-bg-header);
      border-bottom: 2px solid var(--tsa-border-accent);
      border-radius: 6px 6px 0 0;
      padding: 10px 14px;
      display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
    }
    .tsa-title   { color: var(--tsa-accent); font-size: 15px; font-weight: bold; }
    .tsa-version { font-size: 10px; opacity: 0.5; font-weight: normal; }
    .tsa-updated { color: var(--tsa-text-label); font-size: 11px; margin-left: auto; }

    /* Buttons */
    .tsa-btn-primary {
      background: var(--tsa-accent-hover); border: none; border-radius: 4px;
      color: var(--tsa-text-primary); padding: 4px 10px; cursor: pointer; font-size: 12px;
    }
    .tsa-btn-primary:hover { background: var(--tsa-accent); }
    .tsa-btn-secondary {
      background: var(--tsa-bg-deep); border: 1px solid var(--tsa-border-input);
      border-radius: 4px; color: var(--tsa-text-secondary);
      padding: 3px 8px; cursor: pointer; font-size: 11px;
    }
    .tsa-btn-secondary:hover { background: var(--tsa-bg-hover); color: var(--tsa-text-primary); }

    /* Config strip */
    #tsa-config-strip {
      background: var(--tsa-bg-base); border-bottom: 1px solid var(--tsa-border-strip);
      padding: 7px 14px; display: flex; align-items: center;
      gap: 8px; flex-wrap: wrap; font-size: 11px; color: var(--tsa-text-label);
    }
    .tsa-sep     { color: var(--tsa-border-card); }
    .tsa-key-ok  { color: var(--tsa-status-ok);   font-size: 10px; font-weight: bold; }
    .tsa-key-bad { color: var(--tsa-status-crit);  font-size: 10px; font-weight: bold; }

    /* Config panel */
    #tsa-config-panel {
      background: var(--tsa-bg-dark); border-bottom: 2px solid var(--tsa-border-accent);
      padding: 10px 12px; display: none;
    }
    #tsa-config-panel.open { display: block; }
    .tsa-cfg-label {
      font-size: 9px; color: var(--tsa-text-muted); text-transform: uppercase;
      letter-spacing: .5px; margin: 10px 0 5px; display: block;
    }
    .tsa-cfg-label:first-child { margin-top: 0; }
    .tsa-cfg-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
    .tsa-cfg-row label { font-size: 11px; color: var(--tsa-text-secondary); min-width: 160px; }
    .tsa-cfg-input {
      background: var(--tsa-bg-input); border: 1px solid var(--tsa-border-input);
      border-radius: 4px; color: var(--tsa-text-primary); padding: 4px 8px; font-size: 12px;
    }
    .tsa-cfg-input:focus { outline: none; border-color: var(--tsa-accent); }
    .tsa-cfg-input option { background: var(--tsa-bg-dropdown); color: var(--tsa-text-primary); }
    .tsa-row-overbudget td { opacity: 0.4; }
    .tsa-cfg-note { font-size: 10px; color: var(--tsa-text-muted); margin-top: 3px; }
    .tsa-cfg-check-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
    .tsa-cfg-check-row label { font-size: 11px; color: var(--tsa-text-secondary); }

    /* Config accordions */
    .tsa-accord { border: 1px solid var(--tsa-border-strip); border-radius: 4px; margin-bottom: 4px; }
    .tsa-accord-hdr {
      background: var(--tsa-bg-deep); padding: 5px 10px; cursor: pointer;
      display: flex; align-items: center; justify-content: space-between;
      font-size: 11px; color: var(--tsa-text-secondary); border-radius: 4px; user-select: none;
    }
    .tsa-accord-hdr:hover { background: var(--tsa-bg-hover); color: var(--tsa-text-primary); }
    .tsa-accord-ticker {
      background: var(--tsa-border-strip); color: var(--tsa-phase-plan-text);
      font-size: 10px; font-weight: bold; padding: 1px 6px; border-radius: 3px;
      font-family: monospace; margin-right: 8px;
    }
    .tsa-accord-arrow { font-size: 10px; transition: transform .2s; }
    .tsa-accord-hdr.open .tsa-accord-arrow { transform: rotate(180deg); }
    .tsa-accord-body {
      display: none; padding: 8px 10px;
      background: var(--tsa-bg-deep); border-top: 1px solid var(--tsa-border-strip);
    }
    .tsa-accord-body.open { display: block; }
    .tsa-incr-row {
      display: flex; align-items: center; gap: 8px; margin-bottom: 5px;
      font-size: 11px; flex-wrap: wrap;
    }
    .tsa-incr-row label { color: var(--tsa-text-muted); min-width: 130px; }
    .tsa-incr-row .tsa-cfg-input { width: 130px; }
    .tsa-incr-note { font-size: 10px; color: var(--tsa-text-dead); }

    /* Stats bar */
    #tsa-stats-bar {
      background: var(--tsa-bg-deep); border-bottom: 1px solid var(--tsa-border-strip);
      padding: 10px 14px; display: flex; gap: 32px; flex-wrap: wrap; align-items: center;
    }
    .tsa-stat       { display: flex; flex-direction: column; }
    .tsa-stat-label { font-size: 10px; color: var(--tsa-text-secondary); text-transform: uppercase; letter-spacing: .5px; }
    .tsa-stat-value { font-size: 16px; font-weight: bold; color: var(--tsa-accent); }

    /* Body */
    #tsa-body  { padding: 16px 14px; }
    #tsa-error {
      background: var(--tsa-status-crit-bg); border: 1px solid var(--tsa-status-crit-border);
      border-radius: 5px; padding: 8px 12px; margin-bottom: 10px; font-size: 12px;
      color: var(--tsa-banner-crit-text); display: none;
    }
    #tsa-loading { text-align: center; padding: 20px; color: var(--tsa-text-muted); font-size: 12px; display: none; }
    .tsa-spinner {
      display: inline-block; width: 12px; height: 12px;
      border: 2px solid var(--tsa-text-dead); border-top-color: var(--tsa-accent);
      border-radius: 50%; animation: tsa-spin .7s linear infinite;
      vertical-align: middle; margin-right: 6px;
    }
    @keyframes tsa-spin { to { transform: rotate(360deg); } }

    /* Sections */
    .tsa-section { margin-bottom: 8px; padding-top: 18px; border-top: 1px solid var(--tsa-border-strip); }
    .tsa-section:first-child { padding-top: 0; border-top: none; }
    .tsa-section-title {
      color: var(--tsa-accent); font-size: 12px; font-weight: bold;
      text-transform: uppercase; letter-spacing: 1px;
      border-bottom: 1px solid var(--tsa-border-section);
      padding-bottom: 6px; margin-bottom: 10px;
      cursor: pointer; display: flex; align-items: center;
      justify-content: space-between; user-select: none;
    }
    .tsa-section-title::after { content: '\u25be'; font-size: 10px; transition: transform .2s; }
    .tsa-section-title.collapsed::after { transform: rotate(-90deg); }

    /* Recommendation cards */
    #tsa-cards {
      display: grid; grid-template-columns: repeat(auto-fill, minmax(min(270px,100%),1fr));
      gap: 12px; margin-bottom: 10px;
    }
    .tsa-card {
      background: var(--tsa-bg-card); border: 1px solid var(--tsa-border-card);
      border-radius: 6px; padding: 12px 14px; position: relative;
    }
    .tsa-card[title]:hover { border-color: var(--tsa-accent); background: var(--tsa-bg-hover); }
    .tsa-card-rank { position: absolute; top: 8px; right: 10px; font-size: 12px; font-weight: bold; color: var(--tsa-text-dead); }
    .tsa-rank-gold   { color: var(--tsa-status-respect); }
    .tsa-rank-silver { color: var(--tsa-text-secondary); }
    .tsa-rank-bronze { color: var(--tsa-weight-high); }
    .tsa-card-head { font-size: 13px; font-weight: bold; color: var(--tsa-text-card); margin-bottom: 3px; padding-right: 24px; }
    .tsa-card-sub  { margin-bottom: 8px; }
    .tsa-card-line { font-size: 11px; color: var(--tsa-text-label); margin-bottom: 3px; }
    .tsa-card-line strong { color: var(--tsa-text-primary); }
    .tsa-card-roi  { font-size: 10px; color: var(--tsa-accent); font-weight: bold; margin-top: 7px; }
    .tsa-card-partial {
      border-top: 1px solid var(--tsa-border-card); margin-top: 5px; padding-top: 4px;
      font-size: 10px; color: var(--tsa-status-warn);
    }

    /* Holdings */
    .tsa-hold-sublabel {
      font-size: 9px; color: var(--tsa-text-muted); text-transform: uppercase;
      letter-spacing: .5px; margin: 12px 0 6px;
    }
    .tsa-hold-sublabel:first-child { margin-top: 0; }
    .tsa-hold-row {
      display: grid;
      grid-template-columns: 44px minmax(160px,1fr) 80px 100px 130px;
      align-items: center; gap: 0 10px;
      padding: 7px 0; border-bottom: 1px solid var(--tsa-bg-deep); font-size: 11px;
    }
    .tsa-hold-row:last-child { border-bottom: none; }
    .tsa-hold-name { color: var(--tsa-text-card); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .tsa-hold-val  { color: var(--tsa-status-ok); text-align: right; }
    .tsa-hold-partial-txt { color: var(--tsa-status-warn); font-size: 10px; }
    .tsa-hold-partial-row {
      display: grid; grid-template-columns: 44px minmax(160px,1fr) 44px auto;
      align-items: center; gap: 0 10px;
      padding: 5px 0; border-bottom: 1px solid var(--tsa-bg-deep); font-size: 11px;
    }
    .tsa-hold-partial-row:last-child { border-bottom: none; }

    /* Full rankings table */
    table.tsa-table { width: 100%; border-collapse: collapse; font-size: 11px; }
    table.tsa-table th {
      color: var(--tsa-text-muted); font-weight: normal; font-size: 10px;
      text-transform: uppercase; letter-spacing: .5px; padding: 7px 10px;
      border-bottom: 2px solid var(--tsa-border-strip);
      text-align: left; white-space: nowrap; background: var(--tsa-bg-deep);
    }
    table.tsa-table td {
      padding: 7px 10px; border-bottom: 1px solid var(--tsa-border-faint);
      color: var(--tsa-text-card); white-space: nowrap; vertical-align: middle;
    }
    table.tsa-table tbody tr:nth-child(even) td { background: var(--tsa-bg-dark); }
    table.tsa-table tbody tr:nth-child(odd)  td { background: var(--tsa-bg-deep); }
    table.tsa-table tr:hover td { background: var(--tsa-bg-hover) !important; }
    table.tsa-table tr.tsa-row-ignored td  { opacity: 0.25; }
    table.tsa-table tr.tsa-row-passive td  { opacity: 0.40; }
    table.tsa-table tr.tsa-row-hidden  td  { display: none; }
    table.tsa-table th:first-child,
    table.tsa-table td:first-child { text-align: center; padding: 5px 6px; border-right: 1px solid var(--tsa-border-strip); }

    /* Badges */
    .tsa-badge {
      font-size: 10px; padding: 1px 5px; border-radius: 3px;
      font-weight: bold; white-space: nowrap; display: inline-block;
    }
    .tsa-badge-ok      { background: var(--tsa-status-ok-bg);   color: var(--tsa-status-ok);   border: 1px solid var(--tsa-status-ok-border); }
    .tsa-badge-warn    { background: var(--tsa-status-warn-bg);  color: var(--tsa-status-warn); border: 1px solid var(--tsa-status-warn-border); }
    .tsa-badge-info    { background: var(--tsa-phase-plan-bg);   color: var(--tsa-phase-plan-text); border: 1px solid var(--tsa-phase-plan-border); }
    .tsa-badge-muted   { background: var(--tsa-bg-dark);         color: var(--tsa-text-dead); border: 1px solid var(--tsa-border-faint); }
    .tsa-badge-energy  { background: var(--tsa-phase-plan-bg);   color: var(--tsa-status-ok);   border: 1px solid var(--tsa-status-ok-border); }
    .tsa-badge-nerve   { background: var(--tsa-status-crit-bg);  color: var(--tsa-status-blocked); border: 1px solid var(--tsa-status-crit-border); }
    .tsa-badge-happy   { background: var(--tsa-status-warn-bg);  color: var(--tsa-status-respect); border: 1px solid var(--tsa-status-warn-border); }
    .tsa-badge-passive { background: var(--tsa-bg-card);         color: var(--tsa-text-muted); border: 1px solid var(--tsa-border-card); }
    .tsa-badge-cash    { background: var(--tsa-status-ok-bg);    color: var(--tsa-status-ok);   border: 1px solid var(--tsa-status-ok-border); }
    .tsa-badge-item    { background: var(--tsa-phase-plan-bg);   color: var(--tsa-phase-plan-text); border: 1px solid var(--tsa-phase-plan-border); }
    .tsa-badge-other   { background: var(--tsa-bg-dark);         color: var(--tsa-text-secondary); border: 1px solid var(--tsa-border-card); }

    .tsa-ticker {
      font-family: monospace; font-weight: bold; color: var(--tsa-text-primary);
      background: var(--tsa-bg-deep); padding: 1px 5px; border-radius: 3px; font-size: 11px;
    }
    .tsa-rank-num {
      font-size: 11px; font-weight: bold; color: var(--tsa-text-muted);
      display: inline-block; width: 18px; text-align: right;
    }

    /* Swap advisor */
    .tsa-swap-note  { font-size: 10px; color: var(--tsa-text-muted); margin-top: 20px; line-height: 1.6; }
    .tsa-swap-card  {
      border: 1px solid var(--tsa-border-strip); border-radius: 6px;
      padding: 12px 16px; margin-bottom: 6px;
      background: var(--tsa-bg-dark);
    }
    .tsa-swap-card.alt {
      background: var(--tsa-bg-deep); border-color: var(--tsa-border-faint);
      margin-left: 20px;
    }
    .tsa-swap-chain {
      border: 1px solid var(--tsa-border-faint); border-radius: 6px;
      padding: 10px 16px; margin-bottom: 6px; margin-left: 20px;
      background: var(--tsa-bg-deep);
    }
    .tsa-swap-group-header {
      margin: 20px 0 10px; font-size: 11px;
      color: var(--tsa-text-muted);
      padding-bottom: 8px; border-bottom: 1px solid var(--tsa-border-faint);
    }
    .tsa-swap-group-divider {
      border-top: 1px solid var(--tsa-border-strip); margin: 28px 0 0;
    }

    /* Savings plan */
    #tsa-savings-banner {
      margin-bottom: 12px; padding: 10px 14px;
      background: var(--tsa-banner-ok-bg); border: 2px solid var(--tsa-banner-ok-border);
      border-radius: 6px; font-size: 12px; color: var(--tsa-banner-ok-text);
      display: flex; align-items: flex-start; gap: 10px;
      animation: tsa-pulse-ok 2s ease-in-out infinite;
    }
    @keyframes tsa-pulse-ok {
      0%,100% { box-shadow: 0 0 0 0 transparent; }
      50%      { box-shadow: 0 0 8px 2px var(--tsa-status-ok-bg); }
    }
    #tsa-savings-banner .tsa-ban-icon  { font-size: 20px; flex-shrink: 0; }
    #tsa-savings-banner .tsa-ban-body  { flex: 1; }
    #tsa-savings-banner .tsa-ban-title { font-weight: bold; font-size: 13px; margin-bottom: 4px; }
    #tsa-savings-banner .tsa-ban-sell  { font-size: 11px; color: var(--tsa-banner-ok-text); margin-top: 4px; opacity: 0.85; }
    #tsa-savings-banner .tsa-ban-dismiss {
      background: none; border: 1px solid var(--tsa-banner-ok-border);
      color: var(--tsa-banner-ok-text); border-radius: 3px; padding: 2px 8px;
      cursor: pointer; font-size: 10px; flex-shrink: 0; align-self: flex-start;
    }
    #tsa-savings-banner .tsa-ban-dismiss:hover { background: var(--tsa-banner-ok-border); color: var(--tsa-bg-deep); }

    .tsa-plan-goal {
      background: var(--tsa-bg-deep); border: 1px solid var(--tsa-border-strip);
      border-radius: 6px; padding: 12px 14px; margin-bottom: 10px;
    }
    .tsa-plan-goal-hd {
      display: flex; align-items: center; justify-content: space-between;
      margin-bottom: 8px; flex-wrap: wrap; gap: 6px;
    }
    .tsa-plan-goal-name { font-size: 13px; font-weight: bold; color: var(--tsa-text-primary); }
    .tsa-plan-goal-meta { font-size: 11px; color: var(--tsa-text-muted); }
    .tsa-plan-progress  { height: 8px; background: var(--tsa-bg-card); border-radius: 4px; overflow: hidden; margin: 6px 0; }
    .tsa-plan-progress-fill { height: 100%; border-radius: 4px; background: var(--tsa-accent); transition: width .4s ease; }
    .tsa-plan-progress-fill.done { background: var(--tsa-status-ok); }
    .tsa-plan-stats { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 8px; font-size: 11px; }
    .tsa-plan-stat  { display: flex; flex-direction: column; gap: 2px; }
    .tsa-plan-stat-lbl { font-size: 9px; color: var(--tsa-text-muted); text-transform: uppercase; letter-spacing: .5px; }
    .tsa-plan-stat-val { font-weight: bold; color: var(--tsa-accent); }
    .tsa-plan-rec-row {
      display: flex; align-items: center; gap: 8px; padding: 6px 0;
      border-bottom: 1px solid var(--tsa-bg-deep); font-size: 11px; flex-wrap: wrap;
    }
    .tsa-plan-rec-row:last-child { border-bottom: none; }

    /* Stepping stone toggle */
    .tsa-step-toggle {
      background: none; border: 1px solid var(--tsa-border-card); border-radius: 3px;
      color: var(--tsa-text-dead); font-size: 10px; padding: 1px 6px;
      cursor: pointer; transition: all .15s; white-space: nowrap;
    }
    .tsa-step-toggle:hover  { border-color: var(--tsa-accent); color: var(--tsa-accent); }
    .tsa-step-toggle.active { background: var(--tsa-banner-ok-bg); border-color: var(--tsa-status-ok-border); color: var(--tsa-status-ok); }

    /* Item name validator status indicator */
    .tsa-item-status { font-size: 10px; font-weight: bold; margin-left: 6px; }
    .tsa-item-status.ok      { color: var(--tsa-status-ok); }
    .tsa-item-status.miss    { color: var(--tsa-status-crit); }
    .tsa-item-status.pending { color: var(--tsa-status-warn); }
    .tsa-item-status.empty   { color: var(--tsa-text-muted); }

    /* Footer */
    #tsa-disclaimer {
      border-top: 1px solid var(--tsa-border-strip); padding: 7px 14px;
      font-size: 10px; color: var(--tsa-phase-rec-text); background: var(--tsa-phase-rec-bg);
      text-align: center; line-height: 1.5;
    }
    #tsa-footer {
      border-top: 1px solid var(--tsa-border-strip); padding: 6px 12px;
      font-size: 10px; color: var(--tsa-text-dead);
      display: flex; justify-content: space-between;
    }

    /* Dashboard collapse */
    #tsa-root.collapsed > *:not(#tsa-header) { display: none !important; }
    #tsa-root.collapsed { border-radius: 6px; }
    #tsa-root.collapsed #tsa-header { border-radius: 6px; border-bottom: none; }

    /* Mobile / PDA layout <=480px */
    @media (max-width: 480px) {
      #tsa-root { font-size: 12px; margin: 4px 0; border-radius: 4px; }
      #tsa-header { padding: 8px 10px; gap: 6px; }
      .tsa-title { font-size: 13px; }
      #tsa-stats-bar { padding: 8px 10px; gap: 16px; }
      .tsa-stat-value { font-size: 14px; }
      #tsa-body { padding: 8px; }
      .tsa-section { padding-top: 0; border-top: none; margin-bottom: 6px; }
      .tsa-section-title { font-size: 11px; padding: 10px 10px 10px 10px;
        background: var(--tsa-bg-card); border-radius: 5px;
        border: 1px solid var(--tsa-border-card); border-bottom: none;
        margin-bottom: 0; }
      .tsa-section-title.mobile-collapsed { border-radius: 5px; border-bottom: 1px solid var(--tsa-border-card); margin-bottom: 6px; }
      .tsa-section-title.mobile-collapsed::after { transform: rotate(-90deg); }
      .tsa-section-content { border: 1px solid var(--tsa-border-card);
        border-top: none; border-radius: 0 0 5px 5px;
        padding: 10px; margin-bottom: 6px; }
      #tsa-cards { grid-template-columns: 1fr; gap: 8px; }
      .tsa-hold-row { grid-template-columns: 40px 1fr 70px 70px; }
      .tsa-swap-card, .tsa-swap-chain { padding: 9px 10px; }
      .tsa-swap-group-header { font-size: 10px; }
      table.tsa-table { display: none; }
      #tsa-mobile-rankings { display: block; }
    }
    @media (min-width: 481px) {
      #tsa-mobile-rankings { display: none; }
    }

    /* Progress bar */
    .tsa-progress-bar { height: 5px; background: var(--tsa-bg-deep); border-radius: 3px; overflow: hidden; }
    .tsa-progress-fill      { height: 100%; border-radius: 3px; background: var(--tsa-status-ok); }
    .tsa-progress-fill.warn { background: var(--tsa-status-warn); }
    .tsa-progress-fill.crit { background: var(--tsa-status-crit); }
  `);

  // ─── Helpers ─────────────────────────────────────────────────────────────────

  function fmtMoney(v) {
    if (v === null || v === undefined || isNaN(v)) return '\u2014';
    if (v >= 1e9)  return '$' + (v / 1e9).toFixed(2) + 'B';
    if (v >= 1e6)  return '$' + (v / 1e6).toFixed(2) + 'M';
    if (v >= 1e3)  return '$' + (v / 1e3).toFixed(1) + 'k';
    return '$' + Math.round(v).toLocaleString();
  }
  function fmtShares(n) {
    if (!n) return '0';
    if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
    if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
    if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
    return n.toLocaleString();
  }
  function fmtROI(dailyVal, cost) {
    if (!cost || !dailyVal || cost <= 0) return '\u2014';
    return ((dailyVal / cost) * 100).toFixed(3) + '%/day';
  }

  function save(k, v) { GM_setValue(PREFIX + k, v); }
  function load(k, d) {
    const v = GM_getValue(PREFIX + k, d);
    return (v !== undefined && v !== null) ? v : d;
  }

  function wireCollapse(titleEl, contentEl, storeKey, def = 'open') {
    const saved = load(storeKey, def);
    if (saved === 'collapsed') { contentEl.style.display = 'none'; titleEl.classList.add('collapsed'); }
    titleEl.addEventListener('click', () => {
      const hidden = contentEl.style.display === 'none';
      contentEl.style.display = hidden ? '' : 'none';
      titleEl.classList.toggle('collapsed', !hidden);
      save(storeKey, hidden ? 'open' : 'collapsed');
    });
  }

  // ─── Config accessors ─────────────────────────────────────────────────────────
  const getApiKey        = () => load('api_key', '');
  const getIgnored       = () => load('ignored', '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
  const getExNerve       = () => load('ex_nerve',   true);
  const getExEnergy      = () => load('ex_energy',  true);
  const getExHappy       = () => load('ex_happy',   false);
  const getExOther       = () => load('ex_other',   false);
  const getExPassive     = () => load('ex_passive',  true);
  const getRefreshMins   = () => parseInt(load('refresh_mins', 5), 10);
  const getSwapNoSell    = () => load('swap_no_sell', '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
  const getBudget        = () => parseFloat(load('budget', '0')) || 0;
  const getBudgetPct     = () => parseFloat(load('budget_pct', '110')) || 110;
  const getBudgetMode    = () => load('budget_mode', 'grey');
  const getTopN          = () => parseInt(load('top_n', 5), 10);
  const getSavingsGoal   = () => load('savings_goal', '');
  const getTheme         = () => load('theme', 'default');

  // Single cash field — used everywhere: recommendations (budget filter),
  // swap advisor (reduces how much selling must cover), savings plan
  // (stepping-stone suggestions and progress tracking).
  // Returns 0 if not set — explicit zero means "no cash available".
  const getAvailableCash = () => getBudget();

  function isSteppingStone(ticker, incr) { return load(`step_${ticker}_${incr}`, '') === '1'; }
  function setSteppingStone(ticker, incr, val) { save(`step_${ticker}_${incr}`, val ? '1' : ''); }

  // Payout override — cash value per block per interval
  function getPayoutOverride(ticker, def) {
    const v = parseFloat(load(`payout_${ticker}`, ''));
    return (!isNaN(v) && v > 0) ? v : def;
  }
  // Item config overrides
  function getItemQtyOverride(ticker, def) {
    const v = parseInt(load(`itemqty_${ticker}`, ''), 10);
    return (!isNaN(v) && v > 0) ? v : def;
  }
  function getItemNameOverride(ticker, def) {
    const v = load(`itemname_${ticker}`, '');
    return v ? v : def;
  }
  // Units override for energy/nerve/happy
  function getUnitsOverride(ticker, def) {
    const v = parseInt(load(`units_${ticker}`, ''), 10);
    return (!isNaN(v) && v > 0) ? v : def;
  }

  // ─── API ──────────────────────────────────────────────────────────────────────
  async function apiFetch(path, apiKey) {
    const sep  = path.includes('?') ? '&' : '?';
    const resp = await fetch(`${API_BASE}${path}${sep}key=${apiKey}&comment=${SCRIPT}`);
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    const data = await resp.json();
    if (data.error) throw new Error(`API ${data.error.code}: ${data.error.error}`);
    return data;
  }

  async function fetchItemPrice(apiKey, itemId) {
    try {
      const resp = await fetch(`https://api.torn.com/v2/market/${itemId}?selections=itemmarket&key=${apiKey}&comment=${SCRIPT}`);
      const data = await resp.json();
      if (!data.error) { const l = data.itemmarket || []; if (l.length) return l[0].cost || l[0].price || 0; }
    } catch { /* fall through */ }
    try {
      const resp = await fetch(`https://api.torn.com/v1/market/${itemId}?selections=bazaar&key=${apiKey}&comment=${SCRIPT}`);
      const data = await resp.json();
      if (!data.error) { const l = data.bazaar || []; if (l.length) return l[0].cost || l[0].price || 0; }
    } catch { /* fall through */ }
    try {
      const resp = await fetch(`https://api.torn.com/v1/torn/${itemId}?selections=items&key=${apiKey}&comment=${SCRIPT}`);
      const data = await resp.json();
      if (!data.error) { const item = (data.items || {})[itemId]; if (item?.market_value) return item.market_value; }
    } catch { /* give up */ }
    return 0;
  }

  async function resolveItemIds(apiKey, itemNames) {
    const CACHE_KEY = 'item_id_cache', CACHE_TIME = 'item_id_cache_time', TTL = 86400000;
    let nameToId = {};
    try { nameToId = JSON.parse(load(CACHE_KEY, '{}')); } catch {}
    const stale = (Date.now() - load(CACHE_TIME, 0)) > TTL;
    if (!stale && itemNames.every(n => nameToId[n.toLowerCase()] !== undefined)) return nameToId;
    try {
      const resp = await fetch(`https://api.torn.com/v1/torn/?selections=items&key=${apiKey}&comment=${SCRIPT}`);
      const data = await resp.json();
      if (!data.error) {
        for (const [id, item] of Object.entries(data.items || {}))
          nameToId[(item.name||'').toLowerCase()] = parseInt(id, 10);
        save(CACHE_KEY, JSON.stringify(nameToId));
        save(CACHE_TIME, Date.now());
      }
    } catch (e) { console.warn('[TSA] item ID fetch failed:', e); }
    return nameToId;
  }

  async function fetchUserStocks(apiKey) {
    const [userData, tornData] = await Promise.all([
      apiFetch('/user/stocks', apiKey),
      apiFetch('/torn/stocks', apiKey),
    ]);

    const nameToTicker = {};
    for (const s of STOCK_DATA) nameToTicker[s.name.toLowerCase()] = s.ticker;

    const idToTicker = {}, prices = {};
    const tornArr = Array.isArray(tornData.stocks)
      ? tornData.stocks
      : Object.entries(tornData.stocks || {}).map(([id,s]) => ({...s, _id:id}));

    for (const s of tornArr) {
      const id     = String(s.id || s._id || '');
      const ticker = (s.acronym||'').toUpperCase() || nameToTicker[(s.name||'').toLowerCase()] || '';
      if (id && ticker) idToTicker[id] = ticker;
      const mkt   = (typeof s.market === 'object' && s.market) ? s.market : {};
      const price = parseFloat(mkt.price || mkt.current_price || s.price || s.current_price || 0);
      if (ticker && price > 0) prices[ticker] = price;
    }
    for (const [id,tkr] of Object.entries(idToTicker)) tickerToId[tkr] = id;

    // DOM price supplement
    for (const row of document.querySelectorAll('#stockmarketroot table tr')) {
      const cells = [...row.querySelectorAll('td')];
      if (cells.length < 2) continue;
      for (let i = 0; i < cells.length - 1; i++) {
        const m = (cells[i].textContent||'').match(/\(([A-Z]{2,4})\)/);
        if (!m) continue;
        const nm = (cells[i+1]?.textContent||'').replace(/,/g,'').match(/[\d]+\.?\d*/);
        if (nm) { const p = parseFloat(nm[0]); if (p > 0) prices[m[1]] = p; }
        break;
      }
    }

    const holdings = {}, bonusInfo = {};
    const userArr = Array.isArray(userData.stocks)
      ? userData.stocks
      : Object.entries(userData.stocks || {}).map(([id,s]) => ({...s, _id:id}));

    for (const s of userArr) {
      const ticker = idToTicker[String(s.id || s._id || '')];
      if (!ticker) continue;
      const n = typeof s.shares === 'number' ? s.shares : 0;
      if (n > 0) holdings[ticker] = (holdings[ticker] || 0) + n;
      if (s.bonus && typeof s.bonus === 'object') {
        bonusInfo[ticker] = { available: !!s.bonus.available, progress: s.bonus.progress||0, frequency: s.bonus.frequency||0 };
      }
    }

    // DOM holdings fallback
    if (!Object.keys(holdings).length) {
      for (const row of document.querySelectorAll('#stockmarketroot table tr')) {
        const cells = row.querySelectorAll('td');
        if (cells.length < 4) continue;
        const tm = (cells[0]?.textContent||'').match(/\(([A-Z]{2,4})\)/);
        if (!tm) continue;
        const nums = (cells[3]?.textContent||'').replace(/[$,]/g,'').match(/\d+/g);
        if (nums?.length >= 2) { const sh = parseInt(nums[nums.length-1],10); if (sh > 0) holdings[tm[1]] = sh; }
      }
    }

    // ── DOM dividend reader ────────────────────────────────────────────────────
    // Reads cash dividend column on stocks page: total shown / heldBlocks = per-block value.
    // GRN example: DOM shows $12M for 3 blocks -> $4M/block cached.
    const DOM_KEY = 'dom_dividends', DOM_TS = 'dom_dividends_ts', DOM_TTL = 86400000;
    let domDividends = {};
    try {
      const cached = JSON.parse(load(DOM_KEY, '{}'));
      if ((Date.now() - load(DOM_TS, 0)) < DOM_TTL) domDividends = cached;
    } catch {}

    if (location.href.includes('sid=stocks')) {
      const fresh = {};
      for (const row of document.querySelectorAll('#stockmarketroot table tr')) {
        const cells = [...row.querySelectorAll('td')];
        if (cells.length < 4) continue;
        const tm = (cells[0]?.textContent||'').match(/\(([A-Z]{2,4})\)/);
        if (!tm) continue;
        const ticker  = tm[1];
        const stockDef = STOCK_DATA.find(s => s.ticker === ticker);
        if (!stockDef || stockDef.payoutType !== 'cash') continue;
        const dm = (cells[3]?.textContent||'').replace(/,/g,'').match(/\$(\d+)/);
        if (!dm) continue;
        const total = parseInt(dm[1], 10);
        if (!total) continue;
        const sh  = holdings[ticker] || 0;
        const b1  = getB1(ticker);
        let heldBlocks = 0;
        for (let n = 1; n <= 20; n++) {
          if (sh >= (Math.pow(2,n)-1)*b1) heldBlocks = n; else break;
        }
        if (heldBlocks <= 0) continue;
        const perBlock = Math.round(total / heldBlocks);
        if (perBlock > 0) {
          fresh[ticker] = perBlock;
          console.log(`[TSA] DOM dividend: ${ticker} = ${fmtMoney(perBlock)}/block (${fmtMoney(total)} / ${heldBlocks} blocks)`);
        }
      }
      if (Object.keys(fresh).length) {
        domDividends = { ...domDividends, ...fresh };
        try { save(DOM_KEY, JSON.stringify(domDividends)); save(DOM_TS, Date.now()); } catch {}
      }
    }

    return { holdings, prices, bonusInfo, domDividends };
  }

  // ─── Scoring ──────────────────────────────────────────────────────────────────
  async function buildScores(apiKey) {
    const { holdings, prices, bonusInfo, domDividends } = await fetchUserStocks(apiKey);

    // Resolve item IDs — use config overrides for name if set
    const itemStocks = STOCK_DATA.filter(s => s.payoutType === 'item');
    const itemNames  = [...new Set(itemStocks.map(s => getItemNameOverride(s.ticker, s.payoutItemName)).filter(Boolean))];
    const nameToId   = await resolveItemIds(apiKey, itemNames);

    const resolvedIds = {};
    for (const s of itemStocks) {
      const name = getItemNameOverride(s.ticker, s.payoutItemName);
      resolvedIds[s.ticker] = nameToId[name?.toLowerCase()] || s.payoutItemId;
    }

    const uniqueIds  = [...new Set(Object.values(resolvedIds))].filter(Boolean);
    const itemPrices = {};
    const [, pointsPrice] = await Promise.all([
      Promise.all(uniqueIds.map(async id => { itemPrices[id] = await fetchItemPrice(apiKey, id); })),
      (async () => {
        try {
          const resp = await fetch(`https://api.torn.com/market/?selections=pointsmarket&key=${apiKey}&comment=${SCRIPT}`);
          const data = await resp.json();
          if (data.error) return 0;
          const listings = data.pointsmarket || {};
          let cheapest = 0;
          for (const id of Object.keys(listings)) {
            const cost = listings[id].cost;
            if (cost && cost > 1000 && (!cheapest || cost < cheapest)) cheapest = cost;
          }
          return cheapest;
        } catch { return 0; }
      })(),
    ]);

    const ignoredTickers = getIgnored();
    const excludeMap = {
      nerve: getExNerve(), energy: getExEnergy(), happy: getExHappy(),
      other: getExOther(), passive: getExPassive(),
    };

    const rows = [];

    for (const stock of STOCK_DATA) {
      const { ticker, name, payoutType, payoutInterval, payoutDesc } = stock;
      const ignored    = ignoredTickers.includes(ticker);
      const excluded   = excludeMap[payoutType] || false;
      const sharesHeld = holdings[ticker] || 0;
      const price      = prices[ticker]   || 0;

      // Determine incrValue (per block per interval)
      let incrValue = 0;
      if (payoutType === 'item') {
        const qty        = getItemQtyOverride(ticker, stock.payoutQty || 1);
        const resolvedId = resolvedIds[ticker] || stock.payoutItemId;
        const unitPrice  = resolvedId ? (itemPrices[resolvedId] || 0) : 0;
        incrValue = getPayoutOverride(ticker, unitPrice * qty);
      } else if (payoutType === 'points') {
        // Points payout: qty points × live market price per point.
        // Falls back to payoutCashValue if market price unavailable.
        const qty = stock.payoutQty || 100;
        incrValue = getPayoutOverride(ticker, pointsPrice > 0 ? pointsPrice * qty : (stock.payoutCashValue || 0));
      } else if (payoutType === 'cash') {
        // Use DOM-read value if available, otherwise fall back to the stock's
        // default payoutCashValue (set in STOCK_DATA), then user config override.
        const domVal = domDividends[ticker] || 0;
        incrValue = getPayoutOverride(ticker, domVal > 0 ? domVal : (stock.payoutCashValue || 0));
      } else {
        incrValue = getPayoutOverride(ticker, stock.payoutCashValue || 0);
      }

      const dailyValue = payoutInterval > 0 ? incrValue / payoutInterval : 0;

      // Build increments dynamically using doubling rule
      // Visibility: show held blocks + up to 2 ahead (n+2 rule)
      const b1  = getB1(ticker);
      const max = getMaxBlocks(ticker, sharesHeld > 0 ? (() => {
        let hb = 0;
        for (let n = 1; n <= 50; n++) {
          if (sharesHeld >= (Math.pow(2,n)-1)*b1) hb = n; else break;
        }
        return hb;
      })() : 0);

      const increments = buildIncrements(ticker, (() => {
        let hb = 0;
        for (let n = 1; n <= 50; n++) {
          if (sharesHeld >= (Math.pow(2,n)-1)*b1) hb = n; else break;
        }
        return hb;
      })());

      // Count how many complete blocks this user holds
      let heldBlocks = 0;
      for (const { threshold } of increments) {
        if (sharesHeld >= threshold) heldBlocks++; else break;
      }

      // n+2 visibility: show held blocks + 2 ahead (never hide held blocks)
      const visibleUpTo = heldBlocks + 2;

      for (let i = 0; i < increments.length; i++) {
        const { incr, threshold } = increments[i];
        const prevThresh     = i > 0 ? increments[i-1].threshold : 0;
        const incrShares     = threshold - prevThresh;
        const incrCost       = incrShares * price;
        const sharesNeeded   = Math.max(0, threshold - sharesHeld);
        const costToComplete = sharesNeeded * price;

        let status;
        if (ignored)                      status = 'ignored';
        else if (sharesHeld >= threshold)  status = 'held';
        else if (sharesHeld > prevThresh)  status = 'partial';
        else                               status = 'next';

        // Visibility: hide blocks beyond n+2 (but never hide held)
        const beyondVisible = incr > visibleUpTo && status !== 'held';

        const budget     = getBudget();
        const budgetMax  = budget > 0 ? budget * (getBudgetPct()/100) : Infinity;
        // overBudget: true if cost exceeds budget threshold.
        // Also true when budget=0 and mode is 'hide' — zero cash means nothing is affordable.
        const overBudget = budget > 0
          ? costToComplete > budgetMax
          : (getBudgetMode() === 'hide' && costToComplete > 0);

        const scoreable = !ignored && !excluded && status !== 'held' && !beyondVisible && dailyValue > 0 && incrCost > 0;
        // For partial blocks, ROI is based on costToComplete (what you still need to spend),
        // not incrCost (the full block cost). This correctly rewards nearly-finished partials.
        // For fresh blocks costToComplete === incrCost so there's no difference.
        const roiDenominator = (scoreable && status === 'partial' && costToComplete > 0) ? costToComplete : incrCost;
        const dailyROI  = scoreable ? dailyValue / roiDenominator : 0;

        rows.push({
          ticker, name, incr, threshold, prevThresh, incrShares, incrCost,
          sharesHeld, sharesNeeded, costToComplete, heldBlocks,
          price, payoutType, payoutInterval, payoutDesc,
          incrValue, dailyValue, b1,
          status, ignored, excluded, scoreable, dailyROI, overBudget, beyondVisible,
          score: 0,
        });
      }
    }

    // Normalise scores 0-10
    const scoreables = rows.filter(r => r.scoreable);
    const maxROI = scoreables.length ? Math.max(...scoreables.map(r => r.dailyROI)) : 1;
    for (const r of rows) {
      r.score = (r.scoreable && maxROI > 0) ? Math.round((r.dailyROI / maxROI) * 100) / 10 : 0;
    }

    rows.sort((a, b) => {
      if (a.scoreable && b.scoreable) return b.score - a.score;
      if (a.scoreable) return -1;
      if (b.scoreable) return  1;
      if (a.status === 'held' && b.status !== 'held') return -1;
      if (a.status !== 'held' && b.status === 'held') return  1;
      return 0;
    });

    return { rows, holdings, prices, bonusInfo };
  }

  // ─── Swap advisor engine ─────────────────────────────────────────────────────

  function fmtNextPayout(info) {
    if (!info) return null;
    if (info.available) return '<span style="color:var(--tsa-status-ok);font-weight:bold">Ready now</span>';
    if (!info.frequency || info.frequency <= 0) return null;
    const d = info.frequency - info.progress;
    if (d <= 0) return '<span style="color:var(--tsa-status-ok);font-weight:bold">Ready now</span>';
    return `<span style="color:var(--tsa-status-warn)">Next in ${d}d</span> <span style="color:var(--tsa-text-muted);font-size:10px">(${info.progress}/${info.frequency}d)</span>`;
  }

  function buildSwaps(rows) {
    const noSell    = getSwapNoSell();
    const availCash = getAvailableCash();

    // ── Sell candidates ────────────────────────────────────────────────────────

    // Complete blocks the user holds (and is willing to sell)
    const heldByTicker = {};
    for (const r of rows.filter(r => r.status==='held' && !r.ignored && r.dailyValue>0 && !noSell.includes(r.ticker))) {
      if (!heldByTicker[r.ticker]) heldByTicker[r.ticker] = [];
      heldByTicker[r.ticker].push(r);
    }

    // Partial positions the user holds (no complete block payout — selling loses nothing daily)
    const partialRows = rows.filter(r => r.status==='partial' && !r.ignored && !noSell.includes(r.ticker));

    // ── Buy targets ────────────────────────────────────────────────────────────
    const targets = rows.filter(r => r.scoreable && r.score>0 && r.costToComplete>0 && r.dailyValue>0);

    // ── Swap builder ───────────────────────────────────────────────────────────
    // cashReleased: from selling. availCash: cash in hand. Combined = total available.
    // netCostForTarget: how much selling must cover after available cash contributes.
    function makeSwap(sellTicker, sellName, sellType, cashReleased, dailyLost, interval, target, extras=[]) {
      if (target.ticker === sellTicker) return null;
      // Available cash reduces how much the sell needs to cover
      const netCostForTarget = Math.max(0, target.costToComplete - availCash);
      if (netCostForTarget > cashReleased) return null;
      const netDailyGain = target.dailyValue - dailyLost;
      if (netDailyGain <= 0) return null;
      const transCost     = dailyLost * (interval || 1);
      const availCashUsed = Math.max(0, Math.min(availCash, target.costToComplete - cashReleased));
      const tier          = dailyLost > 0 ? 2 : 1;
      return {
        sellTicker, sellName, sellType, cashReleased, dailyLost, transCost,
        leftoverCash:  cashReleased + availCash - target.costToComplete,
        availCashUsed, tier, target, netDailyGain,
        paybackDays:   transCost > 0 ? Math.ceil(transCost / netDailyGain) : 0,
        extraSells: extras, combined: false,
      };
    }

    const swaps = [], sellProfiles = [];

    // ── Tier 0: cash-only — no selling required ───────────────────────────────
    // Only considers blocks that are immediately actionable:
    //   - partial (already started) or the direct next block (incr === heldBlocks + 1)
    // This prevents showing B2 as a direct buy when B1 isn't held yet.
    //
    // Chained purchases: if buying B1 still leaves enough cash for B2, show B2
    // as a separate entry with the reduced remaining cash. Simulates greedy
    // sequential buying — pick best-ROI affordable block, subtract its cost,
    // repeat until cash runs out.
    if (availCash > 0) {
      // Only immediately actionable targets — next direct block or partial
      const immediateTargets = targets.filter(t =>
        t.costToComplete <= availCash &&
        (t.status === 'partial' || t.incr === t.heldBlocks + 1)
      );

      // Greedy simulation: spend cash on highest-ROI affordable blocks in sequence.
      // Each purchase reduces remaining cash; re-check affordability after each pick.
      let remainingCash = availCash;
      const picked = [];
      const candidatePool = [...immediateTargets].sort((a,b) => b.dailyROI - a.dailyROI);

      while (remainingCash > 0 && candidatePool.length > 0) {
        // Find best affordable block not already picked
        const idx = candidatePool.findIndex(t =>
          t.costToComplete <= remainingCash &&
          !picked.some(p => p.ticker === t.ticker && p.incr === t.incr)
        );
        if (idx === -1) break;
        const t = candidatePool.splice(idx, 1)[0];
        picked.push(t);
        remainingCash -= t.costToComplete;

        swaps.push({
          sellTicker: '__CASH__', sellName: 'Available cash', sellType: 'cash',
          cashReleased: 0, dailyLost: 0, transCost: 0,
          leftoverCash:  remainingCash,
          availCashUsed: t.costToComplete,
          tier: 0, target: t, netDailyGain: t.dailyValue,
          paybackDays: 0, extraSells: [], combined: false,
        });
      }
    }

    // ── Tier 1: partial sell — shares held with no completed payout ───────────
    // (handled in partialRows loop below)

    // Targets already affordable with cash alone — no point suggesting a sell to fund them
    const cashAffordable = new Set(targets.filter(t => availCash > 0 && t.costToComplete <= availCash).map(t => `${t.ticker}:${t.incr}`));

    // ── Tier 2: complete block sells (full position or sell-down one block) ───
    // ── Full sell + sell-down for complete blocks ──────────────────────────────
    for (const [sellTicker, held] of Object.entries(heldByTicker)) {
      const sorted = [...held].sort((a,b) => b.incr - a.incr);
      const top    = sorted[0];
      if (!top.price || top.price <= 0) continue;

      const cashFull = top.sharesHeld * top.price;
      const lostFull = held.reduce((s,r) => s + r.dailyValue, 0);
      const maxInt   = Math.max(...held.map(r => r.payoutInterval || 0));

      // Full sell — exclude targets already buyable with cash alone
      swaps.push(...targets
        .filter(t => !cashAffordable.has(`${t.ticker}:${t.incr}`))
        .map(t => makeSwap(sellTicker, top.name, 'full', cashFull, lostFull, maxInt, t))
        .filter(Boolean).sort((a,b) => b.netDailyGain - a.netDailyGain).slice(0, 2));

      sellProfiles.push({ ticker: sellTicker, name: top.name, cash: cashFull, dailyLost: lostFull, interval: maxInt });

      // FIX: Sell-down — sell only shares above the highest COMPLETE block threshold.
      // Find the highest complete block held (that's the block we'd drop FROM).
      // Sell-down lands one block lower, releasing only the shares that block needed.
      if (held.length > 0) {
        // The highest complete block's threshold is top.threshold.
        // The block below it is top.prevThresh.
        // Shares to sell = sharesHeld - top.threshold (excess above the block we're keeping).
        // But if sharesHeld === top.threshold exactly (no excess), sell the whole top block:
        //   shares to sell = top.threshold - top.prevThresh
        const excessAboveTopBlock = top.sharesHeld - top.threshold;
        const sharesToSell = excessAboveTopBlock >= 0
          ? (top.threshold - top.prevThresh) + excessAboveTopBlock  // sell full top block + any excess
          : top.sharesHeld - top.prevThresh; // fallback (shouldn't happen)
        const cashDn = sharesToSell * top.price;
        if (cashDn > 0 && top.prevThresh > 0) {
          swaps.push(...targets
            .filter(t => !cashAffordable.has(`${t.ticker}:${t.incr}`))
            .map(t => makeSwap(sellTicker, top.name, 'down', cashDn, top.dailyValue, top.payoutInterval || 1, t))
            .filter(Boolean).sort((a,b) => b.netDailyGain - a.netDailyGain).slice(0, 2));
        }
      }
    }

    // ── FIX: Partial sell candidates ──────────────────────────────────────────
    // Selling a partial position loses no daily value (no complete block = no payout).
    // The full partial sell value is sharesHeld × price.
    for (const r of partialRows) {
      if (!r.price || r.price <= 0) continue;
      const cashPartial = r.sharesHeld * r.price;
      if (cashPartial <= 0) continue;
      swaps.push(...targets
        .filter(t => t.ticker !== r.ticker && !cashAffordable.has(`${t.ticker}:${t.incr}`))
        .map(t => {
          const netCostForTarget = Math.max(0, t.costToComplete - availCash);
          if (netCostForTarget > cashPartial) return null;
          const leftover = cashPartial + availCash - t.costToComplete;
          // No daily value lost from selling a partial — 0 payout to begin with
          return {
            sellTicker: r.ticker, sellName: r.name, sellType: 'partial',
            cashReleased: cashPartial, dailyLost: 0, transCost: 0,
            leftoverCash: leftover, target: t,
            netDailyGain: t.dailyValue, // no loss, pure gain
            paybackDays: 0, // immediate — no payout cycle missed
            extraSells: [], combined: false,
            availCashUsed: Math.max(0, Math.min(availCash, t.costToComplete - cashPartial)),
          };
        })
        .filter(Boolean).sort((a,b) => b.netDailyGain - a.netDailyGain).slice(0, 2));
    }

    // ── Combined sells (two complete-block stocks together) ───────────────────
    for (let i = 0; i < Math.min(sellProfiles.length, 8); i++) {
      for (let j = i+1; j < Math.min(sellProfiles.length, 8); j++) {
        const pA = sellProfiles[i], pB = sellProfiles[j];
        const cc = pA.cash + pB.cash;
        const cl = pA.dailyLost + pB.dailyLost;
        const ci = Math.max(pA.interval, pB.interval);
        swaps.push(...targets
          .filter(t => {
            const netCost = Math.max(0, t.costToComplete - availCash);
            return t.ticker !== pA.ticker && t.ticker !== pB.ticker
              && netCost > pA.cash && netCost > pB.cash && netCost <= cc;
          })
          .map(t => {
            const net = t.dailyValue - cl;
            if (net <= 0) return null;
            const tc  = cl * ci;
            return {
              sellTicker: `${pA.ticker}+${pB.ticker}`, sellName: `${pA.name} + ${pB.name}`,
              sellType: 'combined', cashReleased: cc, dailyLost: cl, transCost: tc,
              leftoverCash: cc + availCash - t.costToComplete, target: t,
              netDailyGain: net, paybackDays: Math.ceil(tc / net),
              extraSells: [pA.ticker, pB.ticker], combined: true,
              availCashUsed: Math.max(0, Math.min(availCash, t.costToComplete - cc)),
            };
          })
          .filter(Boolean).sort((a,b) => b.netDailyGain - a.netDailyGain).slice(0, 1));
      }
    }

    // Primary: highest netDailyGain DESC (already penalises block sells via dailyLost).
    // Tiebreaker: tier ascending (cash=0, partial=1, complete=2).
    return swaps.sort((a,b) => (b.netDailyGain - a.netDailyGain) || (a.tier - b.tier));
  }

  // ─── Mobile detection ─────────────────────────────────────────────────────────
  // Returns true if viewport width <= 480px (PDA / mobile)
  function isMobile() {
    return window.innerWidth <= 480;
  }

  // ─── Portfolio scoring engine ─────────────────────────────────────────────────
  //
  // Score = weighted combination of three factors (each 0-100):
  //   ROI efficiency  (50%) — actual daily payout / theoretical max if all
  //                           capital were in highest-ROI stock
  //   Idle capital    (30%) — penalty for money in partial blocks earning nothing
  //   Data completeness(20%)— % of cash stocks with known payout values
  //
  // Final score 0-100 maps to grades F through A+

  function gradeFromScore(score) {
    if (score >= 92) return 'A+';
    if (score >= 84) return 'A';
    if (score >= 76) return 'A\u2013';
    if (score >= 68) return 'B+';
    if (score >= 60) return 'B';
    if (score >= 52) return 'B\u2013';
    if (score >= 44) return 'C+';
    if (score >= 36) return 'C';
    if (score >= 28) return 'C\u2013';
    if (score >= 20) return 'D+';
    if (score >= 12) return 'D';
    if (score >= 4)  return 'D\u2013';
    return 'F';
  }

  function gradeColour(grade) {
    if (grade.startsWith('A')) return 'var(--tsa-status-ok)';
    if (grade.startsWith('B')) return 'var(--tsa-status-warn)';
    if (grade.startsWith('C')) return 'var(--tsa-status-crit)';
    return 'var(--tsa-text-muted)';
  }

  function calcPortfolioScore(rows, domDividends, swaps = []) {
    const heldRows      = rows.filter(r => r.status === 'held' && !r.ignored && !r.excluded);
    const partialRows   = rows.filter(r => r.status === 'partial' && !r.ignored);
    const scoreableRows = rows.filter(r => r.scoreable && r.score > 0);

    // ── Factor 1: ROI efficiency ──────────────────────────────────────────────
    // How close is your current portfolio to being fully optimised?
    // Formula: actualDaily / (actualDaily + bestAvailableNetGain)
    // where bestAvailableNetGain = the best swap's net daily gain (what you'd
    // gain by making your single best available move). If no moves exist you're
    // at 100% — nothing left to improve. This fairly rewards portfolios that
    // have already made good decisions, without penalising 31-day cash stocks
    // or diversification choices.
    const actualDaily = heldRows.reduce((s, r) => s + r.dailyValue, 0);

    // Best available net gain: top swap netDailyGain (already feasibility-checked),
    // or best scoreable block affordable with available cash.
    // Do NOT include unaffordable blocks in bestCashGain — they contaminate the
    // optimisation score with targets the user can't actually act on.
    const bestSwapGain = swaps.length
      ? Math.max(0, ...swaps.map(s => s.netDailyGain || 0))
      : 0;
    const availCash     = getAvailableCash();
    const affordableRows = availCash > 0
      ? scoreableRows.filter(r => r.costToComplete <= availCash)
      : [];
    const bestCashGain = affordableRows.length
      ? Math.max(0, ...affordableRows.map(r => r.dailyValue))
      : 0;
    const bestAvailableNetGain = Math.max(bestSwapGain, bestCashGain);

    const roiEfficiency = actualDaily <= 0
      ? 0
      : bestAvailableNetGain <= 0
        ? 100  // nothing better available — fully optimised
        : Math.min(100, (actualDaily / (actualDaily + bestAvailableNetGain)) * 100);

    // ── Factor 2: Idle capital penalty ───────────────────────────────────────
    // Partial blocks have capital deployed but earning nothing
    const totalInvested = heldRows.reduce((s, r) => s + r.incrCost, 0);
    const idleCapital = partialRows.reduce((s, r) => {
      const spent = (r.sharesHeld - r.prevThresh) * r.price;
      return s + spent;
    }, 0);
    const totalDeployed = totalInvested + idleCapital;
    const idlePct = totalDeployed > 0 ? (idleCapital / totalDeployed) * 100 : 0;
    // 0% idle = score 100, 50%+ idle = score 0
    const idleScore = Math.max(0, 100 - (idlePct * 2));

    // ── Factor 3: Data completeness ───────────────────────────────────────────
    // How many held cash stocks have a known payout value.
    // Resolution order: user config override → DOM-read → STOCK_DATA default.
    const heldCashTickers = [...new Set(heldRows
      .filter(r => r.payoutType === 'cash')
      .map(r => r.ticker))];
    const effectiveCashValue = t => {
      const stock  = STOCK_DATA.find(s => s.ticker === t);
      const domVal = domDividends?.[t] || 0;
      return getPayoutOverride(t, domVal > 0 ? domVal : (stock?.payoutCashValue || 0));
    };
    const resolvedCash = heldCashTickers.filter(t => effectiveCashValue(t) > 0).length;
    const unresolvedCashTickers = heldCashTickers.filter(t => effectiveCashValue(t) <= 0);
    const dataScore = heldCashTickers.length > 0
      ? (resolvedCash / heldCashTickers.length) * 100
      : 100; // no cash stocks = no penalty

    // ── Weighted total ────────────────────────────────────────────────────────
    const score = Math.round(
      (roiEfficiency * 0.5) +
      (idleScore     * 0.3) +
      (dataScore     * 0.2)
    );

    return {
      score: Math.min(100, Math.max(0, score)),
      grade: gradeFromScore(score),
      roiEfficiency: Math.round(roiEfficiency),
      idleScore:     Math.round(idleScore),
      dataScore:     Math.round(dataScore),
      actualDaily,
      bestAvailableNetGain,
      idleCapital,
      idlePct:        Math.round(idlePct),
      heldCashTickers,
      resolvedCash,
      unresolvedCash: unresolvedCashTickers.length,
      unresolvedCashTickers,
    };
  }

  // Simulate score after applying a set of buy/sell actions from the swap advisor
  // actions = array of { buy: {ticker, incr}, sell: {ticker} | null }
  function simulateScore(rows, domDividends, actions) {
    if (!actions || !actions.length) return null;

    // Clone rows and apply actions
    const simRows = rows.map(r => ({...r}));

    for (const action of actions) {
      // Apply sell: mark held blocks of sold ticker as not held
      if (action.sellTicker && action.sellTicker !== '__CASH__') {
        const tickers = action.sellTicker.includes('+')
          ? action.sellTicker.split('+')
          : [action.sellTicker];
        for (const ticker of tickers) {
          simRows.forEach(r => {
            if (r.ticker === ticker && r.status === 'held') r.status = 'sold_sim';
          });
        }
      }
      // Apply buy: mark target block as held, update dailyValue
      if (action.target) {
        const t = action.target;
        simRows.forEach(r => {
          if (r.ticker === t.ticker && r.incr === t.incr) {
            r.status = 'held';
            r.scoreable = false;
          }
        });
      }
      // Apply chain buys
      if (action.chainBuys) {
        for (const cb of action.chainBuys) {
          simRows.forEach(r => {
            if (r.ticker === cb.ticker && r.incr === cb.incr) {
              r.status = 'held';
              r.scoreable = false;
            }
          });
        }
      }
    }

    return calcPortfolioScore(simRows, domDividends);
  }

  // ─── Briefing builder ─────────────────────────────────────────────────────────
  // Rule-based natural language summary. Assembles sentences from live data,
  // varied with simple rotation so repeated refreshes don't feel identical.

  function buildBriefing(rows, prices, bonusInfo, swaps, portfolioScore, projectedScore, domDividends) {
    const heldRows    = rows.filter(r => r.status === 'held' && !r.ignored && !r.excluded);
    const partialRows = rows.filter(r => r.status === 'partial' && !r.ignored);
    const totalDaily  = heldRows.reduce((s, r) => s + r.dailyValue, 0);
    const topSwap     = swaps.find(s => s.sellType !== 'cash' && s.netDailyGain > 0);
    const cashSwaps   = swaps.filter(s => s.sellType === 'cash');
    const availCash   = getAvailableCash();

    // Ready payouts
    // Helper: is a ticker a passive stock (no collectable payout)?
    const isPassiveTicker = t => {
      const s = STOCK_DATA.find(d => d.ticker === t);
      return !s || s.payoutType === 'passive' || s.payoutInterval === 0;
    };

    const readyNow = Object.entries(bonusInfo)
      .filter(([ticker, info]) => info.available && !isPassiveTicker(ticker))
      .map(([ticker]) => ticker);

    // Upcoming payouts within 3 days
    const soonPayouts = Object.entries(bonusInfo)
      .filter(([ticker, info]) => !info.available && info.frequency > 0 && (info.frequency - info.progress) <= 3 && !isPassiveTicker(ticker))
      .map(([ticker, info]) => ({ ticker, daysLeft: info.frequency - info.progress }))
      .sort((a, b) => a.daysLeft - b.daysLeft);

    // Best partial
    const biggestPartial = partialRows.sort((a, b) => b.dailyValue - a.dailyValue)[0];

    const parts = [];

    // ── Opening: portfolio state ──────────────────────────────────────────────
    if (totalDaily > 0) {
      const blockCount = heldRows.length;
      parts.push(`Your portfolio is generating ${fmtMoney(totalDaily)}/day across ${blockCount} active block${blockCount !== 1 ? 's' : ''}.`);
    } else {
      parts.push(`You don\u2019t have any active stock blocks yet \u2014 the swap advisor below will help you get started.`);
    }

    // ── Urgent: collect now ───────────────────────────────────────────────────
    if (readyNow.length > 0) {
      const tickers = readyNow.slice(0, 3).join(', ');
      const more    = readyNow.length > 3 ? ` and ${readyNow.length - 3} more` : '';
      parts.push(`${tickers}${more} ${readyNow.length === 1 ? 'is' : 'are'} ready to collect right now \u2014 do that before making any moves.`);
    }
    // Mention upcoming payouts separately — even if some are already ready
    if (soonPayouts.length > 0) {
      const daysGroups = {};
      for (const p of soonPayouts) {
        const key = p.daysLeft === 1 ? 'tomorrow' : `in ${p.daysLeft} days`;
        if (!daysGroups[key]) daysGroups[key] = [];
        daysGroups[key].push(p.ticker);
      }
      for (const [when, tickers] of Object.entries(daysGroups)) {
        const tickerStr = tickers.join(', ');
        const verb = tickers.length === 1 ? 'pays out' : 'pay out';
        const prefix = readyNow.length > 0 ? `${tickerStr} also ${verb}` : `${tickerStr} ${verb}`;
        parts.push(`${prefix} ${when} \u2014 worth waiting to collect before selling any of these shares.`);
      }
    }

    // ── Partial drag ──────────────────────────────────────────────────────────
    if (biggestPartial) {
      const pct = Math.round(((biggestPartial.sharesHeld - biggestPartial.prevThresh) / biggestPartial.incrShares) * 100);
      const idle = (biggestPartial.sharesHeld - biggestPartial.prevThresh) * biggestPartial.price;
      parts.push(`You have ${fmtMoney(idle)} invested in a ${biggestPartial.ticker} partial (${pct}% complete) that\u2019s earning nothing yet \u2014 completing it would unlock ${fmtMoney(biggestPartial.dailyValue)}/day.`);
    }

    // ── Best action ───────────────────────────────────────────────────────────
    if (cashSwaps.length > 0 && availCash > 0) {
      const best = cashSwaps[0];
      parts.push(`With your ${fmtMoney(availCash)} available cash, the highest-ROI move right now is buying ${best.target.ticker} Block ${best.target.incr} \u2014 that\u2019s ${fmtMoney(best.target.dailyValue)}/day, available immediately.`);
    } else if (topSwap) {
      const pb       = topSwap.paybackDays > 0 ? ` You\u2019ll be ahead after ${topSwap.paybackDays} days.` : '';
      const cashHint = getAvailableCash() === 0 ? ' Set your available cash in Config to see more targeted options.' : '';
      parts.push(`Your best swap is selling ${topSwap.sellTicker} to fund ${topSwap.target.ticker} Block ${topSwap.target.incr}, netting +${fmtMoney(topSwap.netDailyGain)}/day.${pb}${cashHint}`);
    }

    // ── Projected score delta ─────────────────────────────────────────────────
    let scoreNote = '';
    if (projectedScore && projectedScore.grade !== portfolioScore.grade) {
      scoreNote = `Following these recommendations would lift your portfolio score from ${portfolioScore.grade} to ${projectedScore.grade}.`;
    } else if (projectedScore && projectedScore.score > portfolioScore.score + 2) {
      scoreNote = `Following these recommendations would improve your portfolio score from ${portfolioScore.score} to ${projectedScore.score} (still ${portfolioScore.grade}).`;
    }

    // ── Unresolved cash stocks warning ───────────────────────────────────────
    if (portfolioScore.unresolvedCash > 0) {
      const names = portfolioScore.unresolvedCashTickers.join(', ');
      parts.push(`${portfolioScore.unresolvedCash} of your cash dividend stock${portfolioScore.unresolvedCash !== 1 ? 's' : ''} ${portfolioScore.unresolvedCash !== 1 ? 'have' : 'has'} no payout value set (${names}) \u2014 visit the stocks page to auto-read them and sharpen the rankings.`);
    }

    // ── Priority actions list ─────────────────────────────────────────────────
    const actions = [];
    if (readyNow.length > 0) {
      actions.push({ text: `Collect ${readyNow.slice(0,3).join(', ')} payout${readyNow.length > 1 ? 's' : ''}`, val: 'Do first' });
    }
    if (soonPayouts.length > 0) {
      const soonTickers = soonPayouts.slice(0, 3).map(p => p.ticker).join(', ');
      const soonExtra   = soonPayouts.length > 3 ? ` +${soonPayouts.length - 3} more` : '';
      const next        = soonPayouts[0];
      const daysStr     = next.daysLeft === 1 ? 'tomorrow' : `in ${next.daysLeft}d`;
      actions.push({ text: `Wait for ${soonTickers}${soonExtra} payout${soonPayouts.length > 1 ? 's' : ''} (${daysStr})`, val: 'Before selling' });
    }
    if (cashSwaps.length > 0 && availCash > 0) {
      const best = cashSwaps[0];
      actions.push({ text: `Buy ${best.target.ticker} Block ${best.target.incr}`, val: `+${fmtMoney(best.target.dailyValue)}/day` });
    } else if (topSwap) {
      actions.push({ text: `Sell ${topSwap.sellTicker} \u2192 buy ${topSwap.target.ticker} B${topSwap.target.incr}`, val: `+${fmtMoney(topSwap.netDailyGain)}/day` });
    }
    if (biggestPartial && (!cashSwaps.length || availCash === 0)) {
      actions.push({ text: `Top up ${biggestPartial.ticker} Block ${biggestPartial.incr}`, val: `+${fmtMoney(biggestPartial.dailyValue)}/day` });
    }
    if (portfolioScore.unresolvedCash > 0) {
      actions.push({ text: 'Visit stocks page to read cash dividends', val: 'Improves scoring' });
    }

    return {
      paragraphs: parts,
      scoreNote,
      actions: actions.slice(0, 4),
    };
  }

  // ─── Render: briefing ─────────────────────────────────────────────────────────

  function renderBriefing(root, rows, prices, bonusInfo, swaps, portfolioScore, projectedScore, domDividends) {
    const el = root.querySelector('#tsa-briefing-content');
    if (!el) return;
    el.innerHTML = '';

    const briefing = buildBriefing(rows, prices, bonusInfo, swaps, portfolioScore, projectedScore, domDividends);

    // Paragraph block
    const paraDiv = document.createElement('div');
    paraDiv.style.cssText = 'font-size:12px;color:var(--tsa-text-secondary);line-height:1.7;margin-bottom:12px';
    paraDiv.textContent = briefing.paragraphs.join(' ');
    el.appendChild(paraDiv);

    // Score delta note
    if (briefing.scoreNote) {
      const noteDiv = document.createElement('div');
      noteDiv.style.cssText = 'font-size:11px;color:var(--tsa-phase-plan-text);background:var(--tsa-phase-plan-bg);border:1px solid var(--tsa-phase-plan-border);border-radius:4px;padding:6px 10px;margin-bottom:12px;line-height:1.5';
      noteDiv.innerHTML = `&#127919; ${briefing.scoreNote}`;
      el.appendChild(noteDiv);
    }

    // Priority actions
    if (briefing.actions.length) {
      const actionsDiv = document.createElement('div');
      actionsDiv.style.cssText = 'display:flex;flex-direction:column;gap:6px';
      briefing.actions.forEach((a, i) => {
        const row = document.createElement('div');
        row.style.cssText = 'background:var(--tsa-bg-deep);border:1px solid var(--tsa-border-strip);border-radius:5px;padding:8px 10px;display:flex;align-items:center;gap:8px';
        row.innerHTML = `
          <span style="color:var(--tsa-accent);font-weight:bold;font-size:13px;min-width:18px">${i + 1}</span>
          <span style="font-size:11px;color:var(--tsa-text-card);flex:1">${a.text}</span>
          <span style="font-size:11px;color:var(--tsa-status-ok);font-weight:bold;white-space:nowrap">${a.val}</span>`;
        actionsDiv.appendChild(row);
      });
      el.appendChild(actionsDiv);
    }
  }

  // ─── Render: portfolio score ──────────────────────────────────────────────────

  function renderPortfolioScore(root, portfolioScore, projectedScore) {
    const el = root.querySelector('#tsa-score-content');
    if (!el) return;
    el.innerHTML = '';

    const { score, grade, roiEfficiency, idleScore, dataScore,
            actualDaily, bestAvailableNetGain, idleCapital, idlePct, unresolvedCash } = portfolioScore;

    const gradeCol = gradeColour(grade);

    // Grade + progress bar
    const gradeDiv = document.createElement('div');
    gradeDiv.style.cssText = 'background:var(--tsa-bg-deep);border:1px solid var(--tsa-border-strip);border-radius:6px;padding:12px 14px;display:flex;align-items:center;gap:16px;margin-bottom:10px';

    const projHtml = projectedScore && projectedScore.grade !== grade
      ? `<span style="color:var(--tsa-text-muted);font-size:12px;margin:0 4px">\u2192</span><span style="font-size:28px;font-weight:bold;color:${gradeColour(projectedScore.grade)}">${projectedScore.grade}</span><span style="font-size:10px;color:var(--tsa-status-ok);margin-left:4px">if advice followed</span>`
      : '';

    gradeDiv.innerHTML = `
      <span style="font-size:44px;font-weight:bold;color:${gradeCol};line-height:1">${grade}</span>
      ${projHtml}
      <div style="flex:1">
        <div style="font-size:12px;font-weight:bold;color:var(--tsa-text-card);margin-bottom:6px">${
          score >= 76 ? 'Well optimised' :
          score >= 52 ? 'Room for improvement' :
          score >= 28 ? 'Significant gaps' : 'Needs attention'
        }</div>
        <div style="height:6px;background:var(--tsa-bg-card);border-radius:3px;overflow:hidden">
          <div style="height:100%;width:${score}%;background:${gradeCol};border-radius:3px;transition:width .4s ease"></div>
        </div>
        <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--tsa-text-muted);margin-top:3px">
          <span>0</span><span style="color:${gradeCol}">${score} / 100</span><span>100</span>
        </div>
      </div>`;
    el.appendChild(gradeDiv);

    // Three factor scores
    const metricsDiv = document.createElement('div');
    metricsDiv.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px';
    const metrics = [
      { lbl: 'Daily payout', val: fmtMoney(actualDaily), sub: '/day from active blocks', col: 'var(--tsa-status-ok)' },
      { lbl: 'Optimisation',
        val: roiEfficiency + '%',
        sub: bestAvailableNetGain > 0
          ? `best available move: +${fmtMoney(bestAvailableNetGain)}/day${getAvailableCash() === 0 ? ' · set cash in Config for accuracy' : ''}`
          : 'no better moves found',
        col: roiEfficiency >= 80 ? 'var(--tsa-status-ok)' : roiEfficiency >= 60 ? 'var(--tsa-status-warn)' : 'var(--tsa-status-crit)' },
      { lbl: 'Idle capital', val: idleCapital > 0 ? fmtMoney(idleCapital) : 'None', sub: idleCapital > 0 ? `${idlePct}% of portfolio earning nothing` : 'No money stuck in partials', col: idleCapital > 0 ? 'var(--tsa-status-warn)' : 'var(--tsa-status-ok)' },
      { lbl: 'Data completeness', val: dataScore + '%', sub: unresolvedCash > 0 ? `${unresolvedCash} cash stock${unresolvedCash !== 1 ? 's' : ''} unresolved` : 'All cash stocks resolved', col: dataScore >= 100 ? 'var(--tsa-status-ok)' : dataScore >= 50 ? 'var(--tsa-status-warn)' : 'var(--tsa-status-crit)' },
    ];
    for (const m of metrics) {
      const card = document.createElement('div');
      card.style.cssText = 'background:var(--tsa-bg-deep);border:1px solid var(--tsa-border-strip);border-radius:5px;padding:8px 10px';
      card.innerHTML = `
        <div style="font-size:9px;color:var(--tsa-text-muted);text-transform:uppercase;letter-spacing:.4px;margin-bottom:3px">${m.lbl}</div>
        <div style="font-size:16px;font-weight:bold;color:${m.col}">${m.val}</div>
        <div style="font-size:10px;color:var(--tsa-text-dead);margin-top:2px">${m.sub}</div>`;
      metricsDiv.appendChild(card);
    }
    el.appendChild(metricsDiv);

    // Insight: what's dragging the score down
    const insights = [];
    if (idleCapital > 0) insights.push(`<strong style="color:var(--tsa-text-card)">${fmtMoney(idleCapital)} idle</strong> in partial blocks — completing them would lift your idle score from ${idleScore} to 100.`);
    if (unresolvedCash > 0) insights.push(`<strong style="color:var(--tsa-text-card)">${unresolvedCash} cash stock${unresolvedCash !== 1 ? 's' : ''}</strong> with no payout value — visit the stocks page to auto-read them.`);
    if (roiEfficiency < 60) {
      const cashNote = getAvailableCash() === 0
        ? ' Set your available cash in Config for a more accurate reading.'
        : '';
      insights.push(`<strong style="color:var(--tsa-text-card)">Best available move: +${fmtMoney(bestAvailableNetGain)}/day</strong> — check the swap advisor for the highest-impact reallocation.${cashNote}`);
    }
    if (!insights.length) insights.push(`<strong style="color:var(--tsa-text-card)">Looking good</strong> — keep an eye on new blocks as stock prices shift.`);

    const insightDiv = document.createElement('div');
    insightDiv.style.cssText = 'background:var(--tsa-bg-dark);border-left:3px solid var(--tsa-status-warn);border-radius:0 4px 4px 0;padding:10px 12px;font-size:11px;color:var(--tsa-text-secondary);line-height:1.6';
    insightDiv.innerHTML = `<strong style="color:var(--tsa-text-card)">What to work on:</strong> ${insights.join(' ')}`;
    el.appendChild(insightDiv);
  }

  // ─── Render: payout calendar ──────────────────────────────────────────────────

  function renderCalendar(root, rows, bonusInfo) {
    const el = root.querySelector('#tsa-calendar-content');
    if (!el) return;
    el.innerHTML = '';

    // Build calendar entries from bonusInfo
    const entries = [];
    const heldTickers = [...new Set(rows.filter(r => r.status === 'held' && !r.ignored).map(r => r.ticker))];

    for (const ticker of heldTickers) {
      const info      = bonusInfo[ticker];
      const heldRows  = rows.filter(r => r.ticker === ticker && r.status === 'held');
      const totalDaily = heldRows.reduce((s, r) => s + r.dailyValue, 0);
      const stock     = STOCK_DATA.find(s => s.ticker === ticker);
      if (!stock || stock.payoutType === 'passive' || stock.payoutInterval === 0) continue;

      let daysLeft = null;
      let ready    = false;

      if (info) {
        if (info.available) {
          ready    = true;
          daysLeft = 0;
        } else if (info.frequency > 0) {
          daysLeft = info.frequency - info.progress;
        }
      }

      // Only show if we have timing data
      if (daysLeft === null && !ready) continue;

      entries.push({ ticker, name: stock.name, daysLeft, ready, totalDaily, interval: stock.payoutInterval, heldBlocks: heldRows.length });
    }

    // Sort: ready first, then by days ascending
    entries.sort((a, b) => {
      if (a.ready && !b.ready) return -1;
      if (!a.ready && b.ready) return 1;
      return (a.daysLeft || 0) - (b.daysLeft || 0);
    });

    if (!entries.length) {
      el.innerHTML = `<div style="color:var(--tsa-text-muted);font-size:12px;padding:6px 0">No payout timing data available \u2014 data is read from the Torn API on each refresh.</div>`;
      return;
    }

    // Summary bar
    // Est. incoming = sum of actual per-cycle payout values for stocks due within 7 days.
    // actual payout per cycle = totalDaily × interval (not × daysLeft)
    const readyCount = entries.filter(e => e.ready).length;
    const soonCount  = entries.filter(e => !e.ready && e.daysLeft <= 7).length;
    const next7d     = entries
      .filter(e => e.ready || (!e.ready && e.daysLeft <= 7))
      .reduce((s, e) => s + (e.totalDaily * e.interval), 0);

    const sumDiv = document.createElement('div');
    sumDiv.style.cssText = 'background:var(--tsa-bg-deep);border:1px solid var(--tsa-border-strip);border-radius:5px;padding:8px 12px;margin-bottom:10px;display:flex;gap:20px;flex-wrap:wrap;font-size:11px;color:var(--tsa-text-muted)';
    sumDiv.innerHTML = `
      <span>Ready now: <strong style="color:var(--tsa-status-ok)">${readyCount}</strong></span>
      <span>Within 7 days: <strong style="color:var(--tsa-status-warn)">${soonCount}</strong></span>
      ${next7d > 0 ? `<span>Est. incoming: <strong style="color:var(--tsa-accent)">${fmtMoney(next7d)}</strong></span>` : ''}`;
    el.appendChild(sumDiv);

    // Calendar rows — fixed-width columns for clean alignment
    const isMob = isMobile();
    const listDiv = document.createElement('div');
    listDiv.style.cssText = 'display:flex;flex-direction:column;gap:4px';

    // Visual group separator between soon (<=7d) and later (>7d)
    let shownSoonSep = false;
    let shownLaterSep = false;

    for (const e of entries) {
      const isReady = e.ready;
      const isSoon  = !e.ready && e.daysLeft <= 7;
      const isLater = !e.ready && e.daysLeft > 7;

      // Group label before first "later" entry
      if (isLater && !shownLaterSep) {
        shownLaterSep = true;
        const sep = document.createElement('div');
        sep.style.cssText = 'font-size:9px;color:var(--tsa-text-dead);text-transform:uppercase;letter-spacing:.5px;padding:6px 0 2px';
        sep.textContent = 'Later';
        listDiv.appendChild(sep);
      } else if ((isReady || isSoon) && !shownSoonSep && readyCount === 0 && soonCount > 0) {
        shownSoonSep = true;
      }

      const row = document.createElement('div');
      row.style.cssText = `
        display:grid;
        grid-template-columns:48px 1fr 110px ${isMob ? '' : '160px'};
        align-items:center;gap:0 10px;padding:8px 10px;
        background:${isReady ? 'var(--tsa-status-ok-bg)' : isSoon ? 'var(--tsa-status-warn-bg)' : 'var(--tsa-bg-deep)'};
        border:1px solid ${isReady ? 'var(--tsa-status-ok-border)' : isSoon ? 'var(--tsa-status-warn-border)' : 'var(--tsa-border-faint)'};
        border-radius:5px`;

      const countdownText = isReady ? 'Collect now'
        : e.daysLeft === 1 ? 'Tomorrow'
        : `In ${e.daysLeft} days`;
      const countdownCol = isReady ? 'var(--tsa-status-ok)'
        : isSoon ? 'var(--tsa-status-warn)'
        : 'var(--tsa-text-label)';

      // Show actual payout value per cycle, not daily
      const cycleValue = e.totalDaily * e.interval;
      const payoutText = cycleValue > 0
        ? `${fmtMoney(cycleValue)} · ${e.heldBlocks} block${e.heldBlocks !== 1 ? 's' : ''} · every ${e.interval}d`
        : `${e.heldBlocks} block${e.heldBlocks !== 1 ? 's' : ''} · every ${e.interval}d`;

      row.innerHTML = `
        <span class="tsa-ticker">${e.ticker}</span>
        <span style="color:var(--tsa-text-card);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${e.name}</span>
        <span style="font-size:11px;font-weight:bold;color:${countdownCol};text-align:right">${countdownText}</span>
        ${isMob ? '' : `<span style="font-size:10px;color:var(--tsa-text-secondary);text-align:right;white-space:nowrap">${payoutText}</span>`}`;
      listDiv.appendChild(row);
    }
    el.appendChild(listDiv);

    // Legend
    const legendDiv = document.createElement('div');
    legendDiv.style.cssText = 'display:flex;gap:14px;margin-top:8px;font-size:10px;color:var(--tsa-text-muted)';
    legendDiv.innerHTML = `
      <span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--tsa-status-ok);margin-right:4px;vertical-align:middle"></span>Ready</span>
      <span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--tsa-status-warn);margin-right:4px;vertical-align:middle"></span>Within 7 days</span>
      <span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--tsa-accent);margin-right:4px;vertical-align:middle"></span>Later</span>`;
    el.appendChild(legendDiv);
  }


  // ─── UI build ─────────────────────────────────────────────────────────────────

  function buildUI() {
    const root = document.createElement('div');
    root.id = 'tsa-root';
    if (load('dashboard_collapsed','0')==='1') root.classList.add('collapsed');

    root.innerHTML = `
      <div id="tsa-header">
        <span class="tsa-title">Torn Stock Advisor <span class="tsa-version">v3.1.6</span></span>
        <span id="tsa-updated" class="tsa-updated">Not loaded</span>
        <button class="tsa-btn-secondary" id="tsa-cfg-toggle">&#9881; Config</button>
        <button class="tsa-btn-primary"   id="tsa-refresh-btn">&#8635; Refresh</button>
        <button class="tsa-btn-secondary" id="tsa-collapse-btn">${load('dashboard_collapsed','0')==='1'?'&#9660;':'&#9650;'}</button>
      </div>

      <div id="tsa-config-strip">
        <span>API Key: <span id="tsa-key-status" class="tsa-key-bad">&#10007; Not set</span></span>
        <span class="tsa-sep">|</span>
        <span>Theme: <span id="tsa-strip-theme" style="color:var(--tsa-accent)">Default</span></span>
        <span class="tsa-sep">|</span>
        <span>Ignored: <span id="tsa-strip-ignored" style="color:var(--tsa-accent)">None</span></span>
        <span class="tsa-sep">|</span>
        <span>Excl: <span id="tsa-strip-excl" style="color:var(--tsa-accent)">None</span></span>
        <span class="tsa-sep">|</span>
        <span id="tsa-strip-budget-wrap">Cash: <span id="tsa-strip-budget" style="color:var(--tsa-accent)">Off</span></span>
        <span class="tsa-sep">|</span>
        <span id="tsa-strip-nosell-wrap" style="display:none">No sell: <span id="tsa-strip-nosell" style="color:var(--tsa-accent)"></span></span>
        <span class="tsa-sep" id="tsa-strip-nosell-sep" style="display:none">|</span>
        <span>Refresh: <span id="tsa-strip-refresh" style="color:var(--tsa-text-secondary)">5 min</span></span>
      </div>

      <div id="tsa-config-panel">

        <span class="tsa-cfg-label">Theme</span>
        <div class="tsa-cfg-row">
          <label>Colour theme</label>
          <select class="tsa-cfg-input" id="tsa-cfg-theme" style="width:220px">
            <option value="default">Default (Dark Blue)</option>
            <option value="torn">Torn Classic</option>
            <option value="highcontrast">High Contrast (WCAG AAA)</option>
            <option value="deuteranopia">Deuteranopia (Red/Green CB)</option>
            <option value="protanopia">Protanopia (Red Deficiency)</option>
            <option value="tritanopia">Tritanopia (Blue/Yellow CB)</option>
            <option value="lowvision">Low Vision</option>
            <option value="light">Light Mode</option>
          </select>
        </div>

        <span class="tsa-cfg-label">API Key &mdash; requires <strong style="color:var(--tsa-accent)">Stocks</strong> access level</span>
        <div class="tsa-cfg-row">
          <label>Torn API Key</label>
          <input class="tsa-cfg-input" id="tsa-cfg-apikey" type="password" placeholder="Enter API key&hellip;" style="width:220px" />
          <button class="tsa-btn-primary" id="tsa-cfg-save-key">Save Key</button>
        </div>
        <div class="tsa-cfg-note">Stored locally only &mdash; never sent except to api.torn.com.</div>

        <span class="tsa-cfg-label">Ignore stocks (comma-separated tickers)</span>
        <div class="tsa-cfg-row">
          <label>Ignored tickers</label>
          <input class="tsa-cfg-input" id="tsa-cfg-ignored" type="text" placeholder="e.g. TCT, GRN" style="width:220px" />
        </div>

        <span class="tsa-cfg-label">Never sell (comma-separated tickers &mdash; excluded from Swap Advisor)</span>
        <div class="tsa-cfg-row">
          <label>Never sell</label>
          <input class="tsa-cfg-input" id="tsa-cfg-swap-no-sell" type="text" placeholder="e.g. TMI, FHG" style="width:220px" />
        </div>

        <span class="tsa-cfg-label">Exclude payout types from scoring</span>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-nerve">   <label for="tsa-ex-nerve">Nerve payouts (CBD)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-energy">  <label for="tsa-ex-energy">Energy payouts (MCS)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-happy">   <label for="tsa-ex-happy">Happiness payouts (EVL)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-other">   <label for="tsa-ex-other">Other non-item payouts (PTS, HRG)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-passive"> <label for="tsa-ex-passive">Passive stocks (TCP, TCI, WLT&hellip;)</label></div>

        <span class="tsa-cfg-label">Savings goal</span>
        <div class="tsa-cfg-row">
          <label>Goal stock &amp; block</label>
          <select class="tsa-cfg-input" id="tsa-cfg-savings-goal" style="width:280px">
            <option value="">&mdash; No goal set &mdash;</option>
          </select>
        </div>

        <span class="tsa-cfg-label">Available cash ($)
          <span style="font-size:9px;color:var(--tsa-text-dead);text-transform:none;font-weight:normal;margin-left:6px">
            Your current liquid cash balance. Used everywhere: filters recommendations by affordability,
            reduces how much selling is needed in Swap Advisor, tracks Savings Plan progress,
            and surfaces partial blocks you can complete right now.
          </span>
        </span>
        <div class="tsa-cfg-row">
          <label>Available cash ($)</label>
          <input class="tsa-cfg-input" id="tsa-cfg-budget" type="number" min="0" placeholder="e.g. 225000000" style="width:180px" />
        </div>
        <div class="tsa-cfg-row">
          <label>Max cost (% of cash)</label>
          <input class="tsa-cfg-input" id="tsa-cfg-budget-pct" type="number" min="100" max="500" style="width:70px" />
          <span style="font-size:10px;color:var(--tsa-text-muted)">e.g. 110 = show blocks up to 10% above your cash</span>
        </div>
        <div class="tsa-cfg-row">
          <label>Over-budget blocks</label>
          <select class="tsa-cfg-input" id="tsa-cfg-budget-mode" style="width:200px">
            <option value="grey">Grey out (still visible)</option>
            <option value="hide">Hide from recommendations</option>
            <option value="show">Show all (filter off)</option>
          </select>
        </div>

        <span class="tsa-cfg-label">Display</span>
        <div class="tsa-cfg-row">
          <label>Recommendation cards to show</label>
          <input class="tsa-cfg-input" id="tsa-cfg-topn" type="number" min="1" max="20" style="width:60px" />
        </div>
        <div class="tsa-cfg-row">
          <label>Auto-refresh (minutes)</label>
          <input class="tsa-cfg-input" id="tsa-cfg-refresh" type="number" min="1" max="60" style="width:60px" />
        </div>

        <span class="tsa-cfg-label">Per-stock overrides &amp; block structure
          <span style="font-size:9px;color:var(--tsa-text-dead);text-transform:none;margin-left:6px">
            B1 = first block share count. All higher blocks derive automatically (2^n-1)*B1.
            Payout = $ per block per interval. Item stocks auto-price via live market.
          </span>
        </span>
        <div id="tsa-cfg-accordions"></div>

        <div style="margin-top:10px;display:flex;gap:8px">
          <button class="tsa-btn-primary" id="tsa-cfg-save-all">Save All Config</button>
          <button class="tsa-btn-secondary" id="tsa-cfg-reset">Reset Defaults</button>
        </div>
      </div>

      <div id="tsa-stats-bar">
        <div class="tsa-stat"><span class="tsa-stat-label">Active Blocks</span><span class="tsa-stat-value" id="tsa-s-active">&mdash;</span></div>
        <div class="tsa-stat"><span class="tsa-stat-label">Partial</span><span class="tsa-stat-value" id="tsa-s-partial" style="color:var(--tsa-status-warn)">&mdash;</span></div>
        <div class="tsa-stat"><span class="tsa-stat-label">Daily Payout</span><span class="tsa-stat-value" id="tsa-s-daily">&mdash;</span></div>
        <div class="tsa-stat"><span class="tsa-stat-label">Best Daily ROI</span><span class="tsa-stat-value" id="tsa-s-roi">&mdash;</span></div>
        <div class="tsa-stat"><span class="tsa-stat-label">Stocks Tracked</span><span class="tsa-stat-value">${STOCK_DATA.length}</span></div>
      </div>

      <div id="tsa-body">
        <div id="tsa-error"></div>
        <div id="tsa-loading"><span class="tsa-spinner"></span>Loading stock data&hellip;</div>
        <div id="tsa-content" style="display:none">
          <div id="tsa-savings-banner" style="display:none"></div>

          <div class="tsa-section" id="tsa-briefing-section">
            <div class="tsa-section-title" id="tsa-briefing-title">&#9889; Today&#8217;s briefing</div>
            <div id="tsa-briefing-content" class="tsa-section-content-inner"></div>
          </div>

          <div class="tsa-section" id="tsa-score-section">
            <div class="tsa-section-title" id="tsa-score-title">&#128202; Portfolio score</div>
            <div id="tsa-score-content" class="tsa-section-content-inner"></div>
          </div>

          <div class="tsa-section" id="tsa-calendar-section">
            <div class="tsa-section-title" id="tsa-calendar-title">&#128197; Payout calendar</div>
            <div id="tsa-calendar-content" class="tsa-section-content-inner"></div>
          </div>

          <div class="tsa-section" id="tsa-savings-section" style="display:none">
            <div class="tsa-section-title" id="tsa-savings-title">&#127919; Savings Plan</div>
            <div id="tsa-savings-content"></div>
          </div>
          <div class="tsa-section">
            <div class="tsa-section-title" id="tsa-recs-title">Top Recommendations</div>
            <div id="tsa-recs-content"><div id="tsa-cards"></div></div>
          </div>
          <div class="tsa-section">
            <div class="tsa-section-title" id="tsa-hold-title">Your Holdings</div>
            <div id="tsa-hold-content"></div>
          </div>
          <div class="tsa-section">
            <div class="tsa-section-title" id="tsa-swap-title">Swap Advisor &mdash; Sell to Buy Better</div>
            <div id="tsa-swap-content"></div>
          </div>
          <div class="tsa-section">
            <div class="tsa-section-title" id="tsa-table-title">All Stocks &mdash; Full Rankings</div>
            <div id="tsa-mobile-rankings" style="padding:0 4px"></div>
            <div id="tsa-table-content">
              <table class="tsa-table">
                <thead><tr>
                  <th style="width:28px">#</th>
                  <th style="min-width:200px">Stock</th>
                  <th style="width:52px">Type</th>
                  <th style="min-width:90px;text-align:right">Payout/blk</th>
                  <th style="width:44px;text-align:center">Cycle</th>
                  <th style="min-width:75px;text-align:right">Held</th>
                  <th style="min-width:85px;text-align:right">Cost</th>
                  <th style="min-width:75px;text-align:right">Daily</th>
                  <th style="min-width:100px;text-align:right">ROI &middot; Score</th>
                  <th style="width:65px;text-align:center">Status</th>
                </tr></thead>
                <tbody id="tsa-tbody"></tbody>
              </table>
            </div>
          </div>
        </div>
      </div>

      <div id="tsa-disclaimer">
        &#9888; For informational purposes only. Stock data may be delayed or inaccurate.
        All investment decisions are made at your own risk. TheOddSod accepts no responsibility for financial losses.
      </div>
      <div id="tsa-footer">
        <span>Torn Stock Advisor &middot; TheOddSod (2640064)</span>
        <span>Scores = incremental daily ROI &middot; Item values via live market</span>
      </div>
    `;
    return root;
  }

  // ─── Populate config ──────────────────────────────────────────────────────────

  function populateConfig(root) {
    root.querySelector('#tsa-cfg-theme').value          = getTheme();
    root.querySelector('#tsa-cfg-apikey').value         = getApiKey();
    root.querySelector('#tsa-cfg-ignored').value        = load('ignored', '');
    root.querySelector('#tsa-cfg-swap-no-sell').value   = load('swap_no_sell', '');
    root.querySelector('#tsa-cfg-budget').value         = getBudget() || '';
    root.querySelector('#tsa-cfg-budget-pct').value     = getBudgetPct();
    root.querySelector('#tsa-cfg-budget-mode').value    = getBudgetMode();
    root.querySelector('#tsa-cfg-topn').value           = getTopN();
    root.querySelector('#tsa-cfg-refresh').value        = getRefreshMins();
    root.querySelector('#tsa-ex-nerve').checked         = getExNerve();
    root.querySelector('#tsa-ex-energy').checked        = getExEnergy();
    root.querySelector('#tsa-ex-happy').checked         = getExHappy();
    root.querySelector('#tsa-ex-other').checked         = getExOther();
    root.querySelector('#tsa-ex-passive').checked       = getExPassive();

    // Savings goal dropdown — built dynamically since blocks are now dynamic
    const goalSel = root.querySelector('#tsa-cfg-savings-goal');
    const savedGoal = getSavingsGoal();
    while (goalSel.options.length > 1) goalSel.remove(1);
    for (const stock of [...STOCK_DATA].sort((a,b)=>a.name.localeCompare(b.name))) {
      if (stock.payoutType === 'passive') continue;
      const b1  = getB1(stock.ticker);
      const max = getMaxBlocks(stock.ticker, 0);
      for (let n = 1; n <= max; n++) {
        const thresh = (Math.pow(2,n)-1)*b1;
        const key = `${stock.ticker}:${n}`;
        const opt = document.createElement('option');
        opt.value = key;
        opt.textContent = `${stock.name} — Block ${n} (${fmtShares(thresh)} shares)`;
        opt.selected = key === savedGoal;
        goalSel.appendChild(opt);
      }
    }

    // Per-stock accordions
    const container = root.querySelector('#tsa-cfg-accordions');
    container.innerHTML = '';
    for (const stock of [...STOCK_DATA].sort((a,b)=>a.name.localeCompare(b.name))) {
      const { ticker, name, payoutType, payoutInterval, payoutDesc } = stock;
      const iLabel = payoutInterval > 0 ? `every ${payoutInterval}d` : 'passive';

      const accord = document.createElement('div');
      accord.className = 'tsa-accord';

      const hdr = document.createElement('div');
      hdr.className = 'tsa-accord-hdr';
      hdr.innerHTML = `<span><span class="tsa-accord-ticker">${ticker}</span>${name}<span style="font-size:10px;color:var(--tsa-text-muted);margin-left:6px">${iLabel}</span></span><span class="tsa-accord-arrow">&#9662;</span>`;

      const body = document.createElement('div');
      body.className = 'tsa-accord-body';

      // B1 threshold input
      const b1Row = document.createElement('div');
      b1Row.className = 'tsa-incr-row';
      b1Row.innerHTML = `
        <label>B1 threshold (shares)</label>
        <input class="tsa-cfg-input tsa-b1-inp" data-ticker="${ticker}" type="number"
               value="${getB1(ticker)}" placeholder="${stock.b1Threshold}" style="width:130px" />
        <span class="tsa-incr-note">Block 1 share count. B2=3x, B3=7x, B4=15x, B5=31x&hellip;</span>`;
      body.appendChild(b1Row);

      // Max blocks input
      const maxRow = document.createElement('div');
      maxRow.className = 'tsa-incr-row';
      maxRow.innerHTML = `
        <label>Max blocks to show</label>
        <input class="tsa-cfg-input tsa-max-inp" data-ticker="${ticker}" type="number" min="1" max="50"
               value="${getMaxBlocks(ticker, 0)}" placeholder="auto (held+4)" style="width:130px" />
        <span class="tsa-incr-note">Blocks beyond held+2 are hidden from recommendations</span>`;
      body.appendChild(maxRow);

      // Payout value override
      const payRow = document.createElement('div');
      payRow.className = 'tsa-incr-row';
      const cashDefault = stock.payoutCashValue > 0 ? `default ${fmtMoney(stock.payoutCashValue)}` : 'auto (from stocks page)';
      const payPlaceholder = payoutType === 'item' ? 'auto (live market)'
        : payoutType === 'cash'   ? cashDefault
        : payoutType === 'points' ? 'auto (live points price)'
        : '0';
      payRow.innerHTML = `
        <label>Payout per block ($)</label>
        <input class="tsa-cfg-input tsa-payout-inp" data-ticker="${ticker}" type="number"
               value="${load(`payout_${ticker}`,'')}" placeholder="${payPlaceholder}" style="width:130px" />
        <span class="tsa-incr-note">${payoutDesc}</span>`;
      body.appendChild(payRow);

      // Item-specific fields: quantity and item name
      if (payoutType === 'item') {
        const qtyRow = document.createElement('div');
        qtyRow.className = 'tsa-incr-row';
        qtyRow.innerHTML = `
          <label>Items per block</label>
          <input class="tsa-cfg-input tsa-itemqty-inp" data-ticker="${ticker}" type="number" min="1"
                 value="${getItemQtyOverride(ticker, stock.payoutQty||1)}" style="width:130px" />
          <span class="tsa-incr-note">Quantity received per block per cycle</span>`;
        body.appendChild(qtyRow);

        const nameRow = document.createElement('div');
        nameRow.className = 'tsa-incr-row';
        const currentItemName = getItemNameOverride(ticker, stock.payoutItemName || '');
        nameRow.innerHTML = `
          <label>Item name (for market lookup)</label>
          <input class="tsa-cfg-input tsa-itemname-inp" data-ticker="${ticker}" type="text"
                 value="${currentItemName}"
                 placeholder="${stock.payoutItemName||'auto'}" style="width:200px" />
          <span class="tsa-item-status empty" data-ticker-status="${ticker}">&#x2022; type name then tab to verify</span>`;
        body.appendChild(nameRow);

        // Inline item name validator — checks against cached item ID map on blur.
        // No extra API call: uses the item_id_cache populated during normal scoring.
        const nameInp    = nameRow.querySelector('.tsa-itemname-inp');
        const statusSpan = nameRow.querySelector(`[data-ticker-status="${ticker}"]`);
        function checkItemName() {
          const query = nameInp.value.trim() || stock.payoutItemName || '';
          if (!query) {
            statusSpan.className = 'tsa-item-status empty';
            statusSpan.textContent = '\u2022 type name then tab to verify';
            return;
          }
          // Read the cached item map from GM storage
          let nameToId = {};
          try { nameToId = JSON.parse(load('item_id_cache', '{}')); } catch {}
          const cacheTime = load('item_id_cache_time', 0);
          const cacheAge  = Date.now() - cacheTime;

          if (!Object.keys(nameToId).length || cacheAge > 86400000 * 2) {
            // Cache empty or very stale — can't verify without an API call
            statusSpan.className = 'tsa-item-status pending';
            statusSpan.textContent = '\u23F3 Save & refresh to verify (cache empty)';
            return;
          }
          const found = nameToId[query.toLowerCase()];
          if (found) {
            statusSpan.className = 'tsa-item-status ok';
            statusSpan.textContent = `\u2713 Found (ID\u00a0${found})`;
          } else {
            statusSpan.className = 'tsa-item-status miss';
            statusSpan.textContent = '\u2717 Not found \u2014 check spelling exactly as it appears in Torn';
          }
        }
        // Check on blur (tab away) and on input with a short debounce
        nameInp.addEventListener('blur', checkItemName);
        let _debounce;
        nameInp.addEventListener('input', () => { clearTimeout(_debounce); _debounce = setTimeout(checkItemName, 600); });
        // Run immediately for pre-filled values so status shows on accordion open
        if (currentItemName) setTimeout(checkItemName, 0);
      }

      // Energy/nerve/happy: units per block
      if (['energy','nerve','happy'].includes(payoutType)) {
        const unitsRow = document.createElement('div');
        unitsRow.className = 'tsa-incr-row';
        const unitLabel = payoutType === 'happy' ? 'happy' : payoutType;
        unitsRow.innerHTML = `
          <label>${unitLabel.charAt(0).toUpperCase()+unitLabel.slice(1)} per block</label>
          <input class="tsa-cfg-input tsa-units-inp" data-ticker="${ticker}" type="number" min="1"
                 value="${getUnitsOverride(ticker, stock.payoutUnits||0)}" style="width:130px" />
          <span class="tsa-incr-note">${unitLabel} units received per block per cycle</span>`;
        body.appendChild(unitsRow);
      }

      hdr.addEventListener('click', () => { hdr.classList.toggle('open'); body.classList.toggle('open'); });
      accord.appendChild(hdr);
      accord.appendChild(body);
      container.appendChild(accord);
    }
  }

  // ─── Config strip ─────────────────────────────────────────────────────────────

  function updateStrip(root) {
    const key = getApiKey();
    const el  = root.querySelector('#tsa-key-status');
    el.textContent = key ? '\u2713 Connected' : '\u2717 Not set';
    el.className   = key ? 'tsa-key-ok' : 'tsa-key-bad';

    const themeNames = { default:'Default', torn:'Torn Classic', highcontrast:'High Contrast',
      deuteranopia:'Deuteranopia', protanopia:'Protanopia', tritanopia:'Tritanopia',
      lowvision:'Low Vision', light:'Light Mode' };
    const themeEl = root.querySelector('#tsa-strip-theme');
    if (themeEl) themeEl.textContent = themeNames[getTheme()] || getTheme();

    const ig = getIgnored();
    root.querySelector('#tsa-strip-ignored').textContent = ig.length ? ig.join(', ') : 'None';

    const excl = [];
    if (getExNerve())   excl.push('Nerve');
    if (getExEnergy())  excl.push('Energy');
    if (getExHappy())   excl.push('Happy');
    if (getExOther())   excl.push('Other');
    if (getExPassive()) excl.push('Passive');
    root.querySelector('#tsa-strip-excl').textContent    = excl.length ? excl.join(', ') : 'None';
    root.querySelector('#tsa-strip-refresh').textContent = getRefreshMins() + ' min';

    const noSell = getSwapNoSell();
    const nsWrap = root.querySelector('#tsa-strip-nosell-wrap');
    const nsSep  = root.querySelector('#tsa-strip-nosell-sep');
    if (nsWrap) {
      nsWrap.style.display = noSell.length ? '' : 'none';
      if (nsSep) nsSep.style.display = noSell.length ? '' : 'none';
      const ns = root.querySelector('#tsa-strip-nosell');
      if (ns) ns.textContent = noSell.join(', ');
    }

    const budget   = getBudget();
    const budgetEl = root.querySelector('#tsa-strip-budget');
    if (budgetEl) budgetEl.innerHTML = budget > 0
      ? `${fmtMoney(budget)}`
      : `<span style="color:var(--tsa-text-dead)">Not set <span style="font-size:9px">(set in Config)</span></span>`;
  }

  // ─── Render: savings plan ─────────────────────────────────────────────────────

  function renderSavingsPlan(root, rows, prices) {
    const goalKey  = getSavingsGoal();
    const section  = root.querySelector('#tsa-savings-section');
    const planEl   = root.querySelector('#tsa-savings-content');
    const bannerEl = root.querySelector('#tsa-savings-banner');
    if (!section || !planEl || !bannerEl) return;
    if (!goalKey) { section.style.display='none'; bannerEl.style.display='none'; return; }

    const [goalTicker, goalIncrStr] = goalKey.split(':');
    const goalIncr  = parseInt(goalIncrStr, 10);
    const goalStock = STOCK_DATA.find(s => s.ticker === goalTicker);
    if (!goalStock) { section.style.display='none'; bannerEl.style.display='none'; return; }
    section.style.display = '';

    const b1             = getB1(goalTicker);
    const goalThresh     = (Math.pow(2,goalIncr)-1)*b1;
    const prevThresh     = goalIncr > 1 ? (Math.pow(2,goalIncr-1)-1)*b1 : 0;
    const goalIncrShares = goalThresh - prevThresh;
    const goalPrice      = prices[goalTicker] || 0;
    const goalRow        = rows.find(r => r.ticker===goalTicker && r.incr===goalIncr);
    const sharesAlready  = goalRow ? Math.max(0, goalRow.sharesHeld - prevThresh) : 0;
    const sharesNeeded   = Math.max(0, goalIncrShares - sharesAlready);
    const goalCost       = goalRow?.costToComplete > 0 ? goalRow.costToComplete : goalIncrShares * goalPrice;

    const availCash = getAvailableCash();
    let taggedValue = 0;
    const taggedStocks = [];
    for (const row of rows) {
      if (row.status !== 'held') continue;
      if (!isSteppingStone(row.ticker, row.incr)) continue;
      const liqVal = row.incrShares * (prices[row.ticker]||0);
      taggedValue += liqVal;
      taggedStocks.push({ ticker:row.ticker, name:row.name, incr:row.incr, blockShares:row.incrShares, liqVal, dailyValue:row.dailyValue });
    }

    const totalProgress = availCash + taggedValue;
    const pct           = goalCost > 0 ? Math.min(100,(totalProgress/goalCost)*100) : 0;
    const remaining     = Math.max(0, goalCost - totalProgress);
    const totalDaily    = rows.filter(r=>r.status==='held').reduce((s,r)=>s+r.dailyValue,0);
    const daysToGoal    = (remaining>0&&totalDaily>0) ? Math.ceil(remaining/totalDaily) : 0;

    const dismissKey  = `savings_banner_dismissed_${goalKey}`;
    if (pct >= 100 && load(dismissKey,'') !== '1') {
      bannerEl.style.display = '';
      const sellList = taggedStocks.length
        ? taggedStocks.map(s=>`<span class="tsa-ticker">${s.ticker}</span> (${fmtMoney(s.liqVal)})`).join(', ')
        : '<span style="opacity:.8">your stepping stone stocks</span>';
      bannerEl.innerHTML = `
        <span class="tsa-ban-icon">&#127919;</span>
        <div class="tsa-ban-body">
          <div class="tsa-ban-title">You can now afford ${goalStock.name} Block ${goalIncr}!</div>
          <div style="font-size:11px;margin-top:2px">Goal: <strong>${fmtMoney(goalCost)}</strong> &nbsp;&middot;&nbsp; You have: <strong>${fmtMoney(totalProgress)}</strong></div>
          <div class="tsa-ban-sell">Sell ${sellList} &rarr; buy ${goalStock.name} Block ${goalIncr}</div>
        </div>
        <button class="tsa-ban-dismiss" id="tsa-ban-dismiss-btn">Dismiss</button>`;
      root.querySelector('#tsa-ban-dismiss-btn')?.addEventListener('click', () => {
        save(dismissKey,'1'); bannerEl.style.display='none';
      });
    } else {
      bannerEl.style.display = 'none';
      if (pct < 100) save(dismissKey, '');
    }

    planEl.innerHTML = '';
    const goalCard = document.createElement('div');
    goalCard.className = 'tsa-plan-goal';
    goalCard.innerHTML = `
      <div class="tsa-plan-goal-hd">
        <div>
          <span class="tsa-plan-goal-name">${goalStock.name} &mdash; Block ${goalIncr}</span>
          <span style="font-size:10px;color:var(--tsa-text-muted);margin-left:8px">${fmtShares(sharesNeeded)} shares needed @ ${fmtMoney(goalPrice)}/share${sharesAlready>0?` <span style="color:var(--tsa-status-ok)">(have ${fmtShares(sharesAlready)} of ${fmtShares(goalIncrShares)})</span>`:''}</span>
        </div>
        <span class="tsa-plan-goal-meta">Goal: <strong style="color:var(--tsa-accent)">${fmtMoney(goalCost)}</strong></span>
      </div>
      <div class="tsa-plan-progress">
        <div class="tsa-plan-progress-fill${pct>=100?' done':''}" style="width:${pct.toFixed(1)}%"></div>
      </div>
      <div style="display:flex;justify-content:space-between;font-size:10px;color:var(--tsa-text-muted);margin-top:3px">
        <span>${pct.toFixed(1)}% reached</span>
        <span>${remaining>0?fmtMoney(remaining)+' still needed':'&#10003; Goal reached!'}</span>
      </div>
      <div class="tsa-plan-stats">
        <div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Available cash</span><span class="tsa-plan-stat-val">${fmtMoney(availCash)}</span></div>
        <div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Stepping stones</span><span class="tsa-plan-stat-val">${taggedStocks.length?fmtMoney(taggedValue):'&mdash;'}</span></div>
        <div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Total progress</span><span class="tsa-plan-stat-val" style="color:${pct>=100?'var(--tsa-status-ok)':'var(--tsa-accent)'}">${fmtMoney(totalProgress)}</span></div>
        ${daysToGoal>0?`<div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Est. days</span><span class="tsa-plan-stat-val">${daysToGoal}d</span></div>`:''}
        ${totalDaily>0?`<div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Daily payout</span><span class="tsa-plan-stat-val">${fmtMoney(totalDaily)}/day</span></div>`:''}
      </div>`;
    planEl.appendChild(goalCard);

    if (taggedStocks.length) {
      const hd = document.createElement('div');
      hd.style.cssText = 'font-size:9px;color:var(--tsa-text-muted);text-transform:uppercase;letter-spacing:.5px;margin:12px 0 6px';
      hd.textContent = 'Tagged stepping stones'; planEl.appendChild(hd);
      for (const s of taggedStocks) {
        const row = document.createElement('div'); row.className = 'tsa-plan-rec-row';
        row.innerHTML = `<span class="tsa-ticker">${s.ticker}</span><span style="color:var(--tsa-text-card);flex:1">${s.name} Block ${s.incr}</span><span style="color:var(--tsa-status-ok);font-size:11px">${s.dailyValue>0?fmtMoney(s.dailyValue)+'/day':'&mdash;'}</span><span style="color:var(--tsa-text-secondary);font-size:10px;margin-left:10px">Sell: ${fmtMoney(s.liqVal)} (${fmtShares(s.blockShares)} sh)</span>`;
        planEl.appendChild(row);
      }
    }

    const investBudget = getAvailableCash();
    if (investBudget > 0) {
      const noSell   = getSwapNoSell();
      const heldKeys = new Set(rows.filter(r=>r.status==='held').map(r=>`${r.ticker}:${r.incr}`));
      const candidates = rows.filter(r =>
        r.ticker!==goalTicker && !noSell.includes(r.ticker) &&
        !heldKeys.has(`${r.ticker}:${r.incr}`) && r.scoreable && r.score>0 &&
        r.costToComplete<=investBudget && r.dailyValue>0
      ).sort((a,b)=>b.dailyROI-a.dailyROI).slice(0,5);

      const recHd = document.createElement('div');
      recHd.style.cssText = 'font-size:9px;color:var(--tsa-text-muted);text-transform:uppercase;letter-spacing:.5px;margin:14px 0 6px';
      recHd.textContent = candidates.length
        ? `Stepping stone suggestions within ${fmtMoney(investBudget)}`
        : `No stepping-stone candidates within ${fmtMoney(investBudget)}`;
      planEl.appendChild(recHd);
      for (const r of candidates) {
        const row = document.createElement('div'); row.className = 'tsa-plan-rec-row';
        row.innerHTML = `<span class="tsa-ticker">${r.ticker}</span><span style="color:var(--tsa-text-card);flex:1">${r.name}<span class="tsa-badge tsa-badge-info" style="font-size:9px;margin-left:4px">Block ${r.incr}</span></span><span style="color:var(--tsa-text-secondary);font-size:10px">${fmtMoney(r.costToComplete)}</span><span style="color:var(--tsa-status-ok);font-size:11px;margin-left:8px">${fmtMoney(r.dailyValue)}/day</span><span style="color:var(--tsa-accent);font-size:10px;margin-left:8px">${fmtROI(r.dailyValue,r.incrCost)}</span>`;
        planEl.appendChild(row);
      }
    } else {
      const hint = document.createElement('div');
      hint.style.cssText = 'color:var(--tsa-text-muted);font-size:12px;padding:8px 0';
      hint.textContent = 'Set an Available to invest amount in Config to see stepping-stone suggestions.';
      planEl.appendChild(hint);
    }
  }

  // ─── Render: recommendation cards ────────────────────────────────────────────

  function renderCards(root, rows) {
    const container = root.querySelector('#tsa-cards');
    container.innerHTML = '';
    const budgetMode  = getBudgetMode();
    const seenTickers = new Set();
    const dedupedRows = [];
    for (const r of rows.filter(r=>r.scoreable&&r.score>0)) {
      if (budgetMode==='hide'&&r.overBudget) continue;
      if (!seenTickers.has(r.ticker)) { seenTickers.add(r.ticker); dedupedRows.push(r); }
    }
    const topRows = dedupedRows.slice(0, getTopN());
    if (!topRows.length) {
      const totalScoreable = rows.filter(r => r.scoreable && r.score > 0).length;
      const hiddenByBudget = budgetMode === 'hide' && getBudget() > 0
        ? rows.filter(r => r.scoreable && r.score > 0 && r.overBudget).length
        : 0;
      const msg = hiddenByBudget > 0 && hiddenByBudget === totalScoreable
        ? `All ${totalScoreable} scoreable block${totalScoreable>1?'s':''} are over your ${fmtMoney(getBudget())} budget &mdash; increase your cash amount, raise the max cost %, or switch over-budget blocks to &ldquo;Grey out&rdquo; to see them here.`
        : 'No scoreable blocks &mdash; check config or API key.';
      container.innerHTML = `<div style="color:var(--tsa-text-muted);font-size:12px;padding:8px 0">${msg}</div>`;
      return;
    }
    topRows.forEach((r, i) => {
      const rank    = i+1;
      const rankCls = rank===1?'tsa-rank-gold':rank===2?'tsa-rank-silver':rank===3?'tsa-rank-bronze':'';
      const iLabel  = r.payoutInterval>0?`every ${r.payoutInterval} days`:'';
      let partialHtml = '';
      if (r.status==='partial') {
        const pct = Math.round(((r.sharesHeld-r.prevThresh)/r.incrShares)*100);
        partialHtml = `<div class="tsa-card-partial">&#9654; ${fmtShares(r.sharesHeld-r.prevThresh)} / ${fmtShares(r.incrShares)} shares (${pct}% there)</div>`;
      }
      const stockDef = STOCK_DATA.find(s=>s.ticker===r.ticker);
      const stockUrl = stockDef?.stockId ? `https://www.torn.com/page.php?sid=stocks&stockID=${stockDef.stockId}&tab=owned` : null;
      function openStockPanel() {
        for (const span of document.querySelectorAll('.tt-acronym')) {
          if (span.textContent.trim()===`(${r.ticker})`) {
            const ul=span.closest('ul');
            if (ul?.children[2]) { ul.children[2].click(); ul.scrollIntoView({behavior:'smooth',block:'center'}); return; }
          }
        }
        if (stockUrl) window.location.href = stockUrl;
      }
      const existingHeld  = rows.filter(rv=>rv.ticker===r.ticker&&rv.status==='held');
      const existingDaily = existingHeld.reduce((s,rv)=>s+rv.dailyValue,0);
      const totalAfterHtml = existingHeld.length>0&&r.dailyValue>0
        ? `<div class="tsa-card-line" style="color:var(--tsa-text-muted)">Total after buy: <strong style="color:var(--tsa-status-ok)">${fmtMoney(existingDaily+r.dailyValue)}/day</strong> <span style="font-size:10px;color:var(--tsa-text-dead)">(${existingHeld.length} block${existingHeld.length>1?'s':''} already)</span></div>`
        : '';
      const card = document.createElement('div');
      card.className='tsa-card'; card.style.cursor='pointer'; card.title=`Open ${r.ticker} buy panel`;
      card.addEventListener('click', openStockPanel);
      card.innerHTML = `
        <div class="tsa-card-rank ${rankCls}">#${rank}</div>
        <div class="tsa-card-head"><span class="tsa-ticker">${r.ticker}</span> &nbsp;${r.name}${stockUrl?'<span style="color:var(--tsa-text-dead);font-size:9px;margin-left:6px">&#8599;</span>':''}</div>
        <div class="tsa-card-sub"><span class="tsa-badge tsa-badge-info">Block ${r.incr}</span></div>
        <div class="tsa-card-line">This block earns: <strong>${r.incrValue>0?fmtMoney(r.incrValue):'set in config'}</strong> ${iLabel}</div>
        ${totalAfterHtml}
        <div class="tsa-card-line">Need: <strong>${fmtShares(r.sharesNeeded)} shares</strong> &nbsp;&middot;&nbsp; Cost: <strong>${fmtMoney(r.costToComplete)}</strong></div>
        <div class="tsa-card-roi">Daily ROI: ${fmtROI(r.dailyValue,r.incrCost)} &nbsp;&middot;&nbsp; Score: ${r.score.toFixed(1)}/10</div>
        ${r.overBudget?`<div style="font-size:10px;color:var(--tsa-status-crit);margin-top:3px">&#9888; Over budget (${fmtMoney(r.costToComplete)})</div>`:''}
        ${partialHtml}`;
      container.appendChild(card);
    });
  }

  // ─── Render: holdings ────────────────────────────────────────────────────────

  function renderHoldings(root, rows) {
    const content = root.querySelector('#tsa-hold-content');
    content.innerHTML = '';
    const held    = rows.filter(r=>r.status==='held'    && !r.ignored);
    const partial = rows.filter(r=>r.status==='partial' && !r.ignored);
    if (!held.length && !partial.length) {
      content.innerHTML = `<div style="color:var(--tsa-text-muted);font-size:12px;padding:6px 0">No active or partial blocks detected.</div>`;
      return;
    }
    if (held.length) {
      const lbl = document.createElement('div'); lbl.className='tsa-hold-sublabel'; lbl.textContent='Active blocks'; content.appendChild(lbl);
      const goalActive = !!getSavingsGoal();
      const byTicker = {};
      for (const r of held) {
        if (!byTicker[r.ticker]) byTicker[r.ticker]={r,count:0,totalDaily:0,blocks:[]};
        byTicker[r.ticker].count++;
        byTicker[r.ticker].totalDaily+=r.dailyValue;
        byTicker[r.ticker].blocks.push(r);
      }
      const entries = Object.values(byTicker).sort((a,b)=>{
        const ap=a.r.payoutType==='passive', bp=b.r.payoutType==='passive';
        if (ap!==bp) return ap?1:-1;
        return a.r.ticker.localeCompare(b.r.ticker);
      });
      let lastActive = true;
      for (const {r,count,totalDaily,blocks} of entries) {
        const isPassive = r.payoutType==='passive';
        if (isPassive && lastActive) {
          const div=document.createElement('div'); div.className='tsa-hold-sublabel';
          div.style.marginTop='8px'; div.style.opacity='0.5';
          div.textContent='Passive benefits (no active payout)'; content.appendChild(div); lastActive=false;
        }
        const iLabel     = r.payoutInterval>0?`every ${r.payoutInterval}d`:'passive';
        const rowsToShow = (goalActive&&blocks.length>1)?blocks.sort((a,b)=>a.incr-b.incr):[{...r,_summary:true,count,totalDaily}];
        for (const blockR of rowsToShow) {
          const isSummary = !!blockR._summary;
          const row = document.createElement('div'); row.className='tsa-hold-row';
          if (isPassive) row.style.opacity='0.55';
          const isStep    = isSteppingStone(blockR.ticker, blockR.incr);
          const blockLbl  = !isSummary?` <span style="color:var(--tsa-text-dead);font-size:10px">Block ${blockR.incr}</span>`:'';
          const badgeTxt  = isSummary?`${count} block${count>1?'s':''} \u2713`:`Block ${blockR.incr} \u2713`;
          const dayVal    = isSummary?totalDaily:blockR.dailyValue;
          row.innerHTML = `
            <span class="tsa-ticker">${blockR.ticker}</span>
            <span class="tsa-hold-name">${blockR.name}${blockLbl}</span>
            <span class="tsa-badge ${isPassive?'tsa-badge-passive':'tsa-badge-ok'}" style="justify-self:end">${badgeTxt}</span>
            <span class="tsa-hold-val">${dayVal>0?fmtMoney(dayVal)+'/day':'&mdash;'}</span>
            <span style="color:var(--tsa-text-dead);font-size:10px;white-space:nowrap">${fmtShares(blockR.sharesHeld)} &nbsp;&middot;&nbsp; ${iLabel}</span>`;
          if (goalActive) {
            const btn=document.createElement('button');
            btn.className='tsa-step-toggle'+(isStep?' active':'');
            btn.textContent=isStep?'\uD83C\uDFAF Stepping stone':'\uD83C\uDFAF Mark stepping stone';
            btn.dataset.ticker=blockR.ticker; btn.dataset.incr=blockR.incr;
            btn.addEventListener('click',(e)=>{
              e.stopPropagation();
              const nv=!isSteppingStone(blockR.ticker,blockR.incr);
              setSteppingStone(blockR.ticker,blockR.incr,nv);
              btn.className='tsa-step-toggle'+(nv?' active':'');
              btn.textContent=nv?'\uD83C\uDFAF Stepping stone':'\uD83C\uDFAF Mark stepping stone';
              renderSavingsPlan(root,lastRows,lastPrices);
            });
            row.appendChild(btn);
          }
          content.appendChild(row);
        }
      }
    }
    if (partial.length) {
      const lbl=document.createElement('div'); lbl.className='tsa-hold-sublabel'; lbl.style.marginTop='8px';
      lbl.textContent='Partial \u2014 working toward next block'; content.appendChild(lbl);
      for (const r of [...partial].sort((a,b)=>a.ticker.localeCompare(b.ticker))) {
        const pct=Math.round(((r.sharesHeld-r.prevThresh)/r.incrShares)*100);
        const row=document.createElement('div'); row.className='tsa-hold-partial-row';
        row.innerHTML=`<span class="tsa-ticker">${r.ticker}</span><span class="tsa-hold-name">${r.name} <span style="color:var(--tsa-text-dead);font-size:10px">block ${r.incr}</span></span><span class="tsa-badge tsa-badge-warn">${pct}%</span><span class="tsa-hold-partial-txt">${fmtShares(r.sharesHeld)} / ${fmtShares(r.threshold)} &middot; need ${fmtShares(r.sharesNeeded)} more &middot; ${fmtMoney(r.costToComplete)}</span>`;
        content.appendChild(row);
      }
    }
  }

  // ─── Render: swap advisor ─────────────────────────────────────────────────────

  function renderSwaps(root, rows, bonusInfo={}) {
    const content=root.querySelector('#tsa-swap-content');
    if (!content) return;
    content.innerHTML='';
    const availCash = getAvailableCash();

    // ── Fix 5: Top-up partials section ────────────────────────────────────────
    // Show partial blocks the user can complete right now with available cash,
    // before considering any sells. Pure "spend to unlock" recommendations.
    const completablePartials = rows.filter(r =>
      r.status === 'partial' && !r.ignored && r.dailyValue > 0 &&
      availCash > 0 && r.costToComplete <= availCash
    ).sort((a,b) => b.dailyValue - a.dailyValue);

    if (completablePartials.length) {
      const hd = document.createElement('div');
      hd.style.cssText = 'font-size:9px;color:var(--tsa-text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px';
      hd.textContent = 'Complete your partials — affordable with available cash';
      content.appendChild(hd);
      for (const r of completablePartials) {
        const card = document.createElement('div');
        card.className = 'tsa-swap-card';
        card.innerHTML = `
          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px">
            <span style="color:var(--tsa-text-dead);font-size:10px;margin-right:4px">Top up</span>
            <span style="font-size:12px;color:var(--tsa-text-card);font-weight:bold">
              <span class="tsa-ticker">${r.ticker}</span>
              <span style="color:var(--tsa-text-secondary);margin-left:4px;font-weight:normal">Block ${r.incr}</span>
              <span style="color:var(--tsa-text-muted);font-size:10px;margin-left:4px;font-weight:normal">${r.name}</span>
            </span>
            <span style="color:var(--tsa-text-muted);font-size:11px">${fmtShares(r.sharesNeeded)} shares &middot; ${fmtMoney(r.costToComplete)}</span>
            <span style="margin-left:auto;font-size:13px;color:var(--tsa-status-ok);font-weight:bold">+${fmtMoney(r.dailyValue)}/day</span>
            <span style="font-size:12px;font-weight:bold;color:var(--tsa-status-ok)">Immediate</span>
          </div>
          <div style="font-size:10px;color:var(--tsa-text-muted);line-height:1.5;padding-top:4px;border-top:1px solid var(--tsa-border-faint)">
            Already hold ${fmtShares(r.sharesHeld - r.prevThresh)} of ${fmtShares(r.incrShares)} shares
            (${Math.round(((r.sharesHeld-r.prevThresh)/r.incrShares)*100)}%)
            &nbsp;&middot;&nbsp; ROI on remaining spend: ${fmtROI(r.dailyValue, r.costToComplete)}
          </div>`;
        content.appendChild(card);
      }
      const div = document.createElement('div');
      div.className = 'tsa-swap-group-divider';
      content.appendChild(div);
    }

    // ── Swap advisor (sell to buy) ─────────────────────────────────────────────
    const swaps = buildSwaps(rows);

    // Show available cash context if set
    if (availCash > 0) {
      const cashNote = document.createElement('div');
      cashNote.style.cssText = 'font-size:10px;color:var(--tsa-text-muted);margin-bottom:10px';
      cashNote.innerHTML = `&#9432; Available cash <strong style="color:var(--tsa-accent)">${fmtMoney(availCash)}</strong> is factored into all swap calculations below &mdash; targets only need selling to cover the remainder.`;
      content.appendChild(cashNote);
    }

    if (!swaps.length) {
      const msg = document.createElement('div');
      msg.style.cssText = 'color:var(--tsa-text-muted);font-size:12px;padding:6px 0';
      msg.textContent = completablePartials.length
        ? 'No additional sell-to-buy swaps found beyond the top-up opportunities above.'
        : 'No positive-gain swaps found \u2014 holdings appear well-optimised.';
      content.appendChild(msg);
      return;
    }

    const groups=[], seenSell=new Map();
    for (const s of swaps) {
      const key=`${s.sellTicker}|${s.sellType}`;
      if (!seenSell.has(key)){seenSell.set(key,[]);groups.push({key,swaps:seenSell.get(key)});}
      seenSell.get(key).push(s);
    }

    for (let groupIdx=0; groupIdx<groups.length; groupIdx++) {
      const group=groups[groupIdx]; const groupRank=groupIdx+1;
      const first=group.swaps[0];
      if (groupIdx>0){const d=document.createElement('div');d.className='tsa-swap-group-divider';content.appendChild(d);}
      const header=document.createElement('div');
      header.className='tsa-swap-group-header';

      const typeBadge =
        first.sellType==='cash'
          ?'<span class="tsa-badge tsa-badge-ok" style="font-size:9px">Use cash</span>'
          :first.sellType==='combined'
        ? '<span class="tsa-badge" style="background:var(--tsa-phase-plan-bg);color:var(--tsa-status-blocked);font-size:9px">Combined sell</span>'
        : first.sellType==='full'
          ? '<span class="tsa-badge" style="background:var(--tsa-status-crit-bg);color:var(--tsa-banner-crit-text);font-size:9px">Full sell</span>'
          : first.sellType==='down'
            ? '<span class="tsa-badge" style="background:var(--tsa-status-warn-bg);color:var(--tsa-status-warn);font-size:9px">Sell down</span>'
            : '<span class="tsa-badge" style="background:var(--tsa-banner-ok-bg);color:var(--tsa-status-ok);font-size:9px">Sell partial</span>';

      const payoutTickers = first.sellType==='cash' ? [] : first.combined ? first.extraSells : [first.sellTicker];
      const payoutLines   = payoutTickers.map(t=>{const fmt=fmtNextPayout(bonusInfo[t]);return fmt?`<span style="color:var(--tsa-text-muted);font-size:10px;margin-left:6px">${t}:</span> ${fmt}`:null;}).filter(Boolean);
      const payoutHtml    = payoutLines.length?`<span style="display:inline-block;margin-left:10px;font-size:10px">${payoutLines.join(' &middot; ')}</span>`:'';

      let sellDesc;
      if (first.sellType==='cash') {
        sellDesc=`No selling needed &mdash; <span style="color:var(--tsa-text-dead)">buy directly using ${fmtMoney(availCash)} available cash</span>`;
      } else if (first.sellType==='combined') {
        sellDesc=`Sell all <span class="tsa-ticker">${first.extraSells[0]}</span> + <span class="tsa-ticker">${first.extraSells[1]}</span> <span style="color:var(--tsa-text-dead)">(${fmtMoney(first.cashReleased)} freed)</span>`;
      } else if (first.sellType==='full') {
        sellDesc=`Sell all <span class="tsa-ticker">${first.sellTicker}</span> <span style="color:var(--tsa-text-dead)">(${first.sellName} &middot; ${fmtMoney(first.cashReleased)} freed &middot; lose ${fmtMoney(first.dailyLost)}/day)</span>`;
      } else if (first.sellType==='down') {
        sellDesc=`Drop <span class="tsa-ticker">${first.sellTicker}</span> one block <span style="color:var(--tsa-text-dead)">(${first.sellName} &middot; ${fmtMoney(first.cashReleased)} freed &middot; lose ${fmtMoney(first.dailyLost)}/day)</span>`;
      } else {
        // partial sell — no daily value lost
        sellDesc=`Sell partial <span class="tsa-ticker">${first.sellTicker}</span> <span style="color:var(--tsa-text-dead)">(${first.sellName} &middot; ${fmtMoney(first.cashReleased)} freed &middot; no payout lost)</span>`;
      }
      header.innerHTML=`<span style="color:var(--tsa-text-muted);font-size:11px;font-weight:bold;margin-right:6px">#${groupRank}</span>${typeBadge} &nbsp;${sellDesc}${payoutHtml}`;
      content.appendChild(header);

      group.swaps.forEach((s,idx)=>{
        // For cash-type groups the chain already shows all follow-on purchases —
        // the remaining entries are not alternatives, just the next-best independent
        // buys which are already surfaced by the chain. Skip them.
        if (first.sellType === 'cash' && idx > 0) return;
        const card=document.createElement('div');
        card.className='tsa-swap-card'+(idx>0?' alt':'');
        const isImmediate = s.paybackDays === 0;
        const pb = isImmediate ? 'var(--tsa-status-ok)'
          : s.paybackDays<=30 ? 'var(--tsa-status-ok)'
          : s.paybackDays<=90 ? 'var(--tsa-status-warn)'
          : 'var(--tsa-text-secondary)';
        const paybackLabel = isImmediate ? 'Immediate' : `Payback: ${s.paybackDays}d`;
        const optLbl=group.swaps.length>1?`<span style="color:var(--tsa-text-dead);font-size:10px;margin-right:6px">${idx>0?'&#8627; or':'Buy'}</span>`:`<span style="color:var(--tsa-text-dead);font-size:10px;margin-right:6px">Buy</span>`;
        const cashNote = s.availCashUsed > 0 && s.sellType !== 'cash'
          ? `<span style="background:var(--tsa-phase-plan-bg);border:1px solid var(--tsa-phase-plan-border);border-radius:3px;color:var(--tsa-phase-plan-text);font-size:9px;padding:1px 5px">+${fmtMoney(s.availCashUsed)} cash</span>`
          : '';
        card.innerHTML=`
          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px">
            ${optLbl}
            <span style="font-size:12px;color:var(--tsa-text-card);font-weight:bold">
              <span class="tsa-ticker">${s.target.ticker}</span>
              <span style="color:var(--tsa-text-secondary);margin-left:4px;font-weight:normal">Block ${s.target.incr}</span>
              <span style="color:var(--tsa-text-muted);font-size:10px;margin-left:4px;font-weight:normal">${s.target.name}</span>
            </span>
            <span style="color:var(--tsa-text-muted);font-size:11px">costs ${fmtMoney(s.target.costToComplete)}</span>
            ${cashNote}
            ${s.leftoverCash>1000?`<span style="color:var(--tsa-text-dead);font-size:10px">&middot; ${fmtMoney(s.leftoverCash)} left</span>`:''}
            <span style="margin-left:auto;font-size:13px;color:var(--tsa-status-ok);font-weight:bold">+${fmtMoney(s.netDailyGain)}/day</span>
            <span style="font-size:12px;font-weight:bold;color:${pb}">${paybackLabel}</span>
          </div>
          <div style="font-size:10px;color:var(--tsa-text-muted);line-height:1.5;padding-top:4px;border-top:1px solid var(--tsa-border-faint)">
            +${fmtMoney(s.target.dailyValue)}/day from <span style="color:var(--tsa-text-secondary)">${s.target.ticker}</span>
            ${s.dailyLost>0
              ? `&nbsp;&middot;&nbsp; <span style="color:var(--tsa-status-crit)">-${fmtMoney(s.dailyLost)}/day</span> lost from sell`
              : `&nbsp;&middot;&nbsp; <span style="color:var(--tsa-status-ok)">no daily value given up</span>`}
          </div>`;
        content.appendChild(card);

        // ── Chain follow-on purchases from leftover ─────────────────────────
        // After the primary buy, greedily spend remaining funds on the best
        // immediately-actionable blocks until money runs out.
        // Only chain from the first (best) option in each group, not the "or" alts.
        if (idx === 0 && s.leftoverCash > 0) {
          // Tickers already committed in this transaction
          const usedTickers = new Set([s.target.ticker, s.sellTicker, ...(s.extraSells||[])]);
          // Blocks already picked in this chain
          const chainPicked = new Set([`${s.target.ticker}:${s.target.incr}`]);
          let chainCash = s.leftoverCash;

          // Pool: all immediately actionable blocks not yet committed
          // "Immediately actionable" = partial, or direct next block (incr === heldBlocks + 1)
          // After buying a block, that stock's next block becomes actionable too —
          // we track virtual holdings to allow consecutive blocks of the same stock.
          const virtualHeld = {}; // ticker -> highest incr virtually held

          // Seed virtual held from actual rows
          for (const r of rows.filter(r => r.status === 'held')) {
            if (!virtualHeld[r.ticker] || r.incr > virtualHeld[r.ticker]) {
              virtualHeld[r.ticker] = r.incr;
            }
          }
          // Count the primary buy as virtually held
          virtualHeld[s.target.ticker] = Math.max(virtualHeld[s.target.ticker]||0, s.target.incr);

          let safety = 0;
          while (chainCash > 0 && safety++ < 20) {
            // Rebuild candidate pool accounting for virtual holdings
            const pool = rows.filter(r => {
              if (!r.scoreable || r.dailyValue <= 0 || r.costToComplete <= 0) return false;
              if (chainPicked.has(`${r.ticker}:${r.incr}`)) return false;
              if (r.status === 'held') return false;
              if (r.costToComplete > chainCash) return false;
              // Must be immediately next block (actual or virtual)
              const vHeld = virtualHeld[r.ticker] || 0;
              const isNext = r.incr === vHeld + 1;
              const isPartial = r.status === 'partial';
              return isNext || isPartial;
            }).sort((a,b) => b.dailyROI - a.dailyROI);

            if (!pool.length) break;
            const next = pool[0];
            chainCash -= next.costToComplete;
            chainPicked.add(`${next.ticker}:${next.incr}`);
            virtualHeld[next.ticker] = Math.max(virtualHeld[next.ticker]||0, next.incr);

            const chainCard = document.createElement('div');
            chainCard.className = 'tsa-swap-chain';
            chainCard.innerHTML = `
              <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px">
                <span style="color:var(--tsa-phase-plan-text);font-size:10px;margin-right:4px">&#8627; then buy</span>
                <span style="font-size:12px;color:var(--tsa-text-card);font-weight:bold">
                  <span class="tsa-ticker">${next.ticker}</span>
                  <span style="color:var(--tsa-text-secondary);margin-left:4px;font-weight:normal">Block ${next.incr}</span>
                  <span style="color:var(--tsa-text-muted);font-size:10px;margin-left:4px;font-weight:normal">${next.name}</span>
                </span>
                <span style="color:var(--tsa-text-muted);font-size:11px">costs ${fmtMoney(next.costToComplete)}</span>
                ${chainCash>1000?`<span style="color:var(--tsa-text-dead);font-size:10px">&middot; ${fmtMoney(chainCash)} remaining</span>`:''}
                <span style="margin-left:auto;font-size:13px;color:var(--tsa-status-ok);font-weight:bold">+${fmtMoney(next.dailyValue)}/day</span>
                <span style="font-size:12px;font-weight:bold;color:var(--tsa-status-ok)">Immediate</span>
              </div>
              <div style="font-size:10px;color:var(--tsa-text-muted);line-height:1.5;padding-top:4px;border-top:1px solid var(--tsa-border-faint)">
                Funded from leftover &middot; no additional selling needed
              </div>`;
            content.appendChild(chainCard);
          }
        }
      });
    }

    const note=document.createElement('div'); note.className='tsa-swap-note';
    const cashFootNote=availCash>0?` &middot; <span style="color:var(--tsa-status-ok)">${fmtMoney(availCash)} available cash</span> included in all cost checks.`:'';
    note.innerHTML=`Ranked by net daily gain (already accounts for income lost by selling).
      <span style="color:var(--tsa-status-ok)">Use cash</span> &rarr;
      <span style="color:var(--tsa-phase-plan-text)">Sell partial</span> &rarr;
      <span style="color:var(--tsa-status-warn)">Sell block</span>
      priority order &mdash; overridden only when a lower-priority option earns significantly more net income per day.
      Payback = days until daily gain recovers any missed payout cycle.${cashFootNote}`;
    content.appendChild(note);
  }

  // ─── Render: table ───────────────────────────────────────────────────────────

  function renderTable(root, rows) {
    const tbody=root.querySelector('#tsa-tbody');
    tbody.innerHTML='';
    let rankIdx=0;
    for (const r of rows) {
      if (r.beyondVisible && r.status!=='held') continue; // enforce n+2 visibility
      const tr=document.createElement('tr');
      if (r.ignored)                      tr.classList.add('tsa-row-ignored');
      else if (r.payoutType==='passive')  tr.classList.add('tsa-row-passive');
      else if (r.overBudget&&getBudgetMode()==='grey') tr.classList.add('tsa-row-overbudget');
      const active=r.scoreable&&r.score>0;
      if (active) rankIdx++;
      const rCls=rankIdx===1?'tsa-rank-gold':rankIdx===2?'tsa-rank-silver':rankIdx===3?'tsa-rank-bronze':'';
      const rankCell=active?`<span class="tsa-rank-num ${rCls}">${rankIdx}</span>`:`<span class="tsa-rank-num" style="color:var(--tsa-text-dead)">&mdash;</span>`;
      const tm={'cash':'tsa-badge-cash','points':'tsa-badge-cash','item':'tsa-badge-item','energy':'tsa-badge-energy','nerve':'tsa-badge-nerve','happy':'tsa-badge-happy','other':'tsa-badge-other','passive':'tsa-badge-passive'};
      const typeBadge=`<span class="tsa-badge ${tm[r.payoutType]||'tsa-badge-muted'}">${r.payoutType}</span>`;
      let statusBadge;
      if (r.ignored)             statusBadge=`<span class="tsa-badge tsa-badge-muted">Ignored</span>`;
      else if (r.excluded)       statusBadge=`<span class="tsa-badge tsa-badge-muted">Excl.</span>`;
      else if (r.status==='held')    statusBadge=`<span class="tsa-badge tsa-badge-ok">Active \u2713</span>`;
      else if (r.status==='partial') statusBadge=`<span class="tsa-badge tsa-badge-warn">Partial</span>`;
      else if (r.status==='next')    statusBadge=`<span class="tsa-badge tsa-badge-info">Next</span>`;
      else                           statusBadge=`<span class="tsa-badge tsa-badge-muted">Future</span>`;
      const iLbl=r.payoutInterval>0?`${r.payoutInterval}d`:'passive';
      const payCell=r.incrValue>0?`<span style="color:var(--tsa-text-card)">${fmtMoney(r.incrValue)}</span>`:`<span style="color:var(--tsa-text-dead)">&mdash;</span>`;
      const heldCell=r.status==='held'?`<span style="color:var(--tsa-status-ok)">${fmtShares(r.sharesHeld)} \u2713</span>`:`<span style="color:var(--tsa-text-muted)">${fmtShares(r.sharesHeld)}</span>`;
      const obBadge=(r.overBudget&&getBudgetMode()!=='show'&&r.status!=='held')?` <span style="color:var(--tsa-status-crit);font-size:9px">&#9650;</span>`:'';
      const costCell=r.status==='held'?`<span style="color:var(--tsa-text-dead)">&mdash;</span>`
        :r.costToComplete>0?`<span style="color:${r.overBudget?'var(--tsa-status-crit)':r.status==='partial'?'var(--tsa-status-warn)':'var(--tsa-text-secondary)'}">${fmtMoney(r.costToComplete)}${obBadge}</span>`
        :`<span style="color:var(--tsa-text-dead)">&mdash;</span>`;
      const dailyCell=r.dailyValue>0?`<span style="color:var(--tsa-status-ok)">${fmtMoney(r.dailyValue)}</span>`:`<span style="color:var(--tsa-text-dead)">&mdash;</span>`;
      const roiCell=active?`<span style="color:var(--tsa-status-ok);font-size:10px">${fmtROI(r.dailyValue,r.incrCost)}</span><span style="color:var(--tsa-accent);font-weight:bold;margin-left:4px">${r.score.toFixed(1)}</span>`:`<span style="color:var(--tsa-text-dead)">&mdash;</span>`;
      tr.innerHTML=`
        <td style="text-align:center">${rankCell}</td>
        <td><span class="tsa-ticker" style="margin-right:4px">${r.ticker}</span><span style="color:var(--tsa-text-secondary);font-size:11px">${r.name}</span><span style="color:var(--tsa-text-dead);font-size:10px;margin-left:4px">B${r.incr}</span></td>
        <td>${typeBadge}</td>
        <td style="text-align:right">${payCell}</td>
        <td style="text-align:center;color:var(--tsa-text-muted);font-size:10px">${iLbl}</td>
        <td style="text-align:right">${heldCell}</td>
        <td style="text-align:right">${costCell}</td>
        <td style="text-align:right">${dailyCell}</td>
        <td style="text-align:right">${roiCell}</td>
        <td style="text-align:center">${statusBadge}</td>`;
      tbody.appendChild(tr);
    }
  }

  // ─── Stats bar ────────────────────────────────────────────────────────────────

  function updateStats(root, rows) {
    root.querySelector('#tsa-s-active').textContent  = rows.filter(r=>r.status==='held'&&!r.ignored).length;
    root.querySelector('#tsa-s-partial').textContent = rows.filter(r=>r.status==='partial'&&!r.ignored).length;
    root.querySelector('#tsa-s-daily').textContent   = fmtMoney(rows.filter(r=>r.status==='held'&&!r.ignored&&!r.excluded).reduce((s,r)=>s+r.dailyValue,0));
    const best=rows.find(r=>r.scoreable&&r.score>0);
    root.querySelector('#tsa-s-roi').textContent     = best ? fmtROI(best.dailyValue,best.incrCost) : '\u2014';
  }

  // ─── Load & render ────────────────────────────────────────────────────────────

  async function loadAndRender(root) {
    const apiKey=getApiKey();
    const errEl=root.querySelector('#tsa-error');
    const loadEl=root.querySelector('#tsa-loading');
    const contEl=root.querySelector('#tsa-content');
    errEl.style.display='none'; loadEl.style.display='block'; contEl.style.display='none';
    if (!apiKey) {
      loadEl.style.display='none'; errEl.style.display='block';
      errEl.textContent='\u26a0 No API key set \u2014 open \u2699 Config and enter your Torn API key (Stocks access required).';
      return;
    }
    try {
      const {rows,bonusInfo={},prices={}} = await buildScores(apiKey);
      loadEl.style.display='none'; contEl.style.display='block';
      lastRows=rows; lastPrices=prices;
      // ── New v3 sections ──────────────────────────────────────────────────
      const domDividends = (() => { try { return JSON.parse(load('dom_dividends', '{}')); } catch { return {}; } })();

      // Build swaps first so calcPortfolioScore can use bestAvailableNetGain
      const swapsForBriefing = buildSwaps(rows);
      const portfolioScore = calcPortfolioScore(rows, domDividends, swapsForBriefing);

      // Simulate score after top recommended actions
      const simActions = swapsForBriefing.slice(0, 3).map(s => ({
        sellTicker: s.sellTicker,
        target:     s.target,
        chainBuys:  [],
      }));
      const projectedScore = simActions.length ? simulateScore(rows, domDividends, simActions) : null;

      renderBriefing(root, rows, prices, bonusInfo, swapsForBriefing, portfolioScore, projectedScore, domDividends);
      renderPortfolioScore(root, portfolioScore, projectedScore);
      renderCalendar(root, rows, bonusInfo);

      // ── Existing sections ─────────────────────────────────────────────────
      renderSavingsPlan(root,rows,prices);
      renderCards(root,rows);
      renderHoldings(root,rows);
      renderSwaps(root,rows,bonusInfo);
      renderTable(root,rows);
      updateStats(root,rows);

      // ── Mobile compact rankings ───────────────────────────────────────────
      renderMobileRankings(root, rows);

      root.querySelector('#tsa-updated').textContent='Updated '+new Date().toLocaleTimeString();
    } catch(e) {
      loadEl.style.display='none'; errEl.style.display='block';
      errEl.textContent='\u26a0 Error: '+e.message;
      console.error('[TSA]',e);
    }
  }


  // ─── Render: mobile compact rankings ─────────────────────────────────────────
  // On mobile the full table is hidden — replaced with a compact scrollable list.

  function renderMobileRankings(root, rows) {
    let el = root.querySelector('#tsa-mobile-rankings');
    if (!el) return;
    el.innerHTML = '';
    const visible = rows.filter(r => !r.beyondVisible || r.status === 'held').slice(0, 30);
    for (const r of visible) {
      if (r.ignored) continue;
      const row = document.createElement('div');
      row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:7px 0;border-bottom:1px solid var(--tsa-border-faint);font-size:11px';
      const statusDot = r.status === 'held'
        ? `<span style="width:7px;height:7px;border-radius:50%;background:var(--tsa-status-ok);flex-shrink:0;display:inline-block"></span>`
        : r.status === 'partial'
          ? `<span style="width:7px;height:7px;border-radius:50%;background:var(--tsa-status-warn);flex-shrink:0;display:inline-block"></span>`
          : r.scoreable && r.score > 0
            ? `<span style="width:7px;height:7px;border-radius:50%;background:var(--tsa-accent);flex-shrink:0;display:inline-block"></span>`
            : `<span style="width:7px;height:7px;border-radius:50%;background:var(--tsa-text-dead);flex-shrink:0;display:inline-block"></span>`;
      row.innerHTML = `
        ${statusDot}
        <span class="tsa-ticker" style="font-size:10px">${r.ticker}</span>
        <span style="color:var(--tsa-text-secondary);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">B${r.incr} ${r.name}</span>
        <span style="color:${r.dailyValue > 0 ? 'var(--tsa-status-ok)' : 'var(--tsa-text-dead)'};white-space:nowrap">${r.dailyValue > 0 ? fmtMoney(r.dailyValue)+'/d' : '—'}</span>
        ${r.scoreable && r.score > 0 ? `<span style="color:var(--tsa-accent);font-size:10px;min-width:28px;text-align:right">${r.score.toFixed(1)}</span>` : '<span style="min-width:28px"></span>'}`;
      el.appendChild(row);
    }
  }

  // ─── Event wiring ─────────────────────────────────────────────────────────────

  function wireEvents(root) {
    root.querySelector('#tsa-collapse-btn').addEventListener('click',()=>{
      const c=root.classList.toggle('collapsed');
      root.querySelector('#tsa-collapse-btn').textContent=c?'\u25bc':'\u25b2';
      save('dashboard_collapsed',c?'1':'0');
    });
    root.querySelector('#tsa-cfg-toggle').addEventListener('click',()=>{
      root.querySelector('#tsa-config-panel').classList.toggle('open');
    });
    // Live theme preview on change
    root.querySelector('#tsa-cfg-theme').addEventListener('change',(e)=>{
      applyTheme(e.target.value);
      updateStrip(root);
    });
    root.querySelector('#tsa-cfg-save-key').addEventListener('click',()=>{
      save('api_key',root.querySelector('#tsa-cfg-apikey').value.trim());
      updateStrip(root); loadAndRender(root);
    });
    root.querySelector('#tsa-cfg-save-all').addEventListener('click',()=>{
      save('theme',       root.querySelector('#tsa-cfg-theme').value);
      save('ignored',     root.querySelector('#tsa-cfg-ignored').value);
      save('ex_nerve',    root.querySelector('#tsa-ex-nerve').checked);
      save('ex_energy',   root.querySelector('#tsa-ex-energy').checked);
      save('ex_happy',    root.querySelector('#tsa-ex-happy').checked);
      save('ex_other',    root.querySelector('#tsa-ex-other').checked);
      save('ex_passive',  root.querySelector('#tsa-ex-passive').checked);
      save('refresh_mins',root.querySelector('#tsa-cfg-refresh').value);
      save('savings_goal',   root.querySelector('#tsa-cfg-savings-goal')?.value||'');
      save('swap_no_sell',root.querySelector('#tsa-cfg-swap-no-sell').value);
      save('budget',      root.querySelector('#tsa-cfg-budget').value);
      save('budget_pct',  root.querySelector('#tsa-cfg-budget-pct').value);
      save('budget_mode', root.querySelector('#tsa-cfg-budget-mode').value);
      save('top_n',       root.querySelector('#tsa-cfg-topn').value);
      root.querySelectorAll('.tsa-payout-inp').forEach(el=>save(`payout_${el.dataset.ticker}`,el.value));
      root.querySelectorAll('.tsa-b1-inp').forEach(el=>save(`b1_${el.dataset.ticker}`,el.value));
      root.querySelectorAll('.tsa-max-inp').forEach(el=>save(`maxblocks_${el.dataset.ticker}`,el.value));
      root.querySelectorAll('.tsa-itemqty-inp').forEach(el=>save(`itemqty_${el.dataset.ticker}`,el.value));
      root.querySelectorAll('.tsa-itemname-inp').forEach(el=>save(`itemname_${el.dataset.ticker}`,el.value));
      root.querySelectorAll('.tsa-units-inp').forEach(el=>save(`units_${el.dataset.ticker}`,el.value));
      applyTheme(getTheme()); updateStrip(root); resetTimer(root); loadAndRender(root);
    });
    root.querySelector('#tsa-cfg-reset').addEventListener('click',()=>{
      if (!confirm('Reset all config to defaults? Your API key will be kept.')) return;
      const key=getApiKey();
      for (const stock of STOCK_DATA) {
        ['payout','b1','maxblocks','itemqty','itemname','units'].forEach(k=>GM_setValue(PREFIX+`${k}_${stock.ticker}`,null));
      }
      ['ignored','ex_nerve','ex_energy','ex_happy','ex_other','ex_passive','refresh_mins','top_n','theme','budget','budget_pct','budget_mode','swap_no_sell','savings_goal'].forEach(k=>GM_setValue(PREFIX+k,null));
      save('api_key',key);
      populateConfig(root); applyTheme('default'); updateStrip(root); loadAndRender(root);
    });
    root.querySelector('#tsa-refresh-btn').addEventListener('click',()=>loadAndRender(root));
    // Mobile: all sections except briefing default to collapsed
    const mob = isMobile();
    wireCollapse(root.querySelector('#tsa-briefing-title'),  root.querySelector('#tsa-briefing-content'),  'collapse_briefing',  'open');
    wireCollapse(root.querySelector('#tsa-score-title'),     root.querySelector('#tsa-score-content'),     'collapse_score',     mob?'collapsed':'collapsed');
    wireCollapse(root.querySelector('#tsa-calendar-title'),  root.querySelector('#tsa-calendar-content'),  'collapse_calendar',  mob?'collapsed':'collapsed');
    wireCollapse(root.querySelector('#tsa-savings-title'),root.querySelector('#tsa-savings-content'),'collapse_savings',mob?'collapsed':'open');
    wireCollapse(root.querySelector('#tsa-recs-title'),   root.querySelector('#tsa-recs-content'),   'collapse_recs',    mob?'collapsed':'open');
    wireCollapse(root.querySelector('#tsa-hold-title'),   root.querySelector('#tsa-hold-content'),   'collapse_holdings',mob?'collapsed':'open');
    wireCollapse(root.querySelector('#tsa-swap-title'),   root.querySelector('#tsa-swap-content'),   'collapse_swaps',   mob?'collapsed':'collapsed');
    wireCollapse(root.querySelector('#tsa-table-title'),  root.querySelector('#tsa-table-content'),  'collapse_table',   mob?'collapsed':'collapsed');
  }

  // ─── Timer & injection ────────────────────────────────────────────────────────

  function resetTimer(root) {
    clearInterval(window._tsaRefreshTimer);
    window._tsaRefreshTimer=setInterval(()=>loadAndRender(root),getRefreshMins()*60*1000);
  }

  function inject() {
    if (!location.href.includes('sid=stocks')) return;
    if (document.getElementById('tsa-root')) return;
    let attempts=0;
    const tryInsert=setInterval(()=>{
      attempts++;
      if (attempts>30||document.getElementById('tsa-root')){clearInterval(tryInsert);return;}
      const smRoot=document.getElementById('stockmarketroot');
      if (!smRoot||!smRoot.firstChild) return;
      clearInterval(tryInsert);
      const root=buildUI();
      smRoot.insertBefore(root,smRoot.firstChild);
      populateConfig(root);
      applyTheme(getTheme());
      updateStrip(root);
      wireEvents(root);
      loadAndRender(root);
      resetTimer(root);
    },500);
  }

  inject();
  window.addEventListener('hashchange',()=>setTimeout(inject,400));

})();