/* ============================================================
   Trade Machine — INTEGRATED into CapMVP V2 (single IIFE)
   ------------------------------------------------------------
   Assembled from the claude.ai/design handoff prototype files
   (tm-data.jsx + tm-components.jsx + tm-app.jsx). Wrapped in one
   IIFE so every component/helper name stays LOCAL — zero collision
   with the site globals (we already own App, CAP_2026, fmt$,
   evaluateTeamSide, LOGO_BY_CODE, ...). The only thing exported is
   window.TradeMachineView.

   Real data is fed in via props (tradeData / allTeams / state /
   derived / teams) and built into the ROSTERS/PICKS/FREE_AGENTS/...
   tables by buildTradeTables(). The prototype CBA helpers are kept
   verbatim and local (a superset of trade-engine.jsx, same shape).
   ============================================================ */
(function () {
/* ============================================================
   Trade Machine — sample data + helpers
   - Mirrors CapMVP V2 data shapes (players, picks, TPEs)
   - 6 detailed team rosters; 30-team logo registry
   - Pure utilities exposed via window: fmt$, evaluateTeamSide,
     cycleDestination, cashLimit
   ============================================================ */

const CAP_2026 = {
  cap:        165_000_000,
  taxLine:    200_474_000,
  apron1:     209_063_000,
  apron2:     221_737_000,
  vetMin:     1_300_000,
  mle:        15_048_000,
  tpmle:      6_065_000,
  bae:        5_478_000,
  roomMle:    9_369_000,
  cashYear:   7_500_000, // CBA per-team per-season cash in or out
};

/* logo registry — all 30 teams */
const LOGO_BY_CODE = {
  ATL: "atlanta_hawks_1610612737", BOS: "boston_celtics_1610612738",
  BKN: "brooklyn_nets_1610612751", CHA: "charlotte_hornets_1610612766",
  CHI: "chicago_bulls_1610612741", CLE: "cleveland_cavaliers_1610612739",
  DAL: "dallas_mavericks_1610612742", DEN: "denver_nuggets_1610612743",
  DET: "detroit_pistons_1610612765", GSW: "golden_state_warriors_1610612744",
  HOU: "houston_rockets_1610612745", IND: "indiana_pacers_1610612754",
  LAC: "la_clippers_1610612746", LAL: "los_angeles_lakers_1610612747",
  MEM: "memphis_grizzlies_1610612763", MIA: "miami_heat_1610612748",
  MIL: "milwaukee_bucks_1610612749", MIN: "minnesota_timberwolves_1610612750",
  NOP: "new_orleans_pelicans_1610612740", NYK: "new_york_knicks_1610612752",
  OKC: "oklahoma_city_thunder_1610612760", ORL: "orlando_magic_1610612753",
  PHI: "philadelphia_76ers_1610612755", PHX: "phoenix_suns_1610612756",
  POR: "portland_trail_blazers_1610612757", SAC: "sacramento_kings_1610612758",
  SAS: "san_antonio_spurs_1610612759", TOR: "toronto_raptors_1610612761",
  UTA: "utah_jazz_1610612762", WAS: "washington_wizards_1610612764",
};

/* team metadata used for the picker + accent strip */
const TEAMS_INDEX = [
  ["ATL","Hawks","Atlanta Hawks","E","#E03A3E","#C1D32F"],
  ["BOS","Celtics","Boston Celtics","E","#007A33","#BA9653"],
  ["BKN","Nets","Brooklyn Nets","E","#FFFFFF","#000000"],
  ["CHA","Hornets","Charlotte Hornets","E","#1D1160","#00788C"],
  ["CHI","Bulls","Chicago Bulls","E","#CE1141","#000000"],
  ["CLE","Cavaliers","Cleveland Cavaliers","E","#860038","#FDBB30"],
  ["DAL","Mavericks","Dallas Mavericks","W","#00538C","#B8C4CA"],
  ["DEN","Nuggets","Denver Nuggets","W","#0E2240","#FEC524"],
  ["DET","Pistons","Detroit Pistons","E","#C8102E","#1D42BA"],
  ["GSW","Warriors","Golden State Warriors","W","#1D428A","#FFC72C"],
  ["HOU","Rockets","Houston Rockets","W","#CE1141","#000000"],
  ["IND","Pacers","Indiana Pacers","E","#002D62","#FDBB30"],
  ["LAC","Clippers","Los Angeles Clippers","W","#C8102E","#1D428A"],
  ["LAL","Lakers","Los Angeles Lakers","W","#552583","#FDB927"],
  ["MEM","Grizzlies","Memphis Grizzlies","W","#5D76A9","#12173F"],
  ["MIA","Heat","Miami Heat","E","#98002E","#F9A01B"],
  ["MIL","Bucks","Milwaukee Bucks","E","#00471B","#EEE1C6"],
  ["MIN","Timberwolves","Minnesota Timberwolves","W","#0C2340","#236192"],
  ["NOP","Pelicans","New Orleans Pelicans","W","#0C2340","#C8102E"],
  ["NYK","Knicks","New York Knicks","E","#006BB6","#F58426"],
  ["OKC","Thunder","Oklahoma City Thunder","W","#007AC1","#EF3B24"],
  ["ORL","Magic","Orlando Magic","E","#0077C0","#C4CED4"],
  ["PHI","76ers","Philadelphia 76ers","E","#006BB6","#ED174C"],
  ["PHX","Suns","Phoenix Suns","W","#1D1160","#E56020"],
  ["POR","Trail Blazers","Portland Trail Blazers","W","#E03A3E","#000000"],
  ["SAC","Kings","Sacramento Kings","W","#5A2D81","#63727A"],
  ["SAS","Spurs","San Antonio Spurs","W","#C4CED4","#000000"],
  ["TOR","Raptors","Toronto Raptors","E","#CE1141","#000000"],
  ["UTA","Jazz","Utah Jazz","W","#002B5C","#00471B"],
  ["WAS","Wizards","Washington Wizards","E","#002B5C","#E31837"],
].map(([code,name,fullName,conf,primary,secondary]) => ({
  code, name, fullName, conf, primary, secondary,
  logo: `assets/logos/${LOGO_BY_CODE[code]}.svg`,
}));

const TEAM_BY_CODE = Object.fromEntries(TEAMS_INDEX.map(t => [t.code, t]));

/* ============================================================
   Roster shape note
   ------------------------------------------------------------
   `salary`        current-year cap hit
   `years`         array of remaining yearly salaries
                   (years[0] === salary by convention)
   `tradeBonus`    trade kicker % (CBA Art VII §3) — display only
   `guaranteeYrs`  index after which only partial/non-guarantee
                   (0 = year-1 fully guaranteed, 1 = thru year-2, ...)
   `partial`       { yearIdx, amount } partial guarantee
   `status`        guaranteed | team_option | player_option | non_guaranteed | two_way
   `noTrade`       full no-trade clause (must consent in writing)
   `bird`          full | early | non | n/a (FAs only)
   ============================================================ */
/* R14 dead-code trim: the prototype's sample ROSTERS/PICKS/FREE_AGENTS/TPES/
   CASH_LEDGER/PRE_SALARY constants (~210 lines of mock data) are removed.
   The real values are populated by buildTradeTables() on every render via
   the data-adapter pipeline (teams-trade-data.json + all-teams-detail.json
   + state overlay). These let bindings are kept so child components can
   reassign — they start empty and are filled each render. */
let ROSTERS = {};
let PICKS = {};
let FREE_AGENTS = {};
let TPES = {};
let EXC = {};   // per-team trade-acquisition exceptions (MLE family), parallel to TPES
let CASH_LEDGER = {};
let PRE_SALARY = {};
let PRE_APRON = {};   // holds-FREE apron base (Apron Team Salary excludes the FA cap hold) — parallel to PRE_SALARY (cap, holds-inclusive)
let HARDCAPS = {};   // 4d: per-team STRICTEST stored hard cap ("firstApron"|"secondApron"|null) from the active scenario — gates later trades


/* ===== Formatting + parsing ===== */
function fmt$(n) {
  if (n == null) return "—";
  const abs = Math.abs(n);
  if (abs >= 1_000_000) {
    let s = (n / 1_000_000).toFixed(abs >= 10_000_000 ? 1 : 2);
    if (s.includes(".")) s = s.replace(/0+$/, "").replace(/\.$/, "");
    return "$" + s + "M";
  }
  if (abs >= 1_000) return "$" + Math.round(n / 1000) + "K";
  return "$" + n;
}
function fmt$Full(n) {
  if (n == null) return "—";
  return "$" + n.toLocaleString("en-US");
}
function parseCashInput(s) {
  if (!s) return 0;
  s = s.toString().trim().toLowerCase().replace(/[$,_\s]/g, "");
  let mult = 1;
  if (s.endsWith("m")) { mult = 1_000_000; s = s.slice(0, -1); }
  else if (s.endsWith("k")) { mult = 1_000; s = s.slice(0, -1); }
  const n = parseFloat(s);
  return isNaN(n) ? 0 : Math.round(n * mult);
}
function cycleDestination(current, codes, exclude) {
  const targets = (codes || []).filter(c => c && c !== exclude);
  if (!targets.length) return null;
  return targets[(targets.indexOf(current) + 1) % targets.length];
}

/* ===== CBA-light trade evaluator (per-side) ===== */
const CAP_2023_24 = 136_021_000;
function tradeMaxIncoming(O, allowance) {
  const C75 = 7_500_000 * (CAP_2026.cap / CAP_2023_24);
  return Math.max(
    Math.min(2.0 * O + allowance, 1.0 * O + C75),
    1.25 * O + allowance
  );
}
/* side: { code, out:[{name,salary,noTrade}], in:[...], preSalary,
          cashOut, cashIn, tpeUsed } */
function evaluateTeamSide(side) {
  const O = side.out.reduce((a, p) => a + (p.salary || 0), 0);
  const I = side.in.reduce((a, p) => a + (p.salary || 0), 0);
  const post = side.preSalary - O + I + (side.cashOut || 0) - (side.cashIn || 0);
  const cap = CAP_2026.cap, ap1 = CAP_2026.apron1, ap2 = CAP_2026.apron2;
  const allowance = post > ap1 ? 0 : 250_000;
  const r = { code: side.code, legal: true, reasons: [], flags: [], O, I, post };

  if (O === 0 && I === 0 && !(side.cashOut > 0 || side.cashIn > 0)) {
    r.legal = false; r.reasons.push(`No assets selected for ${side.code}.`);
    return r;
  }
  for (const p of [...side.out, ...side.in]) {
    if (p.noTrade) {
      r.legal = false;
      r.reasons.push(`${p.name} has a no-trade clause — must consent in writing.`);
    }
  }
  if (side.preSalary > ap2 && side.out.length > 1) {
    r.legal = false;
    r.reasons.push(`${side.code} is above the 2nd apron — cannot aggregate ${side.out.length} outgoing salaries.`);
  }

  const tpe = side.tpeUsed || 0;
  const roomMax = side.preSalary < cap ? (cap - side.preSalary) + allowance : null;
  const tpeMax = tradeMaxIncoming(O, allowance) + tpe;
  let maxIn, basis;
  if (roomMax !== null && roomMax >= tpeMax) {
    maxIn = roomMax;
    basis = `cap room ${fmt$(cap - side.preSalary)} + ${fmt$(allowance)} (Room TPE)`;
  } else {
    maxIn = tpeMax;
    basis = `Expanded TPE on ${fmt$(O)} outgoing` +
            (tpe ? ` + ${fmt$(tpe)} traded-player exception` : "") +
            (allowance === 0 ? " ($250k→$0 above 1st apron)" : "");
  }
  maxIn = Math.round(maxIn);
  if (I > maxIn) {
    r.legal = false;
    r.reasons.push(`${side.code} takes ${fmt$(I)} but max is ${fmt$(maxIn)} — ${basis}.`);
  } else if (I > 0 || O > 0) {
    r.reasons.push(`Salary match OK: ${fmt$(I)} ≤ ${fmt$(maxIn)} — ${basis}.`);
  }

  /* hard-cap flags */
  const usedExpanded = side.preSalary >= cap;
  if (usedExpanded && side.out.length > 1)
    r.flags.push(`Aggregating outgoing salaries → hard-capped at the 2nd apron (${fmt$(ap2)}).`);
  if (usedExpanded && I > O)
    r.flags.push(`Takes back more than it sends → hard-capped at the 1st apron (${fmt$(ap1)}).`);
  if (post > ap2)         r.flags.push(`Post-trade ${fmt$(post)} is ABOVE the 2nd apron.`);
  else if (post > ap1)    r.flags.push(`Post-trade ${fmt$(post)} is between the aprons.`);
  return r;
}

function capStatus(salary) {
  const { cap, apron1, apron2, taxLine } = CAP_2026;
  // [B] 5 distinct tiers (over-cap & over-tax were both amber): green room → grey
  // over-cap (no penalty) → yellow tax → orange between aprons → red over 2nd apron.
  if (salary > apron2) return { key:"ap2",   label:"Above 2nd apron",   color:"var(--danger)" };
  if (salary > apron1) return { key:"ap1",   label:"Between aprons",    color:"var(--orange)" };
  if (salary > taxLine)return { key:"tax",   label:"Over tax",          color:"var(--warn)" };
  if (salary > cap)    return { key:"cap",   label:"Over cap",          color:"var(--neutral)" };
  return                      { key:"room",  label:"Under cap",         color:"var(--pos-text)" };
}

/* Compact tiles describing space to nearest cap lines */
function capTiles(salary) {
  const { cap, taxLine, apron1, apron2 } = CAP_2026;
  const overCap = salary > cap;
  const t = (lab, diff) => ({ lab, val: diff, pos: diff >= 0 });
  return overCap
    ? [ t("Tax", taxLine - salary), t("A1", apron1 - salary), t("A2", apron2 - salary) ]
    : [ t("Cap", cap - salary), t("Tax", taxLine - salary), t("A1", apron1 - salary) ];
}

// #5B: team nickname (no city); abbreviate the long ones to fit the header.
const NAME_ABBR = { "Timberwolves": "T. Wolves", "Trail Blazers": "T. Blazers" };
function abbrevName(name) { return NAME_ABBR[name] || name; }

function statusTag(s) {
  switch (s) {
    case "team_option":    return "team opt";
    case "player_option":  return "player opt";
    case "partially_guaranteed": return "non-guar";
    case "non_guaranteed": return "non-guar";
    case "two_way":        return "two-way";
    default: return null;
  }
}

function contractSummary(p) {
  if (!p.years || p.years.length === 0)
    return { yrs: 1, total: p.salary || 0 };
  return {
    yrs: p.years.length,
    total: p.years.reduce((a, b) => a + (b || 0), 0),
  };
}

function birdLabel(b) {
  switch (b) {
    // [#5] Neutral, NOT blue — bird rights are reference info, ranked by brightness
    // (Full = brightest) instead of colour. Blue stays for actions.
    case "full":  return { txt: "Full Bird",  color: "var(--text)" };
    case "early": return { txt: "Early Bird", color: "var(--text-dim)" };
    case "non":   return { txt: "Non-Bird",   color: "var(--text-faint)" };
    default:      return { txt: "—",          color: "var(--text-faint)" };
  }
}


/* ============================================================
   Trade Machine — components (v2)
   - PlayerRow with in-place selection (no tray jump)
   - FreeAgentRow with S&T toggle
   - PlayerDrawer (full edit options)
   - Compact dest pill + popover
   ============================================================ */
const { useState, useEffect, useRef, useMemo, useLayoutEffect } = React;

const TmIcon = {
  Trade:   (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 7h13l-3-3"/><path d="M17 13H4l3 3"/></svg>,
  Plus:    (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M10 4v12M4 10h12"/></svg>,
  X:       (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M5 5l10 10M15 5L5 15"/></svg>,
  Check:   (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M4 10l4 4 8-9"/></svg>,
  Caret:   (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M5 8l5 5 5-5"/></svg>,
  Bolt:    (p) => <svg viewBox="0 0 20 20" fill="currentColor" {...p}><path d="M11 1L3 12h6l-1 7 8-11h-6l1-7z"/></svg>,
  Cash:    (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="2" y="5" width="16" height="10" rx="2"/><circle cx="10" cy="10" r="2.2"/><path d="M5 8.5h.01M15 11.5h.01"/></svg>,
  Pick:    (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="10" cy="10" r="7"/><path d="M10 4v6l4 2"/></svg>,
  Roster:  (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="10" cy="7" r="3"/><path d="M3 17c1.5-3 4-4.5 7-4.5s5.5 1.5 7 4.5"/></svg>,
  FA:      (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 17a7 7 0 0 1 14 0"/><circle cx="10" cy="7" r="3"/><path d="M14 4l3 3-3 3"/></svg>,
  TPE:     (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 10h14M10 3v14M5 5l10 10M15 5L5 15"/></svg>,
  Back:    (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M12 5l-5 5 5 5"/></svg>,
  Trash:   (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M4 6h12M8 6V4h4v2M6 6l1 10h6l1-10"/></svg>,
  Save:    (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M4 4h10l3 3v10H4z"/><path d="M7 4v5h6V4M7 17v-6h6v6"/></svg>,
  Reset:   (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M4 5v4h4"/><path d="M4 9a7 7 0 1 1 1.7 4.5"/></svg>,
  Slider:  (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M3 6h10M17 6h0M3 14h4M11 14h6"/><circle cx="13" cy="6" r="1.6"/><circle cx="9" cy="14" r="1.6"/></svg>,
  Phone:   (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="6" y="2" width="8" height="16" rx="2"/><path d="M9 16h2"/></svg>,
  Lock:    (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><rect x="4" y="9" width="12" height="8" rx="2"/><path d="M7 9V6a3 3 0 0 1 6 0v3"/></svg>,
  Info:    (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><circle cx="10" cy="10" r="7"/><path d="M10 9v5M10 6v.5"/></svg>,
  More:    (p) => <svg viewBox="0 0 20 20" fill="currentColor" {...p}><circle cx="5" cy="10" r="1.6"/><circle cx="10" cy="10" r="1.6"/><circle cx="15" cy="10" r="1.6"/></svg>,
  Edit:    (p) => <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M4 16h2l8-8-2-2-8 8z"/><path d="M12 6l2-2 2 2-2 2z"/></svg>,
};

function TeamLogo({ code, size = 22, alt = "" }) {
  const team = TEAM_BY_CODE[code];
  if (!team) return <span style={{width:size,height:size,display:"inline-block"}} />;
  return (
    <img className="logo-img" src={team.logo} alt={alt || team.fullName}
         width={size} height={size}
         style={{width:size, height:size, objectFit:"contain"}} />
  );
}

function PlayerAvatar({ player, size = 32 }) {
  const [bad, setBad] = useState(false);
  const initials = (player.name || "?").split(" ").map(s => s[0]).slice(0, 2).join("");
  if (!player.nbaId || bad) {
    return (
      <div className="photo" style={{width:size, height:size, fontSize: Math.floor(size*0.32)}}>
        {initials}
      </div>
    );
  }
  return (
    <div className="photo" style={{width:size, height:size}}>
      <img src={`assets/players/${player.nbaId}.png`} alt=""
           onError={() => setBad(true)} />
    </div>
  );
}

/* --- Destination pill (small, in-row) --- */
function DestPill({ destCode, locked, onClick, mini = false, big = false, sm = false, noCaret = false, fullh = false, tentative = false, dataKey }) {
  const team = TEAM_BY_CODE[destCode];
  // #1c: show the destination team's LOGO (not a colour dot + code) to keep the
  // chip short; the dropdown (DestPopover) still lists logo + full name.
  // noCaret → omit the chevron (clean badge, still clickable). fullh → full-height logo (stack 11).
  // tentative → dashed/amber ring until the user confirms the auto-defaulted destination.
  const size = fullh ? 30 : big ? 28 : sm ? 18 : mini ? 22 : 24;
  return (
    <button data-destkey={dataKey}
            className={`tm-dest logo-only ${big ? "big" : ""} ${sm ? "sm" : ""} ${fullh ? "fullh" : ""} ${tentative ? "tentative" : ""} ${locked ? "locked" : ""}`}
            onClick={(e) => { e.stopPropagation(); if (!locked) onClick(e); }}
            type="button"
            title={team ? (locked ? `Sends to ${team.fullName}` : `Click to change — currently → ${team.fullName}`) : "Choose destination"}>
      {destCode ? <TeamLogo code={destCode} size={size} />
                : <span className="qmark" style={{fontSize: mini ? 11 : 12}}>?</span>}
      {!locked && !noCaret && <TmIcon.Caret className="caret" />}
    </button>
  );
}

/* --- Destination popover --- */
function DestPopover({ anchor, codes, ownCode, current, onPick, onClose, onUndo }) {
  const ref = useRef(null);
  const [style, setStyle] = useState({ left: -9999, top: -9999, position:"fixed" });
  useLayoutEffect(() => {
    if (!anchor || !ref.current) return;
    const r = anchor.getBoundingClientRect();
    const my = ref.current.getBoundingClientRect();
    let left = r.left + r.width/2 - my.width/2;
    let top = r.bottom + 6;
    left = Math.max(8, Math.min(window.innerWidth - my.width - 8, left));
    if (top + my.height + 8 > window.innerHeight) top = r.top - my.height - 6;
    setStyle({ left, top, position:"fixed" });
  }, [anchor]);
  useEffect(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, [onClose]);
  /* Portal to <body> so the popover escapes the `.tm-shell` container-query
     subtree — on iOS Safari a `position:fixed` element near a `container-type`
     ancestor can be painted under in-flow siblings (the outgoing chips). At the
     body level it sits in the root stacking context, above `.tm-fullscreen`. */
  return ReactDOM.createPortal(
    <div ref={ref} className="tm-pop" style={style}>
      <div style={{padding:"4px 8px", fontSize:9.5, letterSpacing:"0.1em", color:"var(--text-faint)", fontWeight:800, textTransform:"uppercase"}}>Send to</div>
      {codes.filter(c => c !== ownCode).map(c => (
        <button key={c} className={`tm-pop-item ${c === current ? "on" : ""}`}
                onClick={() => { onPick(c); onClose(); }}>
          <TeamLogo code={c} size={26} />
          <span style={{flex:1}}>{TEAM_BY_CODE[c]?.fullName || c}</span>
          {c === current && <TmIcon.Check style={{width:13, height:13}} />}
        </button>
      ))}
      {onUndo && (
        <button className="tm-pop-item undo" onClick={() => { onUndo(); onClose(); }}
                title={ownCode ? `Undo — returns to ${TEAM_BY_CODE[ownCode]?.fullName || ownCode}` : "Undo trade"}>
          {/* the player RETURNS to their own (source) team on undo — show that logo */}
          {ownCode ? <TeamLogo code={ownCode} size={26} /> : <TmIcon.X style={{width:18, height:18}} />}
          <span style={{flex:1}}>Undo trade</span>
        </button>
      )}
    </div>,
    document.body
  );
}

/* --- Team picker for add-team --- */
/* TmTeamPicker (prototype's W/E grid) was REPLACED by the main-site's
   TeamMenu component in batch 18 #4 (multi-select with up to 6 cap). The
   prototype version was removed in R14 dead-code trim. */

/* ============================================================
   PLAYER ROW — main interactive surface
   ============================================================ */
function PlayerRow({ player, selected, multi, locked, orgCode, onToggle, onDest, onOpenDrawer,
                    editing, editCtx, onEditChange, onEditApply, onEditClose, editLayout, slotEv, ownTeam }) {
  const tag = statusTag(player.status);
  const c = contractSummary(player);
  const inlineEditing = editing && editLayout !== "drawer";
  const editClass = inlineEditing ? `editing edit-${editLayout || "inline-v2"}` : "";
  return (
    <div className={`tm-row ${selected ? "selected" : ""} ${locked || player._tradeLocked ? "locked" : ""} ${player._resigned ? "resigned" : ""} ${editClass}`}
         data-name={player.name}
         onClick={() => { if (locked || inlineEditing || player._resigned || player._tradeLocked) return; onToggle(player); }}>
      <PlayerAvatar player={player} size={32} />
      <div className="body">
        <div className="nameline">
          {selected && <span className="check"><TmIcon.Check /></span>}
          <span className="name">{player.name}</span>
          {/* Re-signed (signed) player: greyed + not directly tradable. The "Signed"
              chip flips it to Sign & Trade (the legal way to move a just-signed player). */}
          {player._resigned && (
            <button type="button" className="tag signed-chip"
                    title="Re-signed — can't be traded for a few months. Click to switch to Sign & Trade."
                    onClick={(e) => { e.stopPropagation(); onOpenDrawer(player); }}>
              Signed<TmIcon.Edit className="pen" />
            </button>
          )}
          {/* Re-use guard (B): a player acquired in an applied trade can't be re-traded. */}
          {player._tradeLocked && (
            <span className="tag locked-tag"
                  title="Acquired in an applied trade — undo that trade (in the trade list) to move this player again.">
              In a trade
            </span>
          )}
          {tag && !player._resigned && !player._tradeLocked && (
            <button type="button" className={`tag tag-edit ${tag.includes("opt") ? "option" : ""}`}
                    title="Edit option / salary, then trade"
                    onClick={(e) => { e.stopPropagation(); onOpenDrawer(player); }}>
              {tag}<TmIcon.Edit className="pen" />
            </button>
          )}
          {player.noTrade && <span className="tag ntc">NTC</span>}
          {player.tradeBonus > 0 && <span className="tag bonus" title={`${(player.tradeBonus*100)|0}% trade kicker`}>{(player.tradeBonus*100)|0}% kick</span>}
        </div>
        {/* destination pill sits under the name when selected in a multi-team deal */}
        {selected && multi && (
          <div className="subline destline">
            <DestPill destCode={player._dest}
              onClick={(e) => onDest(player, e.currentTarget)} mini />
          </div>
        )}
      </div>
      <div className="right">
        <span className="salary">{fmt$(player.salary)}</span>
        <span className="yrs-left">{c.yrs}y left</span>
        {selected && !multi && (
          <button className="iconbtn rowpen" onClick={(e) => { e.stopPropagation(); onOpenDrawer(player); }}
                  title="Edit trade options">
            <TmIcon.More style={{width:14, height:14}}/>
          </button>
        )}
      </div>
      <span className="tm-row-org"><TeamLogo code={orgCode} size={26} /></span>
      {locked && <span className="lockico"><TmIcon.Lock /></span>}
      {inlineEditing && <InlineEditor ctx={editCtx} layout={editLayout} slotEv={slotEv} ownTeam={ownTeam} onChange={onEditChange} onApply={onEditApply} onClose={onEditClose} />}
    </div>
  );
}

/* ============================================================
   INCOMING ROW — Fanspo-style: appears inline in roster list
   showing player coming TO this team from another
   ============================================================ */
function IncomingRow({ item, onOpenDrawer, onJumpTo, onDest }) {
  const fromTeam = TEAM_BY_CODE[item._from];
  const c = item._kind === "player" ? contractSummary(item)
          : item._kind === "fa"     ? { yrs: item.sntYears, total: item.sntAsk * item.sntYears }
          : { yrs: 0, total: 0 };
  return (
    <div className="tm-row incoming"
         onClick={(e) => onDest && onDest(item, e.currentTarget)}
         title={`From ${fromTeam?.fullName || item._from} — tap to re-route or undo`}>
      <PlayerAvatar player={item} size={32} />
      <div className="body">
        <div className="nameline">
          {/* [#5] incoming-player checkmark removed — the bg + from-team logo + jersey are cue enough. */}
          <span className="name">{item.name}</span>
          {item._kind === "fa" && <span className="tag bonus">S&amp;T</span>}
          {item.noTrade && <span className="tag ntc">NTC</span>}
        </div>
      </div>
      <div className="right">
        <span className="salary">{fmt$(item.salary)}</span>
        {c.yrs > 0 && <span className="yrs-left">{c.yrs}y left</span>}
      </div>
      <span className="tm-row-org"><TeamLogo code={item._from} size={26} /></span>
    </div>
  );
}

function IncomingPickRow({ item, onJumpTo, onDest }) {
  const fromTeam = TEAM_BY_CODE[item._from];
  return (
    <div className="tm-row pick incoming"
         onClick={(e) => onDest && onDest(item, e.currentTarget)}
         title={item.residualNote || `Acquired from ${item._from} — manage in ${item._from}'s column`}>
      <div className="photo">
        {item.round === 1 ? "1st" : "2nd"}
        <small>{(""+item.year).slice(-2)}</small>
      </div>
      <div className="body">
        <div className="nameline">
          <span className="arrow-in"><TmIcon.Check /></span>
          <span className="name">{item.label}</span>
          {item.swap && <span className="tag option">swap</span>}
          {item.residual && <span className="tag bonus">protected</span>}
        </div>
        <div className="subline">{item.sub}</div>
      </div>
      <div className="right">
        <span className="salary" style={{color: "var(--info)", fontSize: 11, fontWeight: 700, letterSpacing: "0.04em"}}>PICK</span>
      </div>
      <span className="tm-row-org"><TeamLogo code={item._from} size={26} /></span>
    </div>
  );
}

function IncomingCashRow({ item, onJumpTo, onDest }) {
  const fromTeam = TEAM_BY_CODE[item._from];
  return (
    <div className="tm-row incoming"
         onClick={(e) => onDest && onDest(item, e.currentTarget)}
         title={`Cash from ${item._from}`}>
      <div className="photo" style={{background:"var(--info-bg)", color:"var(--info)"}}>
        <TmIcon.Cash style={{width: 16, height: 16}}/>
      </div>
      <div className="body">
        <div className="nameline">
          <span className="arrow-in"><TmIcon.Check /></span>
          <span className="name">Cash</span>
        </div>
      </div>
      <div className="right">
        <span className="salary" style={{color:"var(--info)"}}>{fmt$(item.salary)}</span>
      </div>
      <span className="tm-row-org"><TeamLogo code={item._from} size={26} /></span>
    </div>
  );
}
function FreeAgentRow({ fa, selected, multi, onToggle, onDest, onOpenDrawer,
                       editing, editCtx, onEditChange, onEditApply, onEditClose, editLayout, slotEv, ownTeam }) {
  const bird = birdLabel(fa.bird);
  const inlineEditing = editing && editLayout !== "drawer";
  const editClass = inlineEditing ? `editing edit-${editLayout || "inline-v2"}` : "";
  return (
    <div className={`tm-row ${selected ? "selected" : ""} ${editClass}`}
         onClick={() => { if (inlineEditing) return; onToggle(fa); }}>
      <PlayerAvatar player={fa} size={32} />
      <div className="body">
        <div className="nameline">
          {selected && <span className="check"><TmIcon.Check /></span>}
          <span className="name">{fa.name}</span>
          <span className="tag bird" style={{color: bird.color, borderColor: bird.color === "var(--text-dim)" ? "var(--line)" : bird.color}}>
            {bird.txt}
          </span>
          {selected && <span className="tag bonus" style={{color:"var(--warn)"}}>S&amp;T</span>}
        </div>
        <div className="subline">
          {fa.pos}{fa.exp != null ? ` · ${fa.exp}y exp` : ""} · last {fmt$(fa.lastSalary)} · hold {fmt$(fa.capHold)}
        </div>
      </div>
      <div className="right">
        <span className="salary">{fmt$(fa.sntAsk)}</span>
        <span className="salary-sub">asks · {fa.sntYears}y</span>
        {selected && multi && (
          <span className="destpill">
            <DestPill destCode={fa._dest}
              onClick={(e) => onDest(fa, e.currentTarget)} mini />
          </span>
        )}
      </div>
      {selected && !inlineEditing && (
        <div className="actionbar" onClick={(e) => e.stopPropagation()}>
          <span className="lab" style={{color:"var(--warn)"}}>S&amp;T · FA must consent</span>
          {multi && (
            <>
              <span style={{fontSize:10.5, color:"var(--text-dim)"}}>→</span>
              <DestPill destCode={fa._dest}
                onClick={(e) => onDest(fa, e.currentTarget)} />
            </>
          )}
          <span className="spacer" />
          <button className="iconbtn" onClick={() => onOpenDrawer(fa)} title="Edit S&T terms">
            <TmIcon.Edit />
          </button>
          <button className="iconbtn" onClick={() => onToggle(fa)} title="Remove from trade">
            <TmIcon.X />
          </button>
        </div>
      )}
      {inlineEditing && <InlineEditor ctx={editCtx} layout={editLayout} slotEv={slotEv} ownTeam={ownTeam} onChange={onEditChange} onApply={onEditApply} onClose={onEditClose} />}
    </div>
  );
}

/* ============================================================
   PICK ROW (outgoing/own picks)
   ============================================================ */
function PickRow({ pk, selected, multi, onToggle, onDest }) {
  const cls = pk.this ? "this" : pk.residual ? "residual" : pk.swap ? "swap" : "";
  return (
    <div className={`tm-row pick ${cls} ${selected ? "selected" : ""}`}
         onClick={() => onToggle(pk)}
         title={pk.residualNote || undefined}>
      <div className="photo">
        {pk.round === 1 ? "1st" : "2nd"}
        <small>{(""+pk.year).slice(-2)}</small>
      </div>
      <div className="body">
        <div className="nameline">
          {selected && <span className="check"><TmIcon.Check /></span>}
          <span className="name">{pk.label}</span>
          {pk.swap && <span className="tag option">swap</span>}
          {pk.residual && <span className="tag bonus">protected</span>}
        </div>
        <div className="subline">{pk.sub}</div>
      </div>
      <div className="right">
        {selected && multi && (
          <span className="destpill">
            <DestPill destCode={pk._dest}
              onClick={(e) => onDest(pk, e.currentTarget)} mini />
          </span>
        )}
      </div>
    </div>
  );
}

/* ============================================================
   OUTGOING STRIP — collapsible chip strip above the list
   (Fanspo-inspired: outgoing summary, incoming lives in roster)
   ============================================================ */
function OutgoingStrip({ outgoing, multi, cashOut, cashDest, slot, chipLayout, inSalary = 0, net = 0, onRemove, onDest, onCashDest, defaultOpen = true }) {
  const [open, setOpen] = useState(defaultOpen);
  const totalSal = outgoing.reduce((a, p) => a + (p.salary || 0), 0) + (cashOut || 0);
  const players = outgoing.filter(o => o._kind === "player" || o._kind === "fa");
  const picks   = outgoing.filter(o => o._kind === "pick");
  const isEmpty = outgoing.length === 0 && !(cashOut > 0);
  /* #2: auto-open when a player is sent out so the menu never floats over a
     collapsed bar. Picks/cash alone don't force it open; manual collapse sticks
     until the next player is added. */
  const prevPlayers = useRef(players.length);
  useEffect(() => {
    if (players.length > prevPlayers.current) setOpen(true);
    prevPlayers.current = players.length;
  }, [players.length]);
  const idle = isEmpty && (inSalary || 0) <= 0;   // nothing in OR out → show the hint
  return (
    <div className={`tm-col-out ${open ? "open" : "collapsed"} ${isEmpty ? "is-empty" : ""}`}>
      <div className="tm-col-out-h" onClick={() => !isEmpty && setOpen(o => !o)}>
        <span className="lab">OUTGOING</span>
        <span style={{flex:1}} />
        {/* Round 10 #0.5c / #10: dropped the redundant OUT · IN · NET (the header now
            carries net) and the "N to route" badge (auto-confirm made it always read 1
            and it added noise). Keep just the OUT total for at-a-glance context. */}
        {idle
          ? <span className="hint">tap a player to add</span>
          : <span className="tm-out-total"><i>OUT</i>{fmt$(totalSal)}</span>}
        {!isEmpty && <TmIcon.Caret className="caret" />}
      </div>
      {!isEmpty && (
      <div className="tm-col-out-body">
        {outgoing.map(p => (
          <OutgoingChip key={p._key} item={p} multi={multi} layout={chipLayout}
            onRemove={() => onRemove(p)}
            onDest={(e) => onDest(p, e.currentTarget)} />
        ))}
        {cashOut > 0 && (
          <span className="tm-out-chip">
            <span className="ico"><TmIcon.Cash style={{width:11,height:11}}/></span>
            <span className="nm">Cash</span>
            <DestPill destCode={cashDest} locked={!multi}
              onClick={(e) => onCashDest(e.currentTarget)} mini />
            <span className="amt">{"$" + Math.round(cashOut / 1e6) + "M"}</span>
            <button className="rm" onClick={() => onRemove({_kind:"cash"})} title="Remove cash">
              <TmIcon.X />
            </button>
          </span>
        )}
      </div>
      )}
    </div>
  );
}

function OutgoingChip({ item, multi, layout = "inline", onRemove, onDest }) {
  // players/FAs show last name only (saves chip width); picks keep their label
  const isPerson = item._kind === "player" || item._kind === "fa";
  const lastName = (n) => { const p = String(n || "").trim().split(/\s+/); return p.length > 1 ? p.slice(1).join(" ") : n; };
  const label = isPerson ? lastName(item.name) : (item.label || item.name);
  const avatar = isPerson
    ? <PlayerAvatar player={item} size={26} />
    : <span className="ico">{item.round === 1 ? "1" : "2"}</span>;
  const xBtn = <button className="rm" onClick={onRemove} title="Remove"><TmIcon.X /></button>;
  const destKey = item._slotIdx + "-" + item._kind + "-" + item._key;
  const tentative = multi && item._confirmed === false;   // unconfirmed auto-default

  const botRow = ["stacked3", "stacked4", "stacked5", "stacked6", "stacked7", "stacked8", "stacked9", "stacked10"].includes(layout);
  const stackedMid = layout === "stacked" || layout === "stacked2";                  // name/$ stacked, big logo at end
  const topX = layout === "stacked7" || layout === "stacked8" || layout === "stacked9" || layout === "stacked10";   // 7/8/9/10: one-click ✕ by the name
  const noCaretPill = layout === "stacked9" || layout === "stacked10";   // 9/10 = 7/8 but bottom-row logo has no chevron
  const endX = layout === "inline" || layout === "stacked" || layout === "stacked3" || layout === "stacked4"; // ✕ at chip end
  // 2/5/6 have NO ✕ at all → keep the dest pill clickable (even in a 2-team deal)
  // so the dropdown's "Undo trade" stays reachable. Everything else locks normally.
  const noRemoveX = layout === "stacked2" || layout === "stacked5" || layout === "stacked6";
  const pillLocked = noRemoveX ? false : !multi;

  // --- Adaptive (default): the chip changes by team count ---
  //   2-team  → no destination to choose, so just name/$ + a clean ✕ to remove.
  //   3+ teams → name/$ + the team LOGO as the whole tap target (no chevron; the amber
  //              "tentative" ring is the cue). Remove via the menu's "Undo trade".
  if (layout === "auto") {
    // #3b: whole-dollar salary (no decimals) in the chip
    const amt = item.salary > 0 ? <span className="amt">{"$" + Math.round(item.salary / 1e6) + "M"}</span> : null;
    if (multi) {
      // #3a: the WHOLE chip is the tap target (opens the dest menu); logo is the cue.
      return (
        <span className={`tm-out-chip stacked auto multi`} data-destkey={destKey}
              onClick={(e) => onDest(e)} role="button" title="Change destination">
          {avatar}
          <span className="chip-mid"><span className="nm">{label}</span>{amt}</span>
          <DestPill destCode={item._dest} locked={false} onClick={(e) => onDest(e)} big noCaret tentative={tentative} />
        </span>
      );
    }
    return (
      <span className={`tm-out-chip stacked auto two`}>
        {avatar}
        <span className="chip-mid"><span className="nm">{label}</span>{amt}</span>
        {xBtn}
      </span>
    );
  }

  // --- Stacked 3–8: name on top, [small dest pill][$] (or inverted) on the bottom row.
  //     7/8 add a one-click ✕ in the top row, right of the name (away from the logo). ---
  if (botRow) {
    const amt = item.salary > 0 ? <span className="amt">{fmt$(item.salary)}</span> : null;
    const pill = <DestPill destCode={item._dest} locked={pillLocked} onClick={(e) => onDest(e)} sm noCaret={noCaretPill} tentative={tentative} dataKey={destKey} />;
    const moneyFirst = layout === "stacked4" || layout === "stacked6" || layout === "stacked8" || layout === "stacked10";
    return (
      <span className={`tm-out-chip stacked ${layout}`}>
        {avatar}
        <span className="chip-mid">
          <span className="chip-toprow"><span className="nm">{label}</span>{topX && xBtn}</span>
          <span className="chip-botrow">{moneyFirst ? <>{amt}{pill}</> : <>{pill}{amt}</>}</span>
        </span>
        {endX && xBtn}
      </span>
    );
  }

  // --- Stacked / Stacked+ : salary under the name (decimals), big team logo at the end ---
  if (stackedMid) {
    const amt = item.salary > 0 ? <span className="amt">{fmt$(item.salary)}</span> : null;
    return (
      <span className={`tm-out-chip stacked ${layout}`}>
        {avatar}
        <span className="chip-mid">
          <span className="nm">{label}</span>
          {amt}
        </span>
        <DestPill destCode={item._dest} locked={pillLocked} onClick={(e) => onDest(e)} big tentative={tentative} dataKey={destKey} />
        {endX && xBtn}
      </span>
    );
  }

  // --- Stacked 11: [photo][full-height team logo, no chevron][name/$ stacked][✕ end] ---
  if (layout === "stacked11") {
    const amt = item.salary > 0 ? <span className="amt">{fmt$(item.salary)}</span> : null;
    return (
      <span className={`tm-out-chip stacked stacked11`}>
        {avatar}
        <DestPill destCode={item._dest} locked={pillLocked} onClick={(e) => onDest(e)} fullh noCaret tentative={tentative} dataKey={destKey} />
        <span className="chip-mid">
          <span className="nm">{label}</span>
          {amt}
        </span>
        {xBtn}
      </span>
    );
  }

  // --- Inline (default): LastName · TEAM · $ — no decimals so chips pack tight ---
  const amt = item.salary > 0 ? "$" + Math.round(item.salary / 1e6) + "M" : null;
  return (
    <span className="tm-out-chip">
      {avatar}
      <span className="nm">{label}</span>
      <DestPill destCode={item._dest} locked={!multi} onClick={(e) => onDest(e)} mini tentative={tentative} dataKey={destKey} />
      {amt && <span className="amt">{amt}</span>}
      {xBtn}
    </span>
  );
}

/* ============================================================
   GETS STRIP — DEPRECATED, kept for backward refs
   ============================================================ */
function GetsStrip({ incoming, onJumpTo }) {
  if (incoming.length === 0) return null;
  return (
    <div className="tm-col-gets">
      <span className="label">GETS</span>
      {incoming.map(p => (
        <button key={p._key} className="chip"
                onClick={() => onJumpTo(p._from)}
                title={`From ${p._from} — manage in ${p._from}'s column`}>
          {p._kind === "player"
            ? <span className="photo"><img src={p.nbaId ? `assets/players/${p.nbaId}.png` : ""} alt="" onError={(e) => e.target.style.display="none"} /></span>
            : p._kind === "pick"
            ? <span className="ico">{p.round === 1 ? "1" : "2"}</span>
            : <span className="ico"><TmIcon.Cash style={{width:10,height:10}}/></span>}
          <span style={{maxWidth:120, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap"}}>{p.name}</span>
          {p.salary > 0 && <span className="amt">{fmt$(p.salary)}</span>}
          <span className="from">{p._from}</span>
        </button>
      ))}
    </div>
  );
}

/* ============================================================
   PICKS LIST — sectioned
   ============================================================ */
function PicksList({ slot, picks, multi, onToggle, onDest }) {
  if (!picks.length) return <div className="tm-tpe-empty">No tradable picks.</div>;
  const this_ = picks.filter(p => p.this);
  const future = picks.filter(p => !p.this && !p.residual);
  const residual = picks.filter(p => p.residual);
  const render = (pk) => {
    const sel = slot.picks.get(pk.id);
    return <PickRow key={pk.id}
      pk={sel ? { ...pk, _dest: sel.dest } : pk}
      selected={!!sel} multi={multi}
      onToggle={onToggle} onDest={onDest} />;
  };
  return (
    <div className="tm-list">
      {this_.length > 0 && (<><div className="list-section">This year · {this_.length} <span className="sep"/></div>{this_.map(render)}</>)}
      {future.length > 0 && (<><div className="list-section">Future · {future.length} <span className="sep"/></div>{future.map(render)}</>)}
      {residual.length > 0 && (<><div className="list-section warn">Conditional · {residual.length} <span className="sep" style={{background:"rgba(217,160,79,0.3)"}}/></div>{residual.map(render)}</>)}
    </div>
  );
}

/* ============================================================
   PICKS GRID — R11.A: 7-year × 2-round matrix with RealGM popover.
   Tap an owned cell → adds to outgoing (same onToggle path as the list).
   Tap the ⓘ in a non-simple cell → expand the full RealGM ledger string.
   Empty (count=0) cells are muted "—". Selected cells show a green tint
   + check. CBA 7-year forward limit is implicit (we only show 2026-2032,
   i.e. current + 6 years out — all within limit for 2026 season).
   ============================================================ */
/* ============================================================
   CapMVP PICKS — our timeline + ticket card (replaces PicksGrid)
   ------------------------------------------------------------
   Renders the picks truth-machine holdings (picks-data.json, injected into
   PICKS[code] as pk._hold by buildTradeTables). Layout = our year-rail timeline
   (Full / Partial columns + tokens); detail = our ticket card (outcome table /
   swap box / chevron what-if with moot + forced "new top-5 rule" notes).
   CLEAN DATA ONLY — no raw ledger strings, no source-site names. The what-if
   engine (parseExpr / ev / moot enumeration) is lifted framework-free from
   render_picks_variant.py.
   ============================================================ */
const TM_VRE = /[A-Z]{2,4}\|\d{4}\|\d/g;
function tmParseExpr(s) {
  const t = (String(s || "").match(/\(|\)|AND|OR|NOT|[A-Z]{2,4}\|\d{4}\|\d=(?:lo|hi)/g)) || [];
  let i = 0; const pk = () => t[i], nx = () => t[i++];
  function prim() { const x = pk(); if (x === "(") { nx(); const e = orE(); nx(); return e; } if (x === "NOT") { nx(); return { not: prim() }; } nx(); const p = x.split("="); return { v: p[0], is: p[1] }; }
  function andE() { let e = prim(); while (pk() === "AND") { nx(); e = { and: [e, prim()] }; } return e; }
  function orE() { let e = andE(); while (pk() === "OR") { nx(); e = { or: [e, andE()] }; } return e; }
  return t.length ? orE() : null;
}
function tmEv(n, st) {
  if (!n) return null;
  if ("v" in n) { const x = st[n.v]; return x === undefined ? null : x === n.is; }
  if (n.and) { let z = 0; for (const c of n.and) { const r = tmEv(c, st); if (r === false) return false; if (r === null) z = 1; } return z ? null : true; }
  if (n.or) { let z = 0; for (const c of n.or) { const r = tmEv(c, st); if (r === true) return true; if (r === null) z = 1; } return z ? null : false; }
  if (n.not) { const r = tmEv(n.not, st); return r === null ? null : !r; }
  return null;
}
function tmTeamize(s, holder, teamreg) {
  return String(s == null ? "" : s).replace(/\[\[([A-Z]{2,4})\]\]/g, (m, c) => {
    if (c === holder) return c;
    const r = teamreg && teamreg[c];
    return r ? (r.city || c) : c;
  });
}

/* User-BUILT protection/swap obligations for THIS session, keyed `${slotCode}:${pickId}`.
   A separate layer from V4's trade state — picks are $0, so this never touches the salary
   engine; pick-rules.js validates it and the card renders it. */
const TM_OBLIG = {};
const TM_OBLIG_KEY = "capmvp-tm-oblig";
// Restore built obligations from the same-world store so they survive a reload, like
// V4's capmvp-state. (Owner: "as persistent as the reset — it's all one world.")
try { if (typeof localStorage !== "undefined") { const _r = JSON.parse(localStorage.getItem(TM_OBLIG_KEY) || "{}"); if (_r && typeof _r === "object") Object.assign(TM_OBLIG, _r); } } catch (e) {}

/* For an OWN conditional pick (holder keeps it in range, else it conveys away), the
   team it conveys TO — so the outcome table can name a real team instead of the
   confusing "holder ✗". Prefers the structured _convey (built protections); else
   parses the curated "conveys to X" deny text and maps it back to a code. */
function tmConveyTeam(H, teamreg) {
  if (H && H._convey) return H._convey;
  const d = (H && H.cond && H.cond.deny) || "";
  const m = d.match(/conveys to ([A-Za-z .'-]+?)(?:[,.]|$)/i);
  if (!m) return null;
  const raw = m[1].trim();
  if (teamreg && teamreg[raw]) return raw;                       // already a code
  if (teamreg) for (const c in teamreg) { const t = teamreg[c]; if (t.city === raw || t.full === raw || t.abbrev === raw) return c; }
  return raw;                                                    // fall back to the raw name
}

/* Merge a built obligation over the base holding so the card renders it as a conditional
   (outcome table) / swap. Returns { H, vars } including any synthesized what-if var. */
function tmSynthHolding(H0, built, holderCode, year, round, yy) {
  const H = Object.assign({}, H0 || { tm: holderCode, logo: holderCode, lbl: year + " " + (round === 2 ? "2nd" : "1st"), type: "Own", hint: "", badge: "", text: "" });
  const out = { vars: {} };
  const rndWord = round === 2 ? "second" : "first";
  if (built && built.protection && built.protection.keepRange) {
    const hi = built.protection.keepRange[1];
    const vkey = holderCode + "|" + year + "|" + round;
    out.vars[vkey] = { lo: "top-" + hi, hi: (hi + 1) + "-" + (round === 2 ? 60 : 30), label: "[[" + holderCode + "]] '" + yy + " " + rndWord };
    const dest = built.protection.dest;
    H._convey = dest || null;
    H.cond = { expr: vkey + "=lo", grant: holderCode + " keeps", deny: "conveys to " + (dest || "the other team") };
    H.hint = "top-" + hi; H.type = "Own"; H.logo = holderCode; H.tm = holderCode; H.hideText = false;
    H._built = true;
  }
  if (built && built.swap && built.swap.counterTeam) {
    const isRight = built.swap.rightOwner === holderCode;
    H.swap = {
      kind: isRight ? "right" : "subject", with: built.swap.counterTeam, tag: built.swap.counterTeam, side: built.swap.side,
      lbl: isRight ? "+ Swap right" : "− Swapped",
      line: "swap for the " + (built.swap.side || "better") + " of " + holderCode + " & " + built.swap.counterTeam
    };
    H.hideText = true; H._built = true;
  }
  out.H = H;
  return out;
}

/* Cross-panel re-render + persistence: building/clearing an obligation on ONE column
   refreshes the mirror on EVERY subscribed panel AND saves to localStorage. */
const TM_OBLIG_SUBS = new Set();
function tmObligPersist() { try { if (typeof localStorage !== "undefined") localStorage.setItem(TM_OBLIG_KEY, JSON.stringify(TM_OBLIG)); } catch (e) {} }
function tmObligChanged() { tmObligPersist(); TM_OBLIG_SUBS.forEach(fn => { try { fn(); } catch (e) {} }); }
// Reset hook so obligations clear WITH the trade (one world): a team arg clears that
// team's builds; no arg clears all. Wired from app.jsx's reset handlers.
if (typeof window !== "undefined") window.tmObligReset = function (team) {
  for (const k in TM_OBLIG) { if (!team || (TM_OBLIG[k] && TM_OBLIG[k].builder === team)) delete TM_OBLIG[k]; }
  tmObligChanged();
};

/* The synthetic INCOMING holding the destination team sees when team A protects a
   pick headed to it: "B gets A's {yr} {rnd} if it lands in the CONVEY range." */
function tmInjectedHolding(m, destCode) {  // m = { from, year, round, keepRange }
  const hi = m.keepRange[1];
  const vkey = m.from + "|" + m.year + "|" + m.round;
  const yy = String(m.year).slice(2);
  const rndWord = m.round === 2 ? "second" : "first";
  const convey = (hi + 1) + "-" + (m.round === 2 ? 60 : 30);
  return {
    H: {
      tm: destCode, logo: m.from, lbl: m.year + " " + (m.round === 2 ? "2nd" : "1st"), type: "Incoming",
      hint: convey, hideText: false, swap: null, _built: true, _mirror: true,
      cond: { expr: vkey + "=hi", grant: destCode + " gets it", deny: m.from + " keeps it" }
    },
    vars: (function () { const v = {}; v[vkey] = { lo: "top-" + hi, hi: convey, label: "[[" + m.from + "]] '" + yy + " " + rndWord }; return v; })()
  };
}

/* Apply a swap MIRROR to the counter team's existing pick: from this team's view,
   it either holds the right (+ Swap right) or is the subject (− Swapped) vs `other`. */
function tmApplyMirrorSwap(H0, annot, thisTeam) {
  const sw = annot.swap, other = annot.builder, isRight = sw.rightOwner === thisTeam;
  const H = Object.assign({}, H0);
  H.swap = {
    kind: isRight ? "right" : "subject", with: other, tag: other, side: sw.side,
    lbl: isRight ? "+ Swap right" : "− Swapped",
    line: "swap for the " + (sw.side || "better") + " of " + thisTeam + " & " + other
  };
  H.hideText = true; H._mirror = true;
  return H;
}

/* Derive, per team, the mirror effects of every built obligation:
   - injected: synthetic INCOMING picks (a protection's dest team gains one)
   - annot:    swap annotations on a team's EXISTING pick (the counter side) */
function tmMirrors() {
  const out = {};
  const ensure = (t) => (out[t] = out[t] || { injected: [], annot: {} });
  for (const key in TM_OBLIG) {
    const o = TM_OBLIG[key]; if (!o) continue;
    const builder = o.builder, year = o.year, round = o.round;
    if (o.protection && o.protection.dest && o.protection.dest !== builder && year != null) {
      const synth = tmInjectedHolding({ from: builder, year: year, round: round, keepRange: o.protection.keepRange }, o.protection.dest);
      ensure(o.protection.dest).injected.push({
        id: "MIR:" + key, year: year, round: round, _partial: true, _injected: true,
        _hold: synth.H, _injectedVars: synth.vars, _src: builder
      });
    }
    if (o.swap && o.swap.counterTeam && o.swap.counterTeam !== builder && year != null) {
      const annotKey = o.swap.swapWith || (year + "-" + round);
      ensure(o.swap.counterTeam).annot[annotKey] = { swap: o.swap, builder: builder };
    }
  }
  return out;
}

/* One resolver used by both the token and the card: layer own-built obligations
   and cross-team mirrors over the base holding. */
function tmResolve(slotCode, pk, MIRR, baseVars) {
  if (pk._injected) {
    return { H: pk._hold, vars: Object.assign({}, baseVars, pk._injectedVars || {}), built: null, injected: true, mirror: false, readOnly: true, mirrorFrom: pk._src };
  }
  let H = pk._hold, vars = baseVars;
  const built = TM_OBLIG[slotCode + ":" + pk.id] || null;
  if (built) {
    const s = tmSynthHolding(pk._hold, built, slotCode, pk.year, pk.round, String(pk.year).slice(2));
    H = s.H; vars = Object.assign({}, baseVars, s.vars);
  }
  let mirror = false, mirrorFrom = null;
  const annot = MIRR && (MIRR.annot[pk.id] || MIRR.annot[pk.year + "-" + pk.round]);
  if (annot && annot.swap && !(H && H.swap)) {
    H = tmApplyMirrorSwap(H || pk._hold, annot, slotCode); mirror = true; mirrorFrom = annot.builder;
  }
  return { H, vars, built, injected: false, mirror, readOnly: false, mirrorFrom };
}

/* The ticket detail card — header / outcome table / swap box / chevron what-if / build mechanics. */
function TmPickCard({ pk, slot, multi, codes, vars, teamreg, pendingPicksByTeam, movedPickIdsByTeam, onToggle, onDest, onSetDest, onBump, onClose }) {
  const [wf, setWf] = useState({});
  const [wfOpen, setWfOpen] = useState(false);
  const [pendingDest, setPendingDest] = useState(null);
  const [builder, setBuilder] = useState(null);
  const [protErr, setProtErr] = useState(null);
  const [swapErr, setSwapErr] = useState(null);
  const [protDest, setProtDest] = useState(null);   // which partner a built protection conveys to
  const [protN, setProtN] = useState(7);            // protection slider: keep top-N
  const fmtTeam = (c) => (teamreg && teamreg[c] && teamreg[c].city) || c;
  const H0 = pk._hold;
  const oblKey = slot.code + ":" + pk.id;
  // layer own-built obligations + cross-team mirrors over the base holding
  const res = tmResolve(slot.code, pk, tmMirrors()[slot.code] || { injected: [], annot: {} }, vars);
  const H = res.H, effVars = res.vars, built = res.built;
  const isMirror = res.injected || res.mirror;
  const isLegacyObl = !!(H0 && (H0.cond || H0.swap) && H0.legacy_status === "grandfathered");
  const selected = slot.picks.has(pk.id);
  const cardSel = slot.picks.get(pk.id);

  const lblParts = String((H && H.lbl) || pk.label || "").split(" ");
  const yy = (lblParts[0] || "").slice(2);
  const rnd = lblParts[1] || (pk.round === 2 ? "2nd" : "1st");
  const cond = H && H.cond;
  // single self-referential condition -> outcome table instead of the what-if panel
  let tv = null;
  if (cond) {
    const mm = String(cond.expr || "").match(/^([A-Z]{2,4}\|\d{4}\|\d)=(lo|hi)$/);
    if (mm && mm[1].split("|")[0] === H.logo) tv = { v: mm[1], side: mm[2] };
  }
  const src = H ? H.logo : slot.code;
  const holder = H ? H.tm : slot.code;
  const srcN = !src ? holder : (src === holder ? src : fmtTeam(src));
  const rng = tv ? " (" + (H.hint || "") + ")" : "";
  const title = srcN + " '" + yy + " " + rnd + rng;
  const su = (!src ? "pool" : src === holder ? "own" : "to " + holder) + (cond ? " · conditional" : "");

  // ---- what-if compute (multi-condition) ----
  let whatif = null;
  if (cond && !tv) {
    const tree = tmParseExpr(cond.expr);
    const vlist = [...new Set((cond.expr.match(TM_VRE) || []))].sort((a, b) => a.split("|")[1].localeCompare(b.split("|")[1]));
    const base = {}; vlist.forEach(v => { if (wf[v] !== undefined) base[v] = wf[v]; });
    const notes = Array.isArray(cond.mootNote) ? cond.mootNote : [];
    const rows = vlist.map(v => {
      const unset = vlist.filter(x => x !== v && base[x] === undefined);
      const st = { ...base }; let irr = true;
      for (let mk = 0; mk < (1 << unset.length); mk++) {
        unset.forEach((x, i) => { st[x] = (mk >> i & 1) ? "hi" : "lo"; });
        st[v] = "lo"; const a = tmEv(tree, st);
        st[v] = "hi"; const b = tmEv(tree, st);
        if (a !== b) { irr = false; break; }
      }
      const V = effVars[v] || { lo: "lo", hi: "hi", label: v };
      let note = null, forced = false;
      if (irr) {
        const fn = notes.find(n => n.var === v && tmEv(tmParseExpr(n.when), base) === true);
        if (fn) { note = fn.text; forced = true; } else note = "any";
      }
      return { v, label: tmTeamize(V.label, holder, teamreg), lo: V.lo, hi: V.hi, value: base[v], moot: irr, note, forced };
    });
    const vstate = { ...base }; rows.forEach(r => { if (r.moot) delete vstate[r.v]; });
    const rr = tmEv(tree, vstate);
    const verdict = rr === true ? { k: "g", t: "✓ " + cond.grant } : rr === false ? { k: "d", t: "✗ " + cond.deny } : { k: "u", t: "set the options" };
    whatif = { rows, verdict };
  }

  // ---- swap box ----
  const sw = H && H.swap;
  const swCls = sw ? (sw.kind === "subject" ? "neg" : "pos") : "";
  const swHd = sw ? (sw.lbl || (sw.kind === "subject" ? "− Swapped" : (sw.shared ? "+ Shared swap" : "+ Swap right"))) : "";
  const swPool = sw ? (sw.pool && sw.pool.length ? sw.pool : (sw.with ? [sw.with] : [])) : [];

  const toggleVar = (v, val) => setWf(s => ({ ...s, [v]: s[v] === val ? undefined : val }));

  const node = (
    <>
      <div className="tmp-scrim show" onClick={onClose} />
      <div className="tmp-card show" onClick={e => e.stopPropagation()}>
        <div className="tmp-ch">
          {src ? <span className="tmp-clg"><TeamLogo code={src} size={26} /></span> : null}
          <div>
            <div className="tmp-ti">{title}</div>
            <div className="tmp-su">{su}</div>
          </div>
          <button className="tmp-x" onClick={onClose} aria-label="Close">×</button>
        </div>
        <div className="tmp-cbody">
          {tv ? (
            <table className="tmp-otab"><tbody>
              <tr><td className="tmp-oh">{srcN + " " + rnd + " lands"}</td><td>{(effVars[tv.v] || {}).lo}</td><td>{(effVars[tv.v] || {}).hi}</td></tr>
              <tr><td className="tmp-oh">Who gets it</td>
                {["lo", "hi"].map(side => {
                  // win = holder keeps/gets it; otherwise name the team that DOES get it
                  // (own pick → the convey destination; incoming → the source keeps it).
                  const win = side === tv.side;
                  const label = win ? (holder + " ✓") : (holder === src ? (tmConveyTeam(H, teamreg) || "conveys") : src);
                  return <td key={side} className={win ? "tmp-ow" : "tmp-od"}>{label}</td>;
                })}
              </tr>
            </tbody></table>
          ) : (H && !H.hideText && H.text) ? (
            <div className="tmp-cfull">{tmTeamize(H.text, holder, teamreg).split("\n").map((ln, i) => <React.Fragment key={i}>{i ? <br /> : null}{ln}</React.Fragment>)}</div>
          ) : null}

          {sw ? (
            <div className={"tmp-swapcard " + swCls}>
              <div className="tmp-sw-h">
                {swPool.map((c, i) => <span key={i} className="tmp-swlg"><TeamLogo code={c} size={16} /></span>)}
                <span className="tmp-sw-t">{swHd}</span>
                {sw.tag ? <span className="tmp-swcd">{sw.tag}</span> : null}
              </div>
              {sw.line ? <div className="tmp-sw-b">{tmTeamize(sw.line, holder, teamreg)}</div> : null}
            </div>
          ) : null}

          {whatif ? (
            <>
              <button className={"tmp-chev" + (wfOpen ? " open" : "")} onClick={() => setWfOpen(o => !o)}>What if <span className="tmp-cv">▸</span></button>
              {wfOpen ? (
                <div className="tmp-wf">
                  {whatif.rows.map(r => (
                    <div key={r.v} className={"tmp-wr" + (r.moot ? (r.forced ? " moot forced" : " moot") : "")}>
                      <span className="tmp-wl">{r.label}</span>
                      {r.moot
                        ? <span className={"tmp-mlbl" + (r.forced ? " f" : "")}>{r.note}</span>
                        : <span className="tmp-seg">
                            <button className={r.value === "lo" ? "on" : ""} onClick={() => toggleVar(r.v, "lo")}>{r.lo}</button>
                            <button className={r.value === "hi" ? "on" : ""} onClick={() => toggleVar(r.v, "hi")}>{r.hi}</button>
                          </span>}
                    </div>
                  ))}
                  <div className={"tmp-vd " + whatif.verdict.k}>{whatif.verdict.t}</div>
                </div>
              ) : null}
            </>
          ) : null}

          {!res.injected && (() => {
            // Destination selector INLINE, left of a reduced-width Add button (owner):
            // always shown when there's ≥1 partner. STATIC (one partner) or a tap-to-
            // cycle CHANGER (multi) — no pop-up menu. Pick the dest THEN hit Add; the
            // chosen team is applied on add and editable after.
            const cand = (codes || []).filter(c => c !== slot.code);
            const liveDest = selected ? ((cardSel && cardSel.dest) || cand[0]) : (pendingDest || cand[0]);
            const changer = cand.length > 1;
            const cycle = () => {
              if (!changer) return;
              const next = cand[(cand.indexOf(liveDest) + 1) % cand.length];
              if (selected) { if (onSetDest) onSetDest(pk, next); } else setPendingDest(next);
            };
            const add = () => {
              if (selected) { onToggle(pk); return; }
              onToggle(pk);
              if (cand.length && liveDest && onSetDest) onSetDest(pk, liveDest);
            };
            return (
              <div className="tmp-addrow">
                {cand.length ? (
                  <button className={"tmp-destsel" + (changer ? " changer" : "")} onClick={cycle}
                    title={changer ? "Tap to change destination — → " + fmtTeam(liveDest) : "Sends to " + fmtTeam(liveDest)}>
                    <TeamLogo code={liveDest} size={20} />
                    {changer ? <span className="tmp-destcaret">⇄</span> : null}
                  </button>
                ) : null}
                <button className={"tmp-add" + (selected ? " on" : "")} onClick={add}>
                  {selected ? "Pick in trade — tap to remove" : "Trade Pick"}
                </button>
              </div>
            );
          })()}

          {(() => {
            // ----- Trade mechanics: BUILD a protection / swap on this pick -----
            // Our validation layer (pick-rules.js); picks are $0 so the salary engine
            // never sees it. Legacy obligations (from our data) are locked read-only.
            const cand = (codes || []).filter(c => c !== slot.code);
            // Any ticket this team controls can carry trade mechanics. That includes
            // acquired/incoming picks already in the data, not just native own picks.
            const heldTicket = H0 && H0.tm === slot.code;
            const PR = (typeof window !== "undefined" && window.PickRules) || null;
            if (res.injected) {
              return <div className="tmp-mech"><span className="tmp-legacy tmp-mir">Incoming — mirrors {res.mirrorFrom}'s protection</span></div>;
            }
            if (res.mirror) {
              return <div className="tmp-mech"><span className="tmp-legacy tmp-mir">Subject to {res.mirrorFrom}'s swap — mirrors their build</span></div>;
            }
            if (!heldTicket && !built) return null;
            const meta = { builder: slot.code, pickId: pk.id, year: pk.year, round: pk.round };
            const clearBuilt = () => { delete TM_OBLIG[oblKey]; setProtErr(null); setSwapErr(null); setBuilder(null); tmObligChanged(); };
            const builtSwapDest = built && built.swap && built.swap.rightOwner !== slot.code ? built.swap.rightOwner : null;
            const liveProtDest = builtSwapDest || protDest || (selected && cardSel && cardSel.dest) || cand[0] || null;
            const setProt = (K) => {
              const prot = { keepRange: [1, K], dest: liveProtDest };
              const v = PR ? PR.validateProtection(prot, { isNew: true, year: pk.year, round: pk.round }) : { ok: true };
              if (!v.ok) { setProtErr(v.msg); return; }
              setProtErr(null);
              TM_OBLIG[oblKey] = Object.assign({}, TM_OBLIG[oblKey], meta, { protection: prot });
              setBuilder(null); tmObligChanged();
            };
            const swapBundle = (typeof window !== "undefined" && window.__PICKS_BUNDLE) || {};
            const movedFor = (team) => movedPickIdsByTeam && movedPickIdsByTeam[team];
            const movedHas = (team, id) => {
              const set = movedFor(team);
              return !!(set && typeof set.has === "function" && set.has(id));
            };
            const addTarget = (arr, seen, id, H, team, pendingFrom) => {
              if (!id || !H || team == null) return;
              const m = String(id).match(/-(\d{4})-(\d)-/);
              const yr = m ? +m[1] : pk.year;
              const rnd = m ? +m[2] : pk.round;
              if (yr !== pk.year || rnd !== pk.round) return;
              if (id === pk.id && team === slot.code) return;
              const key = team + ":" + id;
              if (seen.has(key)) return;
              seen.add(key);
              arr.push({
                key, id, team, year: yr, round: rnd,
                logo: H.logo || team,
                label: H.lbl || (yr + " " + (rnd === 2 ? "2nd" : "1st")),
                hint: H.hint || H.badge || "",
                pendingFrom: pendingFrom || null,
              });
            };
            const seenTargets = new Set();
            const swapTargets = [];
            if (swapBundle.hold) {
              for (const hid in swapBundle.hold) {
                const Ht = swapBundle.hold[hid];
                const tm = Ht && Ht.tm;
                if (!tm || !(codes || []).includes(tm)) continue;
                if (movedHas(tm, hid)) continue;
                addTarget(swapTargets, seenTargets, hid, Ht, tm, null);
              }
            }
            if (pendingPicksByTeam) {
              for (const tm of (codes || [])) {
                ((pendingPicksByTeam[tm] || [])).forEach(item => addTarget(swapTargets, seenTargets, item.id, item._hold, tm, item._from));
              }
            }
            const setSwap = (target) => {
              if (!target) return;
              const counterTeam = target.team;
              const counterKey = target.id;
              // #4: a pick can't be the subject of two swaps — block if it's already a target.
              const dealt = Object.keys(TM_OBLIG).some(k => { const o = TM_OBLIG[k]; return o && o.swap && k !== oblKey && o.swap.swapWith === counterKey; });
              // On your own page you're sending away, so you GIVE the right: the partner holds it
              // (your pick = subject, worse). Swapping with your OWN other pick → you hold the right.
              const rightOwner = (counterTeam === slot.code) ? slot.code : counterTeam;
              const sw = { rightOwner, counterTeam, side: "better", swapWith: counterKey, counterLogo: target.logo, counterLabel: target.label, counterHint: target.hint };
              const v = PR ? PR.validateSwap(sw, { isNew: true, ownsBase: true, ownsCounter: true, sameYear: true, rightAlreadyDealt: dealt }) : { ok: true };
              if (!v.ok) { setSwapErr(v.msg); return; }
              setSwapErr(null);
              TM_OBLIG[oblKey] = Object.assign({}, TM_OBLIG[oblKey], meta, { swap: sw });
              setBuilder(null); tmObligChanged();
            };
            return (
              <div className="tmp-mech">
                <div className="tmp-mech-h">Trade mechanics</div>
                {isLegacyObl ? <div className="tmp-mech-hint" style={{ marginBottom: 8 }}>Existing obligation kept — you can still stack a swap or protection on top.</div> : null}
                {built ? (
                  <div className="tmp-built">
                    <span>{built.protection ? ("Protected top-" + built.protection.keepRange[1]) : ""}{built.swap ? ((built.protection ? " · " : "") + (built.swap.rightOwner === slot.code ? "Swap right vs " : "Swap (subject) vs ") + fmtTeam(built.swap.counterTeam)) : ""}</span>
                    <button className="tmp-mech-x" onClick={clearBuilt}>Remove</button>
                  </div>
                ) : null}
                <div className="tmp-mech-btns">
                  <button className={"tmp-mech-btn" + (builder === "protect" ? " on" : "")} onClick={() => { setBuilder(builder === "protect" ? null : "protect"); setProtErr(null); }}>Add Protection</button>
                  <button className={"tmp-mech-btn" + (builder === "swap" ? " on" : "")} onClick={() => { setBuilder(builder === "swap" ? null : "swap"); setSwapErr(null); }}>Give Swap Right</button>
                </div>
                {builder === "protect" ? (() => {
                  const snapN = n => (n >= 12 && n <= 15) ? (n <= 13 ? 11 : 16) : n;   // 12–15 is a no-stop zone
                  const convey = protN >= 30 ? "nothing (always keeps)" : (protN + 1) + "–30";
                  const isSwapProtect = !!(built && built.swap);
                  return (
                    <div className="tmp-build">
                      <div className="tmp-slabels">
                        <span className="l">{slot.code} keeps <b>top-{protN}</b></span>
                        <span className="r"><b>{liveProtDest || "the other team"}</b> {isSwapProtect ? "can swap" : "gets"} {convey}</span>
                      </div>
                      <input type="range" className="tmp-slider" min="1" max="30" step="1" value={protN}
                        onChange={e => setProtN(snapN(+e.target.value))} />
                      <div className="tmp-sticks"><span>top-1</span><span className="blk" title="Protections can't cut off in picks 12–15 on new trades (league rule, eff. 2026-05-28)">12–15 ✕</span><span>top-30</span></div>
                      {protErr ? <div className="tmp-mech-err">{protErr}</div> : null}
                      <button className="tmp-add" onClick={() => setProt(protN)}>{isSwapProtect ? "Protect swap" : "Protect pick"} top-{protN}</button>
                    </div>
                  );
                })() : null}
                {builder === "swap" ? (
                  <TmSwapBuilder targets={swapTargets} holder={slot.code} year={pk.year} round={pk.round} fmtTeam={fmtTeam} onConfirm={setSwap} err={swapErr} />
                ) : null}
              </div>
            );
          })()}
        </div>
      </div>
    </>
  );
  return (typeof document !== "undefined" && ReactDOM.createPortal)
    ? ReactDOM.createPortal(node, document.body) : node;
}

/* Swap builder: choose the concrete counter-ticket, who holds the right, and
   whether they take the better/worse. Encodes our swap{} model. */
function TmSwapBuilder({ targets, holder, year, round, fmtTeam, onConfirm, err }) {
  const opts = (targets || []);   // tickets held by teams in this trade; THIS pick excluded upstream
  const [counter, setCounter] = useState((opts[0] && opts[0].key) || "");
  const active = opts.find(t => t.key === counter) || opts[0] || null;
  const rndWord = round === 2 ? "2nd" : "1st";
  if (!opts.length) return <div className="tmp-build"><div className="tmp-mech-err">No other {year} {rndWord} ticket in this trade to swap with.</div></div>;
  const ownPick = active && active.team === holder;   // swapping two of YOUR OWN held tickets vs giving it away
  return (
    <div className="tmp-build">
      <div className="tmp-build-lbl">Swap this {year} {rndWord} with</div>
      <select className="tmp-teamsel" value={(active && active.key) || ""} onChange={e => setCounter(e.target.value)}>
        {opts.map(t => (
          <option key={t.key} value={t.key}>
            {(t.team === holder ? "Your " : fmtTeam(t.team) + " ") + (t.label || (year + " " + rndWord)) + (t.hint ? " (" + t.hint + ")" : "")}
          </option>
        ))}
      </select>
      {active ? (
        <div className="tmp-swtarget">
          <span className="tmp-lg"><TeamLogo code={active.logo || active.team} size={18} /></span>
          <span className="tmp-cd">{active.logo || active.team}</span>
          {active.hint ? <span className="tmp-chint">({active.hint})</span> : null}
          <span className="tmp-swtarget-team">
            <TeamLogo code={active.team} size={18} />{active.pendingFrom ? "pending to " : "held by "}{active.team}
          </span>
        </div>
      ) : null}
      <div className="tmp-mech-hint">
        {ownPick
          ? "Both tickets are yours — you hold the right and take the better, then trade one afterward."
          : ("Gives " + (fmtTeam(active && active.team) || (active && active.team)) + " the swap right — your pick becomes the subject, theirs the counter-ticket.")}
      </div>
      <button className="tmp-add" onClick={() => onConfirm(active)}>
        {ownPick ? "Swap with your own ticket" : ("Give " + (active && active.team) + " the swap right")}
      </button>
      {err ? <div className="tmp-mech-err">{err}</div> : null}
    </div>
  );
}

/* The picks panel — our year-rail timeline (replaces the PicksGrid matrix). */
function TmPicksPanel({ slot, picks, incomingPicks, pendingPicksByTeam, movedPickIdsByTeam, multi, codes, onToggle, onDest, onSetDest }) {
  const [openId, setOpenId] = useState(null);
  const [, setOblVer] = useState(0);          // re-render when ANY column builds/clears an obligation
  useEffect(() => {
    const fn = () => setOblVer(v => v + 1);
    TM_OBLIG_SUBS.add(fn);
    return () => { TM_OBLIG_SUBS.delete(fn); };
  }, []);
  const bundle = (typeof window !== "undefined" && window.__PICKS_BUNDLE) || null;
  const vars = (bundle && bundle.vars) || {};
  const teamreg = (bundle && bundle.teamreg) || {};
  const MIRR = tmMirrors()[slot.code] || { injected: [], annot: {} };   // cross-team mirror effects on THIS team
  // Render ONLY the CapMVP truth-machine holdings (pk._hold). This deliberately
  // excludes V4's 2026 draft_pick rows (parsePick), which still carry raw
  // "(via X)" routing — the owner's no-raw / no-cross-reference rule. Our data
  // is the resolved-2026-forward future-pick model anyway, so 2026 is omitted.
  const seenIds = new Set();
  const basePicks = picks.filter(p => p._hold).map(p => { seenIds.add(p.id); return p; });
  const pendingIn = (incomingPicks || [])
    .filter(p => p && p._hold && !seenIds.has(p.id))
    .map(p => {
      seenIds.add(p.id);
      return Object.assign({}, p, { _hold: Object.assign({}, p._hold, { tm: slot.code }), _pendingIn: true, _src: p._from || p._src });
    });
  const ours = basePicks.concat(pendingIn, MIRR.injected);   // base holdings + trade-pending/mirrored incoming picks
  if (!ours.length) return <div className="tm-tpe-empty">No tradable picks.</div>;

  const years = [];
  for (let y = 2027; y <= 2032; y++) years.push(y);

  const openPk = openId ? ours.find(p => p.id === openId) : null;

  // Stepien advisory (#3): warn if AFTER the outgoing picks the team would be without a
  // first in two consecutive future years. No before/after guard — no team is bare today,
  // so any violation is created by this trade. Window capped to our data range (2027-2032).
  let stepienWarn = null;
  const _PR = (typeof window !== "undefined" && window.PickRules) || null;
  if (_PR) {
    const keptF = {};
    ours.forEach(p => { if (p.round === 1 && !slot.picks.has(p.id)) keptF[p.year] = true; });
    const sv = _PR.validatePickTrade({ firstsByYear: keptF, leagueRules: { forwardYears: 6 } });
    if (!sv.ok) stepienWarn = sv.msg;
  }

  const renderToken = (pk) => {
    const r = tmResolve(slot.code, pk, MIRR, vars);
    const H = r.H;
    const logo = H ? H.logo : slot.code;
    let hint = H ? H.hint : "";
    let badge = H ? H.badge : (pk.sub && pk.sub !== "Own" ? pk.sub : "");
    // Drop redundant "Top-N protected" trade-speak (owner): the (range) hint + the
    // dashed Partial token already convey the protection; that phrasing is for the
    // ACQUIRING team's view post-trade, not the holder's own page.
    if (badge && /protected/i.test(badge)) badge = "";
    const sk = H && H.swap ? H.swap.kind : "none";
    if (H && H.swap && !badge) badge = sk === "right" ? "+ Swap" : "− Swap";   // built/mirrored swaps carry no base badge
    const condTok = pk._partial || !!(H && H.cond);
    const bcls = sk === "right" ? "b-pos" : sk === "subject" ? "b-neg" : "b-neu";
    const sel = slot.picks.has(pk.id);
    const poolLogos = (!logo && H && H.swap && H.swap.pool) ? H.swap.pool : [];
    const flagged = r.built || r.injected || r.mirror || pk._pendingIn;
    return (
      <button key={pk.id} className={"tmp-tok" + (condTok ? " cond" : "") + (sel ? " sel" : "") + (flagged ? " built" : "") + (pk._pendingIn ? " pending" : "")} onClick={() => setOpenId(pk.id)}>
        <span className="tmp-idr">
          {logo
            ? <><span className="tmp-lg"><TeamLogo code={logo} size={18} /></span><span className="tmp-cd">{logo}</span></>
            : poolLogos.length
              ? poolLogos.map((m, i) => <span key={i} className="tmp-lg sm"><TeamLogo code={m} size={14} /></span>)
              : <span className="tmp-pl">pool</span>}
        </span>
        {hint ? <span className="tmp-chint">({hint})</span> : null}
        {badge ? <span className={"tmp-bdg " + bcls}>{badge}</span> : null}
        {pk._pendingIn && pk._src ? <span className="tmp-pending-from"><TeamLogo code={pk._src} size={18} /></span> : null}
        {sel ? <span className="tmp-tok-chk">✓</span> : null}
      </button>
    );
  };

  const renderSection = (title, rnd) => {
    const mine = ours.filter(p => p.round === rnd);
    return (
      <div className="tmp-sec" key={rnd}>
        <div className="tmp-sec-h"><span className="tmp-sq" />{title}<span className="tmp-cnt">{mine.length} held</span></div>
        <div className="tmp-cols2 tmp-subh"><div>Full</div><div>Partial</div></div>
        {years.map((y, i) => {
          const full = mine.filter(p => p.year === y && !p._partial);
          const part = mine.filter(p => p.year === y && p._partial);
          return (
            <div key={y} className={"tmp-yr-row" + (i === years.length - 1 ? " last" : "")}>
              <div className="tmp-yr">{y}</div>
              <div className="tmp-cols2">
                <div className="tmp-subc">{full.length ? full.map(renderToken) : <span className="tmp-dash">—</span>}</div>
                <div className="tmp-subc">{part.length ? part.map(renderToken) : <span className="tmp-dash">—</span>}</div>
              </div>
            </div>
          );
        })}
      </div>
    );
  };

  return (
    <div className="tmp-wrap">
      {stepienWarn ? <div className="tmp-stepien">⚠ {stepienWarn}</div> : null}
      {renderSection("First-round picks", 1)}
      {renderSection("Second-round picks", 2)}
      {openPk ? <TmPickCard pk={openPk} slot={slot} multi={multi} codes={codes} vars={vars} teamreg={teamreg}
        pendingPicksByTeam={pendingPicksByTeam} movedPickIdsByTeam={movedPickIdsByTeam}
        onToggle={onToggle} onDest={onDest} onSetDest={onSetDest} onClose={() => setOpenId(null)} /> : null}
    </div>
  );
}

function PicksGrid({ slot, picks, multi, onToggle, onDest }) {
  // R14 (user item #2): tapping a cell no longer toggles directly — it opens
  // a bottom-anchored slide-up CARD with the full RealGM ledger, a destination
  // pill (multi-team trades), and an explicit "Add to trade" / "Remove" button.
  // The cell click sets `cardFor`; the card portals into the column footer area.
  const [cardFor, setCardFor] = useState(null);
  if (!picks.length) return <div className="tm-tpe-empty">No tradable picks.</div>;
  const YEARS = [2026, 2027, 2028, 2029, 2030, 2031, 2032];

  const byCell = new Map();
  for (const pk of picks) {
    const k = `${pk.year}-${pk.round}`;
    if (!byCell.has(k)) byCell.set(k, []);
    byCell.get(k).push(pk);
  }

  function Cell({ year, round }) {
    const cellPicks = byCell.get(`${year}-${round}`) || [];
    if (cellPicks.length === 0) {
      return <div className="pg-cell pg-empty" aria-label={`${year} ${round===1?"1st":"2nd"} — not owned`}>—</div>;
    }
    return (
      <div className="pg-cell">
        {cellPicks.map(pk => {
          const sel = slot.picks.get(pk.id);
          const selected = !!sel;
          const conditional = !!(pk.swap || pk.residual || (!pk.isOwn && !pk.this));
          const txt = pk.this
            ? (pk.sub && pk.sub.startsWith("#") ? pk.sub : "Own")
            : (pk.isOwn && !pk.swap ? "Own" : (pk.swap ? "Swap" : (pk.residual ? "Prot." : "Cond.")));
          return (
            <button key={pk.id}
              className={`pg-pick ${selected ? "selected" : ""} ${conditional ? "cond" : ""} ${pk.swap ? "swap" : ""} ${pk.residual ? "prot" : ""}`}
              onClick={() => setCardFor(pk.id)}
              title={pk.fullSub || pk.sub || pk.label}>
              <span className="pg-pick-lbl">{txt}</span>
              {selected && <span className="pg-check"><TmIcon.Check style={{width:11,height:11}}/></span>}
            </button>
          );
        })}
      </div>
    );
  }

  const cardPick = cardFor ? picks.find(p => p.id === cardFor) : null;
  const cardSel = cardPick ? slot.picks.get(cardPick.id) : null;

  return (
    <div className="tm-picks-grid">
      <div className="pg-head">
        <div className="pg-yr-h">Year</div>
        <div className="pg-rd-h">1st</div>
        <div className="pg-rd-h">2nd</div>
      </div>
      {YEARS.map(year => (
        <div key={year} className={`pg-row ${year === 2026 ? "is-this" : ""}`}>
          <div className="pg-yr">{year}</div>
          <Cell year={year} round={1} />
          <Cell year={year} round={2} />
        </div>
      ))}
      {cardPick && (
        <>
          <div className="pg-card-backdrop" onClick={() => setCardFor(null)} />
          <div className="pg-card" onClick={(e) => e.stopPropagation()}>
            <div className="pg-card-h">
              <div>
                <div className="pg-card-title">
                  {cardPick.year} {cardPick.round === 1 ? "1st Round" : "2nd Round"} pick
                </div>
                <div className="pg-card-sub">{cardPick.label}</div>
              </div>
              <button className="pg-card-x" onClick={() => setCardFor(null)} title="Close">
                <TmIcon.X />
              </button>
            </div>
            <div className="pg-card-body">
              {(cardPick.swap || cardPick.residual || (cardPick.desc && !cardPick.isOwn)) && (
                <div className="pg-card-cond">
                  {cardPick.residual && <span className="tag bonus">Protected</span>}
                  {cardPick.swap && <span className="tag option">Swap</span>}
                  {cardPick.desc && <b>{cardPick.desc}</b>}
                </div>
              )}
              <div className="pg-card-ledger">
                {cardPick.fullSub || cardPick.sub || (cardPick.isOwn ? "Team owns this pick outright." : "")}
              </div>
            </div>
            <div className="pg-card-foot">
              {cardSel && multi && (
                <div className="pg-card-dest">
                  <span>Sends to</span>
                  <DestPill destCode={cardSel.dest}
                    onClick={(e) => onDest(cardPick, e.currentTarget)} />
                </div>
              )}
              <span className="spacer" style={{flex:1}} />
              <button className="tm-pillbtn ghost" onClick={() => setCardFor(null)}>Close</button>
              <button className={`tm-pillbtn ${cardSel ? "ghost" : "primary"}`}
                      onClick={() => { onToggle(cardPick); setCardFor(null); }}>
                {cardSel ? "Remove from trade" : "Add to trade"}
              </button>
            </div>
          </div>
        </>
      )}
    </div>
  );
}

/* ============================================================
   CASH PANEL
   ============================================================ */
function CashPanel({ slot, codes, ownCode, ledger, multi, onSet, onDest }) {
  const limit = CAP_2026.cashYear;
  const sentSoFar = ledger.sent + (slot.cash?.out || 0);
  const overSent = sentSoFar > limit;
  const pct = Math.min(100, (sentSoFar / limit) * 100);
  const ledgerPct = Math.min(100, (ledger.sent / limit) * 100);
  const cashOut = slot.cash?.out || 0;
  const dest = slot.cash?.dest;
  function setOut(v) { onSet({ out: Math.max(0, v|0) }); }
  const receivedSoFar = ledger.received;
  const remaining = Math.max(0, limit - sentSoFar);
  return (
    <div className="tm-cash">
      {/* #6: season cash summary up top */}
      <div className="tm-cash-summary">
        <span><i>Received</i><b>{fmt$(receivedSoFar)}</b></span>
        <span><i>Sent</i><b>{fmt$(sentSoFar)}</b></span>
        <span className={remaining <= 0 ? "none" : ""}><i>Remaining</i><b>{fmt$(remaining)}</b></span>
      </div>
      <div className="tm-cash-box">
        <div className="tm-cash-row">
          <span className="lbl">Send cash</span>
          {overSent && <span style={{color:"var(--danger)", fontSize:11, fontWeight:700}}>Over season limit</span>}
        </div>
        <div className="tm-cash-input-wrap">
          <input type="text" className="tm-cash-input num"
            value={cashOut ? (cashOut / 1_000_000).toFixed(cashOut % 100000 ? 2 : 1).replace(/\.?0+$/, "") : ""}
            placeholder="0"
            onChange={(e) => setOut(parseCashInput(e.target.value))} />
          <span className="tm-cash-suffix">M</span>
        </div>
        <div className={`tm-cash-meter ${overSent ? "over" : ""}`}>
          <div className="ledger" style={{width: `${ledgerPct}%`}} />
          <div className="now"    style={{width: `${pct}%`}} />
        </div>
        <div className="tm-cash-meta">
          <span>This season: <b>{fmt$(sentSoFar)}</b> sent</span>
          <span className={overSent ? "over" : ""}>of <b>{fmt$(limit)}</b> cap</span>
        </div>
        <div className="tm-cash-quick">
          {[1_000_000, 2_500_000, 5_000_000, 7_500_000].map(v => (
            <button key={v} onClick={() => setOut(v)}>{fmt$(v)}</button>
          ))}
          {cashOut > 0 && <button onClick={() => setOut(0)}>Clear</button>}
        </div>
        {cashOut > 0 && (
          <div className="tm-cash-dest">
            <span>to</span>
            {multi
              ? <DestPill destCode={dest} onClick={(e) => onDest("cash", e.currentTarget)} />
              : <DestPill destCode={dest} locked />}
          </div>
        )}
      </div>
      <div style={{fontSize:11, color:"var(--text-faint)", lineHeight:1.5, padding:"0 2px"}}>
        CBA Art. VII §6 — teams may send <b style={{color:"var(--text-dim)"}}>up to {fmt$(limit)}</b> in cash <em>and</em> receive up to {fmt$(limit)} per season. Gross, not net.
        {ledger.sent > 0 && <><br/>Already sent earlier this season: <b style={{color:"var(--text-dim)"}}>{fmt$(ledger.sent)}</b>.</>}
        {ledger.received > 0 && <><br/>Already received: <b style={{color:"var(--text-dim)"}}>{fmt$(ledger.received)}</b>.</>}
      </div>
    </div>
  );
}

/* ============================================================
   CASH TABLE — R14 rebuild: vertical (more rows / less columns) so the
   important numbers + the input + the dest pill don't fight for width on
   phone. Fixes two reported bugs:
   • Input couldn't accept M-as-millions (was parseCashInput, which treats
     bare "2.5" as $2.50 — now uses parseSalaryFromMField like the rest of
     the salary inputs in the app, so "2.5" in an M field = $2.5M).
   • Local `txt` state for the input so React's controlled-value loop stops
     stripping characters mid-type. Quick-amount buttons sync the txt via
     useEffect when cashOut changes externally.
   • Dest pill always visible (was gated on cashOut > 0 — meant in multi-
     team trades users couldn't pre-pick the receiving team).
   ============================================================ */
function CashTable({ slot, codes, ownCode, ledger, multi, cashIn, onSet, onDest }) {
  const limit = CAP_2026.cashYear;
  const cashOut = slot.cash?.out || 0;
  const dest = slot.cash?.dest;
  const prevSent = ledger.sent || 0;
  const prevRecv = ledger.received || 0;
  const totalSent = prevSent + cashOut;
  const totalRecv = prevRecv + (cashIn || 0);
  const overSent = totalSent > limit;
  const overRecv = totalRecv > limit;

  // Local input text — survives mid-type characters that don't yet parse.
  const fmtM = (n) => n ? String(+((n / 1_000_000).toFixed(3))).replace(/\.?0+$/, "") : "";
  const [txt, setTxt] = useState(fmtM(cashOut));
  // Sync from external changes (quick buttons / clear / undo).
  useEffect(() => {
    const parsed = (typeof window.parseSalaryFromMField === "function")
      ? window.parseSalaryFromMField(txt) : null;
    if (parsed !== cashOut) setTxt(fmtM(cashOut));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cashOut]);
  function commit(s) {
    setTxt(s);
    const n = (typeof window.parseSalaryFromMField === "function")
      ? window.parseSalaryFromMField(s) : parseFloat(s) * 1_000_000;
    onSet({ out: Math.max(0, n || 0) });
  }
  function setOut(v) { onSet({ out: Math.max(0, v|0) }); }

  const sentPct = Math.min(100, (totalSent / limit) * 100);
  const recvPct = Math.min(100, (totalRecv / limit) * 100);
  // j (backlog): explicit per-season room left after this trade.
  const sendLeft = Math.max(0, limit - totalSent);
  const recvLeft = Math.max(0, limit - totalRecv);

  return (
    <div className="tm-cash-v2">
      {/* OUTGOING (send) — I: external heading; meter on top, presets, then the
          input + destination, breakdown, and the running "Sending cash" total last. */}
      <div className="tcv-group">
        <div className="tcv-ext-h">Outgoing Cash</div>
        <section className={`tcv-block ${overSent ? "over" : ""}`}>
          <div className="tcv-meter">
            <div className="tcv-meter-fill" style={{width: sentPct + "%"}} />
          </div>

          <div className="tcv-row tcv-quick">
            {[1_000_000, 2_500_000, 5_000_000, 7_500_000].map(v => (
              <button key={v} className={cashOut === v ? "on" : ""} onClick={() => setOut(v)}>{fmt$(v)}</button>
            ))}
            {cashOut > 0 && <button className="clear" onClick={() => setOut(0)}>Clear</button>}
          </div>

          <div className="tcv-row">
            <span className="tcv-row-lbl">This trade</span>
            <span className="tcv-in">
              <span className="pre">$</span>
              <input className="num" inputMode="decimal" value={txt} placeholder="0"
                     onChange={(e) => commit(e.target.value)} />
              <span className="suf">M</span>
            </span>
          </div>

          <div className="tcv-row tcv-dest">
            <span className="tcv-row-lbl">Send to</span>
            {multi
              ? <DestPill destCode={dest} onClick={(e) => onDest("cash", e.currentTarget)} />
              : <DestPill destCode={dest} locked />}
            {cashOut > 0 && multi && !dest && <span className="tcv-warn">⚠ Pick a destination</span>}
          </div>

          <div className="tcv-bd">
            <div className="tcv-bd-c" title="Most this team may send per season (CBA)"><i>Limit</i><b>{fmt$(limit)}</b></div>
            <div className="tcv-bd-c" title="Cash already sent earlier this season (applied trades only)"><i>Prior</i><b>{fmt$(prevSent)}</b></div>
            <div className="tcv-bd-c" title="Cash in this trade"><i>Proposing</i><b className="now">{cashOut ? fmt$(cashOut) : "—"}</b></div>
            <div className={`tcv-bd-c ${sendLeft <= 0 ? "none" : ""}`} title="Remaining send room after this trade"><i>Left</i><b>{fmt$(sendLeft)}</b></div>
          </div>

          <div className="tcv-h tcv-h-foot">
            <div className="tcv-lbl">Sending cash</div>
            <div className={`tcv-total ${overSent ? "over" : ""}`}>{fmt$(totalSent)} <span className="tcv-of">/ {fmt$(limit)}</span></div>
          </div>
        </section>
      </div>

      {/* INCOMING (receive) — computed, no presets: meter on top, value, breakdown, total. */}
      <div className="tcv-group">
        <div className="tcv-ext-h">Incoming Cash</div>
        <section className={`tcv-block ${overRecv ? "over" : ""}`}>
          <div className="tcv-meter">
            <div className="tcv-meter-fill recv" style={{width: recvPct + "%"}} />
          </div>

          <div className="tcv-row">
            <span className="tcv-row-lbl">This trade</span>
            <span className="tcv-val">{cashIn ? fmt$(cashIn) : "—"}</span>
          </div>

          <div className="tcv-bd">
            <div className="tcv-bd-c" title="Most this team may receive per season (CBA)"><i>Limit</i><b>{fmt$(limit)}</b></div>
            <div className="tcv-bd-c" title="Cash already received earlier this season (applied trades only)"><i>Prior</i><b>{fmt$(prevRecv)}</b></div>
            <div className="tcv-bd-c" title="Cash incoming in this trade"><i>Incoming</i><b className="now">{cashIn ? fmt$(cashIn) : "—"}</b></div>
            <div className={`tcv-bd-c ${recvLeft <= 0 ? "none" : ""}`} title="Remaining receive room after this trade"><i>Left</i><b>{fmt$(recvLeft)}</b></div>
          </div>

          <div className="tcv-h tcv-h-foot">
            <div className="tcv-lbl">Receiving cash</div>
            <div className={`tcv-total ${overRecv ? "over" : ""}`}>{fmt$(totalRecv)} <span className="tcv-of">/ {fmt$(limit)}</span></div>
          </div>
        </section>
      </div>

      <div className="tcv-cba">
        CBA Art. VII §6 — each team may both send <b>and</b> receive up to <b>{fmt$(limit)}</b> in cash per season (gross, not net).
        <br/><span style={{opacity:.85}}><b>Prior</b> counts only trades applied in this tool — real prior-season cash moves aren't in our data yet, so a team's true room may be lower.</span>
      </div>
    </div>
  );
}

/* TpePanel was REPLACED by OptionsPanel in R13.B (TPEs grouped with MLEs +
   cap room under one Options tab). Removed in R14 dead-code trim. */

/* ============================================================
   OPTIONS PANEL — R13.B: TPEs + MLEs + cap room + cap-hold mgmt
   under one "Options" tab. MLE eligibility is derived from the team's
   committed payroll vs cap/apron lines (CBA rules):
     • Cap-space (post < cap)         → Room MLE
     • Over-cap, under 1st apron      → Non-tax MLE + BAE
     • Over 1st apron, under 2nd      → Tax-payer MLE
     • Over 2nd apron                 → no MLE
   ============================================================ */
function OptionsPanel({ slot, tpes, ev, onToggleTpe }) {
  const { cap, apron1, apron2, mle, tpmle, bae, roomMle } = CAP_2026;
  const post = (ev && typeof ev.post === "number") ? ev.post : 0;
  const isCapSpace = post < cap;
  const isOverA1   = post >= apron1;
  const isOverA2   = post >= apron2;
  const capRoom = Math.max(0, cap - post);

  // Available exceptions for this team
  const mles = [];
  if (isOverA2) {
    // None (above 2nd apron → all standard exceptions barred)
  } else if (isOverA1) {
    mles.push({ id: "tpmle", name: "Tax-payer MLE", amount: tpmle, desc: "Above 1st apron — limited to taxpayer MLE only" });
  } else if (isCapSpace) {
    mles.push({ id: "roomMle", name: "Room MLE",     amount: roomMle, desc: "Cap-space teams only — pairs with using cap room" });
  } else {
    mles.push({ id: "mle",   name: "Non-taxpayer MLE", amount: mle, desc: "Over-cap, under 1st apron" });
    mles.push({ id: "bae",   name: "Bi-annual exception", amount: bae, desc: "Available every other season" });
  }

  return (
    <div className="tm-options">
      <section className="tm-opt-sect">
        <h5>Trade exceptions {tpes.length > 0 && <span className="tm-opt-count">{tpes.length}</span>}</h5>
        {tpes.length === 0 ? (
          <div className="tm-opt-empty">
            No active trade exceptions.
            <div className="tm-opt-empty-sub">TPEs are created when a team sends out more salary than it takes back; they absorb incoming salary for up to a year.</div>
          </div>
        ) : tpes.map(tpe => {
          const on = slot.tpes?.has(tpe.id);
          return (
            <div key={tpe.id} className={`tm-opt-row ${on ? "on" : ""}`} onClick={() => onToggleTpe(tpe)}>
              <div className="tm-opt-ico"><TmIcon.TPE style={{width:15,height:15}}/></div>
              <div className="tm-opt-info">
                <div className="tm-opt-name">{fmt$(tpe.amount)} TPE</div>
                <div className="tm-opt-meta">{tpe.source ? `From ${tpe.source}` : "Trade exception"}{tpe.expires ? ` · expires ${tpe.expires}` : ""}</div>
              </div>
              <div className="tm-opt-act">{on ? <TmIcon.Check style={{width:16,height:16,color:"var(--info)"}}/> : <TmIcon.Plus style={{width:14,height:14}}/>}</div>
            </div>
          );
        })}
      </section>

      <section className="tm-opt-sect">
        <h5>Available exceptions</h5>
        {mles.length === 0 ? (
          <div className="tm-opt-empty warn">
            Above the 2nd apron — no mid-level / BAE available.
            <div className="tm-opt-empty-sub">Min-salary signings only.</div>
          </div>
        ) : mles.map(m => (
          <div key={m.id} className="tm-opt-row info">
            <div className="tm-opt-ico"><TmIcon.Bolt style={{width:14,height:14}}/></div>
            <div className="tm-opt-info">
              <div className="tm-opt-name">{m.name} <b style={{color:"var(--text-dim)",fontWeight:700}}>· {fmt$(m.amount)}</b></div>
              <div className="tm-opt-meta">{m.desc}</div>
            </div>
          </div>
        ))}
      </section>

      {isCapSpace && (
        <section className="tm-opt-sect">
          <h5>Cap room</h5>
          <div className="tm-opt-row info">
            <div className="tm-opt-ico"><TmIcon.Cash style={{width:14,height:14}}/></div>
            <div className="tm-opt-info">
              <div className="tm-opt-name">{fmt$(capRoom)} available</div>
              <div className="tm-opt-meta">Sign FAs / absorb incoming salary up to this amount. Once used, MLE → Room MLE.</div>
            </div>
          </div>
        </section>
      )}
    </div>
  );
}

/* PlayerDrawer + NumberRow + ToggleRow + SalaryInputRow were the prototype
   full-page drawer (~210 lines). Superseded by InlineEditor (R11.C inline-v2)
   and ColumnDrawer (R11.C drawer). Removed in R14 dead-code trim. */



/* ============================================================
   InlineEditor — compact in-row option/contract editor (#5/#6).
   Replaces the full-page drawer: expands inside the player/FA row.
   Reuses the millions-input + a small years/raise control.
   ============================================================ */
function InlineEditor({ ctx, onChange, onApply, onClose, layout, slotEv, ownTeam }) {
  const { player, kind, draft } = ctx;
  const isFA = kind === "fa";
  const isOption = !isFA && (player.status === "player_option" || player.status === "team_option");
  const optLabel = player.status === "team_option" ? "Exercise" : "Opt in";
  // Renounce/Hold act on a team's OWN free-agent cap holds — a unilateral CBA
  // election (Art VII §4(g)) that ANY team may take to CREATE room (it deletes the
  // hold + forfeits Bird rights). So offer them for the MANAGED team's own slot
  // REGARDLESS of post-trade payroll (over-cap teams renounce *to get* under the cap),
  // and NEVER for opponents (you can't renounce another team's FAs; only the managed
  // team's write would persist). Matches the roster page. (Was wrongly gated on
  // `teamPost < cap`, which hid renounce exactly when it's most useful.)
  const capMoves = ownTeam ? ["renounce", "hold"] : [];
  const actions = (!isFA && !isOption && player._resigned) ? ["sign", "snt", ...capMoves]   // re-signed: full FA options — keep (Sign), S&T, or (own team) Renounce/Hold
                : isOption ? ["optin", "snt", "sign", ...capMoves]
                : isFA      ? ["snt", "sign", ...capMoves]
                            : ["sign", ...capMoves];
  const LABEL = { optin: optLabel, snt: "S&T", sign: "Sign", renounce: "Renounce", hold: "Hold" };
  const action = actions.includes(draft.action) ? draft.action : actions[0];

  const salary = draft.salary != null ? draft.salary : (isFA ? player.sntAsk : player.salary);
  const ratePct = draft.ratePct != null ? draft.ratePct : 5;
  // Round 10 #4b: Sign defers to the main-app engine for max years / raise %, keyed
  // off the player's Bird rights (TM stores "full"/"early"/"non" — convert back).
  const tmBirdToMain = (b) => b === "full" ? "Bird" : b === "early" ? "EarlyBird" : b === "non" ? "NonBird" : null;
  const mainBird = tmBirdToMain(player.bird) || player.birdRights || null;
  const W = (typeof window !== "undefined") ? window : {};
  const signMaxYrs  = W.maxContractYears   ? W.maxContractYears(mainBird)   : 4;
  const signMaxRate = Math.round(((W.contractRaiseRate ? W.contractRaiseRate(mainBird) : 0.05)) * 100);
  // #3a opt-in: no years. #4a S&T: 3 or 4 only, raise ≤5%. Sign: helper-driven (1..max).
  const yearOpts = action === "snt"  ? [3, 4]
                 : action === "sign" ? Array.from({ length: signMaxYrs }, (_, i) => i + 1)
                                     : [1, 2, 3, 4];
  const maxRaise = action === "snt"  ? 5
                 : action === "sign" ? signMaxRate
                                     : 8;
  const years = action === "snt" ? Math.max(3, draft.years || 3) : (draft.years || 1);
  const showContract = action === "snt" || action === "sign";

  const pick = (a) => {
    const patch = { action: a };
    if (a === "snt") { patch.years = Math.max(3, years); patch.ratePct = Math.min(5, ratePct); }
    onChange(patch);
  };
  const setYears = (y) => onChange({ years: y });
  const setRate  = (p) => onChange({ ratePct: p });

  const [txt, setTxt] = useState(salary != null ? String(+(salary / 1e6).toFixed(2)) : "");
  useEffect(() => { setTxt(salary != null ? String(+(salary / 1e6).toFixed(2)) : ""); }, [salary, action]);
  const commitSal = (s) => {
    setTxt(s);
    const n = parseFloat(String(s).replace(/[^0-9.]/g, ""));
    onChange({ salary: isNaN(n) ? 0 : Math.max(0, Math.round(n * 1e6)) });
  };
  const total = Array.from({ length: years }).reduce(
    (a, _, i) => a + Math.round((salary || 0) * Math.pow(1 + ratePct / 100, i)), 0);

  // #10: one-line description shown beside the actions (top-right), mirroring main site.
  // Renounce/Hold only render for cap-space teams now, so no over-the-cap warning is needed.
  const desc = action === "optin"    ? `Exercises the option — trades at ${fmt$(player.salary)} · 1 yr.`
             : action === "renounce" ? "Renounced — clears the cap hold, drops from the trade."
             : action === "hold"     ? "Kept as a cap hold — not traded."
             : action === "snt"      ? "⚠ Sign-and-trades require player consent."
             : action === "sign"     ? "Signed players can't be traded for a few months — select S&T to trade now." : "";
  const descWarn = action === "snt";

  return (
    <div className={`row-editor re-${layout || "inline-v2"}`} onClick={(e) => e.stopPropagation()}>
      <div className="re-top">
        <div className="re-actions">
          {actions.map(a => (
            <button key={a}
                    className={action === a ? "on" : ""}
                    onClick={() => pick(a)}>{LABEL[a]}</button>
          ))}
        </div>
        {desc && <div className={`re-desc ${descWarn ? "warn" : ""}`}>{desc}</div>}
      </div>

      {showContract && (
        <div className="re-controls">
          <div className="re-field">
            <span className="re-lbl">Salary</span>
            <span className="re-salbox"><span className="pre">$</span>
              <input className="num" inputMode="decimal" value={txt} placeholder="0"
                     onChange={(e) => commitSal(e.target.value)} />
              <span className="suf">M</span></span>
          </div>
          <div className="re-field">
            <span className="re-lbl">Years</span>
            <span className="re-years">
              {yearOpts.map(y => (
                <button key={y} className={years === y ? "on" : ""} onClick={() => setYears(y)}>{y}</button>
              ))}
            </span>
          </div>
          {years > 1 && (
            <div className="re-field">
              <span className="re-lbl">Raise</span>
              <span className="re-raise">
                <button onClick={() => setRate(Math.max(0, ratePct - 1))}>−</button>
                <b>{ratePct.toFixed(0)}%</b>
                <button onClick={() => setRate(Math.min(maxRaise, ratePct + 1))}>+</button>
              </span>
            </div>
          )}
          {years > 1 && <div className="re-total">{years}y · {fmt$Full(total)} total</div>}
        </div>
      )}

      <div className="re-foot">
        <button className="tm-pillbtn ghost" onClick={onClose}>Cancel</button>
        <button className="tm-pillbtn primary" onClick={onApply}>
          {draft.adding && (action === "snt" || action === "optin") ? "Add to trade" : "Confirm"}
        </button>
      </div>
    </div>
  );
}

/* ============================================================
   COLUMN DRAWER — R11.C: slide-up edit pane anchored to the column's
   bottom edge. Same controls as InlineEditor but larger canvas, no row
   height shift. Photo on the left at ~120×140; actions + contract
   controls right; close ✕ top-right; Confirm bottom-right.
   ============================================================ */
function ColumnDrawer({ ctx, onChange, onApply, onClose, slotEv, ownTeam }) {
  if (!ctx) return null;
  const { player, kind, draft } = ctx;
  const isFA = kind === "fa";
  const isOption = !isFA && (player.status === "player_option" || player.status === "team_option");
  const optLabel = player.status === "team_option" ? "Exercise" : "Opt in";
  // Renounce/Hold: the MANAGED team's own FA holds only, regardless of payroll
  // (see InlineEditor) — never for opponents.
  const capMoves = ownTeam ? ["renounce", "hold"] : [];
  const actions = (!isFA && !isOption && player._resigned) ? ["sign", "snt", ...capMoves]   // re-signed: full FA options — keep (Sign), S&T, or (own team) Renounce/Hold
                : isOption ? ["optin", "snt", "sign", ...capMoves]
                : isFA      ? ["snt", "sign", ...capMoves]
                            : ["sign", ...capMoves];
  const LABEL = { optin: optLabel, snt: "S&T", sign: "Sign", renounce: "Renounce", hold: "Hold" };
  const action = actions.includes(draft.action) ? draft.action : actions[0];
  const salary = draft.salary != null ? draft.salary : (isFA ? player.sntAsk : player.salary);
  const ratePct = draft.ratePct != null ? draft.ratePct : 5;
  const tmBirdToMain = (b) => b === "full" ? "Bird" : b === "early" ? "EarlyBird" : b === "non" ? "NonBird" : null;
  const mainBird = tmBirdToMain(player.bird) || player.birdRights || null;
  const W = (typeof window !== "undefined") ? window : {};
  const signMaxYrs  = W.maxContractYears   ? W.maxContractYears(mainBird)   : 4;
  const signMaxRate = Math.round(((W.contractRaiseRate ? W.contractRaiseRate(mainBird) : 0.05)) * 100);
  const yearOpts = action === "snt"  ? [3, 4]
                 : action === "sign" ? Array.from({ length: signMaxYrs }, (_, i) => i + 1)
                                     : [1, 2, 3, 4];
  const maxRaise = action === "snt"  ? 5
                 : action === "sign" ? signMaxRate
                                     : 8;
  const years = action === "snt" ? Math.max(3, draft.years || 3) : (draft.years || 1);
  const showContract = action === "snt" || action === "sign";

  const pick = (a) => {
    const patch = { action: a };
    if (a === "snt") { patch.years = Math.max(3, years); patch.ratePct = Math.min(5, ratePct); }
    onChange(patch);
  };
  const setYears = (y) => onChange({ years: y });
  const setRate  = (p) => onChange({ ratePct: p });

  const [txt, setTxt] = useState(salary != null ? String(+(salary / 1e6).toFixed(2)) : "");
  useEffect(() => { setTxt(salary != null ? String(+(salary / 1e6).toFixed(2)) : ""); }, [salary, action]);
  const commitSal = (s) => {
    setTxt(s);
    const n = parseFloat(String(s).replace(/[^0-9.]/g, ""));
    onChange({ salary: isNaN(n) ? 0 : Math.max(0, Math.round(n * 1e6)) });
  };
  const total = Array.from({ length: years }).reduce(
    (a, _, i) => a + Math.round((salary || 0) * Math.pow(1 + ratePct / 100, i)), 0);

  const desc = action === "optin"    ? `Exercises the option — trades at ${fmt$(player.salary)} · 1 yr.`
             : action === "renounce" ? "Renounced — clears the cap hold, drops from the trade."
             : action === "hold"     ? "Held as a cap hold — not traded."
             : action === "snt"      ? "⚠ Sign-and-trades require player consent."
             : action === "sign"     ? "Signed players can't be traded for a few months — select S&T to trade now." : "";
  const descWarn = action === "snt";

  return (
    <>
      <div className="tm-col-drawer-backdrop" onClick={onClose} />
      <div className="tm-col-drawer" onClick={(e) => e.stopPropagation()}>
        <button className="tm-cd-close" onClick={onClose} title="Close"><TmIcon.X /></button>
        <div className="tm-cd-head">
          <div className="tm-cd-photo">
            <PlayerAvatar player={player} size={96} />
          </div>
          <div className="tm-cd-info">
            <div className="tm-cd-name">{player.name}</div>
            <div className="tm-cd-meta">
              {isFA
                ? <>FA · {birdLabel(player.bird).txt}{player.exp != null ? ` · ${player.exp}y exp` : ""} · last {fmt$(player.lastSalary)}</>
                : <>{statusTag(player.status) || "guaranteed"}{player.noTrade && " · NTC"} · {fmt$(player.salary)}</>}
            </div>
            <div className="tm-cd-actions">
              {actions.map(a => (
                <button key={a} className={action === a ? "on" : ""} onClick={() => pick(a)}>{LABEL[a]}</button>
              ))}
            </div>
            {desc && <div className={`tm-cd-desc ${descWarn ? "warn" : ""}`}>{desc}</div>}
          </div>
        </div>

        {showContract && (
          <div className="tm-cd-controls">
            <div className="tm-cd-field">
              <label>Salary</label>
              <span className="tm-cd-salbox"><span className="pre">$</span>
                <input className="num" inputMode="decimal" value={txt} placeholder="0"
                       onChange={(e) => commitSal(e.target.value)} />
                <span className="suf">M</span></span>
            </div>
            <div className="tm-cd-field">
              <label>Years</label>
              <span className="tm-cd-years">
                {yearOpts.map(y => (
                  <button key={y} className={years === y ? "on" : ""} onClick={() => setYears(y)}>{y}</button>
                ))}
              </span>
            </div>
            {years > 1 && (
              <div className="tm-cd-field">
                <label>Raise</label>
                <span className="tm-cd-raise">
                  <button onClick={() => setRate(Math.max(0, ratePct - 1))}>−</button>
                  <b>{ratePct.toFixed(0)}%</b>
                  <button onClick={() => setRate(Math.min(maxRaise, ratePct + 1))}>+</button>
                </span>
              </div>
            )}
            {years > 1 && <div className="tm-cd-total">{years}y · {fmt$Full(total)} total</div>}
          </div>
        )}

        <div className="tm-cd-foot">
          <button className="tm-pillbtn ghost" onClick={onClose}>Cancel</button>
          <button className="tm-pillbtn primary" onClick={onApply}>
            {draft.adding && (action === "snt" || action === "optin") ? "Add to trade" : "Confirm"}
          </button>
        </div>
      </div>
    </>
  );
}

/* ============================================================
   Trade Machine — main app (v2)
   New shell: top bar + team strip + columns (no rails, no tray)
   ============================================================ */
const { useState: useS, useEffect: useE, useMemo: useM, useRef: useR } = React;

const MANAGED_DEFAULT = "LAL";

const emptySlot = (code) => ({
  code,
  players: new Map(),
  picks:   new Map(),
  cash:    { out: 0, dest: null },
  tpes:    new Map(),
  fas:     new Map(),
});

/* ============================================================
   buildTradeTables — REAL data adapter
   ------------------------------------------------------------
   Builds the ROSTERS / PICKS / FREE_AGENTS / TPES / CASH_LEDGER /
   PRE_SALARY tables the components read, from the site's real data:
     • rosters + this-year picks ← all-teams-detail.json (`allTeams`)  [Phase 1]
     • FA / S&T candidates       ← all-teams-detail.json (`allTeams`)
     • pre-salary (managed team) ← live site `derived.committed`
   Gaps with no real source yet default empty: TPEs ([]), cash
   ledger ({sent:0,received:0}), and future/conditional picks
   (draft_assets.json is narrative text — deferred). Multi-year
   `years[]` isn't in trade-data either → single-year fallback.
   ============================================================ */
/* LIVE trade-exception derivation (#MLE-vs-Room). The MLE-family kinds in
   teams-exceptions.json are UNUSED-POOL FLAGS, not the live type. Per the §6(n)
   master gate, the team's room-vs-over-cap STATUS picks which pool applies:
     cap-space            → Room MLE
     over-cap & < 1st apron → NT-MLE + BAE
     1st..2nd apron        → TP-MLE (engine drops it — signing-only)
     over 2nd apron        → none
   gatePayroll = the team's PRE-trade, holds-INCLUSIVE Team Salary (standing room;
   the incoming is matched separately, not part of the SET classification). The data
   says only WHICH pools are still unused: hasMle = it lists any MLE kind; hasBae =
   it lists bae. Returns engine-shaped [{kind}] (no amount → engine fills its own
   canonical figure). TPEs are NOT touched here — those are real, separate capacity. */
function deriveExceptions(gatePayroll, apronBase, dataExceptions, cba) {
  const list = Array.isArray(dataExceptions) ? dataExceptions : [];
  if (!Number.isFinite(gatePayroll)) return list;   // unknown → don't re-gate
  const has = k => list.some(e => e && e.kind === k);
  const hasMle = has("roomMle") || has("ntMle") || has("tpMle");
  const hasBae = has("bae");
  const { cap, apron1, apron2 } = cba || {};
  // Room vs over-cap = §6(n)(2) on the holds-INCLUSIVE gatePayroll (engine signingMode): a team
  // under the cap by < its unused over-cap exceptions (~$20.5M) is absorbed back over the cap.
  // WHICH over-cap exception is then decided by the apron, measured on the HOLDS-FREE apronBase
  // (§2(e)(1)(iv) excludes the FA Amount) — so a high-holds team (LAL) gets its apron-appropriate
  // channel. Falls back to gatePayroll if apronBase isn't supplied.
  const E = (typeof window !== "undefined" && window.Engine) || {};
  const isRoom = (typeof E.signingMode === "function")
    ? E.signingMode({ teamSalaryWithHolds: gatePayroll }).mode === "room"
    : (gatePayroll < cap);
  const ap = Number.isFinite(apronBase) ? apronBase : gatePayroll;
  const out = [];
  if (isRoom)            { if (hasMle) out.push({ kind: "roomMle" }); }   // room team → Room MLE
  else if (ap >= apron2) { /* over 2nd apron → no MLE/BAE */ }
  else if (ap >= apron1) { if (hasMle) out.push({ kind: "tpMle" }); }     // between aprons → TP-MLE (engine drops it: signing-only)
  else { if (hasMle) out.push({ kind: "ntMle" }); if (hasBae) out.push({ kind: "bae" }); }  // over cap, < 1st apron
  return out;
}
if (typeof window !== "undefined") window.deriveExceptions = deriveExceptions;

/* ============================================================
   P3 — server-authoritative verdict (engine-split cutover). OFF by default: the live app is unchanged until
   window.__USE_WORKER_ENGINE is set truthy. window.__WORKER_URL = the Worker origin (e.g. http://127.0.0.1:8787);
   window.__ENGINE_VERSION (optional) enables the version handshake. See docs/CLOUDFLARE-ENGINE-SPLIT-HANDOFF.md §4.
   ============================================================ */
function _ls(k) { try { return (typeof localStorage !== "undefined") ? localStorage.getItem(k) : null; } catch { return null; } }
function USE_WORKER() { return typeof window !== "undefined" && (!!window.__USE_WORKER_ENGINE || _ls("capmvp_use_worker") === "1"); }
function WORKER_URL() { return (typeof window !== "undefined" && (window.__WORKER_URL || _ls("capmvp_worker_url"))) || ""; }
function CLIENT_ENGINE_VERSION() { return (typeof window !== "undefined" && (window.__ENGINE_VERSION || _ls("capmvp_engine_version"))) || null; }

/* Override the LOCAL verdict with the SERVER verdict when the flag is on. Fail-closed: legal only when a
   server "ok" verdict exists for the CURRENT payload key; pending/error/stale/mismatch → not legal (no Apply).
   Flag off (or no payload) → return the local verdict untouched (byte-identical to today). */
function mergeServerVerdict(localDerived, payload, tradeVerify) {
  if (!USE_WORKER() || !payload || !payload.key) return localDerived;
  const key = payload.key, tv = tradeVerify || {};
  if (tv.status === "ok" && tv.key === key && Array.isArray(tv.evals)) {
    const evals = tv.evals;
    const allOk = evals.length > 0 && evals.every(e => e && e.legal);
    return { ...localDerived, evals, legal: localDerived.anyActivity && allOk && localDerived.toRoute === 0, verifyStatus: "ok" };
  }
  const status = (tv.key === key) ? (tv.status || "pending") : "pending";
  return { ...localDerived, legal: false, verifyStatus: status, verifyError: (tv.key === key ? tv.error : null) || null };
}

function buildTradeTables({ allTeams, teams, managedCode, siteDerived, state, draftAssets, picksData, nbaIdByName, derivedByTeam, teamsExc }) {
  // CapMVP picks bundle (picks-data.json) stashed for TmPicksPanel (what-if vars +
  // team-name registry). Set here because buildTradeTables runs on every render
  // with the freshest picksData, so the panel always reads a current bundle.
  if (picksData && typeof window !== "undefined") window.__PICKS_BUNDLE = picksData;
  const ROSTERS = {}, PICKS = {}, FREE_AGENTS = {}, TPES = {}, EXC = {}, CASH_LEDGER = {}, PRE_SALARY = {}, PRE_APRON = {}, HARDCAPS = {};
  const codes  = (teams || []).map(t => t.code);
  const detail  = allTeams || {};
  const excByCode = (teamsExc && teamsExc.teams) || {};   // PROVISIONAL per-team TPEs + MLE-family trade-acquisition exceptions (teams-exceptions.json)

  // Phase 2 (one-world #14): the opponent Team Salary is now computed once, for
  // every team, by App's derivedByTeam selector (computeDerived from the ONE
  // store) and passed in — replacing the local teamCommitted() + the trade-data
  // sum + the two PRE_SALARY patches. See the PRE_SALARY assignment below.
  const birdMap = (b) => {
    if (!b) return "n/a";
    const s = String(b).toLowerCase();
    if (s.includes("early")) return "early";
    if (s.includes("non"))   return "non";
    if (s.includes("bird"))  return "full";
    return "n/a";
  };
  const parsePick = (code, name) => {
    const yearM = name.match(/(20\d\d)/);
    const round = /2nd|second|round\s*2|\bR2\b/i.test(name) ? 2 : 1;
    const viaM  = name.match(/\(([^)]+)\)/);
    const sub   = viaM ? (/own/i.test(viaM[1]) ? "Own" : "via " + viaM[1]) : "";
    return {
      id: code + "-" + name.replace(/\W+/g, ""),
      year: yearM ? +yearM[1] : 0, round, label: name, sub, this: true,
    };
  };

  // Phase 1 (one-world #14): ROSTERS + this-year PICKS now come from
  // all-teams-detail.json — the SAME source the roster page renders — instead
  // of teams-trade-data.json. So the TM shows each team's roster identically to
  // its roster page, INCLUDING stretched dead money (MIL Lillard, PHX Beal) that
  // trade-data omitted. Totals are unchanged: opponents = teamCommitted(detail),
  // managed = siteDerived.committed — both already detail-based (see PRE_SALARY
  // overwrites below). Two-ways are excluded (out of Team Salary, not standard-
  // tradeable). FREE_AGENTS were already detail-sourced.
  const PES = window.playerEffectiveSalary;
  const ROSTER_STATUS = { under_contract: "guaranteed", player_option: "player_option", team_option: "team_option", non_guaranteed: "non_guaranteed", partially_guaranteed: "guaranteed" };   // FIX: partial = tradeable; "guaranteed" matches at FULL salary (safe — never loosens matching). Precise partial outgoing-reduction is a deferred refinement.
  // Decision-free, on-books 2026-27 salary (option-year for options) — the same
  // per-player figure teamCommitted sums, so Σ ROSTERS agrees with the base.
  const rosterSalary = (p) => {
    if (typeof PES === "function") { const v = PES(p, {}, "apron", false); return Number.isFinite(v) ? v : 0; }
    return (p.seasons || []).find(s => s.season === "2026-27")?.salary || 0;
  };
  for (const code of codes) {
    const dps = (detail[code] && detail[code].players) || [];
    // Engine trade-acquisition capacity (C1/MF-1): a team's AVAILABLE TPEs + MLE-family exceptions.
    // Shapes already match the engine contract (engine.js readSide): tpes [{id,amount,priorSeason?}],
    // exceptions [{kind}] (the engine drops tpMle = signing-only). All available slots are passed; the
    // solver only USES one when a clean salary match fails — purely additive, never flags a clean trade.
    TPES[code] = Array.isArray(excByCode[code] && excByCode[code].tpes) ? excByCode[code].tpes : [];
    EXC[code]  = Array.isArray(excByCode[code] && excByCode[code].exceptions) ? excByCode[code].exceptions : [];
    ROSTERS[code] = dps
      .filter(p => ROSTER_STATUS[p.offseasonStatus] && p.contractType !== "two_way" && !p.deadMoney)   // dead money: counts in the total (tradeBase), not a tradeable row
      .map(p => {
        const fut = (p.seasons || []).filter(s => s && s.season >= "2026-27").map(s => s.salary || 0);
        const sal = rosterSalary(p);
        return {
          name: p.name,
          salary: sal,
          status: ROSTER_STATUS[p.offseasonStatus],
          noTrade: !!p.noTrade,
          nbaId: p.nbaId || (nbaIdByName && nbaIdByName[p.name]) || null,
          years: fut.length ? fut : [sal],
          tradeBonus: 0,
          unlikely: Number(p.unlikelyBonus) || 0,
        };
      })
      .sort((a, b) => b.salary - a.salary);

    PICKS[code] = dps
      .filter(p => p.offseasonStatus === "draft_pick")
      .map(p => parsePick(code, p.name));
    // managed team's own picks live in site state, not trade-data
    if (code === managedCode && state && Array.isArray(state.draftPicks)) {
      PICKS[code] = state.draftPicks.map(dp => ({
        id: dp.id,
        year: (String(dp.name || "").match(/(20\d\d)/) || [])[1] | 0,
        round: /2nd|second/i.test(dp.name || "") ? 2 : 1,
        label: dp.name || "Draft pick",
        sub: dp.pickNumber ? `#${dp.pickNumber} (own)` : "Own",
        this: true,
      }));
    }
    // #9-data: FUTURE picks (2027-2032). PRIMARY source is now the CapMVP picks
    // truth-machine (picks-data.json) — clean consolidated text, explicit
    // cond.expr / swap, one row per held pick — injected with a `_hold` payload so
    // TmPicksPanel can render the ticket card. NO raw ledger string and NO
    // source-site name reaches the UI (owner constraint). Falls back to the
    // draft_assets RealGM-ledger block if the bundle hasn't loaded yet.
    const ourHold = picksData && picksData.hold;
    if (ourHold) {
      const cityOf = (c) => (picksData.teamreg && picksData.teamreg[c] && picksData.teamreg[c].city) || c;
      for (const hid in ourHold) {
        const H = ourHold[hid];
        if (!H || H.tm !== code) continue;
        const m = hid.match(/-(\d{4})-(\d)-/);
        if (!m) continue;
        const yr = +m[1], rnd = +m[2];
        const partial = /-P\d+$/.test(hid);
        const cond = !!H.cond, swap = !!H.swap;
        const isOwn = H.type === "Own" && !swap && !cond;
        const srcCity = (H.logo && H.logo !== code) ? cityOf(H.logo) : "";
        // CLEAN subline derived from our own fields (never the raw ledger):
        let sub;
        if (H.badge) sub = H.badge;
        else if (cond) sub = (srcCity ? srcCity + " " : "") + (H.hint || "conditional");
        else if (srcCity) sub = "from " + srcCity;
        else sub = "Own";
        PICKS[code].push({
          id: hid, year: yr, round: rnd,
          label: H.lbl,
          sub: sub.length > 40 ? sub.slice(0, 38) + "…" : sub,
          desc: H.badge || "",
          fullSub: "",                       // intentionally empty — no raw ledger string on this site
          isOwn, swap, residual: cond,
          residualNote: H.hint || H.badge || "",
          this: false, count: 1,
          _hold: H, _holdId: hid, _partial: partial, _src: H.logo || "",
        });
      }
    } else {
    const da = draftAssets && draftAssets.teams && draftAssets.teams[code];
    if (da && da.years) {
      for (let yr = 2027; yr <= 2032; yr++) {
        const y = da.years[String(yr)]; if (!y) continue;
        [["firstRound", 1], ["secondRound", 2]].forEach(([k, rnd]) => {
          const r = y[k]; if (!r) return;
          const cnt = String(r.count || "0").split("+").reduce((a, p) => a + (parseInt(p, 10) || 0), 0);   // compound counts e.g. "1+2" = 3 (a bare parseInt stops at the '+')
          if (cnt <= 0) return;
          const rg = String(r.realgm || "");
          // k (backlog): split a round's holdings into INDIVIDUAL, separately-
          // tradeable picks so partial trades + leftovers work (trade one, keep
          // the rest). Per-pick condition comes from capsheetsOwned; pad/truncate
          // that list to exactly `cnt` entries so #picks always matches the count.
          const owned = Array.isArray(r.capsheetsOwned) ? r.capsheetsOwned.map(String).filter(Boolean) : [];
          for (let i = 0; i < cnt; i++) {
            const d = owned[i] || owned[owned.length - 1] || rg || "Own";
            const isOwn = /\bown\b/i.test(d) && !/swap/i.test(d);
            const swap  = /favorable|swap|lesser|greater|better|worse/i.test(d);
            // "unprotected" is explicitly NOT protected; otherwise flag conditions.
            const protectedPk = !/unprotected/i.test(d)
              && /protect|top[\s-]?\d|lottery|\bif\b|conv(ey|ert)/i.test(d);
            PICKS[code].push({
              id: `${code}-${yr}-R${rnd}-${i}`, year: yr, round: rnd,
              label: `${yr} ${rnd === 1 ? "1st" : "2nd"}`,
              desc: d,
              sub: d.length > 32 ? d.slice(0, 30) + "…" : d,
              fullSub: rg, isOwn, count: 1,
              swap, residual: protectedPk,
              residualNote: protectedPk ? d : "",
              this: false,
            });
          }
        });
      }
    }
    }

    const dplayers = (detail[code] && detail[code].players) || [];
    FREE_AGENTS[code] = dplayers
      .filter(p => (p.offseasonStatus === "UFA" || p.offseasonStatus === "RFA")
                && (p.capHold || p.lastSalary || p.priorSeasonSalary))
      .map(p => {
        const last = p.lastSalary || p.priorSeasonSalary || 0;
        const hold = p.capHold || Math.round(last * 1.2);
        return {
          name: p.name,
          lastSalary: last,
          bird: birdMap(p.birdRights),
          exp: p.yearsOfExperience != null ? p.yearsOfExperience : null,
          pos: p.position || "",
          nbaId: p.nbaId != null ? String(p.nbaId) : null,
          sntAsk: hold || last,        // default ask = cap hold; user edits in drawer
          sntYears: 3,
          capHold: hold,
          designated: p.designated || null,   // 4e: §8(e)(1)(vi) — a Rose 5th-yr-eligible player's S&T yr1 is capped at 25% of cap
          priorUnlikelyBonus: Number.isFinite(p.priorUnlikelyBonus) ? p.priorUnlikelyBonus : undefined,   // BYC §6(b)(2)(i): PRIOR-year UNLIKELY bonus only (likely is already in lastSalary's cap figure; Data-Op to source)
        };
      })
      .sort((a, b) => b.sntAsk - a.sntAsk);

    // TPES[code] / EXC[code] are populated at the top of this loop from teamsExc
    // (PROVISIONAL teams-exceptions.json). (The old `TPES[code] = []` default that
    // used to sit here was removed — it clobbered the real data.)
    CASH_LEDGER[code] = { sent: 0, received: 0 };
    PRE_SALARY[code] = ROSTERS[code].reduce((a, p) => a + p.salary, 0);
  }

  /* R12 — APPLIED-TRADE OVERLAY (multi-team).
     Fold every applied trade into the corresponding teams' ROSTERS / PICKS /
     CASH_LEDGER so the cap math reflects the post-trade state when the user
     switches managed team, opens the TM again, etc.
     Phase 3 (one-world #14): read each team's ACTIVE-mode bucket
     (modeByTeam[code]), not a hardcoded .apron — so a CAP-MODE decision
     (renounce / kept-hold / re-sign made in cap mode) shapes the TM roster too.
     This is the SAME bucket Phase 2's selectTeamView uses for the total, so the
     line-items and the total stay consistent (R12 keeps FA decisions per-mode;
     APPLY_TRADE writes both buckets, so trades show in either mode). */
  try {
    const sc = (state && state.scenarios && state.activeScenario)
               ? state.scenarios[state.activeScenario] : null;
    if (sc && sc.rosters) {
      for (const code of Object.keys(sc.rosters)) {
        const ovMode = (state.modeByTeam && state.modeByTeam[code]) || "apron";
        const ovBucket = (sc.rosters[code] && sc.rosters[code][ovMode]) || null;
        if (!ovBucket) continue;
        const decisions = ovBucket.decisions || {};
        const additions = ovBucket.additions || [];

        // R12.B live-sync: filter players whose decision puts them OFF this
        // team's tradeable roster. Trades + waivers + every "now-a-FA" path
        // (declined options, renounced holds) — all leave the roster bucket.
        // Re-signings appear via state.additions and are appended below.
        const REMOVE = new Set([
          "traded", "waive",
          "opt-out", "decline", "renounced", "kept-hold",
        ]);
        if (ROSTERS[code]) {
          ROSTERS[code] = ROSTERS[code].filter(p => !REMOVE.has(decisions[p.name]?.kind));
          // Append ALL additions (managed-team main-site signings carry no
          // _fromTrade tag; multi-team APPLY_TRADE adds tag them). Either way,
          // append as a roster entry so the cap math + trade UX picks them up.
          for (const a of additions) {
            if (ROSTERS[code].some(p => p.name === a.name)) continue;
            const src = a._sourcePlayer || {};
            ROSTERS[code].push({
              name: a.name,
              salary: a.salary || 0,
              years: src.years || [a.salary || 0],
              status: "under_contract",
              noTrade: false,
              position: a.position || src.position || "",
              // R12 bugfix: prefer the top-level nbaId/bird (set by APPLY_TRADE)
              // and fall back to _sourcePlayer for back-compat with adds from
              // before this fix landed.
              nbaId: a.nbaId || (src.nbaId != null ? String(src.nbaId) : null),
              bird: a.bird || src.bird || "n/a",
              unlikelyBonus: 0,
              tradeBonus: 0,
              unlikely: 0,
              _fromTrade: a._fromTrade,
              // Re-use guard (B): a player ACQUIRED via an applied trade is LOCKED from
              // being re-traded — this keeps applied trades independent (no chains),
              // which is what makes undo-any-trade safe. Undo that trade to move again.
              _tradeLocked: !!a._fromTrade,
              // Signings (re-signs / FA adds — no _fromTrade tag) are trade-restricted:
              // render greyed + "Signed", not directly tradable (S&T is the legal route).
              _resigned: !a._fromTrade,
              _addId: a.id,
              _reSalary: a.salary || 0,
              _reYears: a.years || 1,
            });
          }
          ROSTERS[code].sort((a, b) => (b.salary || 0) - (a.salary || 0));
        }

        // Filter out traded FAs + re-signed FAs (they now live on the roster as an
        // addition — see below), and tag renounced FAs so the column can render them
        // in a greyed-out group.
        if (FREE_AGENTS[code]) {
          const addedNames = new Set(additions.map(a => a.name));
          FREE_AGENTS[code] = FREE_AGENTS[code]
            .filter(f => decisions[f.name]?.kind !== "traded" && !addedNames.has(f.name))
            .map(f => ({ ...f, _renounced: decisions[f.name]?.kind === "renounced" }));
        }

        // Update CASH_LEDGER from applied trades (sent/received deltas)
        if (CASH_LEDGER[code]) {
          for (const trade of (sc.appliedTrades || [])) {
            for (const sl of (trade.slots || [])) {
              if (sl.code !== code) continue;
              CASH_LEDGER[code].sent += (sl.cashOut || 0);
            }
            // Incoming cash to this team
            for (const sl of (trade.slots || [])) {
              if (sl.cashDest === code) CASH_LEDGER[code].received += (sl.cashOut || 0);
            }
          }
        }

        PRE_SALARY[code] = ROSTERS[code].reduce((a, p) => a + (p.salary || 0), 0);
      }
    }
  } catch (e) {
    // Defensive: never block render if overlay shape unexpected.
    if (typeof console !== "undefined") console.warn("TM overlay error", e);
  }

  /* Phase 2 (one-world #14): PRE_SALARY for EVERY team comes from the shared
     derivedByTeam selector (computeDerived run per team from the ONE store),
     replacing the teamCommitted / Σ-trade-data / 2-patch trichotomy:
       • MANAGED team → derivedByTeam[code].committed (mode-aware; == the live
         siteDerived.committed shown on its salary bar).
       • OPPONENTS    → derivedByTeam[code].tradeBase (holds-free apron base,
         two-ways excluded, decision-aware; == the old teamCommitted for an
         un-edited team — so the __capBaseline gate stays 30/30 — and roster-page-
         consistent for an edited one).
     (§6(j) FA-holds-in-base: now ADDRESSED — c feeds the holds-inclusive capBase
     below so the cap-room test counts FA/draft holds; PRE_APRON separately carries the
     HOLDS-FREE apron base (tradeBase) so the apron / hard-cap tests EXCLUDE FA holds per
     CBA Art VII §2(e)(1)(iv).) Falls back to the Σ ROSTERS sum / siteDerived if the
     selector is absent. */
  if (derivedByTeam) {
    for (const code of codes) {
      const d = derivedByTeam[code];
      if (!d) continue;
      // c: feed the holds-INCLUSIVE Team Salary (capBase) so the engine's cap-room test
      // counts FA/draft cap holds. Was holds-FREE (committed apron-mode / tradeBase) → a
      // below-cap-by-salary team got phantom room. __capBaseline is computed separately
      // (engine-bridge), so its 30/30 gate is unaffected.
      const v = (typeof d.capBase === "number") ? d.capBase
              : ((code === managedCode) ? d.committed : d.tradeBase);
      if (typeof v === "number" && Number.isFinite(v)) PRE_SALARY[code] = v;
      // Apron base must be HOLDS-FREE — CBA Art VII §2(e)(1)(iv): Apron Team Salary
      // EXCLUDES the Free Agent Amount. tradeBase = apron-mode committed (holds-free,
      // two-ways excluded); apronTotal is the same holds-free total, as a fallback.
      const a = (typeof d.tradeBase === "number") ? d.tradeBase
              : (typeof d.apronTotal === "number" ? d.apronTotal : undefined);
      if (typeof a === "number" && Number.isFinite(a)) PRE_APRON[code] = a;
    }
  } else if (siteDerived && typeof siteDerived.committed === "number") {
    PRE_SALARY[managedCode] = siteDerived.committed;   // fallback: at least fix the managed team
    if (typeof siteDerived.apronTotal === "number") PRE_APRON[managedCode] = siteDerived.apronTotal;   // holds-free apron fallback
  }

  // 4d enforcement: per-team STRICTEST stored hard cap (apron name or null) from the
  // active scenario, so the engine post-check can block any later move that would breach it.
  const _activeSc = (state && state.scenarios && state.activeScenario && state.scenarios[state.activeScenario]) || null;
  const _hcRaw = (_activeSc && _activeSc.hardCaps) || {};
  const _strictestAt = (list) => { let b = null; for (const e of (list || [])) { if (e && e.at === "firstApron") return "firstApron"; if (e && e.at === "secondApron") b = b || "secondApron"; } return b; };
  for (const code of Object.keys(_hcRaw)) HARDCAPS[code] = _strictestAt(_hcRaw[code]);

  // 4f: merge session-created TPEs (from applied non-simultaneous trades) into each team's
  // inventory so a LATER trade can use them (engine consumes side.tpes). Off-season → none
  // expire in-session. Usable only AFTER the creating trade (written only by APPLY_TRADE, so
  // absent until then). Engine-ready shape ({id, amount, fromAggregation?, fromSnt?}).
  const _ctRaw = (_activeSc && _activeSc.createdTpes) || {};
  for (const code of Object.keys(_ctRaw)) {
    const created = (_ctRaw[code] || []).map(t => ({ id: t.id, amount: t.amount, source: t.source, fromAggregation: !!t.fromAggregation, fromSnt: !!t.fromSnt }));
    if (created.length) TPES[code] = [...(TPES[code] || []), ...created];
  }

  return { ROSTERS, PICKS, FREE_AGENTS, TPES, EXC, CASH_LEDGER, PRE_SALARY, PRE_APRON, HARDCAPS };
}

/* O.2 / one-world #14, Phase 4: the in-progress trade is no longer a separate
   localStorage store. It lives in the reducer as scenarios[active].draftTrade
   (plain objects), persisted with the rest of capmvp-state. TradeMachineView
   derives Map-shaped `slots` from it and dispatches TM_SET_DRAFT to write. The
   old saveTmSlots/loadTmSlots + the Map replacer/reviver are gone; a one-time
   migration of the legacy capmvp-tm-trade key lives in useAppState (state.jsx). */

function TradeMachineView({ allTeams, state, dispatch, derived: siteDerived, teams, players: managedPlayers, draftAssets, picksData, nbaIdByName, teamsExc, derivedByTeam, onExit, theme, setTheme, tool, setTool, siteTweaks, setSiteTweak, onOpenReset }) {
  const managedCode = (state && state.team) || MANAGED_DEFAULT;

  /* Build the real-data tables and publish them into the IIFE-scoped
     tables the child components read (assigned every render, before
     children render, so they always see current data). */
  const realTables = useM(
    () => buildTradeTables({ allTeams, teams, managedCode, siteDerived, state, draftAssets, picksData, nbaIdByName, derivedByTeam, teamsExc }),
    [allTeams, teams, managedCode, siteDerived, state, draftAssets, picksData, nbaIdByName, derivedByTeam, teamsExc]
  );
  ROSTERS = realTables.ROSTERS;   PICKS = realTables.PICKS;
  FREE_AGENTS = realTables.FREE_AGENTS;   TPES = realTables.TPES;   EXC = realTables.EXC;
  CASH_LEDGER = realTables.CASH_LEDGER;   PRE_SALARY = realTables.PRE_SALARY;   PRE_APRON = realTables.PRE_APRON;   HARDCAPS = realTables.HARDCAPS;
  /* ---------------- state ---------------- */
  // one-world #14, Phase 4: `slots` is a PROJECTION of the reducer's
  // scenarios[active].draftTrade (plain objects), converted to the Map shape the
  // render + mutators expect. `setSlots` is a thin shim that dispatches
  // TM_SET_DRAFT — so the in-progress board is part of the single shared store
  // (persisted with capmvp-state, survives exit/nav/refresh, visible app-wide).
  // Restore guard preserved: only adopt a saved draft for the CURRENT managed team.
  const slots = useM(() => {
    const sc = (state && state.scenarios && state.activeScenario) ? state.scenarios[state.activeScenario] : null;
    const objs = (sc && sc.draftTrade && Array.isArray(sc.draftTrade.slots)) ? sc.draftTrade.slots : null;
    if (objs && objs.length && objs[0] && objs[0].code === managedCode)
      return objs.map(o => (window.slotObjToMap ? window.slotObjToMap(o) : o));
    return [emptySlot(managedCode)];
  }, [state.scenarios, state.activeScenario, managedCode]);
  // setSlots shim → dispatches TM_SET_DRAFT. An ACCUMULATOR (pendingSlotsRef) makes
  // multiple SYNCHRONOUS calls compose like React's functional setState — required
  // because one handler can fire two: a DestPopover pick runs setDest THEN
  // closeDestPop→confirmDest in the same click (same for the Undo item). Without
  // this the 2nd call reads a stale `slots` closure and clobbers the 1st (the dest
  // change / undo would silently do nothing). The accumulator resets whenever the
  // store-derived `slots` changes (i.e. the reducer has caught up).
  const pendingSlotsRef = useR({ base: null, val: null });
  if (pendingSlotsRef.current.base !== slots) pendingSlotsRef.current = { base: slots, val: slots };
  const setSlots = (updater) => {
    const cur = pendingSlotsRef.current.val;
    const next = (typeof updater === "function") ? updater(cur) : updater;
    if (!Array.isArray(next)) return;
    pendingSlotsRef.current.val = next;
    dispatch({ type: "TM_SET_DRAFT", slots: next.map(s => (window.slotMapToObj ? window.slotMapToObj(s) : s)) });
  };
  const hasRestored = slots.length > 1;
  // #7: with no restored trade, open the team picker (mobile lands on team #2);
  // a restored trade shows the board instead.
  const [activeSlot, setActiveSlot] = useS(hasRestored ? 0 : 1);
  const [adding, setAdding] = useS(!hasRestored);
  const [tabs, setTabs] = useS(() => slots.map(() => "players"));
  const [destPop, setDestPop] = useS(null);
  const [pendingDest, setPendingDest] = useS(null);  // auto-open the dest menu after a multi-team add
  const [verdictOpen, setVerdictOpen] = useS(false);
  // P3: async SERVER verdict state (used only when USE_WORKER()). status: idle|pending|ok|error|stale.
  const [tradeVerify, setTradeVerify] = useS({ key: null, status: "idle", evals: null, error: null });
  const shellRef = useRef(null);
  const suppressScroll = useRef(0);   // ignore scroll-driven chrome toggles right after a layout-shift action
  // #4: layout-shift actions briefly suppress scroll-driven handlers so a reflow
  // isn't misread as a user scroll. (Chrome autohide removed — scroll-mechanics handoff.)
  const markMutate = () => { suppressScroll.current = Date.now() + 450; };
  // #4: sticky-last destination — remember where each source team last routed a
  // player. A newly-added asset defaults there, UNLESS a team has been added since
  // (in which case hub-spoke kicks back in so the new team becomes the default).
  const lastDestRef = useRef({});    // srcCode → last dest code it routed to
  const routeSeqRef = useRef({});    // srcCode → monotonic seq at its last route
  const addSeqRef = useRef(0);       // seq at the most-recent team add
  const seqRef = useRef(0);          // monotonic counter
  const noteDest = (src, dst) => {
    if (!src || !dst) return;
    lastDestRef.current[src] = dst;
    routeSeqRef.current[src] = (seqRef.current += 1);
  };
  const [drawer, setDrawer] = useS(null);   // { slotIdx, kind, player, draft }
  const [toast, setToast] = useS(null);
  /* showTweaks removed — the gear now renders the shared main-site SettingsMenu. */
  const [mobilePreview, setMobilePreview] = useS(false);
  const [tweaks, setTweaks] = useS(/*EDITMODE-BEGIN*/{
    "layout": "auto",
    "seed": "none",
    "showFAs": true,
    "surface": "mixed",
    "chipLayout": "auto",
    "autohideChrome": true,
    "headerTotals": "off",
    "picksLayout": "grid",
    "cashLayout": "table",
    "editLayout": "inline-v2",
    "scenariosEnabled": false
  }/*EDITMODE-END*/);
  const [scenarioMenuOpen, setScenarioMenuOpen] = useS(false);

  const codes = slots.map(s => s.code);   // full set (incl. hidden) → dests to a parked team survive
  const visN = slots.filter(s => !s.hidden).length;   // #3: teams the user actually sees
  const multi = visN > 2;
  const ownCode = slots[0]?.code || MANAGED_DEFAULT;

  /* normalize destinations on slot count changes */
  useE(() => {
    setSlots(prev => prev.map((s) => {
      const others = codes.filter(c => c !== s.code);
      const fix = (d) => others.length === 0 ? null : (others.includes(d) ? d : others[0]);
      const fm = (m) => { const nm = new Map(); for (const [k, v] of m) nm.set(k, { ...v, dest: fix(v.dest) }); return nm; };
      return {
        ...s,
        players: fm(s.players),
        picks:   fm(s.picks),
        fas:     fm(s.fas),
        cash:    { ...s.cash, dest: fix(s.cash.dest) || others[0] || null },
      };
    }));
  // eslint-disable-next-line
  }, [slots.length]);

  /* seed scenarios from Tweaks */
  useE(() => {
    if (!tweaks.seed || tweaks.seed === "none") return;
    if (tweaks.seed === "doncic-tatum") {
      const lal = emptySlot("LAL");
      lal.players.set("Austin Reaves", { dest: "BOS" });
      lal.players.set("Rui Hachimura", { dest: "BOS" });
      lal.players.set("Jarred Vanderbilt", { dest: "BOS" });
      lal.picks.set("LAL-2031-1", { dest: "BOS" });
      const bos = emptySlot("BOS");
      bos.players.set("Jaylen Brown", { dest: "LAL" });
      setSlots([lal, bos]); setActiveSlot(0); setAdding(false);
      setTabs(["players", "players"]);
    } else if (tweaks.seed === "three-team") {
      const lal = emptySlot("LAL");
      lal.players.set("Austin Reaves", { dest: "OKC" });
      lal.players.set("Jake LaRavia",  { dest: "OKC" });
      lal.picks.set("LAL-2031-1",      { dest: "OKC" });
      lal.cash.out = 2_500_000; lal.cash.dest = "MIA";
      const okc = emptySlot("OKC");
      okc.players.set("Isaiah Hartenstein", { dest: "LAL" });
      okc.picks.set("OKC-2026-1c", { dest: "MIA" });
      const mia = emptySlot("MIA");
      mia.players.set("Andrew Wiggins", { dest: "OKC" });
      setSlots([lal, okc, mia]); setActiveSlot(0); setAdding(false);
      setTabs(["players", "players", "players"]);
    } else if (tweaks.seed === "illegal") {
      const lal = emptySlot("LAL");
      lal.players.set("Marcus Smart", { dest: "BOS" });
      const bos = emptySlot("BOS");
      bos.players.set("Jayson Tatum", { dest: "LAL" });
      setSlots([lal, bos]); setActiveSlot(0); setAdding(false);
      setTabs(["players", "players"]);
    } else if (tweaks.seed === "snt") {
      const lal = emptySlot("LAL");
      const fa = (FREE_AGENTS.LAL || []).find(f => f.name === "Dorian Finney-Smith");
      if (fa) lal.fas.set(fa.name, { dest: "OKC" });
      lal.players.set("Marcus Smart", { dest: "OKC" });
      const okc = emptySlot("OKC");
      okc.players.set("Alex Caruso", { dest: "LAL" });
      setSlots([lal, okc]); setActiveSlot(0); setAdding(false);
      setTabs(["fas", "players"]);
    }
    setTweaks(t => ({ ...t, seed: "none" }));
  // eslint-disable-next-line
  }, [tweaks.seed]);

  // O.2 persistence is now automatic: `slots` lives in the reducer's draftTrade,
  // saved with capmvp-state (Phase 4). The old saveTmSlots(slots) effect is gone.

  /* After a multi-team add, open the dest menu anchored to the new outgoing chip so
     routing is one obvious tap. RAF waits for the chip to paint. */
  useE(() => {
    if (!pendingDest) return;
    const { i, kind, key } = pendingDest;
    const id = i + "-" + kind + "-" + key;
    const raf = requestAnimationFrame(() => {
      const el = document.querySelector('.tm-col-out [data-destkey="' + (window.CSS && CSS.escape ? CSS.escape(id) : id) + '"]');
      if (el) setDestPop({ slotIdx: i, kind, key, anchor: el });
      setPendingDest(null);
    });
    return () => cancelAnimationFrame(raf);
  // eslint-disable-next-line
  }, [pendingDest, slots]);

  /* Lock the page behind the Trade Machine overlay so it can't scroll — otherwise the
     roster page's scrollbar shows through next to the list's (the "double scrollbar"). */
  useE(() => {
    const prevBody = document.body.style.overflow;
    const prevHtml = document.documentElement.style.overflow;
    document.body.style.overflow = "hidden";
    document.documentElement.style.overflow = "hidden";
    return () => { document.body.style.overflow = prevBody; document.documentElement.style.overflow = prevHtml; };
  }, []);

  /* Scroll-driven "autohide chrome" REMOVED (scroll-mechanics handoff). The top
     bar + team strip now stay in normal flow; each column's header pins via CSS
     (.tm-col-scroll .tm-col-head { position: sticky }) while its roster scrolls. */

  /* P3 — SERVER PATH (additive; off-by-default via window.__USE_WORKER_ENGINE). buildTradeVerificationPayload
     mirrors the engineEvalsOrNull serializer but emits the WORKER request shape (exceptionFlags +
     apronBaseNoUnlikely + priorHardCap instead of a client-derived `exceptions`; the Worker derives them —
     P0 §1/§3). The local engineEvalsOrNull below is left 100% UNTOUCHED so the flag-OFF path is unchanged. */
  function buildTradeVerificationPayload(slots) {
    const ST = { guaranteed:"guaranteed", non_guaranteed:"non_guaranteed", team_option:"team_option", player_option:"player_option", two_way:"two_way", under_contract:"guaranteed" };
    const normBird = b => ({ Bird:"full", full:"full", EarlyBird:"early", early:"early", NonBird:"non", non:"non" }[b] || null);
    const toAsset = p => ({ kind:"player", id:p.name, name:p.name, salary:p.salary, status: ST[p.status] || "guaranteed", noTrade: !!p.noTrade, unlikelyBonus: p.unlikely || 0, years: Array.isArray(p.years) ? p.years : undefined });
    const unlikelyOf = code => (ROSTERS[code] || []).reduce((a, p) => a + (Number(p.unlikely) || 0), 0);
    const firstRoundPicksOwnedFor = code => {
      const years = (draftAssets && draftAssets.teams && draftAssets.teams[code] && draftAssets.teams[code].years) || {};
      const ys = [];
      for (let y = 2027; y <= 2034; y++) {
        const yd = years[String(y)];
        let n = (yd && yd.firstRound && yd.firstRound.count != null)
          ? String(yd.firstRound.count).split("+").reduce((a, p) => a + (parseInt(p, 10) || 0), 0)
          : 1;
        if (!Number.isFinite(n)) n = 1;
        for (let i = 0; i < n; i++) ys.push(y);
      }
      return ys;
    };
    const basePickFor = (code, id) => (PICKS[code] || []).find(pp => pp.id === id) || null;
    const pendingPickFor = (owner, id) => {
      for (const s of slots) {
        if (!s || s.code === owner || !s.picks || !s.picks.has(id)) continue;
        const m = s.picks.get(id);
        if (!m || m.dest !== owner) continue;
        const pk = basePickFor(s.code, id);
        if (pk) return Object.assign({}, pk, { _from: s.code });
      }
      return null;
    };
    const pickFor = (code, id) => basePickFor(code, id) || pendingPickFor(code, id);

    const visible = slots.filter(s => s && !s.hidden);
    const visibleCodes = new Set(visible.map(s => s.code));
    const teams = {};
    visible.forEach(s => {
      const pre = PRE_SALARY[s.code] || 0;
      const apronBase = (PRE_APRON[s.code] != null) ? PRE_APRON[s.code] : pre;
      teams[s.code] = {
        code: s.code, preTeamSalary: pre, preApronSalary: apronBase + unlikelyOf(s.code),
        roster: (ROSTERS[s.code] || []).map(toAsset), tpes: (TPES[s.code] || []),
        exceptionFlags: EXC[s.code] || [], apronBaseNoUnlikely: apronBase,   // P0 §1: the Worker derives `exceptions` from these
        cashSentThisYear: (CASH_LEDGER[s.code] && CASH_LEDGER[s.code].sent) || 0,
        cashReceivedThisYear: (CASH_LEDGER[s.code] && CASH_LEDGER[s.code].received) || 0,
        isSecondApronTeam: ((apronBase + unlikelyOf(s.code)) > CAP_2026.apron2),
        firstRoundPicksOwned: firstRoundPicksOwnedFor(s.code),
        isOffseason: true,
        priorHardCap: HARDCAPS[s.code] || null,   // C-1: the Worker enforces the prior stored hard cap
      };
    });
    const movements = [];
    visible.forEach(s => {
      const code = s.code;
      for (const [name, m] of s.players) {
        if (m.declined || !m.dest || !visibleCodes.has(m.dest)) continue;
        if (m.salaryOverride != null) {
          const base = (ROSTERS[code] || []).find(p => p.name === name);
          const a = base ? Object.assign(toAsset(base), { salary: m.salaryOverride }) : { kind:"player", id:name, name, salary:m.salaryOverride, status:"guaranteed" };
          movements.push({ asset:a, from:code, to:m.dest });
        } else movements.push({ asset:name, from:code, to:m.dest });
      }
      for (const [id, m] of s.picks) {
        if (!m.dest || !visibleCodes.has(m.dest)) continue;
        const pk = pickFor(code, id);
        if (pk) movements.push({ asset:{ kind:"pick", round:pk.round, year:pk.year, name:pk.label }, from:code, to:m.dest });
      }
      for (const [name, m] of s.fas) {
        if (!m.dest || !visibleCodes.has(m.dest)) continue;
        const fa = (FREE_AGENTS[code] || []).find(p => p.name === name);
        const sal = m.sntSalary != null ? m.sntSalary : (fa ? fa.sntAsk : 0);
        const _bird = normBird(fa && fa.bird);
        const _capBase = PRE_SALARY[code];
        const _overCapAfter = Number.isFinite(_capBase)
          ? (_capBase - ((fa && Number.isFinite(fa.capHold)) ? fa.capHold : 0) + sal) > CAP_2026.cap
          : undefined;
        const _meta = (typeof window !== "undefined" && window.__tmData && window.__tmData.meta) || {};
        const _yos = (fa && fa.exp != null) ? fa.exp : null;
        const _minForYos = (_meta.minScale && _yos != null) ? Number(_meta.minScale[Math.min(Math.max(_yos, 0), 10)]) : undefined;
        const asset = { kind:"player", id:name, name, salary:sal, isSignAndTrade:true,
          birdRights: _bird,
          priorSalary: (fa && Number.isFinite(fa.lastSalary)) ? fa.lastSalary : undefined,
          yos: (fa && fa.exp != null) ? fa.exp : undefined,
          minForYos: Number.isFinite(_minForYos) ? _minForYos : undefined,
          unlikelyBonus: 0,   // §8(e)(1)(vii): S&T acquirer-Room gate reads the NEW contract's yr1 Unlikely Bonuses (distinct from priorUnlikelyBonus below). No unlikely-bonus input in the S&T flow → 0; WITHOUT this the engine fail-closes every S&T.
          priorUnlikelyBonus: (fa && Number.isFinite(fa.priorUnlikelyBonus)) ? fa.priorUnlikelyBonus : undefined,
          priorHold: (fa && Number.isFinite(fa.capHold)) ? fa.capHold : undefined,
          teamOverCapAfterSigning: _overCapAfter,
          signAndTrade: {
            years: m.sntYears != null ? m.sntYears : (fa ? fa.sntYears : 3),
            firstYearSalary: sal,
            firstYearFullyProtected: true,
            finishedPriorSeasonOnRoster: true,
            exceptionUsed: _bird,
            higherMaxEligible: !!(fa && fa.designated),
          } };
        teams[code].roster.push(asset);
        movements.push({ asset, from:code, to:m.dest });
      }
      if ((s.cash?.out || 0) > 0 && s.cash?.dest && visibleCodes.has(s.cash.dest)) movements.push({ asset:{ kind:"cash", salary:s.cash.out }, from:code, to:s.cash.dest });
    });
    const isPlayerAsset = a => typeof a === "string" || (a && a.kind === "player");
    const isSnTAsset = a => a && typeof a === "object" && a.isSignAndTrade === true;
    visible.forEach(s => {
      const pre = (typeof derivedByTeam !== "undefined" && derivedByTeam && derivedByTeam[s.code]) || {};
      let inN = 0, outN = 0;
      for (const m of movements) {
        if (!isPlayerAsset(m.asset)) continue;
        if (m.to === s.code) inN++;
        if (m.from === s.code && !isSnTAsset(m.asset)) outN++;
      }
      if (Number.isFinite(pre.rosterCount)) teams[s.code].rosterCountAfter = pre.rosterCount + inN - outN;
      if (Number.isFinite(pre.twoWayCount)) teams[s.code].twoWayCountAfter = pre.twoWayCount;
    });
    if (!movements.length) return null;
    const v = CLIENT_ENGINE_VERSION() || undefined;
    const request = { engine: { clientEngineVersion: v, expectedServerEngineVersion: v }, ctx: { currentDraftYear: 2027 }, trade: { teams, movements } };
    return { key: JSON.stringify(request.trade), request };
  }

  /* Map the Worker's res.sides → the app `evals` shape (mirrors the engineEvalsOrNull mapping at the bottom of
     the local memo). The Worker already enforced the prior hard cap (C-1), so we render its priorHardCapBlock
     rather than re-checking HARDCAPS here. */
  function mapWorkerSidesToEvals(res) {
    const sides = (res && res.sides) || [];
    const consvOk = !res || !res.conservation || res.conservation.ok;
    return slots.map(s => {
      if (s.hidden) return { code:s.code, legal:true, reasons:[], flags:[], O:0, I:0, post: PRE_SALARY[s.code] || 0 };
      const side = sides.find(x => x.code === s.code);
      if (!side) return { code:s.code, legal:true, reasons:[], flags:[], O:0, I:0, post: PRE_SALARY[s.code] || 0 };
      const flags = (side.flags || []).filter(f => !/^Hard-capped at the/.test(f));
      (side.hardCaps || []).forEach(hc => flags.push(hc === "firstApron" ? `Hard-capped at the 1st apron (${fmt$(CAP_2026.apron1)}).` : hc === "secondApron" ? `Hard-capped at the 2nd apron (${fmt$(CAP_2026.apron2)}).` : "Hard cap: " + hc));
      if (!consvOk && res && res.conservation) (res.conservation.issues || []).forEach(iss => flags.push("⚠ " + iss));
      if (side.priorHardCapBlock) { const b = side.priorHardCapBlock; flags.push(`⛔ Hard-capped at the ${b.at === "firstApron" ? "1st" : "2nd"} apron (${fmt$(b.ceiling)}) — this trade would push you to ${fmt$(b.post)}.`); }
      const legal = !!side.legal && !side.indeterminate && consvOk;
      return { code:s.code, legal, reasons: (side.reasons || []).slice(), flags, hardCaps: (side.hardCaps || []).slice(), O: side.O, I: side.I, post: side.postBase != null ? side.postBase : side.post, outValues: (side.outValues || []).slice(), bankedTpes: (side.bankedTpes || []).slice(), methods: (side.methods || []).slice(), maxIncoming: side.maxIncoming || 0 };
    });
  }

  /* ---------------- derived ---------------- */
  const localDerived = useM(() => {
    function basePickFor(code, id) {
      return (PICKS[code] || []).find(pp => pp.id === id) || null;
    }
    function pendingPickFor(owner, id) {
      for (const s of slots) {
        if (!s || s.code === owner || !s.picks || !s.picks.has(id)) continue;
        const m = s.picks.get(id);
        if (!m || m.dest !== owner) continue;
        const pk = basePickFor(s.code, id);
        if (pk) return Object.assign({}, pk, { _from: s.code, _pendingIn: true, _hold: pk._hold ? Object.assign({}, pk._hold, { tm: owner }) : pk._hold });
      }
      return null;
    }
    function pickFor(code, id) {
      return basePickFor(code, id) || pendingPickFor(code, id);
    }
    function outOf(i) {
      const slot = slots[i];
      const items = [];
      for (const [name, m] of slot.players) {
        if (m.declined) continue;              // option declined → not tradeable
        const p = (ROSTERS[slot.code] || []).find(pp => pp.name === name);
        if (p) items.push({ ...p, _kind:"player", _key:name, _dest:m.dest, _slotIdx:i, _confirmed:m.confirmed,
          salary: m.salaryOverride != null ? m.salaryOverride : p.salary });
      }
      for (const [id, m] of slot.picks) {
        const pk = pickFor(slot.code, id);
        if (pk) items.push({ ...pk, _kind:"pick", _key:id, _dest:m.dest, salary:0, _slotIdx:i, _confirmed:m.confirmed });
      }
      for (const [name, m] of slot.fas) {
        const fa = (FREE_AGENTS[slot.code] || []).find(pp => pp.name === name);
        if (fa) {
          // year-1 of the (user-set) S&T contract is the outgoing salary for matching
          const sal = m.sntSalary != null ? m.sntSalary : fa.sntAsk;
          items.push({
            ...fa, _kind:"fa", _key:name, _dest:m.dest, _slotIdx:i, _confirmed:m.confirmed,
            salary: sal, sntAsk: sal, sntYears: m.sntYears != null ? m.sntYears : fa.sntYears,
            name: fa.name,
          });
        }
      }
      return items;
    }
    function inOf(i) {
      const owner = slots[i].code;
      const items = [];
      slots.forEach((s, j) => {
        if (j === i) return;
        for (const [name, m] of s.players) {
          if (m.dest !== owner || m.declined) continue;
          const p = (ROSTERS[s.code] || []).find(pp => pp.name === name);
          if (p) items.push({ ...p, _kind:"player", _key:s.code+"-"+name, _from:s.code,
            salary: m.salaryOverride != null ? m.salaryOverride : p.salary });
        }
        for (const [id, m] of s.picks) {
          if (m.dest !== owner) continue;
          const pk = pickFor(s.code, id);
          if (pk) items.push({ ...pk, _kind:"pick", _key:s.code+"-"+id, _from:s.code, salary:0 });
        }
        for (const [name, m] of s.fas) {
          if (m.dest !== owner) continue;
          const fa = (FREE_AGENTS[s.code] || []).find(pp => pp.name === name);
          if (fa) {
            const sal = m.sntSalary != null ? m.sntSalary : fa.sntAsk;
            items.push({ ...fa, _kind:"fa", _key:s.code+"-"+name, _from:s.code,
              salary: sal, sntAsk: sal, sntYears: m.sntYears != null ? m.sntYears : fa.sntYears });
          }
        }
        if ((s.cash?.out || 0) > 0 && s.cash?.dest === owner) {
          items.push({ _kind:"cash", _key:s.code+"-cash", name:"Cash", salary: s.cash.out, _from:s.code });
        }
      });
      return items;
    }
    const out = slots.map((_, i) => outOf(i));
    const inc = slots.map((_, i) => inOf(i));

    /* R3 FLIP — the CANONICAL engine (window.Engine, 513-test verified) is now the
       trade verdict. The local evaluateTeamSide (line ~166) is the V1 PLACEHOLDER,
       kept ONLY as a graceful fallback if the engine/adapter ever fails. Adapter
       mirrors Private/_integration/trade-harness.mjs + adapter-spec §6, reading the
       LIVE slot state + tables (ROSTERS/PICKS/FREE_AGENTS/PRE_SALARY). */
    function engineEvalsOrNull() {
      const E = window.Engine;
      if (!E || typeof E.evaluateTradeFromMovements !== "function") return null;
      try {
        const ST = { guaranteed:"guaranteed", non_guaranteed:"non_guaranteed", team_option:"team_option", player_option:"player_option", two_way:"two_way", under_contract:"guaranteed" };
        const normBird = b => ({ Bird:"full", full:"full", EarlyBird:"early", early:"early", NonBird:"non", non:"non" }[b] || null);   // H2: never pass "n/a" — engine wants full|early|non|null
        const toAsset = p => ({ kind:"player", id:p.name, name:p.name, salary:p.salary, status: ST[p.status] || "guaranteed", noTrade: !!p.noTrade, unlikelyBonus: p.unlikely || 0, years: Array.isArray(p.years) ? p.years : undefined });   // M2: pass years[]
        const unlikelyOf = code => (ROSTERS[code] || []).reduce((a, p) => a + (Number(p.unlikely) || 0), 0);
        // 4c: the YEARS a team CONTROLS a 1st-rounder, as a multiset (engine applies THIS deal's pick
        // movements on top to get post-holdings). From draft_assets.json counts; window starts 2027
        // (2026 drafts now → outside the Stepien horizon); missing/beyond-window years default to OWN
        // (owner: a team holds its own pick unless the data says traded — count 0). 2033-2034 pad covers
        // the horizon (currentDraftYear 2027 + 7). NOTE: doesn't yet net out in-app APPLIED-trade picks
        // (rare); the engine still applies the current deal's pick moves.
        const firstRoundPicksOwnedFor = code => {
          const years = (draftAssets && draftAssets.teams && draftAssets.teams[code] && draftAssets.teams[code].years) || {};
          const ys = [];
          for (let y = 2027; y <= 2034; y++) {
            const yd = years[String(y)];
            // Compound counts e.g. "1+2" mean 3 first-rounders that year — SUM the parts (a bare
            // parseInt stops at the '+' and undercounts → a phantom Stepien gap / false block).
            let n = (yd && yd.firstRound && yd.firstRound.count != null)
              ? String(yd.firstRound.count).split("+").reduce((a, p) => a + (parseInt(p, 10) || 0), 0)
              : 1;   // missing year → own (owner: a team holds its own pick unless the data says traded)
            if (!Number.isFinite(n)) n = 1;
            for (let i = 0; i < n; i++) ys.push(y);
          }
          return ys;
        };
        const visible = slots.filter(s => s && !s.hidden);   // H1: hidden (soft-removed) teams must NOT participate in the engine deal
        const visibleCodes = new Set(visible.map(s => s.code));   // H3: an asset still routed to a removed team is treated as unrouted (skip) — NOT a conservation error
        const teams = {};
        visible.forEach(s => { const pre = PRE_SALARY[s.code] || 0; const apronBase = (PRE_APRON[s.code] != null) ? PRE_APRON[s.code] : pre; teams[s.code] = { code:s.code, preTeamSalary: pre, preApronSalary: apronBase + unlikelyOf(s.code), roster: (ROSTERS[s.code] || []).map(toAsset), tpes: (TPES[s.code] || []), exceptions: deriveExceptions(pre, apronBase, EXC[s.code], CAP_2026),
          cashSentThisYear: (CASH_LEDGER[s.code] && CASH_LEDGER[s.code].sent) || 0,           // 4b: cumulative cash this cap year (engine assumes 0 if omitted → under-counts the 5.15% bucket)
          cashReceivedThisYear: (CASH_LEDGER[s.code] && CASH_LEDGER[s.code].received) || 0,
          isSecondApronTeam: ((apronBase + unlikelyOf(s.code)) > CAP_2026.apron2),               // 4c: drives the frozen-pick default [draftYear+7]; uses the SAME apron measure as preApronSalary (incl. unlikely)
          firstRoundPicksOwned: firstRoundPicksOwnedFor(s.code),                              // 4c: Stepien inventory (silently flagged if a 1st moves but this is absent)
          isOffseason: true,                                                                  // 4b: launch is off-season only → 21-incl-two-ways roster limit
        }; });   // exceptions: room-vs-over-cap gated on PRE_SALARY (capBase, holds-IN, §6(n)); MLE-type gated on apronBase (PRE_APRON, holds-FREE, §2(e)(1)(iv)); tpes = PROVISIONAL per-team trade-acquisition capacity (teams-exceptions.json)
        const movements = [];
        visible.forEach(s => {
          const code = s.code;
          for (const [name, m] of s.players) {
            if (m.declined || !m.dest || !visibleCodes.has(m.dest)) continue;   // H3: dest team was removed → treat as unrouted
            if (m.salaryOverride != null) {                                   // M1: keep noTrade/status/unlikely — override only the salary
              const base = (ROSTERS[code] || []).find(p => p.name === name);
              const a = base ? Object.assign(toAsset(base), { salary: m.salaryOverride }) : { kind:"player", id:name, name, salary:m.salaryOverride, status:"guaranteed" };
              movements.push({ asset:a, from:code, to:m.dest });
            } else movements.push({ asset:name, from:code, to:m.dest });
          }
          for (const [id, m] of s.picks) {
            if (!m.dest || !visibleCodes.has(m.dest)) continue;   // H3
            const pk = pickFor(code, id);
            if (pk) movements.push({ asset:{ kind:"pick", round:pk.round, year:pk.year, name:pk.label }, from:code, to:m.dest });
          }
          for (const [name, m] of s.fas) {
            if (!m.dest || !visibleCodes.has(m.dest)) continue;   // H3
            const fa = (FREE_AGENTS[code] || []).find(p => p.name === name);
            const sal = m.sntSalary != null ? m.sntSalary : (fa ? fa.sntAsk : 0);
            const _bird = normBird(fa && fa.bird);
            // 4e: enrich the S&T asset so the engine applies BYC (§6(j)(5)) on the SENDER's outgoing
            // matching value + validates construction (§8(e)(1)). Without these the asset matched at FULL
            // salary (BYC never fired). teamOverCapAfterSigning = "Team Salary immediately after the
            // signing, before the trade" (§6(j)(5)(y)): replace the FA's cap hold (in capBase/PRE_SALARY,
            // holds-IN) with the new first-year salary. undefined when capBase is unknown → engine keeps
            // its flag path rather than a silent "not over cap".
            const _capBase = PRE_SALARY[code];
            const _overCapAfter = Number.isFinite(_capBase)
              ? (_capBase - ((fa && Number.isFinite(fa.capHold)) ? fa.capHold : 0) + sal) > CAP_2026.cap
              : undefined;
            // engine BYC/apron fix (2026-06-04): the §6(b)(2)(ii) 120%-of-minimum prong needs the player's
            // minimum for his YOS (from meta.minScale); the base-post needs the FA's pre-trade cap HOLD.
            const _meta = (typeof window !== "undefined" && window.__tmData && window.__tmData.meta) || {};
            const _yos = (fa && fa.exp != null) ? fa.exp : null;
            const _minForYos = (_meta.minScale && _yos != null) ? Number(_meta.minScale[Math.min(Math.max(_yos, 0), 10)]) : undefined;
            const asset = { kind:"player", id:name, name, salary:sal, isSignAndTrade:true,
              birdRights: _bird,
              priorSalary: (fa && Number.isFinite(fa.lastSalary)) ? fa.lastSalary : undefined,   // §6(j)(5)(i) BYC prong + threshold
              yos: (fa && fa.exp != null) ? fa.exp : undefined,                                  // maxSalary tier in the BYC threshold
              minForYos: Number.isFinite(_minForYos) ? _minForYos : undefined,                   // §6(b)(2)(ii) BYC 120%-of-min prong (fixes over-trigger for low/zero-prior FAs)
              unlikelyBonus: 0,   // §8(e)(1)(vii): S&T acquirer-Room gate reads the NEW contract's yr1 Unlikely Bonuses (distinct from priorUnlikelyBonus below). No unlikely-bonus input in the S&T flow → 0; WITHOUT this the engine fail-closes every S&T.
              priorUnlikelyBonus: (fa && Number.isFinite(fa.priorUnlikelyBonus)) ? fa.priorUnlikelyBonus : undefined,   // §6(b)(2)(i) BYC: prior-year UNLIKELY bonus only (likely already in priorSalary; Data-Op pending → engine flags)
              priorHold: (fa && Number.isFinite(fa.capHold)) ? fa.capHold : undefined,           // base-post: the FA's pre-trade cap hold (S&T nets the hold, not the new salary)
              teamOverCapAfterSigning: _overCapAfter,                                            // §6(j)(5)(y) BYC gate
              signAndTrade: {
                years: m.sntYears != null ? m.sntYears : (fa ? fa.sntYears : 3),
                firstYearSalary: sal,                       // §8(e)(1)(vi) 25%-of-cap ceiling test
                firstYearFullyProtected: true,              // S&T yr1 is fully protected by construction (§8(e)(1)(iv)) — the app only builds legal ones
                finishedPriorSeasonOnRoster: true,          // own FA (sourced from THIS team's FREE_AGENTS) → true by construction (§8(e)(1)(i))
                exceptionUsed: _bird,                       // own-FA S&T signs via Bird/Early/Non-Bird rights, never NT/Room-MLE → engine MLE-bar not tripped
                higherMaxEligible: !!(fa && fa.designated),  // 4e: §8(e)(1)(vi) — a Rose 5th-yr-eligible (designated) player's S&T yr1 is capped at 25% of the cap
              } };
            teams[code].roster.push(asset);   // S&T FA isn't on the roster → add so engine ownership resolves
            movements.push({ asset, from:code, to:m.dest });
          }
          if ((s.cash?.out || 0) > 0 && s.cash?.dest && visibleCodes.has(s.cash.dest)) movements.push({ asset:{ kind:"cash", salary:s.cash.out }, from:code, to:s.cash.dest });   // H3: cash to a removed team → skip
        });
        // 4b: POST-trade roster counts (the engine SILENTLY skips the 15/21/3 size checks if these are
        // absent). Pre counts from the shared derived selector; delta from this deal's player moves —
        // a regular outgoing player frees a body, but an outgoing S&T FA was a cap hold, not a body.
        // Two-ways are excluded from trades, so twoWayCount is carried through unchanged.
        const isPlayerAsset = a => typeof a === "string" || (a && a.kind === "player");
        const isSnTAsset = a => a && typeof a === "object" && a.isSignAndTrade === true;
        visible.forEach(s => {
          const pre = (typeof derivedByTeam !== "undefined" && derivedByTeam && derivedByTeam[s.code]) || {};
          let inN = 0, outN = 0;
          for (const m of movements) {
            if (!isPlayerAsset(m.asset)) continue;
            if (m.to === s.code) inN++;
            if (m.from === s.code && !isSnTAsset(m.asset)) outN++;
          }
          // #3: if the pre-count is unavailable, LEAVE rosterCountAfter undefined so the engine SKIPS
          // the size check — don't fabricate it from 0 (which would fail-OPEN a real over-roster).
          if (Number.isFinite(pre.rosterCount)) teams[s.code].rosterCountAfter = pre.rosterCount + inN - outN;
          if (Number.isFinite(pre.twoWayCount)) teams[s.code].twoWayCountAfter = pre.twoWayCount;
        });
        if (!movements.length) return null;
        const res = E.evaluateTradeFromMovements({ teams, movements }, E.CBA_2026_27, { currentDraftYear: 2027 });   // 4b: anchor the Stepien/frozen-pick draft year (2026 drafts now)
        if (!res || !Array.isArray(res.sides)) return null;
        const consvOk = !res.conservation || res.conservation.ok;
        return slots.map(s => {
          if (s.hidden) return { code:s.code, legal:true, reasons:[], flags:[], O:0, I:0, post: PRE_SALARY[s.code] || 0 };   // hidden slot → neutral pass (kept for index alignment)
          const side = res.sides.find(x => x.code === s.code);
          if (!side) return { code:s.code, legal:true, reasons:[], flags:[], O:0, I:0, post: PRE_SALARY[s.code] || 0 };
          // Drop the engine's full-number hard-cap flag ("Hard-capped at the … ($221,737,000).") — the app re-adds
          // it in compact $M just below, so keeping both showed the line TWICE (owner-caught 2026-06-05).
          const flags = (side.flags || []).filter(f => !/^Hard-capped at the/.test(f));
          (side.hardCaps || []).forEach(hc => flags.push(hc === "firstApron" ? `Hard-capped at the 1st apron (${fmt$(CAP_2026.apron1)}).` : hc === "secondApron" ? `Hard-capped at the 2nd apron (${fmt$(CAP_2026.apron2)}).` : "Hard cap: " + hc));
          if (!consvOk) (res.conservation.issues || []).forEach(iss => flags.push("⚠ " + iss));
          let legal = !!side.legal && !side.indeterminate && consvOk;
          // 4d enforcement: a PRIOR stored hard cap (from an earlier applied trade or an
          // NT-MLE/BAE/TP-MLE signing) blocks any move whose post-trade Apron Team Salary
          // (holds-free + unlikely = side.post) would exceed that apron ceiling. Owner: block all illegal.
          const _storedAt = HARDCAPS[s.code];
          if (_storedAt && Number.isFinite(side.post)) {
            const _ceil = _storedAt === "firstApron" ? CAP_2026.apron1 : CAP_2026.apron2;
            if (side.post > _ceil + 0.5) {
              legal = false;
              flags.push(`⛔ Hard-capped at the ${_storedAt === "firstApron" ? "1st" : "2nd"} apron (${fmt$(_ceil)}) — this trade would push you to ${fmt$(side.post)}.`);
            }
          }
          return { code:s.code, legal, reasons: (side.reasons || []).slice(), flags, hardCaps: (side.hardCaps || []).slice(), O: side.O, I: side.I, post: side.postBase != null ? side.postBase : side.post, outValues: (side.outValues || []).slice(), bankedTpes: (side.bankedTpes || []).slice(), methods: (side.methods || []).slice(), maxIncoming: side.maxIncoming || 0 };
        });
      } catch (e) {
        window.__engineTradeFallback = (e && e.message) || true;
        if (window.console) console.error("[engine trade] error:", e && e.message);
        // 4a: the V1 placeholder fallback ONLY does salary matching — it ignores picks, cash, and
        // sign-and-trade. So if the engine throws on a deal carrying ANY of those, do NOT fall through
        // to a lenient verdict — hard-FAIL it (fail-closed). Pure player-for-player may still fall back.
        let nonVanilla = false;
        try { nonVanilla = slots.some(s => s && !s.hidden && (((s.cash && s.cash.out) > 0) || (s.picks && s.picks.size > 0) || (s.fas && s.fas.size > 0))); }
        catch (_) { nonVanilla = true; }
        if (nonVanilla) return slots.map(s => ({ code:s.code, legal:false, reasons:["Engine unavailable — can't certify a trade with picks, cash, or a sign-and-trade."], flags:[], O:0, I:0, post: PRE_SALARY[s.code] || 0 }));
        return null;
      }
    }

    const localEvals = slots.map((s, i) => {
      const tpeUsed = [...(s.tpes || new Map()).keys()].reduce(
        (a, id) => a + ((TPES[s.code] || []).find(t => t.id === id)?.amount || 0), 0);
      return evaluateTeamSide({
        code: s.code,
        out: out[i].filter(x => x._kind === "player" || x._kind === "fa"),
        in:  inc[i].filter(x => x._kind === "player" || x._kind === "fa"),
        preSalary: PRE_SALARY[s.code] || 0,
        cashOut: s.cash?.out || 0,
        cashIn:  inc[i].filter(x => x._kind === "cash").reduce((a,x)=>a+x.salary,0),
        tpeUsed,
      });
    });
    const evals = engineEvalsOrNull() || localEvals;
    const anyActivity = out.some(o => o.length) || inc.some(o => o.length)
      || slots.some(s => (s.cash?.out || 0) > 0);
    const allOk = evals.every(e => e.legal);
    const toRoute = slots.length > 2
      ? out.flat().filter(x => x._confirmed === false).length : 0;
    return { out, inc, evals, anyActivity, legal: anyActivity && allOk, toRoute };
    // dep on realTables too: out/inc read the module-global ROSTERS/FREE_AGENTS/PICKS
    // (rebuilt from `state`), so an edit that re-buckets a player via SET_DECISION /
    // ADD_PLAYER / mode-switch (without changing `slots` identity) must still refresh
    // the outgoing/incoming strip. (Adding a dep only adds recomputes — safe.)
  }, [slots, realTables]);

  // P3 — SERVER PATH (off-by-default). Build the Worker request (same memo deps as the local verdict), then
  // OVERRIDE the local verdict with the server's when USE_WORKER(). Flag off → `derived` === `localDerived`.
  const payload = useM(() => (USE_WORKER() ? buildTradeVerificationPayload(slots) : null), [slots, realTables]);
  const derived = mergeServerVerdict(localDerived, payload, tradeVerify);
  // Debounced fetch with a stale-guard (ignore a response whose key no longer matches) + fail-closed errors.
  useE(() => {
    if (!USE_WORKER()) return;
    const key = payload && payload.key;
    if (!key) { setTradeVerify(v => (v.status === "idle" ? v : { key: null, status: "idle", evals: null, error: null })); return; }
    let aborted = false;
    const ctrl = new AbortController();
    setTradeVerify(v => ({ ...v, key, status: "pending" }));
    const timer = setTimeout(() => {
      fetch(WORKER_URL() + "/eval/trade", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload.request), signal: ctrl.signal })
        .then(r => r.json().then(j => ({ status: r.status, j })))
        .then(({ status, j }) => {
          if (aborted) return;
          if (status === 409) { setTradeVerify({ key, status: "stale", evals: null, error: "Engine updated — reload the page." }); return; }
          if (status !== 200 || !j || !j.result) { setTradeVerify({ key, status: "error", evals: null, error: (j && j.error) || ("Server error (" + status + ")") }); return; }
          const exp = CLIENT_ENGINE_VERSION();
          if (exp && j.serverEngineVersion && exp !== j.serverEngineVersion) { setTradeVerify({ key, status: "stale", evals: null, error: "Engine version mismatch — reload." }); return; }
          setTradeVerify({ key, status: "ok", evals: mapWorkerSidesToEvals(j.result), error: null });
        })
        .catch(e => { if (!aborted) setTradeVerify({ key, status: "error", evals: null, error: (e && e.message) || "Network error" }); });
    }, 350);
    return () => { aborted = true; ctrl.abort(); clearTimeout(timer); };
  }, [payload && payload.key]);

  const pendingPicksByTeam = {};
  const movedPickIdsByTeam = {};
  slots.forEach((s, i) => {
    if (!s) return;
    pendingPicksByTeam[s.code] = (derived.inc[i] || []).filter(x => x && x._kind === "pick");
    const moved = new Set();
    for (const [id, m] of (s.picks || new Map())) {
      if (m && m.dest && m.dest !== s.code) moved.add(id);
    }
    movedPickIdsByTeam[s.code] = moved;
  });

  /* ---------------- mutators ---------------- */
  function patchSlot(i, fn) { markMutate(); setSlots(prev => prev.map((s, idx) => idx === i ? fn(s) : s)); }
  // Hub-spoke default destination. 1 other team → that team (zero-click 2-team trade).
  // 3+ teams → pinned/first team sends to the last-added team; every other team sends
  // to the pinned team. (Routing is still one tap to change via the auto-opened menu.)
  function defaultDest(c) {
    const others = codes.filter(x => x !== c);
    if (others.length === 0) return null;
    if (others.length === 1) return others[0];
    // #4 sticky-last: if this source already routed a player somewhere (still a
    // valid partner) AND no team has been added since, stick to that destination.
    const last = lastDestRef.current[c];
    if (last && others.includes(last) && (routeSeqRef.current[c] || 0) >= addSeqRef.current)
      return last;
    return c === codes[0] ? codes[codes.length - 1] : codes[0];
  }
  // In multi-team, a freshly-added asset starts UNCONFIRMED (tentative cue + auto-open
  // menu). In a 2-team deal the dest is forced, so it's confirmed on the spot.
  function togglePlayer(i, p) {
    const has = slots[i]?.players.has(p.name);
    patchSlot(i, s => {
      const m = new Map(s.players);
      if (m.has(p.name)) m.delete(p.name);
      else { const d = defaultDest(s.code); noteDest(s.code, d); m.set(p.name, { dest: d, confirmed: !multi }); }
      return { ...s, players: m };
    });
    if (!has && multi) setPendingDest({ i, kind: "player", key: p.name });
  }
  function togglePick(i, pk) {
    const has = slots[i]?.picks.has(pk.id);
    patchSlot(i, s => {
      const m = new Map(s.picks);
      if (m.has(pk.id)) m.delete(pk.id);
      else { const d = defaultDest(s.code); noteDest(s.code, d); m.set(pk.id, { dest: d, confirmed: !multi }); }
      return { ...s, picks: m };
    });
    if (!has && multi) setPendingDest({ i, kind: "pick", key: pk.id });
  }
  function toggleFa(i, fa) {
    const has = slots[i]?.fas.has(fa.name);
    patchSlot(i, s => {
      const m = new Map(s.fas);
      if (m.has(fa.name)) m.delete(fa.name);
      else { const d = defaultDest(s.code); noteDest(s.code, d); m.set(fa.name, { dest: d, confirmed: !multi }); }
      return { ...s, fas: m };
    });
    if (!has && multi) setPendingDest({ i, kind: "fa", key: fa.name });
  }
  function setCash(i, patch) {
    patchSlot(i, s => {
      const dest = s.cash.dest || defaultDest(s.code);
      noteDest(s.code, dest);
      return { ...s, cash: { ...s.cash, ...patch, dest } };
    });
  }
  function toggleTpe(i, tpe) {
    patchSlot(i, s => {
      const m = new Map(s.tpes || new Map());
      if (m.has(tpe.id)) m.delete(tpe.id); else m.set(tpe.id, {});
      return { ...s, tpes: m };
    });
  }
  // Trade-local player edits (option salary / decline) — does NOT touch main
  // app state; only how the player is valued/treated in THIS trade.
  function setPlayerMeta(i, name, patch) {
    patchSlot(i, s => {
      const m = new Map(s.players);
      const cur = m.get(name) || { dest: defaultDest(s.code) };
      m.set(name, { ...cur, ...patch });
      return { ...s, players: m };
    });
  }
  // Add/update a free agent in the trade with a user-set S&T contract.
  function commitFa(i, fa, patch) {
    patchSlot(i, s => {
      const m = new Map(s.fas);
      const cur = m.get(fa.name) || { dest: defaultDest(s.code) };
      m.set(fa.name, { ...cur, ...patch });
      return { ...s, fas: m };
    });
  }
  function setDest(i, kind, key, newDest) {
    patchSlot(i, s => {
      noteDest(s.code, newDest);   // #4: a manual pick becomes this source's sticky default
      if (kind === "cash") return { ...s, cash: { ...s.cash, dest: newDest, confirmed: true } };
      const map = kind === "player" ? "players" : kind === "pick" ? "picks" : "fas";
      const m = new Map(s[map]);
      if (m.has(key)) m.set(key, { ...m.get(key), dest: newDest, confirmed: true });
      return { ...s, [map]: m };
    });
  }
  // Acknowledge a dest without changing it (menu dismissed) → clears the tentative cue.
  function confirmDest(i, kind, key) {
    patchSlot(i, s => {
      if (kind === "cash") return { ...s, cash: { ...s.cash, confirmed: true } };
      const map = kind === "player" ? "players" : kind === "pick" ? "picks" : "fas";
      const m = new Map(s[map]);
      if (m.has(key)) m.set(key, { ...m.get(key), confirmed: true });
      return { ...s, [map]: m };
    });
  }
  function closeDestPop() {
    if (destPop && destPop.kind) confirmDest(destPop.slotIdx, destPop.kind, destPop.key);
    setDestPop(null);
  }
  // #3 (round 8): soft-remove via a `hidden` flag. Adding either UN-hides a parked
  // slot (restoring its assets + moving it to the end = the reorder mechanism) or
  // appends a fresh one. A hidden slot stays in `slots` so its data — and every
  // other team's routes to it — survive untouched until the ✓ commits the purge.
  function addTeam(code) {
    if (slots.some(s => s.code === code && !s.hidden)) return;        // already visible
    if (slots.filter(s => !s.hidden).length >= 6) return;            // 6 visible max
    addSeqRef.current = (seqRef.current += 1);  // #4: a team was added → sticky defaults reset to hub-spoke
    const hiddenIdx = slots.findIndex(s => s.code === code && s.hidden);
    if (hiddenIdx >= 0) {
      setSlots(prev => [...prev.filter((_, idx) => idx !== hiddenIdx),
                        { ...prev[hiddenIdx], hidden: false }]);
      setTabs(prev => [...prev.filter((_, idx) => idx !== hiddenIdx),
                       prev[hiddenIdx] || "players"]);
    } else {
      setSlots(prev => [...prev, emptySlot(code)]);
      setTabs(prev => [...prev, "players"]);
    }
    if (slots.filter(s => !s.hidden).length + 1 >= 6) setAdding(false);   // #4E: auto-close at 6
  }
  // Close the picker → COMMIT: purge any still-hidden slots (and their assets) for good.
  function closePicker() {
    setAdding(false);
    const keptLen = slots.filter(s => !s.hidden).length;
    setTabs(prev => prev.filter((_, idx) => !slots[idx]?.hidden));
    setSlots(prev => prev.filter(s => !s.hidden));
    setActiveSlot(s => Math.max(0, Math.min(s, keptLen - 1)));
  }
  // #4: one picker button (+ / ✎ / ✓) toggles the multi-select panel; closing lands on a real team
  function togglePicker() {
    if (adding) closePicker();
    else setAdding(true);
  }
  // Soft-remove: hide the column but keep the slot (data + routes preserved). slots.length
  // is unchanged so the normalize effect never reroutes the remaining teams' destinations.
  function removeTeam(i) {
    if (i === 0) return;
    setSlots(prev => prev.map((s, idx) => idx === i ? { ...s, hidden: true } : s));
    setActiveSlot(a => {
      if (a !== i) return a;
      const vis = slots.map((s, idx) => ({ idx, hidden: s.hidden }))
                       .filter(x => !x.hidden && x.idx !== i).map(x => x.idx);
      return vis.length ? vis[vis.length - 1] : 0;
    });
  }
  function clearAll() {
    setSlots(prev => prev.map(s => ({
      ...s, players: new Map(), picks: new Map(),
      cash: { out: 0, dest: defaultDest(s.code) }, tpes: new Map(), fas: new Map(),
    })));
    flash("Trade cleared.");
  }
  function flash(msg) { setToast(msg); setTimeout(() => setToast(null), 2200); }
  function setTab(i, k) { setTabs(prev => prev.map((t, idx) => idx === i ? k : t)); }
  function setTweak(k, v) { setTweaks(t => ({ ...t, [k]: v })); }

  function openDrawer(slotIdx, kind, player) {
    markMutate();
    const slot = slots[slotIdx];
    if (kind === "fa") {
      const meta = slot && slot.fas.get(player.name);
      const draft = {
        action: "snt",
        salary: meta && meta.sntSalary != null ? meta.sntSalary : player.sntAsk,
        years: meta && meta.sntYears != null ? meta.sntYears : (player.sntYears || 3),
        ratePct: 5,
        adding: !meta,   // FA not yet in the deal → Apply ADDS it
      };
      setDrawer({ slotIdx, kind, player, draft });
    } else {
      const meta = slot && slot.players.get(player.name);
      const isOption = player.status === "player_option" || player.status === "team_option";
      const draft = {
        action: isOption ? "optin" : "sign",
        salary: meta && meta.salaryOverride != null ? meta.salaryOverride : player.salary,
        years: (player.years && player.years.length) || 1,
        ratePct: 5,
      };
      setDrawer({ slotIdx, kind, player, draft });
    }
  }
  function changeDrawer(p) { markMutate(); setDrawer(d => d ? { ...d, draft: { ...d.draft, ...p } } : d); }
  function applyDrawer() {
    markMutate();
    const d = drawer;
    if (!d) return;
    const a = d.draft.action;
    // Round 10 #4a: Sign drops the player from this trade too — signed players can't
    // be traded for a few months; S&T is the only way to move them now. (The
    // contract itself isn't persisted to the main roster yet — that's the deferred
    // apply-trade feature; for now we surface the memo via flash.)
    const dropFromTrade = (a === "renounce" || a === "hold" || a === "sign");
    const yrs = d.draft.years || 1;
    const signMemo = `${d.player.name}: signed (${fmt$(d.draft.salary || 0)}${yrs > 1 ? ` · ${yrs}y` : ""}). Signed players can't be traded for a few months — pick S&T to trade now.`;
    // Re-signed (signed) player — let the user change their mind to ANY of the FA's
    // original options. "Sign" updates the re-sign; everything else first UNDOES the
    // re-sign (REMOVE_ADDITION) then applies the chosen action as if editing the FA:
    //   • S&T  → re-add as an outgoing sign-and-trade piece (the legal way to move a
    //            just-signed player — CBA 3-month/Dec-15 restriction);
    //   • Renounce / Hold → write the FA decision so it re-buckets to that group.
    if (d.player._resigned) {
      const reSal = Math.max(0, Math.round(d.draft.salary || d.player._reSalary || 0));
      if (a === "sign") {
        const reP = { name: d.player.name, birdRights: d.player.bird === "full" ? "Bird" : d.player.bird === "early" ? "EarlyBird" : "NonBird", yearsOfExperience: d.player.exp, priorSeasonSalary: d.player.lastSalary };   // FA object uses exp/lastSalary (NOT yos/prior) — wrong names collapsed the Non-Bird rights ceiling to $0, false-blocking every TM re-sign
        if (typeof window.reSignBlocked === "function" && window.reSignBlocked(reP, { salary: reSal, years: d.draft.years || d.player._reYears || 1 }, dispatch)) return;
        if (dispatch && d.player._addId)
          dispatch({ type: "PATCH_ADDITION", id: d.player._addId, patch: { salary: reSal, years: d.draft.years || d.player._reYears || 1 } });
        flash(`${d.player.name}: re-signed${reSal ? ` (${fmt$(reSal)})` : ""}.`);
      } else {
        if (dispatch && d.player._addId) dispatch({ type: "REMOVE_ADDITION", id: d.player._addId });
        if (a === "snt") {
          commitFa(d.slotIdx, { name: d.player.name }, {
            sntSalary: reSal, sntYears: Math.max(3, d.draft.years || d.player._reYears || 3), adding: true,
          });
          flash(`${d.player.name}: switched to sign-and-trade.`);
        } else if ((a === "renounce" || a === "hold") && dispatch) {
          dispatch({ type: "SET_DECISION", player: d.player.name, decision: { kind: a === "renounce" ? "renounced" : "kept-hold" } });
          flash(`${d.player.name}: ${a === "renounce" ? "renounced" : "held"}.`);
        }
      }
      setDrawer(null);
      return;
    }
    if (d.kind === "fa") {
      if (dropFromTrade) {
        if (slots[d.slotIdx] && slots[d.slotIdx].fas.has(d.player.name)) toggleFa(d.slotIdx, d.player);
        // one-world #14: PERSIST a renounce/hold of the MANAGED team's OWN FA into the
        // shared store (SET_DECISION targets the managed team) so the FA re-buckets to
        // the Renounced / Hold group HERE and on the roster page — previously it only
        // dropped from the trade + flashed a memo, so it never moved. (Opponent FAs
        // can't be renounced from the TM — SET_DECISION only writes the managed team —
        // so they keep the drop-from-trade-only behavior.)
        const ownFa = slots[d.slotIdx] && slots[d.slotIdx].code === managedCode;
        if (dispatch && ownFa && (a === "renounce" || a === "hold"))
          dispatch({ type: "SET_DECISION", player: d.player.name, decision: { kind: a === "renounce" ? "renounced" : "kept-hold" } });
        // S&T -> roster: PERSIST the re-sign so the FA actually LANDS on the roster as an
        // addition (mirrors the Sign-FA modal's ADD_PLAYER) instead of only flashing a memo
        // and reverting to a candidate. buildTradeTables appends additions to ROSTERS and
        // now filters re-signed names out of the FA candidate list, so it re-buckets live.
        if (ownFa && a === "sign") {
          const reP = { name: d.player.name, birdRights: d.player.bird === "full" ? "Bird" : d.player.bird === "early" ? "EarlyBird" : "NonBird", yearsOfExperience: d.player.exp, priorSeasonSalary: d.player.lastSalary };   // FA object uses exp/lastSalary (NOT yos/prior) — wrong names collapsed the Non-Bird rights ceiling to $0, false-blocking every TM re-sign
          if (typeof window.reSignBlocked === "function" && window.reSignBlocked(reP, { salary: Math.max(0, Math.round(d.draft.salary || 0)), years: d.draft.years || 1 }, dispatch)) return;
        }
        if (dispatch && ownFa && a === "sign")
          dispatch({ type: "ADD_PLAYER", player: {
            id: "resign-" + d.player.name.replace(/\W+/g, ""),
            name: d.player.name,
            salary: Math.max(0, Math.round(d.draft.salary || 0)),
            years: d.draft.years || 1,
            position: d.player.position || "",
            source: "Re-sign",
            nbaId: d.player.nbaId != null ? String(d.player.nbaId) : null,
            bird: d.player.bird || undefined,
          } });
        flash(a === "sign"
              ? (ownFa ? `${d.player.name}: re-signed onto the roster (${fmt$(d.draft.salary || 0)}${yrs > 1 ? ` · ${yrs}y` : ""}). Can't be traded for a few months — use S&T to trade now.`
                       : signMemo)
              : ownFa ? `${d.player.name}: ${a === "renounce" ? "renounced" : "held for trade"}.`
              : `${d.player.name}: ${a === "renounce" ? "renounced" : "held"} — not in this trade.`);
      } else {
        commitFa(d.slotIdx, d.player, {
          sntSalary: Math.max(0, Math.round(d.draft.salary || 0)), sntYears: d.draft.years,
        });
        flash(`${d.player.name}: sign-and-trade ${fmt$(d.draft.salary || 0)} · ${d.draft.years}y${d.draft.adding ? " — added" : ""}.`);
      }
    } else {
      if (dropFromTrade) {
        if (slots[d.slotIdx] && slots[d.slotIdx].players.has(d.player.name)) togglePlayer(d.slotIdx, { name: d.player.name });
        // one-world #14: persist a renounce/hold of the MANAGED team's own OPTION
        // player (player_option/team_option) so it re-buckets off the roster — same
        // as the FA case. (under_contract can't be renounced; opponents can't be
        // written via SET_DECISION, which only targets the managed team.)
        const isOpt = d.player.status === "player_option" || d.player.status === "team_option";
        const ownOpt = isOpt && slots[d.slotIdx] && slots[d.slotIdx].code === managedCode;
        if (dispatch && ownOpt && (a === "renounce" || a === "hold"))
          dispatch({ type: "SET_DECISION", player: d.player.name, decision: { kind: a === "renounce" ? "renounced" : "kept-hold" } });
        flash(a === "sign" ? signMemo
              : ownOpt ? `${d.player.name}: ${a === "renounce" ? "renounced" : "held"}.`
              : `${d.player.name}: ${a === "renounce" ? "renounced" : "held"} — not in this trade.`);
      } else {
        // opt-in / snt — both keep the player in the trade at the entered salary
        const sal = a === "optin" ? d.player.salary : d.draft.salary;
        setPlayerMeta(d.slotIdx, d.player.name, { salaryOverride: Math.max(0, Math.round(sal || 0)), declined: false });
        flash(`${d.player.name}: ${a === "optin" ? "opt in" : "sign-and-trade"} ${fmt$(sal || 0)}.`);
      }
    }
    setDrawer(null);
  }
  /* R12 — APPLY_TRADE: build a full trade record from the current TM state,
     then dispatch to the global reducer. The reducer folds the modifications
     into every participating team's apron AND cap buckets so the overlay
     survives mode/team switches. The Undo button in the top bar can pop the
     most-recent trade. */
  function applyTrade() {
    if (!derived.legal) return;
    if (!dispatch) { flash("Apply unavailable — dispatch missing."); return; }

    const visSlots = slots.filter(s => !s.hidden);
    const codeForVisIdx = (vi) => visSlots[vi] && visSlots[vi].code;
    const visCodes = new Set(visSlots.map(v => v.code));   // 4e/4f fix: ignore routes to soft-removed (hidden) teams (mirror engineEvalsOrNull) — else _outCount inflates (suppresses a TPE) + a player can vanish from state
    const tradeId = `t-${Date.now()}`;

    // For each visible slot, gather its outgoing assets + incoming assets.
    const tradeSlots = visSlots.map((s, vi) => {
      const code = s.code;
      // Outgoing players → resolve destCode
      const outPlayers = Array.from(s.players.entries()).filter(([name, meta]) => meta.dest && visCodes.has(meta.dest)).map(([name, meta]) => {
        const src = (ROSTERS[code] || []).find(p => p.name === name);
        return {
          name, destCode: meta.dest || null,
          salaryOverride: meta.salaryOverride,
          sourcePlayer: src ? {
            name: src.name, position: src.position,
            salary: meta.salaryOverride != null ? meta.salaryOverride : src.salary,
            years: src.years || [src.salary || 0],
            nbaId: src.nbaId, bird: src.bird,
          } : null,
        };
      });
      // Outgoing FAs (S&T) → snapshot from FREE_AGENTS
      const outFas = Array.from(s.fas.entries()).filter(([name, meta]) => meta.dest && visCodes.has(meta.dest)).map(([name, meta]) => {
        const src = (FREE_AGENTS[code] || []).find(f => f.name === name);
        return {
          name, destCode: meta.dest || null,
          sntSalary: meta.sntSalary != null ? meta.sntSalary : (src ? src.sntAsk : 0),
          sntYears: meta.sntYears || (src ? src.sntYears : 3),
          sourcePlayer: src ? { ...src } : null,
        };
      });
      // Outgoing picks
      const outPicks = Array.from(s.picks.entries()).filter(([id, meta]) => meta.dest && visCodes.has(meta.dest)).map(([id, meta]) => ({
        id, destCode: meta.dest || null,
      }));
      // Incoming — read from derived.inc. NOTE: derived.inc is indexed by the FULL slots array
      // (incl. hidden slots), but `vi` is the VISIBLE index — a hidden slot before this one would
      // offset the lookup to the wrong team. Use the true slot position (adversarial finding 2026-06-04).
      const incoming = (derived.inc[slots.indexOf(s)] || []).map(it => {
        if (it._kind === "player") {
          return { kind: "player",
            name: it.name, salary: it.salary,
            years: Array.isArray(it.years) ? it.years.length : (it.years || (it.salary ? 1 : 0)),  // it.years is a per-season SALARY array; the addition row renders a COUNT (was showing the concatenated array → "trillions")
            position: it.position, fromCode: it._from,
            sourcePlayer: { name: it.name, salary: it.salary, position: it.position,
                            years: it.years || [it.salary || 0],
                            nbaId: it.nbaId, bird: it.bird },
          };
        } else if (it._kind === "fa") {
          return { kind: "fa",
            name: it.name, salary: it.sntSalary || it.sntAsk || 0,
            years: it.sntYears || 3,
            position: it.pos, fromCode: it._from,
            sourcePlayer: { ...it },
          };
        } else if (it._kind === "pick") {
          return { kind: "pick",
            id: it.id, label: it.label, year: it.year, round: it.round,
            fromCode: it._from };
        } else if (it._kind === "cash") {
          return { kind: "cash", amount: it.salary, fromCode: it._from };
        }
        return null;
      }).filter(Boolean);

      const ev = (derived.evals || []).find(e => e.code === code);
      // 4f: TPE CREATION (§6(j)(1)(i), §8.1). Each outgoing player NOT used to absorb incoming salary
      // banks his OWN Standard TPE = his outgoing MATCH value. Gates (adversarial panel 2026-06-05):
      //  • _legal: NEVER bank from an illegal trade (fixes a latent hole — the old single-player path
      //    had no legality check, so an illegal trade could bank a phantom TPE).
      //  • amount comes from the ENGINE's per-player ev.outValues (haircut-correct: unguaranteed→0,
      //    two-way excluded, BYC), NEVER raw UI salary (which would OVER-GRANT on a haircut player).
      //  • fail-closed: a $0-value player banks nothing (value>0 filter).
      const _outCount = outPlayers.length + outFas.length;
      const _outNames = [...outPlayers.map(o => o.name), ...outFas.map(f => f.name)].filter(Boolean);
      // Incoming Team Salary on THIS side (picks/cash carry none). Zero ⇒ a pure salary dump.
      const _incSalary = (incoming || []).reduce((a, it) => a + ((it.kind === "player" || it.kind === "fa") ? (it.salary || 0) : 0), 0);
      const _legal = !!(ev && ev.legal);
      let createdTpe = null;    // legacy single-object path (single-player undermatch / single S&T-out)
      let createdTpes = null;   // 4f: per-player ARRAY (pure standard-player dump, zero incoming)
      if (_legal && _incSalary === 0 && Array.isArray(ev.outValues) && ev.outValues.length) {
        // PURE DUMP (no incoming salary): one Standard TPE per outgoing player. Each is a single
        // un-aggregated player → fromAggregation:false. Provenance is PER-PLAYER: an outgoing SIGN-AND-TRADE
        // banks with fromSnt:true (Row J → 2nd-apron on later use); a standard player fromSnt:false. Amount
        // from ev.outValues (engine, haircut-correct incl. the S&T BYC value).
        const _per = ev.outValues
          .filter(v => v && Number.isFinite(v.value) && v.value > 0)
          .map((v, i) => ({ id: `ctpe-${tradeId}-${code}-${i}`, amount: Math.round(v.value),
                            source: v.name || "trade", fromAggregation: false, fromSnt: !!v.fromSnt }));
        if (_per.length) createdTpes = _per;
      } else if (_legal && _incSalary > 0 && _outCount >= 2 && Array.isArray(ev.bankedTpes) && ev.bankedTpes.length) {
        // 4f: MULTI-OUT WITH INCOMING (matching-only) → read the engine's decomposition-aware bankedTpes
        // for the chosen method: a fully-unmatched leftover banks his full value, a single-player undermatch
        // banks the difference, an aggregated group forfeits, and the engine REFUSES all banking if incoming
        // was diverted to a pre-existing TPE/exception (the bank-while-absorb-elsewhere over-grant). Every
        // entry is a single un-aggregated leftover → fromAggregation:false; provenance is PER-PLAYER
        // (an outgoing S&T leftover banks fromSnt:true → Row J, a standard one fromSnt:false).
        const _per = ev.bankedTpes
          .filter(v => v && Number.isFinite(v.value) && v.value > 0)
          .map((v, i) => ({ id: `ctpe-${tradeId}-${code}-b${i}`, amount: Math.round(v.value),
                            source: v.name || "trade", fromAggregation: false, fromSnt: !!v.fromSnt }));
        if (_per.length) createdTpes = _per;
      } else {
        // Legacy single-outgoing path (§8.1), now gated on _legal: a SINGLE player matched for LESS than
        // he's worth banks O − I. Covers single-player undermatch (incoming present) AND a single S&T
        // dumped for nothing (fromSnt → Row J 2nd-apron on later use). S&T mixed into a multi-out dump
        // stays SUPPRESSED, as does any case the engine declined to surface a banked set for.
        const _tpeAmt = (_legal && _outCount === 1 && Number.isFinite(ev.O) && Number.isFinite(ev.I) && ev.O > ev.I) ? Math.round(ev.O - ev.I) : 0;
        createdTpe = _tpeAmt > 0 ? {
          id: `ctpe-${tradeId}-${code}`,
          amount: _tpeAmt,
          source: _outNames.length ? _outNames.join(", ") : "trade",
          fromAggregation: false,
          fromSnt: outFas.length > 0,
        } : null;
      }
      return {
        code, outPlayers, outFas, outPicks,
        cashOut: s.cash?.out || 0, cashDest: s.cash?.dest || null,
        incoming,
        hardCaps: (ev && Array.isArray(ev.hardCaps)) ? ev.hardCaps.slice() : [],   // 4d: persist this side's hard-cap consequence
        createdTpe,    // 4f: single TPE banked by a single-outgoing leg (null in the dump case)
        createdTpes,   // 4f: per-player TPE array banked by a pure standard-player dump (null otherwise)
      };
    });

    const desc = visSlots.map(s => s.code).join(" ↔ ");
    const trade = { id: tradeId, ts: Date.now(), desc, slots: tradeSlots };

    dispatch({ type: "APPLY_TRADE", trade });
    // 4f: surface created TPEs — both the single-object leg and the per-player dump array.
    const _newTpes = tradeSlots.flatMap(sl => {
      const arr = [];
      if (sl.createdTpe && sl.createdTpe.amount > 0) arr.push({ code: sl.code, amount: sl.createdTpe.amount });
      if (Array.isArray(sl.createdTpes)) for (const t of sl.createdTpes) if (t && t.amount > 0) arr.push({ code: sl.code, amount: t.amount });
      return arr;
    });
    flash(_newTpes.length
      ? `Applied: ${desc} · created TPE ${_newTpes.map(t => `${t.code} ${fmt$(t.amount)}`).join(", ")}`
      : `Applied: ${desc}`);
    setTimeout(clearAll, 400);
  }

  /* ---------------- render ---------------- */
  /* B: column sizing is now width-based (CSS flex min/max on .tm-col), not
     count-based — so columns fill when few teams, scroll when many. */

  return (
    <div className={`tm-app ${mobilePreview ? "mobile" : ""} surface-${tweaks.surface || "mixed"}`}>
      {mobilePreview && (
        <button className="mobile-toggle" onClick={() => setMobilePreview(false)}>
          <TmIcon.X style={{width:12, height:12}} /> Close mobile preview
        </button>
      )}
      <div className="tm-shell" ref={shellRef} onClick={() => destPop && closeDestPop()}>

        {/* ============ TOP BAR ============ */}
        <div className="tm-top">
          <button className="tm-back" title="Exit Trade Machine" onClick={onExit}>
            <TmIcon.Back /><span className="lbl">Exit</span>
          </button>
          <span className="divider" />
          <div className="wordmark tm-wordmark">
            <a href="#" className="brand" onClick={(e) => e.preventDefault()} title="CapMVP">
              {window.BrandLogo ? <window.BrandLogo height={24} /> : "CapMVP"}
            </a>
            {/* [COLOUR] Roster/tool dropdown removed from the trade heading — logo + "Trades" only. */}
          </div>
          <span className="tm-trades">Trades</span>
          <span className="spacer" />

          {/* 3-state FILLED pill (V3): GREY Available (idle / routing in progress) · GREEN
              Apply (legal) · oxblood Blocked. toRoute stays in the logic — routing folds
              into the grey "Available" state rather than surfacing an "N to route" count. */}
          <button className={`tm-top-verdict ${derived.verifyStatus === "pending" ? "checking" : derived.legal ? "legal" : (derived.anyActivity && derived.toRoute === 0) ? "illegal" : ""}`}
                  onClick={() => setVerdictOpen(v => !v)}>
            <span className="lbl">
              {derived.verifyStatus === "pending" ? "Checking…" : derived.legal ? "Apply" : (derived.anyActivity && derived.toRoute === 0) ? "Blocked" : "Available"}
            </span>
            <TmIcon.Caret className="caret" />
          </button>

          <button className={`tm-pillbtn add-team-top ${adding ? "done" : ""} ${visN > 1 && !adding ? "is-edit" : ""}`}
                  onClick={togglePicker}
                  title={adding ? "Done selecting teams" : "Add or remove teams"}>
            {adding ? <><TmIcon.Check /> Done</>
                    : visN > 1 ? <><TmIcon.Edit /> Teams</>
                    : <><TmIcon.Plus /> Add team</>}
          </button>
          {!tweaks.scenariosEnabled && (
            <button className="tm-pillbtn save-btn" title="Save scenario">
              <TmIcon.Save /> Save
            </button>
          )}
          {/* Transactions — icon button mirroring the header opener (icon-btn +
              th-badge, no text): opens the trade-undo/history window via the same
              window event the header opener + per-player undo use. */}
          {(state?.appliedTrades?.length > 0) && (
            <button className="icon-btn th-open-btn"
                    aria-label="Transactions"
                    onClick={() => window.dispatchEvent(new CustomEvent("open-trade-history"))}
                    title={`Transactions — review / undo applied trades (${state.appliedTrades.length})`}>
              <Icon.Trade />
              <span className="th-badge">{state.appliedTrades.length}</span>
            </button>
          )}
          {derived.anyActivity && (
            <button className="tm-pillbtn ghost clear-btn" onClick={clearAll} title="Clear all">
              <TmIcon.Reset /> Clear
            </button>
          )}
          {/* R12.B — named scenario chip (visible only when scenariosEnabled).
              Click → menu with list/rename/duplicate/delete + "New from current". */}
          {tweaks.scenariosEnabled && state?.scenarios && (
            <button className={`tm-pillbtn scenario-chip ${scenarioMenuOpen ? "on" : ""}`}
                    onClick={(e) => { e.stopPropagation(); setScenarioMenuOpen(o => !o); }}
                    title="Scenarios">
              <TmIcon.Save />
              <span className="lbl">{state.scenarios[state.activeScenario]?.name || (state.activeScenario === "default" ? "Default" : state.activeScenario)}</span>
              <TmIcon.Caret style={{width:10, height:10, marginLeft:2}}/>
            </button>
          )}
          {window.SettingsMenu && (
            <window.SettingsMenu tweaks={siteTweaks} setTweak={setSiteTweak}
              state={state} dispatch={dispatch} theme={theme} setTheme={setTheme}
              onOpenReset={onOpenReset}
              editLayout={tweaks.editLayout} setEditLayout={(v) => setTweak("editLayout", v)} />
          )}
        </div>

        {/* R12.B — Scenario switcher menu. localStorage-backed list of scenarios. */}
        {tweaks.scenariosEnabled && scenarioMenuOpen && state?.scenarios && (
          <div className="tm-scenario-menu" onClick={(e) => e.stopPropagation()}>
            <div className="tm-sm-h">
              <b>Scenarios</b>
              <button className="tm-sm-x" onClick={() => setScenarioMenuOpen(false)}><TmIcon.X /></button>
            </div>
            <div className="tm-sm-list">
              {Object.entries(state.scenarios).map(([id, sc]) => {
                const active = id === state.activeScenario;
                const label = sc.name || (id === "default" ? "Default" : id);
                const tradesN = (sc.appliedTrades || []).length;
                return (
                  <div key={id} className={`tm-sm-row ${active ? "active" : ""}`}>
                    <button className="tm-sm-load"
                            onClick={() => { dispatch && dispatch({ type: "LOAD_SCENARIO", id }); setScenarioMenuOpen(false); flash(`Loaded: ${label}`); }}>
                      {active && <TmIcon.Check style={{width:13, height:13, marginRight:6, color:"var(--pos)"}}/>}
                      <span className="tm-sm-name">{label}</span>
                      <span className="tm-sm-meta">{tradesN} trade{tradesN === 1 ? "" : "s"}</span>
                    </button>
                    {id !== "default" && (
                      <>
                        <button className="tm-sm-act" title="Rename"
                                onClick={() => {
                                  const next = (typeof prompt !== "undefined") ? prompt("Rename scenario:", label) : null;
                                  if (next && next.trim()) dispatch && dispatch({ type: "RENAME_SCENARIO", id, name: next.trim() });
                                }}>
                          <TmIcon.Edit style={{width:13,height:13}}/>
                        </button>
                        <button className="tm-sm-act danger" title="Delete"
                                onClick={() => {
                                  if (typeof confirm !== "undefined" && !confirm(`Delete scenario "${label}"?`)) return;
                                  dispatch && dispatch({ type: "DELETE_SCENARIO", id });
                                }}>
                          <TmIcon.X style={{width:13,height:13}}/>
                        </button>
                      </>
                    )}
                  </div>
                );
              })}
            </div>
            <div className="tm-sm-foot">
              <button className="tm-pillbtn ghost"
                      onClick={() => {
                        const nm = (typeof prompt !== "undefined") ? prompt("Scenario name:", `Scenario ${Object.keys(state.scenarios).length + 1}`) : null;
                        if (!nm || !nm.trim()) return;
                        dispatch && dispatch({ type: "CREATE_SCENARIO", name: nm.trim(), fromActive: true });
                        setScenarioMenuOpen(false);
                        flash(`Created: ${nm.trim()} (from current)`);
                      }}>
                <TmIcon.Plus style={{width:13,height:13}}/> New from current
              </button>
              <button className="tm-pillbtn ghost"
                      onClick={() => {
                        const nm = (typeof prompt !== "undefined") ? prompt("Scenario name (empty):", `Scenario ${Object.keys(state.scenarios).length + 1}`) : null;
                        if (!nm || !nm.trim()) return;
                        dispatch && dispatch({ type: "CREATE_SCENARIO", name: nm.trim(), fromActive: false });
                        setScenarioMenuOpen(false);
                        flash(`Created: ${nm.trim()} (empty)`);
                      }}>
                <TmIcon.Plus style={{width:13,height:13}}/> New empty
              </button>
            </div>
          </div>
        )}

        {/* Verdict popover */}
        {verdictOpen && (
          <VerdictPopover
            slots={slots} derived={derived}
            onClose={() => setVerdictOpen(false)}
            onClear={() => { clearAll(); setVerdictOpen(false); }}
            onApply={() => { applyTrade(); setVerdictOpen(false); }}
          />
        )}

        {/* ============ TEAM STRIP (mobile-only via CSS) ============ */}
        <div className={`tm-strip ${visN >= 4 ? "compact" : ""}`}>
          <div className="tm-strip-scroll">
            {slots.map((s, i) => {
              if (s.hidden) return null;   // #3: soft-removed → not in the strip (data preserved)
              const ev = derived.evals[i];
              const status = capStatus(ev.post);
              return (
                <button key={s.code}
                        className={`tm-strip-team ${activeSlot === i && !adding ? "active" : ""}`}
                        onClick={() => { setActiveSlot(i); setAdding(false); }}>
                  <span className="logo"><TeamLogo code={s.code} size={26} /></span>
                  <span className="nm">
                    {s.code}
                    <small>{fmt$(ev.post)}</small>
                  </span>
                  <span className="pip" style={{background: status.color}} title={status.label} />
                </button>
              );
            })}
          </div>
          <button className={`tm-strip-add ${adding ? "done" : ""}`}
                  onClick={togglePicker}
                  title={adding ? "Done selecting teams" : "Add or remove teams"}>
            {adding ? <TmIcon.Check /> : visN > 1 ? <TmIcon.Edit /> : <TmIcon.Plus />}
          </button>
        </div>

        {/* ============ BOARD ============ */}
        <div className={`tm-board ${visN === 1 ? "single" : ""}`}>
          {slots.map((s, i) => s.hidden ? null : (
            <TeamColumn key={s.code} slot={s} idx={i} pinned={i === 0}
              codes={codes} multi={multi} active={activeSlot === i && !adding}
              ownCode={ownCode} tab={tabs[i] || "players"}
              outgoing={derived.out[i]} incoming={derived.inc[i]} ev={derived.evals[i]}
              pendingPicksByTeam={pendingPicksByTeam} movedPickIdsByTeam={movedPickIdsByTeam}
              tweaks={tweaks}
              rowOrder={state?.tmRowOrders?.[s.code] || null}
              onReorder={(order) => dispatch && dispatch({ type: "REORDER_ROSTER", team: s.code, order })}
              onSetTab={(k) => setTab(i, k)}
              onTogglePlayer={(p) => togglePlayer(i, p)}
              onTogglePick={(pk) => togglePick(i, pk)}
              onPickSetDest={(pk, code) => setDest(i, "pick", pk.id, code)}
              onToggleFa={(fa) => toggleFa(i, fa)}
              onSetCash={(patch) => setCash(i, patch)}
              onToggleTpe={(t) => toggleTpe(i, t)}
              onSelect={() => setActiveSlot(i)}
              onRemoveTeam={i === 0 ? null : () => removeTeam(i)}
              onIncomingDest={(item, anchor) => setDestPop({
                slotIdx: codes.indexOf(item._from), kind: item._kind,
                key: item._kind === "pick" ? item.id : item._kind === "cash" ? "cash" : item.name,
                anchor,
              })}
              onDest={(item, anchor) =>
                setDestPop({ slotIdx: i, kind: item._kind, key: item._key, anchor })}
              onCashDest={(anchor) =>
                setDestPop({ slotIdx: i, kind: "cash", key: "cash", anchor })}
              onJumpTo={(code) => { const idx = codes.indexOf(code); if (idx >= 0) setActiveSlot(idx); }}
              onOpenDrawer={(player, kind) => openDrawer(i, kind, player)}
              drawer={drawer} onDrawerChange={changeDrawer}
              onDrawerApply={applyDrawer} onDrawerClose={() => { markMutate(); setDrawer(null); }}
              onRemoveAsset={(item) => {
                if (item._kind === "cash") setCash(i, { out: 0 });
                else if (item._kind === "player") togglePlayer(i, { name: item._key });
                else if (item._kind === "fa")     toggleFa(i, { name: item._key });
                else if (item._kind === "pick")   togglePick(i, { id: item._key });
              }}
            />
          ))}
          {adding && (
            <section className="tm-addcol active">
              {/* #4: multi-select — tap to add a team's column, re-tap to remove; ✓ when done. */}
              <window.TeamMenu
                currentTeam={(teams || []).find(t => t.code === managedCode) || { code: managedCode }}
                teams={teams} multi selectedCount={slots.filter(s => !s.hidden).length}
                selectedCodes={slots.filter(s => !s.hidden && s.code !== managedCode).map(s => s.code)}
                onToggle={(t) => {
                  const vis = slots.find(s => s.code === t.code && !s.hidden);
                  if (vis) { const i = slots.indexOf(vis); if (i > 0) removeTeam(i); }
                  else addTeam(t.code);   // absent OR hidden → add / un-hide
                }}
                onClose={closePicker} />
            </section>
          )}
        </div>
      </div>

      {/* Destination popover (fixed-positioned, outside shell) */}
      {destPop && (
        <DestPopover
          anchor={destPop.anchor}
          codes={codes}
          ownCode={slots[destPop.slotIdx]?.code}
          current={
            destPop.kind === "cash"
              ? slots[destPop.slotIdx]?.cash?.dest
              : (destPop.kind === "player" ? slots[destPop.slotIdx]?.players.get(destPop.key)?.dest
                : destPop.kind === "pick"   ? slots[destPop.slotIdx]?.picks.get(destPop.key)?.dest
                                            : slots[destPop.slotIdx]?.fas.get(destPop.key)?.dest)
          }
          onPick={(c) => setDest(destPop.slotIdx, destPop.kind, destPop.key, c)}
          onUndo={() => {
            const i = destPop.slotIdx, k = destPop.key;
            if (destPop.kind === "player") togglePlayer(i, { name: k });
            else if (destPop.kind === "fa") toggleFa(i, { name: k });
            else if (destPop.kind === "pick") togglePick(i, { id: k });
            else if (destPop.kind === "cash") setCash(i, { out: 0 });
          }}
          onClose={() => closeDestPop()}
        />
      )}

      {/* Tweaks panel */}
      {/* TM tweaks panel removed — the gear is now the shared main-site SettingsMenu
          (Theme · Palette · Header · Density · Team skin · Multi-year · Edit layout · Reset). */}

      {/* Player editing is now INLINE in the row (InlineEditor) — no full-page drawer. */}

      {/* Toast */}
      {toast && (
        <div className="toast"><TmIcon.Check /><span>{toast}</span></div>
      )}
    </div>
  );
}

/* ============================================================
   TeamColumn
   ============================================================ */
function TeamColumn({
  slot, idx, pinned, codes, multi, active, ownCode, tab, tweaks,
  outgoing, incoming, ev, pendingPicksByTeam, movedPickIdsByTeam,
  onSetTab, onTogglePlayer, onTogglePick, onPickSetDest, onToggleFa, onSetCash, onToggleTpe,
  onSelect, onRemoveTeam, onDest, onCashDest, onJumpTo, onIncomingDest, onOpenDrawer, onRemoveAsset,
  drawer, onDrawerChange, onDrawerApply, onDrawerClose,
  rowOrder, onReorder,
}) {
  // Which row in THIS column is being edited inline (#5/#6)?
  const editHere = drawer && drawer.slotIdx === idx ? drawer : null;
  const editPlayer = editHere && editHere.kind === "player" ? editHere.player.name : null;
  const editFa     = editHere && editHere.kind === "fa" ? editHere.player.name : null;
  const editLayout = tweaks?.editLayout || "inline-v2";
  const editProps = {
    editCtx: editHere, onEditChange: onDrawerChange,
    onEditApply: onDrawerApply, onEditClose: onDrawerClose,
    editLayout,
    slotEv: ev,
    // Renounce/Hold are offered only on the MANAGED team's own slot (own FA holds),
    // never opponents — see InlineEditor/ColumnDrawer.
    ownTeam: slot.code === ownCode,
  };
  const team = TEAM_BY_CODE[slot.code] || {};
  const status = capStatus(ev.post);
  const tiles = capTiles(ev.post);
  const accentVars = { "--team-primary": team.primary, "--team-secondary": team.secondary };
  // R14.A: roster display ordering. Apply the user's drag-saved order first
  // (names in rowOrder render in that sequence); fall through to the default
  // salary-desc sort for any roster members not yet ranked.
  const baseRoster = ROSTERS[slot.code] || [];
  const roster = useMemo(() => {
    if (!Array.isArray(rowOrder) || rowOrder.length === 0) return baseRoster;
    const ix = new Map(rowOrder.map((n, i) => [n, i]));
    return [...baseRoster].sort((a, b) => {
      const ai = ix.has(a.name) ? ix.get(a.name) : Infinity;
      const bi = ix.has(b.name) ? ix.get(b.name) : Infinity;
      if (ai !== bi) return ai - bi;
      return (b.salary || 0) - (a.salary || 0);
    });
  }, [baseRoster, rowOrder]);

  // R14.A: Sortable.js drag-to-reorder. Initializes on the roster list when
  // the Roster tab is active; tears down on tab change. Long-press starts
  // drag on touch (delay 200ms); a quick tap still fires the row's onClick
  // (toggle into trade). Editing rows are filtered out (can't drag mid-edit).
  const listRef = useRef(null);
  useEffect(() => {
    if (tab !== "players") return;
    const el = listRef.current;
    const Sortable = (typeof window !== "undefined") ? window.Sortable : null;
    if (!el || !Sortable || typeof onReorder !== "function") return;
    const inst = Sortable.create(el, {
      draggable: "[data-name]",                  // only roster rows; incoming rows ignored
      filter: ".tm-row.editing, .tm-row.locked", // don't drag mid-edit / locked
      preventOnFilter: false,
      delay: 200, delayOnTouchOnly: true, touchStartThreshold: 5,
      animation: 160,
      ghostClass: "sort-ghost",
      chosenClass: "sort-chosen",
      dragClass: "sort-drag",
      onEnd() {
        const order = [...el.querySelectorAll("[data-name]")]
          .map(n => n.getAttribute("data-name"));
        onReorder(order);
      },
    });
    return () => inst.destroy();
  }, [tab, onReorder]);
  const picks = PICKS[slot.code] || [];
  const tpes = TPES[slot.code] || [];
  const fas = FREE_AGENTS[slot.code] || [];
  const activeFas = fas.filter(f => !f._renounced);
  const renouncedFas = fas.filter(f => f._renounced);
  const ledger = CASH_LEDGER[slot.code] || { sent: 0, received: 0 };
  const counts = {
    players: slot.players.size + slot.fas.size,
    picks:   slot.picks.size,
    cash:    (slot.cash?.out || 0) > 0 ? 1 : 0,
    tpes:    (slot.tpes?.size || 0),
  };

  const outSalary = outgoing.reduce((a, p) => a + (p.salary || 0), 0)
                  + (slot.cash?.out || 0);
  const inSalary  = incoming.reduce((a, p) => a + (p.salary || 0), 0);
  const net = inSalary - outSalary;

  const incomingPlayers = incoming.filter(x => x._kind === "player" || x._kind === "fa");
  const incomingPicks   = incoming.filter(x => x._kind === "pick");
  const incomingCash    = incoming.find(x => x._kind === "cash");

  // Header: ONE style only — the main-site mobile salary strip (`mobilebar`).
  // (Stacked / lean / payroll header variants + their cap/tax/apron/payroll-bar
  // geometry removed per the Roster Colours trade-machine handoff. Only `idle`
  // and `netStr` survive — the mobile-bar block uses them for the NET label.)
  const hs = "mobilebar";
  const idle = outgoing.length === 0 && incoming.length === 0 && !(slot.cash?.out > 0);
  const netStr = (net > 0 ? "+" : net < 0 ? "−" : "") + fmt$(Math.abs(net));

  return (
    <section className={`tm-col ${pinned ? "pinned" : ""} ${active ? "active" : ""}`}
             style={accentVars}
             onClick={onSelect}>
      <div className="tm-col-accent" />
      <header className={`tm-col-head hs-${hs}`}>
        <div className="logo"><TeamLogo code={slot.code} size={hs === "stacked" ? 34 : 24} /></div>
        <div className="ident">
          {/* The column header IS the main-site mobile salary strip: team-colour
              top meter, smooth green→red bottom meter, white CBA ticks. The
              committed total slides by its position vs the aprons; the "Payroll"
              label is replaced by the trade NET. */}
          {(() => {
            const M = (typeof window !== "undefined") ? window : {};
            const skin = (M.TEAM_SKINS && M.TEAM_SKINS[slot.code]) || null;
            const topFill = skin ? `linear-gradient(90deg, ${skin[0]}, ${skin[1]})` : undefined;
            const netLabel = idle ? "No change" : `${netStr} net`;
            // 4d: draw the binding apron ceiling on this slot's bar (committed = ev.post,
            // the holds-free apron post-trade). Strictest of the team's STORED hard cap
            // (HARDCAPS — from earlier applied trades/signings) and any cap THIS pending
            // trade would itself trigger (ev.hardCaps). firstApron (lower $) is stricter.
            const _hcSrc = [HARDCAPS[slot.code]].concat(Array.isArray(ev.hardCaps) ? ev.hardCaps : []);
            const _slotHardCap = _hcSrc.includes("firstApron") ? "apron1"
              : _hcSrc.includes("secondApron") ? "apron2" : null;
            return (
              <div className="tm-mb">
                {M.MobileBarMeters
                  ? <M.MobileBarMeters committed={ev.post} mode="apron" topFill={topFill}
                                       hardCap={_slotHardCap} />
                  : null}
                <div className="mb-inner">
                  {M.MobileBarText
                    ? <M.MobileBarText committed={ev.post} mode="apron"
                                       hardCap={_slotHardCap}
                                       headerLines="all"
                                       lineLabelMode={codes.length >= 2 ? "mini" : "short"}
                                       numColor="var(--logo-mvp, #3b6fd4)"
                                       totalLabel={netLabel} centered={false} />
                    : null}
                </div>
              </div>
            );
          })()}
        </div>
      </header>

      <OutgoingStrip outgoing={outgoing} multi={multi}
        cashOut={slot.cash?.out || 0} cashDest={slot.cash?.dest}
        slot={slot} chipLayout={tweaks.chipLayout} inSalary={inSalary} net={net}
        onRemove={onRemoveAsset}
        onDest={(item, anchor) => onDest(item, anchor)}
        onCashDest={(anchor) => onCashDest(anchor)} />

      {/* #6: tabs + panels share ONE scroll region so the tabs scroll away with the
          list; only the cap header + outgoing strip above stay pinned. */}
      <div className="tm-col-scroll">
      <nav className="tm-tabs">
        {[
          ["players", <TmIcon.Roster/>, "Roster",  null],
          ["picks",   <TmIcon.Pick/>,   "Picks",   null],
          ["cash",    <TmIcon.Cash/>,   "Cash",    null],
          ["tpes",    <TmIcon.TPE/>,    "Options", null],
        ].map(([k, ic, lbl]) => {
          const sel = counts[k] || 0;
          return (
            <button key={k} className={`tm-tab ${tab === k ? "on" : ""}`}
                    onClick={(e) => { e.stopPropagation(); onSetTab(k); }}>
              {ic}<span>{lbl}</span>
              {sel > 0 && <span className="count">{sel}</span>}
            </button>
          );
        })}
      </nav>

      <div className="tm-col-panels">
        {tab === "players" && (
          <div className="tm-list" ref={listRef}>
            {incomingPlayers.map(item => (
              <IncomingRow key={item._key} item={item} onJumpTo={onJumpTo} onDest={onIncomingDest} />
            ))}
            {/* Incoming PICKS moved out of the player rows; they render in the Picks tab. */}
            {incomingCash && (
              <IncomingCashRow item={incomingCash} onJumpTo={onJumpTo} onDest={onIncomingDest} />
            )}

            {/* #10: incoming + roster share one list (no "Current roster" divider).
                selected players move UP into the outgoing strip — hide them here,
                except the one currently being edited inline (keep its row to expand) */}
            {roster.filter(p => !slot.players.has(p.name) || p.name === editPlayer).map(p => (
              <PlayerRow key={p.name}
                player={p} selected={!!slot.players.has(p.name)} multi={multi} orgCode={slot.code}
                editing={p.name === editPlayer} {...editProps}
                onToggle={() => onTogglePlayer(p)}
                onDest={(player, anchor) => onDest({ _kind:"player", _key:p.name }, anchor)}
                onOpenDrawer={(player) => onOpenDrawer(player, "player")} />
            ))}

            {(() => {
              // One renderer shared by the active + renounced FA groups so the
              // greyed list reuses the exact same FreeAgentRow markup.
              const renderFa = fa => {
                const sel = slot.fas.get(fa.name);
                return (
                  <FreeAgentRow key={fa.name}
                    fa={sel ? { ...fa, _dest: sel.dest } : fa}
                    selected={!!sel} multi={multi}
                    editing={fa.name === editFa} {...editProps}
                    /* unselected → open the S&T salary editor inline; selected → remove */
                    onToggle={() => sel ? onToggleFa(fa) : onOpenDrawer(fa, "fa")}
                    onDest={(player, anchor) => onDest({ _kind:"fa", _key:fa.name }, anchor)}
                    onOpenDrawer={(p) => onOpenDrawer(p, "fa")} />
                );
              };
              return (
                <>
                  {activeFas.length > 0 && (
                    <>
                      <div className="list-section">
                        Free agents · S&amp;T candidates · {activeFas.length}
                        <span className="sep" style={{background:"var(--line)"}} />
                      </div>
                      {activeFas.map(renderFa)}
                    </>
                  )}
                  {renouncedFas.length > 0 && (
                    <div className="tm-fa-renounced" style={{opacity:0.5}}>
                      <div className="list-section">Renounced · {renouncedFas.length}</div>
                      {renouncedFas.map(renderFa)}
                    </div>
                  )}
                </>
              );
            })()}
          </div>
        )}
        {tab === "picks" && (
          <>
            {/* Incoming picks render inside the ticket rail itself, with a pending-in-trade token. */}
            {(tweaks?.picksLayout || "grid") === "grid"
              ? <TmPicksPanel slot={slot} picks={picks} incomingPicks={incomingPicks}
                  pendingPicksByTeam={pendingPicksByTeam} movedPickIdsByTeam={movedPickIdsByTeam}
                  multi={multi} codes={codes}
                  onToggle={onTogglePick} onSetDest={onPickSetDest}
                  onDest={(pk, anchor) => onDest({ _kind:"pick", _key:pk.id }, anchor)} />
              : <PicksList slot={slot} picks={picks} multi={multi}
                  onToggle={onTogglePick}
                  onDest={(pk, anchor) => onDest({ _kind:"pick", _key:pk.id }, anchor)} />}
          </>
        )}
        {tab === "cash" && (
          (tweaks?.cashLayout || "table") === "table"
            ? <CashTable slot={slot} codes={codes} ownCode={ownCode} ledger={ledger} multi={multi}
                cashIn={ev.cashIn || 0}
                onSet={onSetCash}
                onDest={(_, anchor) => onCashDest(anchor)} />
            : <CashPanel slot={slot} codes={codes} ownCode={ownCode} ledger={ledger} multi={multi}
                onSet={onSetCash}
                onDest={(_, anchor) => onCashDest(anchor)} />
        )}
        {tab === "tpes" && (
          <OptionsPanel slot={slot} tpes={tpes} ev={ev} onToggleTpe={onToggleTpe} />
        )}
      </div>
      </div>

      {/* R11.C — Column drawer mode: when editLayout === "drawer", the editor
          slides up from the column's bottom edge over the list area. */}
      {editLayout === "drawer" && editHere && (
        <ColumnDrawer ctx={editHere} slotEv={ev} ownTeam={slot.code === ownCode} onChange={onDrawerChange} onApply={onDrawerApply} onClose={onDrawerClose} />
      )}

    </section>
  );
}

/* ============================================================
   Verdict popover
   ============================================================ */
function VerdictPopover({ slots, derived, onClose, onClear, onApply }) {
  const ref = useR(null);
  useE(() => {
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
    setTimeout(() => document.addEventListener("mousedown", h), 50);
    return () => document.removeEventListener("mousedown", h);
  }, [onClose]);
  // #1 trade-verdict frontier: name which channel absorbed the incoming (engine methods[0].uses).
  const _chanLabel = { match: "matching", room: "cap room", ntMle: "Non-Tax MLE", bae: "BAE", roomMle: "Room MLE" };
  const absorbedVia = (ev) => {
    const uses = (ev.methods && ev.methods[0] && ev.methods[0].uses) || [];
    if (!uses.length) return "";
    return uses.map(u => u.slot === "tpe" ? `${fmt$(u.amount)} TPE` : (_chanLabel[u.slot] || u.slot)).join(" + ");
  };
  return (
    <div className="tm-verdict-pop" ref={ref}>
      <div className="hd">
        {!derived.anyActivity ? (<>
          <h2 style={{color:"var(--text)"}}>Trade is empty</h2>
          <div className="sub">Pick a player, pick or cash to start building.</div>
        </>) : derived.verifyStatus === "pending" ? (<>
          <h2 style={{color:"var(--text)"}}>Checking with the server…</h2>
          <div className="sub">Verifying this trade against the hosted rules engine.</div>
        </>) : (derived.verifyStatus === "error" || derived.verifyStatus === "stale") ? (<>
          <h2 style={{color:"var(--danger)"}}>Can’t verify ✗</h2>
          <div className="sub">{derived.verifyError || "The rules server is unreachable — the trade can’t be finalized right now."}</div>
        </>) : derived.legal ? (<>
          <h2 style={{color:"var(--pos)"}}>Trade is legal ✓</h2>
          <div className="sub">All {slots.length} teams pass salary-matching and apron rules. Apron flags (if any) below.</div>
        </>) : (<>
          <h2 style={{color:"var(--danger)"}}>Trade is blocked ✗</h2>
          <div className="sub">Fix the highlighted teams — usually means adding outgoing salary or removing an asset.</div>
        </>)}
      </div>
      <div className="body">
        {slots.map((s, i) => {
          const ev = derived.evals[i];
          const hasOwn = derived.out[i].length > 0 || (s.cash?.out || 0) > 0;
          const hasIn  = derived.inc[i].length > 0;
          const dormant = !hasOwn && !hasIn;
          const sendSal = derived.out[i].filter(x => x._kind === "player" || x._kind === "fa").reduce((a, p) => a + p.salary, 0);
          const takeSal = derived.inc[i].filter(x => x._kind === "player" || x._kind === "fa").reduce((a, p) => a + p.salary, 0);
          return (
            <div key={s.code} className="side">
              <div className="hd2">
                <TeamLogo code={s.code} size={20} />
                <span>{s.code}</span>
                <span className={`vmark ${dormant ? "" : ev.legal ? "ok" : "bad"}`}>
                  {dormant ? <span style={{fontSize:9.5, fontWeight:800, letterSpacing:"0.05em", color:"var(--text-faint)"}}>IDLE</span> : ev.legal ? <TmIcon.Check style={{width:14, height:14}}/> : <TmIcon.X style={{width:14, height:14}}/>}
                </span>
              </div>
              {!dormant && <div className="line">Sends <span className="num">{fmt$(sendSal)}</span> · Takes <span className="num">{fmt$(takeSal)}</span></div>}
              {!dormant && takeSal > 0 && (ev.maxIncoming > 0) && (
                <div className="line" style={{ opacity: 0.82 }}>
                  Can take back up to ~<span className="num">{fmt$(ev.maxIncoming)}</span>
                  {ev.legal && absorbedVia(ev) ? <> · via {absorbedVia(ev)}</> : null}
                </div>
              )}
              {!dormant && ev.reasons.map((r, k) => (
                <div key={k} className={`line ${ev.legal ? "" : "bad"}`}>{ev.legal ? "✓" : "✗"} {r}</div>
              ))}
              {!dormant && ev.flags.map((f, k) => (
                <div key={"f"+k} className="line warn">⚠ {f}</div>
              ))}
              {dormant && <div className="line" style={{fontStyle:"italic"}}>Nothing in or out yet.</div>}
            </div>
          );
        })}
      </div>
      <div className="ft">
        {derived.anyActivity && <button className="tm-pillbtn ghost" onClick={onClear}><TmIcon.Reset /> Clear</button>}
        <button className="tm-pillbtn primary" onClick={onApply} disabled={!derived.legal}>
          <TmIcon.Bolt /> Apply trade
        </button>
      </div>
    </div>
  );
}

/* DrawerHost was the wrapper for PlayerDrawer (centered modal pattern).
   Removed in R14 dead-code trim alongside PlayerDrawer. Replaced by
   ColumnDrawer (R11.C drawer layout, anchored to column bottom). */



/* ---- export the root view (App was renamed to TradeMachineView) ---- */
Object.assign(window, { TradeMachineView });
})();
