/* ============================================================
   App root — pulls it all together
   ============================================================ */

const { createRoot } = ReactDOM;

const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "salaryBarStyle": "linear",
  "density": "comfortable",
  "teamSkin": "off",
  "headerLines": "all",
  "lineLabel": "short",
  "headerStyle": "standard",
  "stepBar": "mid"
}/*EDITMODE-END*/;

/* LAL's curated opt-in/out defaults. The rich dataset doesn't carry a
   `defaultDecision`, so we re-attach the editorial ones for the Lakers so
   their Step-1 defaults (Reaves/Smart opt-out, Nick Smith Jr. decline) don't
   regress. Every other team falls back to the status-based seed in App. */
const LAL_DEFAULT_DECISIONS = {
  "Austin Reaves": "opt-out",
  "Marcus Smart": "opt-out",
  "Nick Smith Jr.": "decline",
  "Deandre Ayton": "opt-in",
  "Bronny James": "keep",
};

/* Headshot backfill. The new all-teams dataset dropped `nbaId` for many players
   (≈347 null), so their `assets/players/<nbaId>.png` photos vanished. We rebuild
   a name→nbaId map from teams-trade-data.json (which still carries ids for most
   of the league) + this explicit supplement for the few LAL players missing from
   that file. A wrong/absent id just falls back to initials (img onError), so the
   backfill is safe. Durable fix is to restore nbaId upstream in the Data Operation. */
const LAL_NBA_IDS = {
  "LeBron James": "2544",
  "Rui Hachimura": "1629060",
  "Luke Kennard": "1628379",
  "Maxi Kleber": "1628467",
  "Jaxson Hayes": "1629637",
};

/* Rich managed roster for ANY team, from data/all-teams-detail.json. This is
   the same player schema the LAL flow already expects (bird rights, cap holds,
   option amounts, multi-year `seasons`), sorted by status then 2026-27 salary
   — so the rich cap flow now applies to all 30 teams, not just LAL. */
function richTeamPlayers(allTeams, code, nbaIdByName = {}) {
  const t = allTeams && allTeams[code];
  if (!t || !Array.isArray(t.players)) return [];
  const order = { under_contract: 0, player_option: 1, team_option: 2, partially_guaranteed: 3, non_guaranteed: 3, UFA: 4, RFA: 5, draft_pick: 6 };
  const defaults = code === "LAL" ? LAL_DEFAULT_DECISIONS : null;
  return t.players
    .map(p => {
      const dd = defaults && defaults[p.name] && !p.defaultDecision ? defaults[p.name] : p.defaultDecision;
      const nbaId = p.nbaId || nbaIdByName[p.name] || null;   // backfill missing headshot id
      return (nbaId !== p.nbaId || dd !== p.defaultDecision) ? { ...p, nbaId, defaultDecision: dd } : p;
    })
    .sort((a, b) => {
      const da = order[a.offseasonStatus] ?? 9;
      const db = order[b.offseasonStatus] ?? 9;
      if (da !== db) return da - db;
      const sa = a.seasons?.find(s => s.season === "2026-27")?.salary || a.priorSeasonSalary || 0;
      const sb = b.seasons?.find(s => s.season === "2026-27")?.salary || b.priorSeasonSalary || 0;
      return sb - sa;
    });
}

function App() {
  const [state, dispatch] = useAppState();
  const [theme, setTheme] = useState(() => localStorage.getItem("capmvp-theme") || "dark");
  const useTweaksHook = window.useTweaks || ((d) => [d, () => {}]);
  const [tweaks, setTweak] = useTweaksHook(TWEAK_DEFAULTS);
  const [modal, setModal] = useState(null);          // 'trade' | 'sign-fa' | 'draft-prospect'
  const [resetOpen, setResetOpen] = useState(false); // reset-rosters confirmation
  const [draftTarget, setDraftTarget] = useState(null); // pick id for the prospect picker
  const openDraftPicker = useCallback((id) => { setDraftTarget(id); setModal("draft-prospect"); }, []);
  // B/C/H trade-undo window. A window-level event lets the per-player ↺ (deep in the
  // roster, with no setModal in scope) and the header "Trades" button open it without
  // prop-drilling. detail.tradeId (optional) focuses that trade in the list.
  const [tradeHistoryFocus, setTradeHistoryFocus] = useState(null);
  useEffect(() => {
    const open = (e) => { setTradeHistoryFocus((e.detail && e.detail.tradeId) || null); setModal("trade-history"); };
    window.addEventListener("open-trade-history", open);
    return () => window.removeEventListener("open-trade-history", open);
  }, []);
  const [allTeams, setAllTeams] = useState(null);    // rich per-team detail (data/all-teams-detail.json)
  const [draftAssets, setDraftAssets] = useState(null);  // future draft picks (data/draft_assets.json)
  const [picksData, setPicksData] = useState(null);  // picks truth-machine bundle (picks-data.json)
  const [nbaIds, setNbaIds] = useState(null);        // static name->nbaId headshot backfill (data/nba-ids.json)
  const [teamsExc, setTeamsExc] = useState(null);    // per-team TPEs + trade-acquisition exceptions (data/teams-exceptions.json) — PROVISIONAL
  const teams = useMemo(() => buildTeams(allTeams), [allTeams]);
  const isMobile = useIsMobile();                    // ≤720px → new mobile salary bar (mobile-bar.jsx)
  const [scrolled, setScrolled] = useState(false);   // mobile: collapse the salary bar + shrink tabs once scrolled
  const [tool, setToolRaw] = useState(() =>
    /(^|[#/])calc/i.test(location.hash) ? "calc" : "roster");
  const setTool = useCallback((t) => {
    setToolRaw(t);
    try { location.hash = t === "calc" ? "/calc" : "/roster"; } catch {}
  }, []);
  const [step, setStep] = useState(() => +(sessionStorage.getItem("capmvp-step") || 1));
  const [maxStepReached, setMaxStepReached] = useState(() =>
    +(sessionStorage.getItem("capmvp-maxstep") || 1));
  // The Cap-strategy editor (full box + "About this strategy") is the ONE way to
  // change strategy — first time AND after. It auto-shows until confirmed; the
  // chip re-opens it. Reset when the team changes.
  const [strategyExpanded, setStrategyExpanded] = useState(false);
  useEffect(() => { setStrategyExpanded(false); }, [state.team]);

  // Active managed roster: every team now uses the rich per-team dataset.
  // name→nbaId map for headshot backfill (static data/nba-ids.json + LAL supplement).
  const nbaIdByName = useMemo(() => {
    const m = { ...((nbaIds && nbaIds.ids) || {}) };
    Object.assign(m, LAL_NBA_IDS);   // explicit supplement wins
    return m;
  }, [nbaIds]);
  const activePlayers = useMemo(
    () => richTeamPlayers(allTeams, state.team, nbaIdByName),
    [state.team, allTeams, nbaIdByName]
  );
  // Track which (team) shared-options and (team:mode) buckets we've seeded this
  // session; resetNonce forces a re-seed after a reset clears the persisted state.
  const seededOptsRef = useRef(new Set());
  const seededModesRef = useRef(new Set());
  const [resetNonce, setResetNonce] = useState(0);
  function resetThisTeam() {
    const team = state.team;
    seededOptsRef.current.delete(team);
    [...seededModesRef.current].forEach(k => { if (k.startsWith(team + ":")) seededModesRef.current.delete(k); });
    dispatch({ type: "RESET_TEAM", team });
    try { if (window.tmObligReset) window.tmObligReset(team); } catch (e) {}
    setResetNonce(n => n + 1);
    setResetOpen(false);
    dispatch({ type: "SET_TOAST", toast: `Reset ${team} to defaults.` });
  }
  function resetAllTeams() {
    seededOptsRef.current.clear();
    seededModesRef.current.clear();
    dispatch({ type: "RESET_ALL" });
    try { if (window.tmObligReset) window.tmObligReset(); } catch (e) {}
    setResetNonce(n => n + 1);
    setResetOpen(false);
    dispatch({ type: "SET_TOAST", toast: "Reset all teams to defaults." });
  }

  // Rich per-team rosters (salaries, bird rights, cap holds, options) for all
  // 30 teams. `richTeamPlayers` selects + sorts the active team on demand.
  useEffect(() => {
    fetch("data/all-teams-detail.json?v=113").then(r => r.json())  // ?v busts CDN/browser cache when the dataset changes (v113: priorUnlikelyBonus owner overrides — Sexton 500K (CBA-confirmed) + Poole 4.25M. v112: priorUnlikelyBonus (2025-26 unlikely bonuses, 30 players; Spotrac per-season + SalarySwish 25-26 cross-check + CBA log, no Capsheets) for the engine prior-year cap/BYC math; no cap-baseline change. v111: 8 rookie-scale RFA qualifying offers (Mathurin/Dieng/Duren/Agbaji/M.Williams/Eason/Kessler/Watson) + metStarterCriteria, provisional until official ~2026-06-30; no cap-baseline change. v110: full first-party roster sweep (NBA commonteamroster, 2003-26) -> 24 yearsOfExperience corrections — injured-rostered seasons BBRef missed (Embiid 10->12, Durant 18->19, Klay 13->15, Holmgren 3->4, Dejounte Murray 9->10, ...) + filled nulls; ZERO cap impact (one TOR min-hold fix, Fultz). v109: + Jaylen Clark YOS 2→3, box-score-confirmed 2023-24 ACL rookie year — corrects his min-salary scale (RFA QO unchanged). v108: Wallace/Liddell/Cooper YOS. v107: Murray/Zion. v106: priorEarnedBonus. v105: dead money + declined options + QO + meta.minScale/eaps + designated)
      .then(data => setAllTeams(data.teams || null)).catch(() => {});
    fetch("data/draft_assets.json?v=93").then(r => r.json())       // future draft picks (2026-2032)
      .then(data => setDraftAssets(data || null)).catch(() => {});
    fetch("picks-data.json?v=1").then(r => r.json())               // clean picks timeline/card bundle
      .then(data => setPicksData(data || null)).catch(() => {});
    fetch("data/teams-exceptions.json?v=3").then(r => r.json())    // per-team TPEs + exceptions + baeUsedPriorYear (v3: per-team baeUsedPriorYear true=CHA/DET/LAL/UTA/WAS; engine derives BAE band-eligibility live and ANDs with NOT the flag. canonical 2026-06-03 — DEN TPE dedup, 58 TPEs)
      .then(data => setTeamsExc(data || null)).catch(() => {});
  }, []);

  // Static headshot-id backfill. Phase 1b (one-world #14): replaced the
  // teams-trade-data.json fetch — every roster/salary fact now comes from
  // all-teams-detail.json (the single source). nba-ids.json is a name->nbaId
  // map merged from both files so headshot coverage is preserved.
  useEffect(() => {
    fetch("data/nba-ids.json?v=1").then(r => r.json()).then(setNbaIds).catch(() => {});
  }, []);

  // Seed defaults for a FRESH (team) shared-options layer and a fresh
  // (team:mode) roster bucket. Persisted/visited buckets are NOT re-seeded, so
  // a refresh or a mode-switch keeps the user's saved work. Options (opt-in/out,
  // exercise/decline, keep/waive) are shared; FA holds + draft picks are per-mode.
  useEffect(() => {
    if (activePlayers.length === 0) return;
    const team = state.team, mode = state.mode, modeKey = team + ":" + mode;
    const optsSeeded = seededOptsRef.current.has(team)
      || (state.options[team] && Object.keys(state.options[team]).length > 0);
    const modeSeeded = seededModesRef.current.has(modeKey)
      || !!(state.rosters[team] && state.rosters[team][mode]);
    if (optsSeeded && modeSeeded) return;

    const seedPicks = [];
    for (const p of activePlayers) {
      const dd = p.defaultDecision;
      // Options / non-guaranteed start UNDECIDED — the user resolves them in the
      // "Decide these first" section (Chunk 2). Removed the player_option→opt-in /
      // team_option→exercise / non_guar→keep auto-seed and the LAL editorial opt-out seed.
      // per-mode FA defaults + draft picks
      if (!modeSeeded) {
        if (dd && !OPTION_KINDS.has(dd)) {
          dispatch({ type: "SET_DECISION", player: p.name, decision: { kind: dd } });
        } else if (!dd && (p.offseasonStatus === "UFA" || p.offseasonStatus === "RFA")) {
          dispatch({ type: "SET_DECISION", player: p.name, decision: { kind: "kept-hold" } });
        } else if (p.offseasonStatus === "draft_pick") {
          const pickMatch = (p.name || "").match(/#(\d+)/);
          seedPicks.push({
            id: "pick-" + p.name.replace(/\W+/g, ""), name: p.name,
            pickNumber: pickMatch ? parseInt(pickMatch[1], 10) : null,
            capHold: p.capHold || 0, note: p.note || "", disposition: "keep",
          });
        }
      }
    }
    if (!modeSeeded && seedPicks.length > 0) dispatch({ type: "SEED_DRAFT_PICKS", picks: seedPicks });
    seededOptsRef.current.add(team);
    seededModesRef.current.add(modeKey);
  }, [activePlayers, state.team, state.mode, resetNonce]);

  useEffect(() => {
    const root = document.documentElement;
    root.classList.remove("dark", "light");
    root.classList.add(theme);
    localStorage.setItem("capmvp-theme", theme);
    // Palette (dev) is PER-THEME: data-palette="<theme>-<variant>" overrides the
    // base block; the base value (moody / daylight) clears it.
    const pal = theme === "light"
      ? (localStorage.getItem("capmvp-palette-light") || "daylight")
      : (localStorage.getItem("capmvp-palette") || "moody2");
    const base = theme === "light" ? "daylight" : "moody";
    if (pal === base) root.removeAttribute("data-palette");
    else root.dataset.palette = theme + "-" + pal;
  }, [theme]);

  useEffect(() => {
    document.documentElement.setAttribute("data-density", tweaks.density);
  }, [tweaks.density]);

  // #0.5 (round 9): compact app header is always on — gear toggle dropped.
  useEffect(() => {
    document.documentElement.setAttribute("data-compact-header", "");
  }, []);

  // Header layout density (gear → Header layout): tight | standard | roomy.
  useEffect(() => {
    document.documentElement.setAttribute("data-header-style", tweaks.headerStyle || "standard");
  }, [tweaks.headerStyle]);

  // Cap-mode step bar style (gear → Step bar): compact | mini | left.
  useEffect(() => {
    document.documentElement.setAttribute("data-stepbar", tweaks.stepBar || "mid");
  }, [tweaks.stepBar]);

  // #4c: how many lines the payroll bar shows (3 slots for apron/tax, 4 for
  // "all"), so the strip grid can widen to fit the extra luxury-tax slot.
  useEffect(() => {
    document.documentElement.setAttribute("data-headerlines", tweaks.headerLines || "all");
    document.documentElement.setAttribute("data-linelabel", tweaks.lineLabel || "word");
  }, [tweaks.headerLines, tweaks.lineLabel]);

  // Per-team skin. `teamSkin` is "off" | "a" | "b"; A = team primary
  // accent, B = secondary. Colours come from the active team.
  useEffect(() => {
    const root = document.documentElement;
    const mode = (tweaks.teamSkin === "a" || tweaks.teamSkin === "b") ? tweaks.teamSkin : "off";
    const sk = TEAM_SKINS[state.team];
    if (mode === "off" || !sk) {
      root.setAttribute("data-team-skin", "off");
      ["--skin-1", "--skin-2", "--skin-accent", "--skin-accent-soft", "--skin-accent-line"]
        .forEach(v => root.style.removeProperty(v));
      return;
    }
    const [c1, c2] = sk;
    const accent = mode === "b" ? c2 : c1;
    root.setAttribute("data-team-skin", mode);
    root.style.setProperty("--skin-1", c1);
    root.style.setProperty("--skin-2", c2);
    root.style.setProperty("--skin-accent", accent);
    root.style.setProperty("--skin-accent-soft", hexA(accent, 0.14));
    root.style.setProperty("--skin-accent-line", hexA(accent, 0.40));
  }, [tweaks.teamSkin, state.team]);

  useEffect(() => {
    sessionStorage.setItem("capmvp-step", step);
    if (step > maxStepReached) {
      setMaxStepReached(step);
      sessionStorage.setItem("capmvp-maxstep", step);
    }
    // Changing step should bring you back to the top of the page.
    window.scrollTo({ top: 0, behavior: "auto" });
  }, [step]);

  useEffect(() => {
    if (state.toast) {
      const t = setTimeout(() => dispatch({ type: "SET_TOAST", toast: null }), state.toastMs || 2200);
      return () => clearTimeout(t);
    }
  }, [state.toast]);

  // Mobile: track whether the page has scrolled, to collapse the sticky top
  // stack (full salary bar → one-liner; step tabs → titles-only).
  // HYSTERESIS: collapsing shrinks the sticky stack, which can nudge the scroll
  // position back across a single threshold and flip-flop. Two thresholds with a
  // dead band (16–64px) wider than the height change prevent that oscillation.
  useEffect(() => {
    if (!isMobile) { setScrolled(false); return; }
    const onScroll = () => setScrolled(prev => prev ? window.scrollY > 16 : window.scrollY > 64);
    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, [isMobile]);

  const buckets = useMemo(() => bucketPlayers(activePlayers, state), [activePlayers, state]);

  // Mode-aware flow. Every team now carries rich data, so the cap-team flow is
  // available to all; `isLite` is retained as a harmless guard (false in
  // practice now that no team uses the lite roster).
  const isLite = !!(activePlayers[0] && activePlayers[0]._lite);
  const capFlow = state.mode === "cap" && !isLite;
  // Default (above-cap) = SINGLE PAGE: no Options/Build-Roster steps; option/waiver
  // decisions are made in-row. The cap-space flow keeps its multi-step wizard.
  // `singlePage` gates every single-page-only behavior and defaults false where it's
  // threaded, so the cap path is provably untouched. (isLite is false in practice ⇒
  // singlePage ⟺ apron mode.)
  const singlePage = !capFlow;
  const steps = capFlow
    ? [{ n: 1, label: "Options" }, { n: 2, label: "Cap Holds" }, { n: 3, label: "Cap Moves" }, { n: 4, label: "Final Roster" }]
    : [{ n: 1, label: "Options" }, { n: 2, label: "Build Roster" }];
  const stepCount = steps.length;
  // Cap mode = the 4-step wizard (Options · Cap Holds · Cap Moves · Final
  // Roster) with the numbered step header + Back/Next. Over-cap = single page.
  const apronView = capFlow && step === 4;          // cap step 4 = apron ops (full salaries)
  const derived = useDerived(activePlayers, state, apronView);
  // One-world (#14, Phase 2): run computeDerived for EVERY team from the single
  // store, so any team's payroll/zone is readable anywhere. The Trade Machine
  // reads `tradeBase` for opponent PRE_SALARY (a future league view can read
  // committed/zone). The active team's entry === the `derived` above.
  //   • full      = computeDerived over the whole roster in the team's own mode
  //                 (== the roster page's figure for that team; committed/zone).
  //   • tradeBase = holds-free apron base with TWO-WAYS EXCLUDED (CBA §1.5) +
  //                 drafted-rookie holds — i.e. the old teamCommitted, but now
  //                 decision-aware. This is the opponent's pre-trade Team Salary.
  // Deps are narrowed to the parts selectTeamView/computeDerived actually read
  // (options + rosters + modeByTeam + team), NOT the whole `state` — so a Phase-4
  // draftTrade edit (which changes scenarios[active] identity but leaves
  // options/rosters untouched) does NOT recompute all 30 teams (design perf note).
  const _activeSc = (state.scenarios && state.activeScenario) ? state.scenarios[state.activeScenario] : null;
  const derivedByTeam = useMemo(() => {
    const out = {};
    const codes = allTeams ? Object.keys(allTeams) : [];
    for (const code of codes) {
      const players = richTeamPlayers(allTeams, code, nbaIdByName);
      const view = selectTeamView(state, code);
      // draft-pick holds: visited teams carry them in the bucket; for un-visited
      // teams synthesize from the draft_pick rows (mirrors the App seed effect +
      // the old teamCommitted) so every team's base includes drafted-rookie holds.
      let dpicks = view.draftPicks;
      if ((!dpicks || !dpicks.length) && code !== state.team) {
        dpicks = players.filter(p => p.offseasonStatus === "draft_pick")
          .map(p => ({ id: "seedpick-" + (p.name || "").replace(/\W+/g, ""), capHold: p.capHold || 0, disposition: "keep" }));
      }
      const av = (code === state.team) ? apronView : false;
      const full = computeDerived(players, view.decisions, view.mode, view.additions, dpicks, av);
      const noTwoWay = players.filter(p => p.contractType !== "two_way");
      const tradeBase = computeDerived(noTwoWay, view.decisions, "apron", view.additions, dpicks, false).committed;
      // c (owner note, 2026-06-02): holds-INCLUSIVE Team Salary (cap mode) — the correct
      // base for the Trade Machine's cap-room test. Without it, a team below the cap by
      // SALARY but over once FA/draft cap holds count got phantom room and accepted
      // trades it shouldn't (the previously-deferred "FA-holds-in-base" gap).
      const capBase = computeDerived(noTwoWay, view.decisions, "cap", view.additions, dpicks, false).committed;
      // Issue-c (Option 1): per-signing "freed" amounts so the banner can name the EXACT cut. For each
      // cuttable signing (a non-trade FA addition, or a re-sign decision), freed = how much the base DROPS
      // if it's removed — computed via the SAME computeDerived recompute-without (ground truth; no drift).
      // Gated on the team actually having signings (most teams skip → []).
      const _nonTradeAdds = (view.additions || []).filter(x => !x._fromTrade);
      const _reSignNames = Object.keys(view.decisions || {}).filter(nm => view.decisions[nm] && view.decisions[nm].kind === "signed");
      // freed must be differenced on the SAME measure each overage uses (adversarial finding 2026-06-05),
      // or it drifts: cap-room overage strips the incomplete-roster charge (so removing a signing that drops
      // the roster below 12 wouldn't otherwise net out the empty-slot charge); hard-cap overage is apronTotal
      // PLUS unlikely bonuses (apron committed omits them). Match both so "freed" is the real relief.
      const _ic = (rc) => Math.max(0, (CAP_2026.rosterMin || 0) - (rc || 0)) * (CAP_2026.incompleteCharge || 0);
      const _measure = (m, r) => m === "cap"
        ? (r.committed || 0) - _ic(r.rosterCount)
        : (r.apronTotal != null ? r.apronTotal : (r.committed || 0)) + (r.unlikely || 0);
      const cutsFor = (m) => {
        if (!_nonTradeAdds.length && !_reSignNames.length) return [];
        const baseVal = _measure(m, computeDerived(noTwoWay, view.decisions, m, view.additions, dpicks, false));
        const cuts = [];
        for (const a of _nonTradeAdds) {
          const without = _measure(m, computeDerived(noTwoWay, view.decisions, m, view.additions.filter(x => x.id !== a.id), dpicks, false));
          cuts.push({ name: a.name, freed: Math.round(baseVal - without), kind: "signing" });
        }
        for (const nm of _reSignNames) {
          const decNo = { ...view.decisions }; delete decNo[nm];
          const without = _measure(m, computeDerived(noTwoWay, decNo, m, view.additions, dpicks, false));
          cuts.push({ name: nm, freed: Math.round(baseVal - without), kind: "re-sign" });
        }
        return cuts.filter(c => c.freed > 0).sort((x, y) => y.freed - x.freed);
      };
      out[code] = { ...full, tradeBase, capBase, capCuts: cutsFor("cap"), apronCuts: cutsFor("apron") };
    }
    return out;
  }, [allTeams, nbaIdByName, apronView, state.team, state.modeByTeam, state.activeScenario,
      _activeSc && _activeSc.options, _activeSc && _activeSc.rosters]);
  // Dev diagnostic: publish a compact per-team snapshot so __capBaseline-style
  // checks can read the live figures without opening the Trade Machine.
  useEffect(() => {
    const snap = {};
    for (const [code, d] of Object.entries(derivedByTeam))
      snap[code] = { committed: Math.round(d.committed), tradeBase: Math.round(d.tradeBase), capBase: Math.round(d.capBase || 0), apronTotal: Math.round(d.apronTotal), unlikely: Math.round(d.unlikely || 0), zone: d.zone };   // 4d: unlikely added so reSignBlocked can build the apron measure
    window.__derivedByTeam = snap;
  }, [derivedByTeam]);
  const isRosterStep = !capFlow || step === stepCount;
  const overCapBy = derived.isUnderCap ? 0 : derived.committed - CAP_2026.cap;
  // Issue b (owner 2026-06-04): NO under-cap gate. A team may reach the final/trade page (step 4 =
  // the universal RosterStep) ANY time and operate there like the Trade Machine — illegal moves are
  // already blocked at the page (signingLegality gates cap signings by capBase, trades by the engine
  // seam). So a mixed cap+over-cap plan is possible; to do a cap signing you free room first.
  const canAdvance = true;

  // Lite teams can't be cap teams (no cap-hold data) — force apron.
  useEffect(() => {
    if (isLite && state.mode === "cap") dispatch({ type: "SET_MODE", mode: "apron" });
  }, [isLite, state.mode]);

  // #3c: align the right panel's top with the FIRST player-listing box, not the
  // salary bar / step tabs / strategy box. Measure where the first non-chrome
  // child of .page-main sits and offset .page-side to match. Re-runs on any
  // layout-affecting state change + on resize.
  const pageMainRef = useRef(null);
  const pageSideRef = useRef(null);
  useEffect(() => {
    if (isMobile) return;
    const align = () => {
      const main = pageMainRef.current, side = pageSideRef.current;
      if (!main || !side) return;
      const mainTop = main.getBoundingClientRect().top;
      const topOf = (el) => el ? el.getBoundingClientRect().top - mainTop : null;
      // Left-column landmarks each right-panel block lines up with:
      //  • strategy box  (.strat-anchor)
      //  • the actions heading: "Decide these first" / "Roster" / "Cap Hits"
      //    (the section-intro or committed-head that owns the player area)
      //  • first player CARD (.section)
      const stratTop = topOf(main.querySelector(".strat-anchor"));
      const cardTop = topOf(main.querySelector(".section"));
      // Reset margins first so each block's natural position is clean.
      const blocks = [...side.querySelectorAll(".side-align")];
      blocks.forEach(b => { b.style.marginTop = "0px"; });
      // [g / V3 #4] SELF-CORRECTING align: instead of one delta from a pre-reset
      // measurement (the old single-pass left a ~14px residual when blocks interact
      // or layout hadn't settled), measure each block's CURRENT residual gap and
      // nudge by exactly that, iterating to converge.
      const targetFor = (b) => {
        if (b.dataset.align === "strategy") return { el: b, want: stratTop };
        // exceptions: align the CARD (not the block top) to the first player card.
        return { el: b.querySelector(".exceptions-card") || b, want: cardTop };
      };
      for (let pass = 0; pass < 2; pass++) {
        blocks.forEach(b => {
          const { el, want } = targetFor(b);
          if (want == null || !el) return;
          const cur = el.getBoundingClientRect().top - mainTop;       // where the target row is NOW
          const curMargin = parseFloat(b.style.marginTop) || 0;
          const next = curMargin + (want - cur);                      // close the residual gap exactly
          b.style.marginTop = Math.max(0, next) + "px";               // never negative (actions would poke off-page)
        });
      }
    };
    // Re-align across several settle points; the math is idempotent (reads the
    // CURRENT gap and closes it), so repeated runs just converge to gap 0.
    const timers = [];
    const settle = () => {
      requestAnimationFrame(() => requestAnimationFrame(align));
      [30, 90, 200, 400].forEach(ms => timers.push(setTimeout(align, ms)));
    };
    align(); settle();
    // Observe the MAIN column only (its height drives where the cards sit; we only
    // write margins to SIDE blocks, so observing main won't self-trigger).
    const ro = new ResizeObserver(settle);
    if (pageMainRef.current) ro.observe(pageMainRef.current);
    const mo = new MutationObserver(settle);
    if (pageMainRef.current) mo.observe(pageMainRef.current, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "style"] });
    window.addEventListener("resize", settle);
    return () => { ro.disconnect(); mo.disconnect(); window.removeEventListener("resize", settle); timers.forEach(clearTimeout); };
  }, [isMobile, capFlow, step, state.decisions, state.mode, state.team, state.modeConfirmed, derived.committed,
      tweaks.stepBar, tweaks.headerLines]);

  // Keep `step` valid. `mode` isn't persisted but `step` is, so a stale
  // sessionStorage step can exceed the count after reload — clamp on
  // first run; restart the wizard when the flow shape changes.
  const flowRef = useRef(null);
  useEffect(() => {
    const firstRun = flowRef.current === null;
    flowRef.current = capFlow;
    if (firstRun) {
      if (step > stepCount) setStep(1);
      if (maxStepReached > stepCount) setMaxStepReached(stepCount);
    } else {
      setStep(1); setMaxStepReached(1);
    }
  }, [capFlow]);

  if (tool === "calc") {
    return <CalcPlaceholder tool={tool} setTool={setTool} theme={theme} setTheme={setTheme} />;
  }

  // Layout is now fixed: bars span full width; Sign FA / Trades live in the
  // right panel (desktop). These were gear experiments — locked in as default.
  const barsFull = true;
  const actionsAbove = true;
  const teamCanUseCap = (CAP_SPACE_TEAMS || []).includes(state.team) || state.capForAllTeams;
  // The cap-strategy editor (full box + About card) is open either because the
  // team hasn't confirmed yet, OR the user re-opened it via the chip. This is the
  // ONLY way to change strategy now (no separate chip dropdown).
  const strategyOpen = !capFlow && teamCanUseCap &&
    (!state.modeConfirmed?.[state.team] || strategyExpanded);
  // (D) Cap-flow Step 1 has its OWN strategy open/collapsed state. You're already in
  // cap mode there, so it is NOT gated on teamCanUseCap: confirming the pick collapses
  // the editor to the chip, and the chip re-opens it (via strategyExpanded). Scoped to
  // the strategy block only — Step 1's option tables stay visible regardless.
  const capStrategyOpen = !state.modeConfirmed?.[state.team] || strategyExpanded;
  // The right-panel "About this strategy" card shows whenever the editor is on
  // screen: over-cap → strategyOpen; cap-flow Step 1 → capStrategyOpen. Either way the
  // box-to-box alignment targets .strat-anchor.
  const showStrategyBox = !isMobile && (strategyOpen || (capFlow && step === 1 && capStrategyOpen));

  const headerEl = (
    <Header state={state} dispatch={dispatch} derived={derived} theme={theme} setTheme={setTheme} teams={teams} tweaks={tweaks} setTweak={setTweak} tool={tool} setTool={setTool} onOpenReset={() => setResetOpen(true)} onSnapshot={() => setModal("snapshot")} />
  );
  const stepTabsEl = (compact) => (
    // issue b (owner 2026-06-04): final/trade page reachable any time — no under-cap lock
    <StepTabs step={step} setStep={setStep} maxStepReached={maxStepReached} steps={steps}
              lockedFrom={null} compact={compact} />
  );

  return (
    <>
      {isMobile ? (
        /* Mobile: header + salary bar pin together as ONE sticky stack. Cap mode
           adds the step tabs below (over-cap is single-page, no tabs). */
        <div className={`mobile-topstack ${scrolled ? "scrolled" : ""}`}>
          {headerEl}
          <MobileBar derived={derived} state={state} apronView={apronView} scrolled={scrolled} headerLines={tweaks.headerLines || "all"} lineLabelMode={tweaks.lineLabel || "short"} />
          {capFlow && stepTabsEl(scrolled)}
        </div>
      ) : (
        /* Desktop: just the header. The salary bar (+ the cap step tabs) live
           INSIDE the roster column so they match its width (B / C-2). */
        headerEl
      )}

      <main className={"page" + (!isMobile ? " has-sidepanel" : "") + (!capFlow ? " is-single" : "")}>
        {/* Apron-zone alert spans BOTH columns (above the grid) so the roster +
            chart stay top-aligned whether or not it's showing (M.1). The
            over-2nd-apron banner was removed; only the 1st-apron hard-cap
            warning remains here. */}
        {isRosterStep && derived.zone === "apron-zone-1" && (
          <div className="page-alerts">
            <Alert tone="warn">{`Over the 1st apron (${fmt$(CAP_2026.apron1)}) — using the full MLE / taking back extra salary hard-caps you here.`}</Alert>
          </div>
        )}
        {/* Salary bar + cap step tabs are a full-width band spanning BOTH columns
            (locked default). The roster column + right panel align on the row
            below them. Mobile keeps them in the sticky topstack above. */}
        {!isMobile && (
          <div className="page-bars-full">
            <div className="page-salary">
              <MobileBar derived={derived} state={state} apronView={apronView}
                         scrolled={false} headerLines={tweaks.headerLines || "all"} lineLabelMode={tweaks.lineLabel || "short"} />
            </div>
            {capFlow && <div className="page-steptabs">{stepTabsEl(false)}</div>}
          </div>
        )}
        <div className="page-main" ref={pageMainRef}>
          <PendingTradeBanner state={state} dispatch={dispatch} onOpen={() => setModal("trade")} />
          {capFlow && step === 1 && <Step1Options buckets={buckets} state={state} dispatch={dispatch} teams={teams} isLite={isLite}
            strategyEditorOpen={capStrategyOpen} onOpenStrategy={() => setStrategyExpanded(true)} onCloseStrategy={() => setStrategyExpanded(false)} />}
          {capFlow && step === 2 && <Step2CapHolds buckets={buckets} state={state} dispatch={dispatch} derived={derived} />}
          {capFlow && step === 3 && <Step2CapRoster buckets={buckets} state={state} dispatch={dispatch} derived={derived} openModal={setModal} openDraftPicker={openDraftPicker} />}
          {(!capFlow || step === 4) &&
            <RosterStep buckets={buckets} state={state} dispatch={dispatch} derived={derived} derivedByTeam={derivedByTeam} openModal={setModal} openDraftPicker={openDraftPicker} apronView={apronView} singlePage={!capFlow}
              canUseCap={teamCanUseCap} actionsAbove={actionsAbove && !isMobile}
              strategyOpen={strategyOpen} onOpenStrategy={() => setStrategyExpanded(true)} onCloseStrategy={() => setStrategyExpanded(false)} />}

          {capFlow && (
            <StepNav step={step} setStep={setStep} steps={steps} canAdvance={canAdvance}
                     onBlockedAdvance={() => dispatch({ type: "SET_TOAST", ms: 5000,
                       toast: `${fmt$(overCapBy)} over the cap — renounce holds or drop a signing to continue.` })} />
          )}

          {/* Mobile: thermometer at the end of the page (unchanged). */}
          {isMobile && <MobileBottomThermometer derived={derived} state={state} apronView={apronView} />}
        </div>
        {/* Desktop right panel. Its top is measured to align with the first
            player CARD (#3c). Contents, top-to-bottom:
            • #3 toggle 2 ON  → the Sign FA / Trades actions (moved here)
            • #3 toggle 2 OFF + strategy box showing → a team strategy info card
              (lines up box-to-box with the on-page Cap-strategy box)
            • Exceptions card, then the chart. */}
        {!isMobile && (
          <aside className="page-side" ref={pageSideRef}>
            {/* Two aligned anchors (margin-top set by usePageSideAlign):
                  • about-strategy → the Cap-strategy BOX (.strat-anchor)
                  • exceptions block → the first player CARD (.section)
                Sign FA / Trades are NOT separately aligned anymore — they ride
                just ABOVE the exceptions card (small fixed gap), so the hard
                line-up is Exceptions ↔ first player card. */}
            {showStrategyBox && <div className="side-align" data-align="strategy"><StrategyInfoCard state={state} /></div>}
            <div className="side-align" data-align="exceptions">
              {/* issue a (owner 2026-06-04): the Sign FA / Trades actions belong on the trade pages
                  only — show on the over-cap single page, else only on cap steps 3-4 (after the user
                  has set up their cap space on steps 1-2). */}
              {(!capFlow || step >= 3) && (
                <div className="side-actions">
                  <button className="btn" onClick={() => setModal("sign-fa")}><Icon.UserPlus /> Sign FA</button>
                  <button className="btn" onClick={() => setModal("trade")}><Icon.Trade /> Trades</button>
                </div>
              )}
              <ExceptionsCard derived={derived} />
              <MobileBottomThermometer derived={derived} state={state} apronView={apronView} />
            </div>
          </aside>
        )}
      </main>

      {/* New full-bleed Trade Machine (replaces the old TradeMachineModal, which
          is retired but kept in modals.jsx as dead code for now). Rendered as a
          fixed overlay that takes over the whole window; Exit returns. */}
      {modal === "trade" && (
        <div className="tm-fullscreen">
          <TradeMachineView allTeams={allTeams} state={state}
            dispatch={dispatch} derived={derived} teams={teams} players={activePlayers}
            draftAssets={draftAssets} picksData={picksData} nbaIdByName={nbaIdByName} teamsExc={teamsExc} derivedByTeam={derivedByTeam} onExit={() => setModal(null)}
            theme={theme} setTheme={setTheme} tool={tool} setTool={setTool}
            siteTweaks={tweaks} setSiteTweak={setTweak} onOpenReset={() => setResetOpen(true)} />
        </div>
      )}
      {modal === "sign-fa" && <SignFAModal onClose={() => setModal(null)} state={state} dispatch={dispatch} allTeams={allTeams}
        capBase={derivedByTeam[state.team] && derivedByTeam[state.team].capBase}
        apronTotal={derivedByTeam[state.team] && derivedByTeam[state.team].apronTotal}
        unlikely={derivedByTeam[state.team] && derivedByTeam[state.team].unlikely}
        additions={(selectTeamView(state, state.team) || {}).additions || []} />}
      {modal === "snapshot" && <SnapshotModal onClose={() => setModal(null)} state={state} players={activePlayers} derived={derived}
        teamCode={state.team} teamName={(teams.find(t => t.code === state.team) || {}).name} />}
      {modal === "draft-prospect" && <DraftProspectModal onClose={() => setModal(null)} pickId={draftTarget} dispatch={dispatch} />}
      {modal === "trade-history" && <TradeHistoryModal onClose={() => setModal(null)} focusId={tradeHistoryFocus} state={state} dispatch={dispatch} teams={teams} />}

      {resetOpen && (
        <Modal onClose={() => setResetOpen(false)}>
          <div className="modal-head">
            <div>
              <h3>Reset rosters</h3>
              <div className="desc">Clear your off-season moves back to the seeded defaults. This can't be undone.</div>
            </div>
          </div>
          <div className="modal-body">
            <ResetChoices
              teamSub={(shortTeam ? shortTeam(state.team) : state.team) + " · Cap + Apron"}
              onResetTeam={resetThisTeam} onResetAll={resetAllTeams} />
          </div>
          <div className="modal-foot">
            <button className="btn" onClick={() => setResetOpen(false)}>Cancel</button>
          </div>
        </Modal>
      )}

      <CapMvpTweaks tweaks={tweaks} setTweak={setTweak} />

      {state.toast && <div className="toast" onClick={() => dispatch({ type: "SET_TOAST", toast: null })}>{state.toast}</div>}
    </>
  );
}

/* ============================================================
   Bucketing
   ============================================================ */
function bucketPlayers(players, state) {
  const underContract = [];
  const optsP = [];
  const optsT = [];
  const nonGuar = [];
  const ufas = [];
  const rfas = [];
  const deadMoney = [];   // one-world #14: pre-existing dead money → tombstone tier, never an option/roster bucket

  for (const p of players) {
    if (p.deadMoney) { deadMoney.push(p); continue; }   // e.g. MIL Lillard / PHX Beal (waived+stretched) — counts in Team Salary but isn't a tradeable/decision-able row
    switch (p.offseasonStatus) {
      case "under_contract":  underContract.push(p); break;
      case "player_option":   optsP.push(p); break;
      case "team_option":     optsT.push(p); break;
      case "partially_guaranteed":   // FIX: render + decide like a non-guaranteed row (full salary kept; guaranteed floor if waived)
      case "non_guaranteed":  nonGuar.push(p); break;
      case "UFA":             ufas.push(p); break;
      case "RFA":             rfas.push(p); break;
    }
  }
  return { underContract, optsP, optsT, nonGuar, ufas, rfas, deadMoney };
}

/* ============================================================
   Step tabs — single-line labels, completion checkmark.
   A step is "complete" when the user has moved past it.
   ============================================================ */
// Reset wipes off-season work and can't be undone, so it's a two-step confirm:
// the choices stay neutral (house style) until you ARM one — it turns our dark
// red and reveals a ✓ that actually performs the reset. Re-tapping the armed
// choice (or arming the other) cancels; closing the modal unmounts + resets it.
function ResetChoices({ teamSub, onResetTeam, onResetAll }) {
  const [armed, setArmed] = useState(null); // "team" | "all" | null
  const row = (id, label, sub, onConfirm) => {
    const isArmed = armed === id;
    return (
      <div className="reset-choice-row">
        <button className={"btn reset-choice" + (isArmed ? " armed" : "")}
                onClick={() => setArmed(a => (a === id ? null : id))} aria-pressed={isArmed}>
          <span>{label}</span>
          <span className="reset-sub">{sub}</span>
        </button>
        {isArmed && (
          <button className="btn reset-go" title="Confirm — this can't be undone"
                  aria-label={"Confirm: " + label} onClick={onConfirm}>
            <Icon.Check />
          </button>
        )}
      </div>
    );
  };
  return (
    <div className="reset-choices">
      {row("team", "Reset this team", teamSub, onResetTeam)}
      {row("all", "Reset all teams", "every team, both modes", onResetAll)}
    </div>
  );
}

function StepTabs({ step, setStep, maxStepReached, steps, lockedFrom, compact }) {
  return (
    <div className={`step-tabs ${compact ? "compact" : ""}`}>
      <div className="step-tabs-inner">
        {steps.map(s => {
          const isActive = step === s.n;
          const isDone = maxStepReached > s.n && !isActive;
          // Same gate as the Next button — can't tab past it either.
          const locked = lockedFrom != null && s.n >= lockedFrom && s.n > step;
          return (
            <button key={s.n}
                    className={`step-tab ${isActive ? "active" : ""} ${isDone ? "done" : ""} ${locked ? "locked" : ""}`}
                    disabled={locked}
                    title={locked ? "Get under the cap to continue" : undefined}
                    onClick={() => { if (!locked) setStep(s.n); }}>
              <span className="step-num">
                {isDone ? <Icon.Check style={{ width: 14, height: 14 }} /> : s.n}
              </span>
              <span className="step-label">{s.label}</span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

function StepNav({ step, setStep, steps, canAdvance, onBlockedAdvance }) {
  const lastStep = steps[steps.length - 1].n;
  const nextLabel = (steps.find(s => s.n === step + 1) || {}).label;
  // When over the cap the Next button stays tappable (not disabled) so the tap
  // can surface a transient toast explaining why it's blocked — the persistent
  // red banner is gone (it duplicated the salary bar's "over cap").
  return (
    <div className="step-nav">
      <button className="btn" disabled={step === 1} onClick={() => setStep(step - 1)}>← Back</button>
      <span style={{ flex: 1 }} />
      {step < lastStep && (
        <button className={`btn btn-primary ${canAdvance ? "" : "is-blocked"}`}
                onClick={() => { if (canAdvance) setStep(step + 1); else onBlockedAdvance && onBlockedAdvance(); }}>
          Next{nextLabel ? `: ${nextLabel}` : ""} →
        </button>
      )}
    </div>
  );
}

/* ============================================================
   Step 1 — Options
   ============================================================ */
function Step1Options({ buckets, state, dispatch, teams, isLite, strategyEditorOpen = true, onOpenStrategy, onCloseStrategy }) {
  const hasOptions = buckets.optsP.length || buckets.optsT.length || buckets.nonGuar.length;
  return (
    <>
      {/* Cap mode uses the SAME checkmark strategy editor as over-cap (never the
          old sans-checkmark two-card picker). data-aboveplayers keeps the
          right-panel alignment skipping past it to the first player box.
          (D) Once the strategy is confirmed, this block collapses to the chip — the
          option tables below stay visible. The chip re-opens the editor; confirming
          (onCloseStrategy) collapses it again. */}
      <div data-aboveplayers>
        {strategyEditorOpen ? (
          <>
            <SectionIntro title="Cap strategy"
              desc="Choose how this team operates this off-season. This placeholder text will explain, in two sentences, what cap space vs. staying over the cap means for the moves you can make." />
            <div className="strat-anchor"><ModeDecideRow state={state} dispatch={dispatch} onConfirm={onCloseStrategy} /></div>
          </>
        ) : (
          <SectionIntro title="Cap strategy" action={<ModeChip state={state} onOpen={onOpenStrategy} />} />
        )}
      </div>

      {buckets.optsP.length > 0 && (
        <>
          <SectionIntro title="Player options" count={buckets.optsP.length} />
          <PlainTable players={buckets.optsP} state={state} dispatch={dispatch} kind="roster" />
        </>
      )}

      {buckets.optsT.length > 0 && (
        <>
          <SectionIntro title="Team options" count={buckets.optsT.length} />
          <PlainTable players={buckets.optsT} state={state} dispatch={dispatch} kind="roster" />
        </>
      )}

      {buckets.nonGuar.length > 0 && (
        <>
          <SectionIntro title="Non-guaranteed" count={buckets.nonGuar.length} />
          <PlainTable players={buckets.nonGuar} state={state} dispatch={dispatch} kind="roster" />
        </>
      )}

      {!hasOptions && (
        <div className="apron-blurb" style={{ marginTop: 14 }}>
          <span className="ico"><Icon.Check style={{ width: 16, height: 16 }} /></span>
          <div>No options to resolve for this team. The strategy above sets the rest.</div>
        </div>
      )}
    </>
  );
}

/* ============================================================
   Cap-strategy picker: two thin stacked options at the top of Step 1
   (compact, minimal text). "Cap-space team" = cap mode; "Over-the-cap
   team" = apron mode. The isLite disable path is inert (all teams have data).
   ============================================================ */
function CapStrategyPicker({ state, dispatch, teams = [], isLite }) {
  const team = teams.find(t => t.code === state.team)
            || teams.find(t => t.code === "LAL")
            || teams[0] || { code: state.team, recommendedMode: null };
  // Cap-space first (#4a), then over-the-cap.
  const rows = [
    { mode: "cap",   title: "Cap-space team",    desc: `Clear room to sign. Room MLE ${fmt$(CAP_2026.roomMle)}.`, disabled: isLite },
    { mode: "apron", title: "Over-the-cap team", desc: "Bird rights, the MLE and trades.", disabled: false },
  ];
  return (
    <div className="strategy-rows">
      {rows.map(r => {
        const on = r.mode === "cap" ? (state.mode === "cap" && !isLite) : (state.mode === "apron" || isLite);
        return (
          <button key={r.mode}
            className={`strategy-row ${on ? "on" : ""} ${r.disabled ? "disabled" : ""}`}
            disabled={r.disabled}
            onClick={() => !r.disabled && dispatch({ type: "SET_MODE", mode: r.mode })}>
            <span className="strategy-mark"><Icon.Check style={{ width: 12, height: 12 }} /></span>
            <span className="strategy-row-title">{r.title}</span>
            <span className="strategy-row-desc">{r.desc}</span>
          </button>
        );
      })}
    </div>
  );
}

/* ============================================================
   Draft Picks — editable pick label, default Sign. No reorder arrows.
   ============================================================ */
function DraftPicksSection({ state, dispatch }) {
  function addPick() {
    const n = state.draftPicks.length + 1;
    dispatch({ type: "ADD_DRAFT_PICK", pick: {
      id: "pick-" + Date.now(),
      name: `Pick #${30 + n}`,
      capHold: 1_300_000,
      note: "",
      disposition: "keep",
    }});
  }
  return (
    <>
      <SectionIntro title="Draft picks" count={state.draftPicks.length}
        desc="Kept picks carry the rookie-scale salary on your books. Trade the pick before the draft for $0, or sign-and-trade. Click a pick to rename (e.g. trade up #25 → #17)." />
      <section className="section">
        <table className="tbl">
          <thead>
            <tr>
              <th style={{ width: "40%" }}>Pick</th>
              <th className="num" style={{ width: 150 }}>Salary</th>
              <th style={{ width: 360, textAlign: "right", paddingRight: 18 }}>Decision</th>
            </tr>
          </thead>
          <tbody>
            {state.draftPicks.length === 0 && (
              <tr>
                <td colSpan="3" style={{ padding: "20px 18px", color: "var(--text-faint)", fontStyle: "italic" }}>
                  No draft picks. Add one below.
                </td>
              </tr>
            )}
            {state.draftPicks.map((p, i) => (
              <DraftPickRow key={p.id} pick={p} index={i} dispatch={dispatch} />
            ))}
          </tbody>
        </table>
        <div style={{ padding: "10px 14px", borderTop: "1px solid var(--line)", background: "var(--bg-row)" }}>
          <button className="btn btn-ghost" onClick={addPick} style={{ height: 30, padding: "0 10px" }}>
            <Icon.Plus /> Add draft pick
          </button>
        </div>
      </section>
    </>
  );
}

/* A single draft pick row — has an editable pick number that auto-fills
   the cap hold from the rookie scale, plus a renameable label so users
   can put the actual prospect's name once known. */
function DraftPickRow({ pick: p, index, dispatch }) {
  const [editingName, setEditingName] = useState(false);
  const [draft, setDraft] = useState(p.name);
  useEffect(() => { if (!editingName) setDraft(p.name); }, [p.name, editingName]);

  function saveName() {
    const trimmed = draft.trim();
    if (trimmed && trimmed !== p.name) {
      dispatch({ type: "SET_DRAFT_PICK", id: p.id, patch: { name: trimmed } });
    }
    setEditingName(false);
  }

  function setPickNumber(raw) {
    const n = parseInt(raw, 10);
    if (!Number.isFinite(n) || n < 1 || n > 60) {
      // clear
      dispatch({ type: "SET_DRAFT_PICK", id: p.id, patch: { pickNumber: null } });
      return;
    }
    const hold = capHoldForPick(n);
    // If the label still looks auto-generated, sync it to the new pick #.
    const looksAuto = /Pick #\d+/.test(p.name) || /Draft Pick #\d+/.test(p.name);
    const patch = { pickNumber: n, capHold: hold };
    if (looksAuto) patch.name = p.name.replace(/#\d+/, "#" + n);
    dispatch({ type: "SET_DRAFT_PICK", id: p.id, patch });
  }

  return (
    <tr>
      <td>
        <div className="player-cell">
          <div className="player-photo-placeholder">{(index + 1).toString()}</div>
          <div className="player-meta">
            {editingName ? (
              <input
                className="inline-edit"
                autoFocus
                value={draft}
                onChange={(e) => setDraft(e.target.value)}
                onBlur={saveName}
                onKeyDown={(e) => {
                  if (e.key === "Enter") saveName();
                  if (e.key === "Escape") { setDraft(p.name); setEditingName(false); }
                }}
              />
            ) : (
              <button className="player-name-edit" onClick={() => setEditingName(true)} title="Click to rename pick">
                {p.name} <Icon.Pencil className="edit-hint" />
              </button>
            )}
            <div className="player-sub">
              <span className="pick-num-wrap">
                Pick #
                <input
                  type="number"
                  min="1" max="60"
                  className="pick-num-input"
                  value={p.pickNumber ?? ""}
                  placeholder="—"
                  onChange={(e) => setPickNumber(e.target.value)}
                  title="Set draft pick #1–60 — auto-fills cap hold"
                />
              </span>
              {p.pickNumber != null && (
                <span style={{ color: "var(--text-faint)" }}>
                  · rookie scale {p.pickNumber <= 30 ? "(1st round)" : "(2nd round)"}
                </span>
              )}
            </div>
          </div>
        </div>
      </td>
      <td className="num">{fmt$Full(draftPickSalary(p))}</td>
      <td style={{ paddingRight: 18 }}>
        <div className="row-actions" style={{ justifyContent: "flex-end" }}>
          <div className="decisions equal three">
            {[
              { key: "keep",           label: "Keep" },
              { key: "trade-pick",     label: "Trade pick" },
              { key: "sign-and-trade", label: "Sign & trade" },
            ].map(o => {
              const on = (p.disposition || "keep") === o.key;
              return (
                <button key={o.key} className={on ? "on-yes" : ""}
                        title={o.key === "trade-pick" ? "Pick is dealt before the draft — $0"
                             : o.key === "sign-and-trade" ? "Draft, sign, then trade (30-day) — salary on books until dealt"
                             : "Draft and keep — rookie-scale salary on the books"}
                        onClick={() => dispatch({ type: "SET_DRAFT_PICK", id: p.id, patch: { disposition: o.key } })}>
                  {on && <Icon.Check className="dec-icon" />}
                  <span className="dec-label">{o.label}</span>
                </button>
              );
            })}
          </div>
          <button className="pencil cancel" title="Remove this pick entirely"
                  onClick={() => dispatch({ type: "REMOVE_DRAFT_PICK", id: p.id })}>
            <Icon.X />
          </button>
        </div>
      </td>
    </tr>
  );
}

/* ============================================================
   Shared roster building blocks
   ============================================================ */
function buildCommittedRoster(buckets, state, apronView, singlePage = false) {
  const traded = (p) => state.decisions[p.name]?.kind === "traded";
  const waived = (p) => state.decisions[p.name]?.kind === "waive";
  const out = [];
  for (const p of buckets.underContract)
    if (!traded(p) && !waived(p)) out.push({ ...p, _salary: salaryOf(p, state, apronView) });
  // Options + non-guar appear in the roster only once SETTLED (kept / exercised / re-signed).
  // Undecided / opted-out / declined / waived are handled in the "Decide first" section.
  for (const p of buckets.optsP) {
    const k = state.decisions[p.name]?.kind;
    if (!traded(p) && (k === "opt-in" || k === "signed")) out.push({ ...p, _salary: salaryOf(p, state, apronView), _isOwnFA: k === "signed", _isOption: true });
  }
  for (const p of buckets.optsT) {
    const k = state.decisions[p.name]?.kind;
    if (!traded(p) && (k === "exercise" || k === "signed")) out.push({ ...p, _salary: salaryOf(p, state, apronView), _isOwnFA: k === "signed", _isOption: true });
  }
  for (const p of buckets.nonGuar) {
    const k = state.decisions[p.name]?.kind;
    if (!traded(p) && k === "keep") out.push({ ...p, _salary: salaryOf(p, state, apronView) });
  }
  // Dead-money tier — waived players. A waived partial leaves its GUARANTEED
  // amount on the books as dead money (downstream of the waive, not a property
  // of the kept contract); a fully non-guaranteed min deal leaves $0. These do
  // NOT occupy a roster slot (excluded from `out`, so they don't enter the
  // ghost-count math) but DO render as tombstone rows carrying the real figure.
  const deadList = [];
  // A USER waive of ANY on-books status (under_contract / opted-in option / kept-or-
  // partial non-guar) → a tombstone. The charge mirrors playerEffectiveSalary's
  // waivedCharge: a negotiated BUYOUT amount, a STRETCHED per-year share, or — default
  // — the STRAIGHT charge for that status (full salary for guaranteed; guaranteed
  // amount for a partial). Label + stretch years come from the decision.
  const s2627of = (p) => p.seasons?.find(s => s.season === "2026-27")?.salary || 0;
  const deadRow = (p, straight) => {
    const d = state.decisions[p.name] || {};
    const cap = (d.deadMoneyKind === "buyout"   && d.buyoutAmount  != null) ? d.buyoutAmount
              : (d.deadMoneyKind === "stretched" && d.stretchPerYear != null) ? d.stretchPerYear
              : straight;
    const lbl = d.deadMoneyKind === "buyout" ? "Buyout"
              : d.deadMoneyKind === "stretched" ? "Waived & Stretched"
              : "Waived";
    return { ...p, _deadCap: cap, _deadLabel: lbl, _deadYears: d.stretchYears || 1 };
  };
  for (const p of buckets.underContract) if (!traded(p) && waived(p)) deadList.push(deadRow(p, s2627of(p)));
  for (const p of buckets.optsP)         if (!traded(p) && waived(p)) deadList.push(deadRow(p, p.optionSalary || s2627of(p)));
  for (const p of buckets.optsT)         if (!traded(p) && waived(p)) deadList.push(deadRow(p, p.optionSalary || s2627of(p)));
  for (const p of buckets.nonGuar)       if (!traded(p) && waived(p)) deadList.push(deadRow(p, guaranteedAmountFor(p)));
  // Pre-existing dead money (waived-and-stretched, flagged `deadMoney` in the
  // data — e.g. Lillard/Beal): a fixed 2026-27 charge shown as a tombstone, never
  // a roster slot or a "player option". Counts in the total via playerEffectiveSalary.
  for (const p of (buckets.deadMoney || [])) {
    // label reflects how the dead money was created (data: deadMoneyKind);
    // stretched -> "Waived & Stretched", buyout -> "Buyout", else "Waived".
    const lbl = p.deadMoneyKind === "buyout" ? "Buyout"
              : p.deadMoneyKind === "stretched" ? "Waived & Stretched"
              : "Waived";
    deadList.push({ ...p,
      _deadCap: p.seasons?.find(s => s.season === "2026-27")?.salary || 0,
      _deadLabel: lbl,
      _deadYears: 1 });
  }
  for (const p of [...buckets.ufas, ...buckets.rfas])
    if (state.decisions[p.name]?.kind === "signed") out.push({ ...p, _salary: salaryOf(p, state, apronView), _isOwnFA: true });
  for (const dp of state.draftPicks)
    if (dp.disposition !== "trade-pick")
      out.push({ name: dp.name, position: "", _salary: draftPickSalary(dp), _isDraft: true, _draftId: dp.id });
  for (const a of state.additions)
    out.push({ name: a.name, position: a.position, _salary: a.salary, _addition: a, _isAddition: true, nbaId: a.nbaId || null });
  // R15: ordering. A manual drag order / salary-header sort is stored per team
  // in tmRowOrders (the same store the Trade Machine drag-reorder uses). Ranked
  // names render in that sequence; unranked NEW additions still pin to the very
  // top (so a freshly-acquired player surfaces), other unranked players fall in
  // by salary desc. With no stored order it's the plain default: additions on
  // top, the rest by salary desc.
  const rowOrder = state.tmRowOrders && state.tmRowOrders[state.team];
  const rank = (rowOrder && rowOrder.length) ? new Map(rowOrder.map((n, i) => [n, i])) : null;
  out.sort((a, b) => {
    if (rank) {
      const ra = rank.has(a.name) ? rank.get(a.name) : (a._isAddition ? -1 : Infinity);
      const rb = rank.has(b.name) ? rank.get(b.name) : (b._isAddition ? -1 : Infinity);
      if (ra !== rb) return ra - rb;
    }
    if (!!a._isAddition !== !!b._isAddition) return a._isAddition ? -1 : 1;
    return (b._salary || 0) - (a._salary || 0);
  });
  // Carry the dead-money tier alongside the active roster array (an extra own
  // property leaves .length/.slice/.sort/.map untouched, so both call sites'
  // existing destructuring keeps working).
  out._deadList = deadList;
  return out;
}

/* ============================================================
   PendingTradeBanner — one-world #14, Phase 5a
   ------------------------------------------------------------
   Surfaces the active (un-applied) draft trade on the ROSTER page. Reads
   scenarios[active].draftTrade straight from the shared store (Phase 4) — so a
   half-built deal is no longer invisible once you leave the Trade Machine. Shows
   the managed team's pending outgoing (→ dest) + incoming (← from). Renders
   nothing when there's no pending activity for the current team.
   ============================================================ */
function PendingTradeBanner({ state, dispatch, onOpen }) {
  const [hidden, setHidden] = useState(false);
  const sc = (state.scenarios && state.activeScenario) ? state.scenarios[state.activeScenario] : null;
  const dt = sc && sc.draftTrade;
  if (!dt || !Array.isArray(dt.slots) || !dt.slots.length) return null;
  const me = dt.slots.find(s => s && s.code === state.team && !s.hidden);
  if (!me) return null;
  const out = [];
  for (const [name, m] of Object.entries(me.players || {})) if (m && m.dest && !m.declined) out.push({ name, dest: m.dest });
  for (const [name, m] of Object.entries(me.fas || {}))     if (m && m.dest) out.push({ name, dest: m.dest, snt: true });
  const outPicks = Object.values(me.picks || {}).filter(m => m && m.dest).length;
  const inc = [], otherSlots = dt.slots.filter(s => s && s.code !== state.team && !s.hidden);
  let incPicks = 0;
  for (const s of otherSlots) {
    for (const [name, m] of Object.entries(s.players || {})) if (m && m.dest === state.team && !m.declined) inc.push({ name, from: s.code });
    for (const [name, m] of Object.entries(s.fas || {}))     if (m && m.dest === state.team) inc.push({ name, from: s.code, snt: true });
    incPicks += Object.values(s.picks || {}).filter(m => m && m.dest === state.team).length;
  }
  if (!out.length && !inc.length && !outPicks && !incPicks) return null;
  if (hidden) return null;
  return (
    <div className="pending-trade-banner" role="status">
      <span className="ptb-icon"><Icon.Trade /></span>
      <div className="ptb-body">
        <div className="ptb-head">Pending trade <span className="ptb-tag">not yet applied</span></div>
        <div className="ptb-chips">
          {out.map(o => <span key={"o-" + o.name} className="ptb-chip out" title={`${o.name} → ${o.dest}${o.snt ? " (sign-and-trade)" : ""} — pending`}>{o.name} <b>→ {o.dest}</b>{o.snt ? " · S&T" : ""}</span>)}
          {outPicks > 0 && <span className="ptb-chip out">{outPicks} pick{outPicks > 1 ? "s" : ""} <b>out</b></span>}
          {inc.map(o => <span key={"i-" + o.name} className="ptb-chip in" title={`${o.name} ← ${o.from}${o.snt ? " (sign-and-trade)" : ""} — pending`}><b>{o.from} →</b> {o.name}{o.snt ? " · S&T" : ""}</span>)}
          {incPicks > 0 && <span className="ptb-chip in"><b>in:</b> {incPicks} pick{incPicks > 1 ? "s" : ""}</span>}
        </div>
      </div>
      <div className="ptb-actions">
        <button className="ptb-btn resume" onClick={() => onOpen && onOpen()} title="Open the Trade Machine to finish this deal">Resume</button>
        <button className="ptb-btn discard" onClick={() => dispatch && dispatch({ type: "TM_SET_DRAFT", slots: [] })} title="Discard this pending trade">Discard</button>
        <button className="ptb-btn hide" onClick={() => setHidden(true)} title="Hide for now (keeps the trade)" aria-label="Hide">✕</button>
      </div>
    </div>
  );
}

/* autofocus+select salary input for the inline roster editor */
function RosterEditInput({ value, onChange, onSave, onCancel }) {
  const ref = useRef();
  useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
  return (
    <span className="salary-edit inline-final roster-medit">
      <span className="pre">$</span>
      <input ref={ref} type="text" inputMode="decimal" value={value}
        onChange={(e) => onChange(e.target.value)}
        onKeyDown={(e) => { if (e.key === "Enter") onSave(); if (e.key === "Escape") onCancel(); }}
        onFocus={(e) => e.target.select()} placeholder="e.g. 12.5" />
      {mFieldShowM(value) && <span className="pre" style={{ fontSize: 12 }}>M</span>}
    </span>
  );
}

/* The face inside a dead-money tombstone. Mirrors trade-machine.jsx's
   PlayerAvatar markup (a `.photo` box holding the headshot, initials on
   miss) so the EXACT outgoing-chip crop in `.tombstone .photo img`
   (object-position 57% 6% / scale 1.55) applies verbatim — without reaching
   into the Trade Machine's IIFE-scoped PlayerAvatar (kept untouched). The
   grayscale + ~75% opacity dead-money treatment is pure CSS on that selector. */
function TombstoneFace({ player }) {
  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">{initials}</div>;
  }
  return (
    <div className="photo">
      <img src={`assets/players/${player.nbaId}.png`} alt="" onError={() => setBad(true)} />
    </div>
  );
}

/* Faint head-and-shoulders bust for the discretionary "optional" open slot
   (a latent person could stand here). currentColor at low opacity via CSS. */
function GhostSilhouette() {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <circle cx="12" cy="8" r="4" />
      <path d="M4 21c0-4.4 3.6-7 8-7s8 2.6 8 7v1H4v-1z" />
    </svg>
  );
}

/* Waive / buy out / stretch panel — launched from the "Dead money" section button.
   Pick any on-books player, then: Waive (straight — guaranteed amount sticks), Buy out
   (negotiated amount), or Stretch (engine spreads the guaranteed pool over 2N+1 / 2M+1
   years, gated by the §7(d)(6)(iii)(A) 15%-of-cap maximum-dead-money rule). Writes a
   waive decision the data model already understands (deadMoneyKind / buyoutAmount /
   stretchYears / stretchPerYear). Engine is on window.Engine (no wiring needed). */

/* ── Waiver guardrail ─────────────────────────────────────────────────────────
   An always-on nudge (+ an "I understand — waive him anyway" checkbox) shown in the
   WaivePanel before a team books dead money it could have avoided ENTIRELY: a TEAM
   OPTION it could simply decline, or a player it SIGNED and could simply not sign.
   (Player options are the player's call; guaranteed deals can't be avoided; a plain
   non-guaranteed waive is already the cheap path — none of those trip the guard.)
   The Waive button stays disabled until the user acknowledges. */
function waiverGuardInfo(p) {
  if (!p) return null;
  if (p.offseasonStatus === "team_option") return { kind: "option", verb: "decline his team option" };
  if (p._isAddition) return { kind: "sign", verb: "not sign him" };
  return null;
}
function WaiverGuard({ msg, acked, onToggle, ackLabel }) {
  return (
    <div className={"waiver-guard" + (acked ? " acked" : "")}>
      <div className="waiver-guard-msg"><span className="wg-ico" aria-hidden="true">💡</span><span>{msg}</span></div>
      <label className="waiver-guard-ack">
        <input type="checkbox" checked={!!acked} onChange={(e) => onToggle(e.target.checked)} />
        <span>{ackLabel}</span>
      </label>
    </div>
  );
}

function WaivePanel({ committed, dispatch, onClose }) {
  const s2627of = (p) => p.seasons?.find(s => s.season === "2026-27")?.salary || 0;
  const straightOf = (p) => {
    const st = p.offseasonStatus;
    if (st === "non_guaranteed" || st === "partially_guaranteed") return guaranteedAmountFor(p);
    if (st === "player_option" || st === "team_option") return p.optionSalary || s2627of(p);
    return s2627of(p) || p._salary || 0;   // guaranteed / addition / default → full salary
  };
  const candidates = (committed || []).filter(p => !p._isDraft && !p.deadMoney);
  const [name, setName] = useState("");
  const [mode, setMode] = useState("waive");
  const [buyoutTxt, setBuyoutTxt] = useState("");
  const [offSeason, setOffSeason] = useState(true);
  const [stretchOn, setStretchOn] = useState(false);
  const [acked, setAcked] = useState(false);
  const p = candidates.find(c => c.name === name) || null;
  const straight = p ? straightOf(p) : 0;
  // Defined BEFORE the stretch useMemo below, which references it (a buyout+stretch
  // stretches this reduced amount). Declaring it after would leave it undefined
  // inside the memo (babel hoists const), silently zeroing the buyout stretch.
  const buyoutAmount = Math.max(0, Math.round((parseFloat(String(buyoutTxt).replace(/[^0-9.]/g, "")) || 0) * 1e6));
  const guardInfo = waiverGuardInfo(p);
  const guardBlocked = !!guardInfo && !acked;

  const stretch = useMemo(() => {
    if (!p || !stretchOn) return null;
    const E = (typeof window !== "undefined") && window.Engine;
    if (!E || !E.stretchWaiver) return { error: true };
    const rem = (p.seasons || []).filter(s => s.season >= "2026-27");
    const N = rem.length || 1;
    // A buyout reduces the guaranteed pool first; the CBA then stretches the
    // REDUCED amount over the same 2N+1. Spread the buyout total across the N
    // remaining seasons so the engine gets the right total AND the right N.
    // Only GUARANTEED money is stretchable (§7(d)(6)). A partially/non-guaranteed player
    // protects just guaranteedAmountFor(p) (= `straight`), NOT the full salary — the data
    // ships the protected figure top-level (guaranteedAmount), and the per-season
    // s.guarantee shape is never populated, so reading s.salary over-charged ~3×. Spread
    // the protected pool over N (same total + N the engine re-spreads over 2N+1). A fully
    // guaranteed contract keeps each remaining season's full salary.
    const isPartialGuar = p.offseasonStatus === "partially_guaranteed" || p.offseasonStatus === "non_guaranteed";
    const protectedByYear = mode === "buyout"
      ? Array.from({ length: N }, () => buyoutAmount / N)
      : isPartialGuar
        ? Array.from({ length: N }, () => straight / N)
        : (rem.length ? rem.map(s => (s.guarantee && Number(s.guarantee.amount)) || s.salary || 0) : [straight]);
    const sw = E.stretchWaiver({ protectedByYear, offSeasonElection: offSeason });
    if (!sw.eligible) return { eligible: false, reason: sw.reason };
    // §7(d)(6)(iii)(A): the 15% gate is AGGREGATE across ALL the team's waived/former
    // players (stretched or not), per future year. Sum the existing dead-money tier —
    // each row's per-year charge (_deadCap) spread over its years (_deadYears) — and
    // feed it to the engine gate so a new stretch is blocked if it pushes any year over.
    const existing = [];
    for (const dp of ((committed && committed._deadList) || [])) {
      if (dp.name === p.name) continue;
      const yrs = dp._deadYears || 1, per = dp._deadCap || 0;
      for (let y = 0; y < yrs; y++) existing[y] = (existing[y] || 0) + per;
    }
    const gate = E.stretchWithinLimit(sw.capHits, existing, E.CBA_2026_27);
    return { ...sw, allowed: gate.allowed, limit: gate.limit, breaches: gate.breaches, existingY0: existing[0] || 0 };
  }, [p, stretchOn, mode, offSeason, straight, buyoutAmount, committed]);

  const baseAmount = mode === "buyout" ? buyoutAmount : straight;
  const charge = stretchOn ? ((stretch && stretch.perYear) || 0) : baseAmount;
  const blocked = stretchOn && stretch && (stretch.error || stretch.eligible === false || stretch.allowed === false);

  function apply() {
    if (!p || blocked || guardBlocked) return;
    const decision = { kind: "waive",
      deadMoneyKind: stretchOn ? "stretched" : (mode === "buyout" ? "buyout" : "straight") };
    if (mode === "buyout") decision.buyoutAmount = buyoutAmount;
    if (stretchOn && stretch && stretch.eligible) {
      decision.stretchYears = stretch.stretchYears;
      decision.stretchPerYear = stretch.perYear;
    }
    dispatch({ type: "SET_DECISION", player: p.name, decision });
    onClose();
  }

  return (
    <div className="wp-backdrop" onClick={onClose}>
      <div className="wp-panel" onClick={(e) => e.stopPropagation()}>
        <div className="wp-head"><b>Waive / buy out a player</b>
          <button className="wp-x" onClick={onClose} aria-label="Close">✕</button></div>
        <div className="wp-field"><span>Player</span>
          <div className="wp-picklist">
            {candidates.map(c => {
              const init = (c.name || "?").split(" ").map(s => s[0]).slice(0, 2).join("");
              return (
                <button key={c.name} type="button" className={"wp-pick" + (name === c.name ? " on" : "")}
                        onClick={() => { setName(c.name); setMode("waive"); setBuyoutTxt(""); setStretchOn(false); setAcked(false); }}>
                  <span className="wp-pick-photo" data-init={init}>
                    {c.nbaId && <img src={`assets/players/${c.nbaId}.png`} alt="" onError={(e) => e.currentTarget.remove()} />}
                  </span>
                  <span className="wp-pick-name">{c.name}</span>
                  <span className="wp-pick-sal">{fmt$(c._salary || s2627of(c))}
                    <span className="wp-pick-yrs">{((c.seasons || []).filter(s => s.season >= "2026-27").length) || 1} yr</span>
                  </span>
                </button>
              );
            })}
          </div>
        </div>
        {p && (<>
          <div className="wp-seg">
            {[["waive", "Waive"], ["buyout", "Buy out"]].map(([m, lbl]) => (
              <button key={m} className={mode === m ? "on" : ""} onClick={() => setMode(m)}>{lbl}</button>
            ))}
          </div>
          {mode === "buyout" && (
            <label className="wp-field"><span>Negotiated buyout ($M)</span>
              <input type="text" value={buyoutTxt} onChange={(e) => setBuyoutTxt(e.target.value)}
                     placeholder={String(+(straight / 1e6).toFixed(2))} /></label>
          )}
          {/* #6: Stretch is a MODIFIER available under both Waive and Buy out — a buyout
              just reduces the amount first, and the CBA lets you stretch the reduced pool.
              The length is CBA-fixed at 2N+1 (no year-picker); the only lever is the
              off-season / in-season election. */}
          <label className="wp-toggle">
            <input type="checkbox" checked={stretchOn} onChange={(e) => setStretchOn(e.target.checked)} />
            <span className="wp-toggle-txt">Stretch the dead money
              <span className="wp-toggle-sub">spread it evenly over the CBA-fixed 2N+1 seasons (lowers the yearly hit)</span></span>
          </label>
          {stretchOn && (
            <div className="wp-stretch">
              <div className="wp-elect">
                <span className="wp-elect-lbl">Election window</span>
                <div className="wp-seg wp-seg-sm">
                  <button type="button" className={offSeason ? "on" : ""} onClick={() => setOffSeason(true)}>Off-season</button>
                  <button type="button" className={!offSeason ? "on" : ""} onClick={() => setOffSeason(false)}>In-season</button>
                </div>
              </div>
              {stretch && stretch.error && <div className="wp-warn">Engine not loaded — can't compute the stretch.</div>}
              {stretch && stretch.eligible === false && <div className="wp-warn">Can't stretch: {stretch.reason}</div>}
              {stretch && stretch.eligible && (<>
                <div className="wp-len">Stretched over <b>{stretch.stretchYears}</b> seasons · <b>{fmt$(stretch.perYear)}</b>/yr <span className="wp-len-note">{offSeason ? "(whole protected pool)" : "(future pool; current year unchanged)"}</span></div>
                {stretch.allowed === false
                  ? <div className="wp-warn">⚠ {stretch.existingY0 > 0 ? `With ${fmt$(stretch.existingY0)} existing dead money, this ` : "This "}exceeds the 15%-of-cap limit ({fmt$(stretch.limit)}/yr) — not allowed.</div>
                  : <div className="wp-ok">Within the 15%-of-cap limit ({fmt$(stretch.limit)}/yr){stretch.existingY0 > 0 ? ` · incl. ${fmt$(stretch.existingY0)} existing` : ""}.</div>}
              </>)}
            </div>
          )}
          <div className="wp-summary">
            <span className="wp-sum-line"><span>Full 2026-27 salary</span><b>{fmt$(s2627of(p))}</b></span>
            <span className="wp-sum-line"><span>2026-27 dead money</span><b className="wp-dead">{fmt$(charge)}</b></span>
          </div>
          {guardInfo && (
            <WaiverGuard
              msg={`You can simply ${guardInfo.verb} — that's $0, no dead money — instead of waiving him.`}
              acked={acked} onToggle={setAcked} ackLabel="I understand — waive him anyway." />
          )}
        </>)}
        <div className="wp-foot">
          <button className="wp-cancel" onClick={onClose}>Cancel</button>
          <button className="wp-go" disabled={!p || blocked || guardBlocked} onClick={apply}>
            {(mode === "buyout" ? "Buy out" : "Waive") + (stretchOn ? " & stretch" : "")}
          </button>
        </div>
      </div>
    </div>
  );
}

function CommittedRosterTable({ committed, state, dispatch, openDraftPicker, capSheet, heldFAs = [], renouncedFAs = [], apronView = false, singlePage = false }) {
  const ROSTER_MIN = 14, ROSTER_MAX = 15;
  const filledCount = committed.length;
  const ghostSlots = Math.max(0, ROSTER_MAX - filledCount);
  const overMaxStart = ROSTER_MAX;
  // Dead-money tier (waived players) — carried alongside the active array by
  // buildCommittedRoster. They don't occupy roster slots (so they're not in
  // filledCount / the ghost math), but render as a tombstone tier below.
  const deadList = (committed && committed._deadList) || [];
  const [editKey, setEditKey] = useState(null);
  const [salaryDesc, setSalaryDesc] = useState(null);  // #5: salary-sort dir — null = unsorted (↓ hint), true = desc (↓), false = asc (↑)
  const [waiveOpen, setWaiveOpen] = useState(false);   // Waive / buy out / stretch panel
  const [editVal, setEditVal] = useState("");
  const [editName, setEditName] = useState("");
  // Full contract fields for editing an addition (mirrors PlainTable's re-sign
  // editor): years / final-year option / raises.
  const [editYears, setEditYears] = useState(1);
  const [editOption, setEditOption] = useState(null);
  const [editRaisePct, setEditRaisePct] = useState(0.05);
  const [editRaises, setEditRaises] = useState([]);
  const [pyExpanded, setPyExpanded] = useState(false);

  // R15: drag-to-reorder the roster — same Sortable.js mechanism the Trade
  // Machine uses (long-press on touch; quick tap still hits the row's controls).
  // Only the clean committed-only table is sortable; the cap-mode view that also
  // lists held / renounced FAs is left alone. The drag order is stored per team
  // in tmRowOrders (shared with the TM) via REORDER_ROSTER.
  const listRef = useRef(null);
  useEffect(() => {
    if (heldFAs.length || renouncedFAs.length) return;
    const el = listRef.current;
    const Sortable = (typeof window !== "undefined") ? window.Sortable : null;
    if (!el || !Sortable) return;
    const inst = Sortable.create(el, {
      draggable: "[data-name]",
      filter: ".flex-row.editing, .open-slot-row, .roster-sep, .roster-zone-head",
      preventOnFilter: false,
      delay: 200, delayOnTouchOnly: true, touchStartThreshold: 6,
      animation: 160,
      ghostClass: "sort-ghost", chosenClass: "sort-chosen", dragClass: "sort-drag",
      onEnd() {
        if (el.querySelector(".flex-row.editing")) return;   // never reorder mid-edit
        const order = [...el.querySelectorAll("[data-name]")]
          .map(n => n.getAttribute("data-name")).filter(Boolean);
        dispatch({ type: "REORDER_ROSTER", team: state.team, order });
      },
    });
    return () => inst.destroy();
  }, [state.team, heldFAs.length, renouncedFAs.length]);

  // Lifted FA-edit state (cap sheet): only one CapSheetRow editor open at a
  // time. faEditKey = player name; faEditVal = millions string; faEditKind =
  // the PENDING decision (not committed until ✓); faDirty = the open edit has
  // unsaved changes (gates pencil-switching, 2ways-style).
  const [faEditKey, setFaEditKey] = useState(null);
  const [faEditVal, setFaEditVal] = useState("");
  const [faEditKind, setFaEditKind] = useState(null);
  const [faDirty, setFaDirty] = useState(false);
  // Contract fields for a Signed cap-move (mirror PlainTable's editor).
  const [faEditYears, setFaEditYears] = useState(2);
  const [faEditOption, setFaEditOption] = useState(null);
  const [faEditRaisePct, setFaEditRaisePct] = useState(null);
  const [faEditRaises, setFaEditRaises] = useState([]);
  const [faPyExpanded, setFaPyExpanded] = useState(false);
  const anyFaEditing = faEditKey != null;
  const faProps = (p, salary, tone) => ({
    p, state, dispatch, salary, tone, apronView,
    editing: faEditKey === p.name,
    editVal: faEditVal,
    setEditVal: (v) => { setFaEditVal(v); setFaDirty(true); },
    editKind: faEditKind,
    setEditKind: (k) => { setFaEditKind(k); setFaDirty(true); },
    editYears: faEditYears,
    setEditYears: (v) => { setFaEditYears(v); setFaDirty(true); },
    editOption: faEditOption,
    setEditOption: (v) => { setFaEditOption(v); setFaDirty(true); },
    editRaisePct: faEditRaisePct,
    setEditRaisePct: (v) => { setFaEditRaisePct(v); setFaDirty(true); },
    editRaises: faEditRaises,
    setEditRaises: (v) => { setFaEditRaises(v); setFaDirty(true); },
    pyExpanded: faPyExpanded,
    setPyExpanded: setFaPyExpanded,
    anyEditing: anyFaEditing,
    dirty: faDirty,
    // Open this row's editor. If another row is mid-edit WITH unsaved changes,
    // the click is ignored (must ✓/✗ first); otherwise it switches.
    onEdit: () => {
      if (faEditKey && faEditKey !== p.name && faDirty) return;
      const dec = state.decisions[p.name];
      const isSig = dec?.kind === "signed";
      const seed = isSig && dec.salary ? dec.salary : salary;
      setEditKey(null);
      setFaEditKey(p.name);
      setFaEditVal((isSig && dec.salary) ? toMField(seed) : toMFieldSeed(seed));   // #5: clean 1-decimal seed when proposing
      setFaEditKind(dec?.kind || null);
      setFaEditYears(isSig && dec.years ? dec.years : 2);
      setFaEditOption(isSig ? (dec.option || null) : null);
      setFaEditRaisePct(isSig && dec.raisePct != null ? dec.raisePct : null);
      setFaEditRaises(isSig && Array.isArray(dec.raises) ? dec.raises : []);
      setFaPyExpanded(isSig && Array.isArray(dec.raises) && dec.raises.length > 0);
      setFaDirty(false);
    },
    onClose: () => { setFaEditKey(null); setFaDirty(false); },
  });

  const rowKeyOf = (p) =>
    p._isAddition ? "a:" + p._addition.id
    : p._isDraft  ? "d:" + p._draftId
    : "p:" + p.name;

  function startEdit(p) {
    setEditKey(rowKeyOf(p));
    setEditVal(toMField(p._salary));   // edit in millions (short)
    if (p._isDraft) {
      const dp = state.draftPicks.find(d => d.id === p._draftId);
      setEditName(dp?.name || p.name);
    }
    if (p._isAddition) {
      const a = p._addition;
      setEditYears(a.years || 1);
      setEditOption(a.option || null);
      setEditRaisePct(a.raisePct == null ? 0.05 : a.raisePct);
      setEditRaises(Array.isArray(a.raises) ? a.raises : []);
      setPyExpanded(Array.isArray(a.raises) && a.raises.length > 0);
    } else if (!p._isDraft) {
      // re-signed own/declined FA — seed the full contract from its decision
      const d = state.decisions[p.name];
      if (d?.kind === "signed") {
        setEditYears(d.years || 2);
        setEditOption(d.option || null);
        setEditRaisePct(d.raisePct != null ? d.raisePct : null);
        setEditRaises(Array.isArray(d.raises) ? d.raises : []);
        setPyExpanded(Array.isArray(d.raises) && d.raises.length > 0);
      }
    }
  }
  function cancelEdit() { setEditKey(null); }
  function saveEdit(p) {
    const n = parseSalaryFromMField(editVal);
    if (n == null || n < 0) { cancelEdit(); return; }
    if (p._isAddition) {
      const patch = { salary: n };
      if (state.multiYear) {
        patch.years = editYears;
        patch.option = editYears > 1 ? editOption : null;
        if (state.adjustRaises && editYears > 1) {
          if (pyExpanded && editYears > 2) { patch.raises = editRaises.slice(0, editYears - 1); patch.raisePct = null; }
          else { patch.raisePct = editRaisePct; patch.raises = null; }
        } else { patch.raisePct = null; patch.raises = null; }
      }
      if (reSignBlocked(p, { salary: n, years: patch.years || (p._addition && p._addition.years) || 1, raisePct: patch.raisePct, raises: patch.raises }, dispatch, state)) return;
      dispatch({ type: "PATCH_ADDITION", id: p._addition.id, patch });
    } else if (p._isDraft) {
      // Sanity guard (mirrors the roster override): a draft pick's salary is its rookie-scale
      // cap hold (≤ ~$13M for the #1 pick); reject an absurd typo so the books can't be
      // poisoned with e.g. a $300M "draft pick". NOT a CBA ceiling — just a fat-finger catch.
      if (n > CAP_2026.cap * 0.6) {
        dispatch({ type: "SET_TOAST", toast: `That salary (${fmt$(n)}) looks too high for a draft pick — please re-check.` });
        return;
      }
      const patch = { capHold: n };
      const nm = editName.trim();
      if (nm) patch.name = nm;
      dispatch({ type: "SET_DRAFT_PICK", id: p._draftId, patch });
    } else {
      // re-signed own/declined FA — save salary + (multi-year) years/option/raises
      const prev = state.decisions[p.name] || {};
      const dec = { kind: "signed", salary: n };
      if (state.multiYear) {
        dec.years = editYears;
        dec.option = editYears > 1 ? editOption : null;
        if (state.adjustRaises && editYears > 1) {
          if (pyExpanded && editYears > 2) dec.raises = editRaises.slice(0, editYears - 1);
          else if (editRaisePct != null) dec.raisePct = editRaisePct;
        }
      } else {
        dec.years = prev.years || 2;
      }
      if (reSignBlocked(p, { salary: dec.salary, years: dec.years || 1, raisePct: dec.raisePct, raises: dec.raises }, dispatch, state)) return;
      dispatch({ type: "SET_DECISION", player: p.name, decision: dec });
    }
    setEditKey(null);
  }
  // Reset a draft pick's salary to its rookie-scale cap hold
  // (capHoldForPick already = 120%-of-base cap-hold figure).
  function resetDraft(p) {
    const dp = state.draftPicks.find(d => d.id === p._draftId);
    setEditVal(toMField(capHoldForPick(dp?.pickNumber)));
  }
  // Undo a signing entirely (not just edit salary).
  function undoSigning(p) {
    setEditKey(null);
    if (p._isAddition) { dispatch({ type: "REMOVE_ADDITION", id: p._addition.id }); return; }
    // In cap mode, deleting a signing clears the player (renounced) rather
    // than leaving a hold — hold via the Cap Holds page if you want one.
    if (state.mode === "cap") {
      dispatch({ type: "SET_DECISION", player: p.name, decision: { kind: "renounced" } });
      dispatch({ type: "SET_TOAST", toast: `Renounced ${p.name} — cleared from the cap.` });
      return;
    }
    const back = p.offseasonStatus === "player_option" ? { kind: "opt-out" }
               : p.offseasonStatus === "team_option"   ? { kind: "decline" }
               : { kind: "kept-hold" };
    dispatch({ type: "SET_DECISION", player: p.name, decision: back });
    dispatch({ type: "SET_TOAST", toast: `Undid ${p.name}'s signing — back to free agents.` });
  }
  const rowProps = (p) => ({
    p, state, dispatch, capSheet, apronView, singlePage,
    isEditing: editKey === rowKeyOf(p),
    anyEditing: editKey != null,
    editVal, setEditVal, editName, setEditName,
    editYears, setEditYears, editOption, setEditOption,
    editRaisePct, setEditRaisePct, editRaises, setEditRaises, pyExpanded, setPyExpanded,
    onStart: () => startEdit(p),
    onSave: () => saveEdit(p),
    onCancel: cancelEdit,
    onUndo: () => undoSigning(p),
    onResetDraft: () => resetDraft(p),
    onPickProspect: openDraftPicker ? () => openDraftPicker(p._draftId) : null,
  });

  return (
    <section ref={listRef} className={`section flex-tbl roster-tbl ${editKey ? "has-edit" : ""}`}>
      <div className="flex-row header">
        <div className="hdr-photo-spacer" aria-hidden="true" />
        <div className="info-col">
          <span className="hdr-wide">PLAYER</span>
          <span className="hdr-narrow">Salary</span>
        </div>
        <div className={"salary-col salary-sort-hdr" + (salaryDesc === false ? " asc" : "")} role="button" tabIndex={0}
             title={"Sort by salary — " + (salaryDesc === false ? "lowest first" : "highest first") + " (click to flip)"}
             onClick={() => {
               const desc = salaryDesc === null ? true : !salaryDesc;   // first click = ↓; then flip each click
               setSalaryDesc(desc);
               dispatch({ type: "REORDER_ROSTER", team: state.team,
                 order: [...committed].sort((x, y) => desc ? (y._salary || 0) - (x._salary || 0) : (x._salary || 0) - (y._salary || 0)).map(p => p.name) });
             }}>
          Salary
        </div>
        <div className="row-spacer" />
        <div className="decision-col" />
      </div>

      {committed.slice(0, overMaxStart).map((p, i) => (
        // Option players get the full CapSheetRow editor in BOTH modes (so apron
        // Build Roster can change opt-in/out) — but only when they have the data
        // (non-lite). Re-signed own FAs use CapSheetRow in cap; apron keeps them
        // on CommittedRow's contract editor.
        (capSheet && (p._isOption ? !p._lite : (state.mode === "cap" && p._isOwnFA)))
          ? <CapSheetRow key={rowKeyOf(p) + i} {...faProps(p, p._salary)} />
          : <CommittedRow key={rowKeyOf(p) + i} {...rowProps(p)} />
      ))}

      {/* Dead-money tier — waived players, below the active rows and above the
          open-slot ghosts. The salary cell shows the REAL dead money (the
          guaranteed amount that stuck), not $0. Tombstone-framed face crop
          (the reused PlayerAvatar) at ~75% opacity + grayscale. */}
      {/* Dead-money tier — header + "+ Waive / buy out" button ALWAYS present (so
          waiving is reachable even with no dead money); tombstone tier shows when populated. */}
      <>
          <div className="roster-zone-head dead-money">
            Dead money<span className="count num">{deadList.length}</span>
            <button className="waive-add" title="Waive, buy out, or stretch a player"
                    onClick={() => setWaiveOpen(true)}>+ Waive / buy out</button>
          </div>
          {deadList.length > 0 && (
          <div className={`dead-tier dead-n-${Math.min(deadList.length, 4)}`}>
            {deadList.map(p => (
              <div key={"dead-" + p.name} className="flex-row body-row cap-row dead-row">
                <div className="row-photo dead-art">
                  <span className="tombstone">
                    <TombstoneFace player={p} />
                  </span>
                </div>
                <div className="info-col">
                  <div className="cap-line1">
                    {/* no position tag on a tombstone (one-world #14 UI feedback) */}
                    <div className="player-name">{p.name}</div>
                    <span className="cap-salary">
                      <span className="money">{p._deadCap > 0 ? fmt$(p._deadCap) : "$0"}</span>
                    </span>
                  </div>
                  <div className="player-sub">
                    <span className="dead-tag">{p._deadLabel}</span>
                    {/* Pre-existing dead money (deadMoney flag, e.g. Lillard/Beal): a fixed
                        charge — no "guaranteed of" math and NO Restore (it was never the
                        user's decision to undo). Waived players keep both. */}
                    {p.deadMoney
                      ? <span className="dead-meta">· counts in full on the books</span>
                      : p._deadLabel === "Waived & Stretched"
                        ? <span className="dead-meta">· {fmt$(p._deadCap)}/yr over {p._deadYears} yrs</span>
                      : p._deadLabel === "Buyout"
                        ? <span className="dead-meta">· {fmt$(p._deadCap)} buyout</span>
                      : (p._deadCap > 0
                          ? <span className="dead-meta">· {fmt$(p._deadCap)} guaranteed of {fmt$(p.seasons?.find(s => s.season === "2026-27")?.salary || 0)}</span>
                          : <span className="dead-meta">· no guarantee — off the books</span>)}
                    {!p.deadMoney && (
                      <button className="restore-link" title="Bring this player back at his full salary"
                              onClick={() => dispatch({ type: "SET_DECISION", player: p.name, decision: { kind: "keep" } })}>
                        ↺ Restore
                      </button>
                    )}
                  </div>
                </div>
              </div>
            ))}
          </div>
          )}
          {waiveOpen && <WaivePanel committed={committed} dispatch={dispatch} onClose={() => setWaiveOpen(false)} />}
        </>

      {ghostSlots > 0 && <div className="roster-zone-head minimum">Minimum roster spots</div>}
      {Array.from({ length: ghostSlots }).map((_, i) => {
        const slotN = filledCount + i + 1;
        const optional = slotN === ROSTER_MAX;
        // Incomplete-roster charge: cap mode only, only for slots
        // below the 12-man minimum, at the 0-years rookie min.
        const charged = state.mode === "cap" && slotN <= CAP_2026.rosterMin;
        const chargeVal = charged ? fmt$(CAP_2026.incompleteCharge) : "$0";
        // Split: the discretionary 15th ("optional") spot reads as a latent
        // person → a ghost silhouette; plain empty minimum spots stay the
        // "add a player here" dashed + box.
        return (
          <div key={"slot-" + i} className={`flex-row body-row cap-row open-slot-row ${optional ? "optional" : ""}`}>
            <div className={`row-photo placeholder ${optional ? "ghost-art" : "open-slot-art"}`}>
              {optional ? <GhostSilhouette /> : <span className="slot-ghost-icon" aria-hidden="true" />}
            </div>
            <div className="info-col">
              <div className="cap-line1">
                <div className="player-name">{optional ? "Optional Roster Spot" : `Roster Spot ${slotN}`}</div>
                <span className="cap-salary"><span className="money">{chargeVal}</span></span>
              </div>
            </div>
          </div>
        );
      })}

      {capSheet && heldFAs.length > 0 && (
        <>
          <div className="roster-zone-head">Hold for trade<span className="count num">{heldFAs.length}</span></div>
          {heldFAs.map(p => (
            <CapSheetRow key={"held-" + p.name} {...faProps(p, computeCapHold(p), "held")} />
          ))}
        </>
      )}

      {committed.length > overMaxStart && (
        <>
          <div className="roster-sep">Over maximum (NBA cap: 15)</div>
          {committed.slice(overMaxStart).map((p, i) => (
            <CommittedRow key={"over-" + rowKeyOf(p) + i} {...rowProps(p)} overMax />
          ))}
        </>
      )}

      {capSheet && renouncedFAs.length > 0 && (
        <>
          <div className="roster-zone-head renounced">Renounced<span className="count num">{renouncedFAs.length}</span></div>
          {renouncedFAs.map(p => (
            <CapSheetRow key={"ren-" + p.name} {...faProps(p, 0, "renounced")} />
          ))}
        </>
      )}
    </section>
  );
}

/* One merged "Free Agents" section (own FAs + declined options +
   stashed/traded picks) with sub-headers. faMode drives the row action
   ("apron" → single Re-sign; "cap" → Re-sign/Hold/Renounce). */
function FreeAgentsSection({ buckets, state, dispatch, apronView, singlePage = false }) {
  const faMode = apronView ? "apron" : state.mode;
  const heldFAs = [...buckets.ufas, ...buckets.rfas].filter(p => {
    const k = state.decisions[p.name]?.kind;
    return k !== "signed" && k !== "renounced";
  });
  // Opted-out player options / declined team options are free agents you didn't
  // re-sign. Shown here on the single page too now: "Don't re-sign" keeps their
  // rights, so they stay available to re-sign or sign-and-trade from this list.
  // _optStatus preserves option identity so the edit box keeps its option choices.
  const declined = [
    ...buckets.optsP.filter(p => state.decisions[p.name]?.kind === "opt-out"),
    ...buckets.optsT.filter(p => state.decisions[p.name]?.kind === "decline"),
  ].map(p => ({ ...p, _optStatus: p.offseasonStatus, offseasonStatus: "UFA", lastSalary: p.priorSeasonSalary }));
  const fas = [...declined, ...heldFAs];
  const stashed = state.draftPicks.filter(dp => dp.disposition === "trade-pick");
  const total = fas.length + stashed.length;
  if (total === 0) return null;
  return (
    <>
      <SectionIntro title="Free agents" count={total}
        desc="Declined options and your own free agents. Re-sign them, or hold their rights." />
      {fas.length > 0 && (
        <PlainTable players={fas} state={state} dispatch={dispatch} kind="ufa" faMode={faMode} />
      )}
      {stashed.length > 0 && (
        <>
          <div className="fa-subhead">Stashed picks</div>
          <section className="section" style={{ marginTop: 4 }}>
            <table className="tbl">
              <tbody>
                {stashed.map((p) => (
                  <tr key={p.id}>
                    <td>
                      <div className="player-cell">
                        <div className="player-photo-placeholder">DP</div>
                        <div className="player-meta">
                          <div className="player-name">{p.name}</div>
                          <div className="player-sub">
                            <span className="status-chip" style={{ color: "var(--info)", background: "var(--info-bg)" }}>Draft pick</span>
                            <span>Traded before draft</span>
                          </div>
                        </div>
                      </div>
                    </td>
                    <td className="num">{fmt$Full(0)}</td>
                    <td style={{ paddingRight: 18, width: 320 }}>
                      <div className="row-actions" style={{ justifyContent: "flex-end" }}>
                        <button className="btn btn-ghost" style={{ height: 28, padding: "0 10px", fontSize: 12 }}
                                onClick={() => dispatch({ type: "SET_DRAFT_PICK", id: p.id, patch: { disposition: "keep" } })}>
                          ↺ Bring back
                        </button>
                        <button className="pencil cancel" title="Remove pick entirely"
                                onClick={() => dispatch({ type: "REMOVE_DRAFT_PICK", id: p.id })}>
                          <Icon.X />
                        </button>
                      </div>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </section>
        </>
      )}
    </>
  );
}

/* The roster step — used by Over-the-Cap teams (Step 2 "Build Roster") and by
   Cap Space teams as Step 3 "Final Roster" (apronView). */
/* ============================================================
   Chunk 2 — "Decide these first": player/team options + non-guaranteed calls,
   made ABOVE the roster with a simple checkmark control (nothing pre-selected,
   no X) + an inline cascade. SINGLE-PAGE (above-cap) only. A row CLEARS once
   resolved: opt-in/exercise/keep/re-signed → drop into the roster; waived /
   let-walk → gone. "Pending" = undecided, or opted-out/declined awaiting the
   Re-sign / Let-walk follow-up.
   ============================================================ */
function DecideRow({ p, state, dispatch }) {
  const isPO = p.offseasonStatus === "player_option";
  const isTO = p.offseasonStatus === "team_option";

  // Nothing commits until ✓. The first-level choice and the opted-out follow-up
  // are LOCAL state only — a click never makes the row vanish; only ✓ does.
  // (Seed from a committed opt-out/decline so any legacy state still resolves.)
  const [pend, setPend] = useState(null);        // local first choice (opt-in/opt-out · …)
  const [second, setSecond] = useState(null);   // opted-out follow-up: "resign" | "walk"
  const optedOut = pend === "opt-out" || pend === "decline";

  const firstOpts = isPO ? [{ key: "opt-in", label: "Opt in" }, { key: "opt-out", label: "Opt out", pol: "neutral" }]
                  : isTO ? [{ key: "exercise", label: "Exercise" }, { key: "decline", label: "Decline", pol: "neutral" }]
                  :        [{ key: "keep", label: "Keep" }, { key: "waive", label: "Waive", pol: "neg" }];
  const pickFirst = (k) => { setPend(k); setSecond(null); };

  // Re-sign contract editor (local; reuses the shared ContractFields/contractCalc
  // so the raise UI matches the FA re-sign editor: inline stepper + "Per year ▾").
  const bird = p.birdRights;
  const maxYrs = (typeof maxContractYears === "function") ? maxContractYears(bird) : 4;
  const maxRate = (typeof contractRaiseRate === "function") ? contractRaiseRate(bird) : 0.05;
  const seed = p.optionSalary || (p.seasons && p.seasons.find(s => s.season === "2026-27")?.salary) || p.priorSeasonSalary || 0;
  const [salTxt, setSalTxt] = useState(seed ? String(+(seed / 1e6).toFixed(2)) : "");
  const [years, setYears] = useState(1);
  const [raisePct, setRaisePct] = useState(null);   // fraction; null → defaults to maxRate
  const [raises, setRaises] = useState([]);
  const [pyExpanded, setPyExpanded] = useState(false);
  const base = parseSalaryFromMField(salTxt) || 0;
  const calc = contractCalc(base, years, { adjustRaises: true, raisePct, raises, pyExpanded, maxRate });

  const resignOpen = optedOut && second === "resign";
  const canConfirm = pend != null && (!optedOut
    ? true
    : (second === "walk" || (second === "resign" && base > 0)));
  const confirm = () => {
    if (!canConfirm) return;
    let dec;
    if (!optedOut) dec = { kind: pend };                 // opt-in / exercise / keep / waive
    // "Don't re-sign" KEEPS his rights → opted-out/declined free agent (available,
    // S&T-able, $0 over the cap). NOT renounced — renouncing would forfeit the
    // Bird rights a sign-and-trade needs. (Renounce stays a cap-space lever.)
    else if (second === "walk") dec = { kind: isPO ? "opt-out" : "decline" };
    else {                                               // re-sign
      dec = { kind: "signed", salary: base, years };
      if (years > 1) {
        if (pyExpanded && years > 2) dec.raises = calc.raisesArr.slice(0, years - 1);
        else if (raisePct != null) dec.raisePct = raisePct;
      }
      // Block illegal re-signs (player max / raise / years) before committing — this opt-out→
      // re-sign editor was the one signing path missing the gate the others all have
      // (CapMovesTable.confirmSign, CapSheetRow.commitPending, saveEdit, roster.jsx).
      if (reSignBlocked(p, { salary: base, years, raisePct: dec.raisePct, raises: dec.raises }, dispatch, state)) return;
    }
    dispatch({ type: "SET_DECISION", player: p.name, decision: dec });
  };

  const photoSrc = p.nbaId ? `assets/players/${p.nbaId}.png` : null;
  const initials = (p.name || "").split(" ").map(s => s[0]).slice(0, 2).join("");
  const typeLabel = isPO ? "Player option" : isTO ? "Team option" : "Non-guaranteed";
  const optAmt = (isPO || isTO) ? (p.optionSalary || seed) : seed;

  // Contextual sentence in the bottom confirm bar — says exactly what ✓ will do.
  let footText;
  if (!pend) {
    footText = isPO ? `Will ${p.name} opt in or opt out?`
             : isTO ? `Pick up ${p.name}'s team option, or decline it?`
             :        `Keep ${p.name}, or waive him?`;
  } else if (!optedOut) {
    footText = pend === "opt-in"   ? `${p.name} stays at his ${fmt$(optAmt)} option. Nothing else to decide.`
             : pend === "exercise" ? `${p.name}'s team option is exercised at ${fmt$(optAmt)}. Nothing else to decide.`
             : pend === "keep"     ? `${p.name} stays on his non-guaranteed ${fmt$(optAmt)}. Nothing else to decide.`
             : ((p.offseasonStatus === "non_guaranteed" || p.offseasonStatus === "partially_guaranteed") && isPartial(p))
                                    ? `${p.name} is waived — ${fmt$(guaranteedAmountFor(p))} sticks as dead money (the guaranteed part of his ${fmt$(optAmt)}).`
                                    : `${p.name} is waived. He comes off your books.`;
  } else if (second === "walk") {
    footText = `${p.name} comes off your roster, but you keep his rights. He'll be available to sign-and-trade.`;
  } else if (second === "resign") {
    footText = base > 0
      ? `Re-signs · ${years} yr${years > 1 ? "s" : ""} · ${fmt$(calc.total)}${years > 1 ? " total" : ""}`
      : `Enter ${p.name}'s first-year salary to re-sign.`;
  } else {
    footText = `Re-sign ${p.name}, or let him be a free agent.`;
  }

  return (
    <div className={"flex-row body-row decide-row" + (pend ? " is-active" : "")} data-name={p.name}>
      {photoSrc
        ? <img className="row-photo" src={photoSrc} alt="" onError={(e) => { e.currentTarget.style.display = "none"; e.currentTarget.nextSibling.style.display = "grid"; }} />
        : null}
      <span className="row-photo placeholder" style={{ display: photoSrc ? "none" : "grid" }}>{initials}</span>
      <div className="info-col">
        <div className="cap-line1">
          <div className="player-name">{p.name}{p.position && <span className="pos-name">{p.position}</span>}<span className="decide-tag">{typeLabel}</span></div>
          {/* Partial-guarantee cost ("$X gtd if waived", e.g. MPJ $18.5M) sits UNDER the
              salary, RIGHT-aligned — so the figure is on the right and the salary row
              stays aligned with the option rows (no full-width hint line cluttering it). */}
          <span className="cap-salary">
            <span className="money">{fmt$Full(optAmt)}</span>
            {(p.offseasonStatus === "non_guaranteed" || p.offseasonStatus === "partially_guaranteed") && isPartial(p) && (
              <span className="pg-hint pg-sub">{fmt$(guaranteedAmountFor(p))} gtd if waived</span>
            )}
          </span>
        </div>
        <div className="decide-controls">
          <DecisionPick options={firstOpts} value={pend} onChange={pickFirst} />
          {optedOut && (
            <DecisionPick options={[{ key: "resign", label: "Re-sign" }, { key: "walk", label: "Don't re-sign", pol: "neg" }]}
              value={second} onChange={setSecond} />
          )}
        </div>
      </div>
      {resignOpen && (
        <div className="decide-resign">
          <div className="decide-resign-row"><span className="resign-hdr">Salary</span>
            <RosterEditInput value={salTxt} onChange={setSalTxt} onSave={confirm} onCancel={() => setSecond(null)} /></div>
          <ContractFields base={base} years={years} setYears={setYears} maxYears={maxYrs} maxRate={maxRate}
            calc={calc} setRaisePct={setRaisePct} setRaises={setRaises} setPyExpanded={setPyExpanded} />
          {calc.usePerYear && <ContractPerYear calc={calc} maxRate={maxRate} setRaises={setRaises} />}
        </div>
      )}
      {/* Full-width confirm bar — contextual sentence + the single ✓ (commit-on-check). */}
      <div className="decide-foot">
        <span className="decide-foot-text">{footText}</span>
        {/* ✓ only appears once a choice is pending — no stray greyed checkmark
            on untouched rows (screenshot 3). */}
        {canConfirm && <button className="pencil confirm" onClick={confirm} title="Confirm"><Icon.Check /></button>}
      </div>
    </div>
  );
}

/* Shared strategy options (cap-space first, #4a) — the canonical look (iii),
   reused by the decision box AND the chip dropdown so they're identical. */
function StrategyOptions({ value, onPick }) {
  const rows = [
    { mode: "cap",   title: "Cap-space team",    desc: `Clear room to sign. Room MLE ${fmt$(CAP_2026.roomMle)}.` },
    { mode: "apron", title: "Over-the-cap team", desc: "Bird rights, the MLE and trades." },
  ];
  return (
    <div className="strategy-rows">
      {rows.map(r => (
        <button key={r.mode} className={`strategy-row ${value === r.mode ? "on" : ""}`}
          onClick={() => onPick(r.mode)}>
          <span className="strategy-mark"><Icon.Check style={{ width: 12, height: 12 }} /></span>
          <span className="strategy-row-title">{r.title}</span>
          <span className="strategy-row-desc">{r.desc}</span>
        </button>
      ))}
    </div>
  );
}

/* D-2: the Over / Cap-space strategy choice — shown for cap-eligible teams that
   haven't yet confirmed. One self-contained box (no profile-pic slot): a title
   row, the two stacked options (cap-space first, #4a), and a ✓ on the bottom
   bar. Pre-selects cap for BKN/CHI (CAP_DEFAULT_TEAMS), over for the rest. Must
   hit ✓ to commit; on confirm it sets the mode + collapses to the small chip. */
function ModeDecideRow({ state, dispatch, onConfirm }) {
  // Pre-select the team's current strategy: its saved per-team pick if any,
  // else the live mode (which CAP_DEFAULT_TEAMS seed to "cap" at startup), else
  // the seed default. This is what makes BKN/CHI open on "Cap-space team".
  const seedPick = (CAP_DEFAULT_TEAMS || []).includes(state.team) ? "cap" : "apron";
  const [pick, setPick] = useState(state.modeByTeam?.[state.team] || state.mode || seedPick);
  const confirm = () => {
    dispatch({ type: "SET_MODE", mode: pick });
    dispatch({ type: "CONFIRM_MODE" });
    onConfirm && onConfirm();   // collapse the editor back to the chip
  };
  // The "Cap strategy" title is now a section heading ABOVE this box (rendered by
  // RosterStep), so the box itself is just the options + the ✓ confirm bar.
  return (
    <div className="mode-decide-box">
      <StrategyOptions value={pick} onPick={setPick} />
      <div className="mode-decide-foot">
        <span style={{ flex: 1 }} />
        <button className="pencil confirm" onClick={confirm} title="Confirm strategy"><Icon.Check /></button>
      </div>
    </div>
  );
}

function DecideFirstSection({ buckets, state, dispatch, chip = null }) {
  // Only PENDING players (undecided). Resolved rows drop out: kept/exercised/
  // re-signed into the roster; waived gone; "Don't re-sign" → Free Agents list.
  const pending = (p) => state.decisions[p.name]?.kind == null;
  const notTraded = (p) => state.decisions[p.name]?.kind !== "traded";
  const cats = [
    { key: "po", label: "Player options",  list: buckets.optsP.filter(notTraded).filter(pending) },
    { key: "to", label: "Team options",    list: buckets.optsT.filter(notTraded).filter(pending) },
    { key: "ng", label: "Non-guaranteed",  list: buckets.nonGuar.filter(notTraded).filter(pending) },
  ].filter(c => c.list.length > 0);
  const total = cats.reduce((n, c) => n + c.list.length, 0);
  if (total === 0) return null;
  return (
    <>
      {/* The collapsed cap-strategy chip rides this heading's action slot (right
          side) so it adds no extra row/height. */}
      <SectionIntro title="Decide these first" count={total} action={chip}
        desc="Settled players drop into the roster below, but they can still be edited if you change your mind." />
      {cats.map(c => (
        <div className="decide-cat" key={c.key}>
          <div className="decide-cat-head">{c.label}<span className="count num">{c.list.length}</span></div>
          <section className="section flex-tbl decide-tbl">
            {c.list.map(p => <DecideRow key={p.name} p={p} state={state} dispatch={dispatch} />)}
          </section>
        </div>
      ))}
    </>
  );
}

/* On-page strategy chip (by the Roster header) shown once the strategy is set.
   Clicking it RE-OPENS the full Cap-strategy editor (box + About card) above —
   that editor is the single, consistent way to change strategy. */
function ModeChip({ state, onOpen }) {
  const isCap = state.mode === "cap";
  return (
    <button className="mode-chip" onClick={onOpen} title="Change cap strategy">
      {isCap ? "Cap-space team" : "Over-the-cap team"}<span className="mode-chip-caret">▾</span>
    </button>
  );
}

function RosterStep({ buckets, state, dispatch, derived, derivedByTeam = {}, openModal, openDraftPicker, apronView, singlePage = false, canUseCap = false, actionsAbove = false, strategyOpen = false, onOpenStrategy, onCloseStrategy }) {
  const committed = buildCommittedRoster(buckets, state, apronView, singlePage);
  const tradedOut = [...buckets.underContract, ...buckets.optsP, ...buckets.optsT, ...buckets.nonGuar]
    .filter(p => state.decisions[p.name]?.kind === "traded");
  const decidePending = singlePage
    ? [...buckets.optsP, ...buckets.optsT, ...buckets.nonGuar].filter(p => state.decisions[p.name]?.kind == null).length
    : 0;
  // The strategy chip (shown when the editor is collapsed) just RE-OPENS the full
  // editor — it's the one and only way to change strategy now (no own dropdown).
  const chip = (singlePage && canUseCap && !strategyOpen)
    ? <ModeChip state={state} onOpen={onOpenStrategy} /> : null;
  return (
    <>
      {(() => {
        // Issue c — re-validate the committed state on the final-roster page (where edits happen).
        // Final-roster edits are NOT gated by the 4d sign/trade-time enforcement, so a bump here can
        // leave an illegal state. Two detection checks (the ordered-replay subsystem would add finer
        // per-transaction attribution — see _integration/phase4-persistence-design-2026-06-04.md):
        const issues = [];
        const me = (derivedByTeam && derivedByTeam[state.team]) || {};
        // Issue-c (Option 1): turn the breach into actionable guidance — name the exact signing(s) to cut.
        // If any single signing frees ≥ the overage, list those (drop any ONE → legal); else list the
        // largest signings to trim across. `cuts` = per-signing freed amounts from derivedByTeam (ground truth).
        const howToFix = (cuts, overBy) => {
          const list = (cuts || []).filter(c => c && c.freed > 0);
          if (!list.length || !(overBy > 0)) return "";
          const fixes = list.filter(c => c.freed >= overBy);
          if (fixes.length) return ` Drop any one to fix it: ${fixes.slice(0, 4).map(c => `${c.name} (frees ${fmt$(c.freed)})`).join(", ")}.`;
          return ` Trim ${fmt$(overBy)} across your signings (largest first): ${list.slice(0, 4).map(c => `${c.name} ${fmt$(c.freed)}`).join(", ")}.`;
        };
        // (1) cap-ROOM: use capBase (holds-INCLUSIVE, Bird-CAPPED — the SAME measure the sign-time room
        // gate enforced), NOT derived.committed (which on the apron-view Final Roster counts re-signs at
        // FULL salary → inflates a legal Bird re-sign into a false "over the cap"). Exclude the incomplete-
        // roster charge (an unfilled roster isn't illegal). Gated on hasSignings so a pre-renounce cap team
        // (holds only, no signings) does NOT false-fire. (Adversarial finding 2026-06-04.)
        const incompleteCharge = Math.max(0, (CAP_2026.rosterMin || 0) - (me.rosterCount || 0)) * (CAP_2026.incompleteCharge || 0);
        const overCapBy = Math.round((me.capBase || 0) - incompleteCharge - CAP_2026.cap);
        const hasSignings = (state.additions || []).some(a => !a._fromTrade)
          || Object.values(state.decisions || {}).some(d => d && d.kind === "signed");
        if (!singlePage && overCapBy > 0 && hasSignings) {
          issues.push(`${state.team} is ${fmt$(overCapBy)} over the cap after your edits — a cap-space team's signings can't exceed its room.${howToFix(me.capCuts, overCapBy)} (Or renounce a cap hold, or operate over the cap.)`);
        }
        // (2) HARD-CAP breach (holds-FREE apron — no confound). Sweep EVERY team carrying a stored cap,
        // not just the active one — an applied-S&T acquirer (a counterparty) can be left over its ceiling
        // by a later edit while you work on another team (adversarial finding 2026-06-04).
        const hcMap = (typeof window !== "undefined" && typeof window.curSc === "function") ? (window.curSc(state).hardCaps || {}) : {};
        for (const code of Object.keys(hcMap)) {
          const hcAt = (typeof window !== "undefined" && typeof window.strictestHardCap === "function") ? window.strictestHardCap(hcMap[code]) : null;
          if (!hcAt) continue;
          const hcCeil = hcAt === "firstApron" ? CAP_2026.apron1 : CAP_2026.apron2;
          const td = (derivedByTeam && derivedByTeam[code]) || {};
          const apronBase = Math.round((td.apronTotal || 0) + (td.unlikely || 0));
          if (apronBase - hcCeil > 0)
            issues.push(`${code}'s apron Team Salary (${fmt$(apronBase)}) exceeds its ${hcAt === "firstApron" ? "1st" : "2nd"}-apron hard cap (${fmt$(hcCeil)}) by ${fmt$(apronBase - hcCeil)}. A hard cap can't be exceeded by any move.${howToFix(td.apronCuts, apronBase - hcCeil)}`);
        }
        if (!issues.length) return null;
        return (
          <div role="alert" style={{ margin: "10px 0", padding: "11px 13px", borderRadius: 8,
            background: "rgba(214,40,40,0.12)", border: "1px solid rgba(214,40,40,0.55)", fontSize: 13 }}>
            <b style={{ color: "#e0162b" }}>⚠ An edit made this roster illegal</b>
            <ul style={{ margin: "6px 0 0", paddingLeft: 18, color: "var(--text)" }}>
              {issues.map((m, i) => <li key={i} style={{ marginTop: i ? 4 : 0 }}>{m}</li>)}
            </ul>
          </div>
        );
      })()}
      {/* Cap-strategy editor at the TOP only when OPEN (full box + the right-panel
          About card). When collapsed, the chip rides an existing heading row (the
          "Decide these first" or "Roster" header) so it never adds a row/height. */}
      {strategyOpen && (
        <>
          <SectionIntro title="Cap strategy" desc="How this team operates this off-season." />
          <div className="strat-anchor"><ModeDecideRow state={state} dispatch={dispatch} onConfirm={onCloseStrategy} /></div>
        </>
      )}

      {singlePage && <DecideFirstSection buckets={buckets} state={state} dispatch={dispatch}
                        chip={!strategyOpen ? chip : null} />}

      <div className="committed-head">
        <div className="committed-head-left">
          <h3>Roster <span className="count num">{committed.length}</span></h3>
          {/* chip rides the Roster header when there's no Decide section to host it */}
          {!strategyOpen && decidePending === 0 && chip}
        </div>
        {/* Desktop: Sign FA / Trades live in the right panel (aligned to the
            Decide/Roster heading). Mobile (no right panel) keeps them here. */}
        {!actionsAbove && (
          <div className="actions-right">
            <button className="btn" onClick={() => openModal("sign-fa")}>
              <Icon.UserPlus /> Sign FA
            </button>
            <button className="btn" onClick={() => openModal("trade")}>
              <Icon.Trade /> Trades
            </button>
          </div>
        )}
      </div>

      <CommittedRosterTable committed={committed} state={state} dispatch={dispatch} openDraftPicker={openDraftPicker} capSheet apronView={apronView} singlePage={singlePage} />

      <FreeAgentsSection buckets={buckets} state={state} dispatch={dispatch} apronView={apronView} singlePage={singlePage} />

      {tradedOut.length > 0 && (
        <>
          <SectionIntro title="Traded away" count={tradedOut.length}
            desc="Sent out in the Trade Machine — off your books. Undo to bring a player back (remove the incoming player from New additions if needed)." />
          <PlainTable players={tradedOut} state={state} dispatch={dispatch} kind="roster" />
        </>
      )}

      {/* [#3] ToolsSection ("X/15 tools available") render removed — a dev/reference grid,
          not fan-facing (the component def is left in place, dead, per the V3 redesign). */}
    </>
  );
}

/* Cap-Moves "Cap holds" list: enter each FA's intended FINAL salary and
   the engine advises Sign / Hold for Trade / Renounce (and what it does
   to cap room) via capSignClassify. */
function CapMovesTable({ players, state, dispatch }) {
  const [editKey, setEditKey] = useState(null);
  const [editVal, setEditVal] = useState("");
  // Contract fields (shown when Signing with Multi-year on) — mirror CapSheetRow.
  const [editYears, setEditYears] = useState(2);
  const [editOption, setEditOption] = useState(null);
  const [editRaisePct, setEditRaisePct] = useState(null);
  const [editRaises, setEditRaises] = useState([]);
  const [pyExpanded, setPyExpanded] = useState(false);
  if (players.length === 0) return null;

  const isRenounced = (p) => state.decisions[p.name]?.kind === "renounced";
  const active = players.filter(p => !isRenounced(p));
  const gone = players.filter(isRenounced);

  function startSign(p) {
    const d = state.decisions[p.name];
    // Re-open at the current signed salary, else seed at the cap hold —
    // shown in millions, full precision (short to edit).
    const isSig = d?.kind === "signed";
    const base = (isSig && d.salary) ? d.salary : computeCapHold(p);
    // #5: seed a PROPOSED salary (from the cap hold) as a clean 1-decimal figure ($20.9M, not
    // $20.906361M); re-editing a value you already set keeps its exact figure.
    setEditVal((isSig && d.salary) ? toMField(base) : toMFieldSeed(base));
    setEditYears(isSig && d.years ? d.years : 2);
    setEditOption(isSig ? (d.option || null) : null);
    setEditRaisePct(isSig && d.raisePct != null ? d.raisePct : null);
    setEditRaises(isSig && Array.isArray(d.raises) ? d.raises : []);
    setPyExpanded(isSig && Array.isArray(d.raises) && d.raises.length > 0);
    setEditKey(p.name);
  }
  function confirmSign(p) {
    const n = parseSalaryFromMField(editVal);
    if (n == null || n <= 0) { setEditKey(null); return; }
    const dec = { kind: "signed", salary: n };
    if (state.multiYear) {
      dec.years = editYears;
      dec.option = editYears > 1 ? editOption : null;
      if (state.adjustRaises && editYears > 1) {
        if (pyExpanded && editYears > 2) dec.raises = editRaises.slice(0, editYears - 1);
        else if (editRaisePct != null) dec.raisePct = editRaisePct;
      }
    } else {
      dec.years = state.decisions[p.name]?.years || 2;
    }
    if (reSignBlocked(p, { salary: dec.salary, years: dec.years || 1, raisePct: dec.raisePct, raises: dec.raises }, dispatch)) return;
    dispatch({ type: "SET_DECISION", player: p.name, decision: dec });
    setEditKey(null);
  }
  function holdForTrade(p) {
    dispatch({ type: "SET_DECISION", player: p.name, decision: { kind: "kept-hold" } });
    if (editKey === p.name) setEditKey(null);
  }
  function renounce(p) {
    dispatch({ type: "SET_DECISION", player: p.name, decision: { kind: "renounced" } });
    if (editKey === p.name) setEditKey(null);
  }
  const rowProps = (p) => ({
    p, state,
    isEditing: editKey === p.name, editVal, setEditVal,
    editYears, setEditYears, editOption, setEditOption,
    editRaisePct, setEditRaisePct, editRaises, setEditRaises, pyExpanded, setPyExpanded,
    onSign: () => startSign(p), onConfirm: () => confirmSign(p), onCancel: () => setEditKey(null),
    onHold: () => holdForTrade(p), onRenounce: () => renounce(p),
  });

  return (
    <section className="section flex-tbl roster-tbl cap-moves-tbl">
      <div className="flex-row header">
        <div className="hdr-photo-spacer" aria-hidden="true" />
        <div className="info-col">
          <span className="hdr-wide">PLAYER · CAP HOLD</span>
          <span className="hdr-narrow">PLAYER</span>
        </div>
      </div>
      {active.map(p => <CapMovesRow key={p.name} {...rowProps(p)} />)}
      {gone.length > 0 && (
        <div className="cap-renounced-head">Renounced — rights cleared ($0). Sign or Hold to bring one back.</div>
      )}
      {gone.map(p => <CapMovesRow key={p.name} {...rowProps(p)} />)}
    </section>
  );
}

function CapMovesRow({ p, state, isEditing, editVal, setEditVal,
                      editYears, setEditYears, editOption, setEditOption,
                      editRaisePct, setEditRaisePct, editRaises, setEditRaises,
                      pyExpanded, setPyExpanded,
                      onSign, onConfirm, onCancel, onHold, onRenounce }) {
  const decision = state.decisions[p.name];
  const hold = computeCapHold(p);
  const multiYear = state.multiYear, adjustRaises = state.adjustRaises;
  const maxYears = maxContractYears(p.birdRights);
  const maxRate = contractRaiseRate(p.birdRights);
  const editBase = parseSalaryFromMField(editVal) || 0;
  const editYrs = multiYear ? editYears : 1;
  const calc = contractCalc(editBase, editYrs, { adjustRaises, raisePct: editRaisePct, raises: editRaises, pyExpanded, maxRate });
  const signed = decision?.kind === "signed";
  // A declined team/player option (kind opt-out/decline) is on a cap hold
  // just like an undecided FA — so it defaults to "Hold for Trade" too.
  // NB: by here Step2CapHolds has remapped offseasonStatus → "UFA", so test
  // the decision kind directly rather than the (rewritten) status.
  const held = (decision?.kind === "kept-hold" || !decision
                || decision?.kind === "opt-out" || decision?.kind === "decline") && !isEditing;
  const renounced = decision?.kind === "renounced";
  const photoSrc = p.nbaId ? `assets/players/${p.nbaId}.png` : null;
  const BR = { Bird: "Bird Rights", EarlyBird: "Early Bird", NonBird: "Non-Bird" };
  // Editing → advise on the entered salary; otherwise a passive hint at the hold.
  const advice = capSignClassify(p, isEditing ? parseSalaryFromMField(editVal) : hold);
  const tone = advice.action === "illegal" ? "error" : advice.needsRoom ? "warn" : "info";
  const initials = p.name.split(" ").map(s => s[0]).slice(0, 2).join("");
  // #3 — a Bird/EB re-sign ABOVE the hold counts the (lower) hold for cap
  // room; show both figures.
  const signedSalary = signed ? (decision.salary || 0) : 0;
  const isBirdLike = p.birdRights === "Bird" || p.birdRights === "EarlyBird";
  const countsHold = signed && isBirdLike && signedSalary > hold;

  const photoEl = (
    <>
      {photoSrc
        ? <img className="row-photo" src={photoSrc} alt="" onError={(e) => { e.currentTarget.style.display = "none"; e.currentTarget.nextSibling.style.display = "grid"; }} />
        : null}
      <span className="row-photo placeholder" style={{ display: photoSrc ? "none" : "grid" }}>{initials}</span>
    </>
  );

  // ---- editing (Signing): the shared .resign-edit grid (matches Build
  // Roster free agents) — Cap hold ref-row, advice, Years/option, ✓/✗. ----
  if (isEditing) {
    const refEl = <span className="cap-locked"><span className="cap-lbl">Cap hold</span><span className="money dim">{fmt$(hold)}</span></span>;
    return (
      <div className={`flex-row body-row cap-row editing resign-edit ${multiYear ? "cap-grid" : ""}`}>
        {photoEl}
        <div className="info-col">
          {/* Cap hold rides the name line; salary box on its own line below. */}
          <div className="cap-line1">
            <div className="player-name">{p.name}{p.position && <span className="pos-name">{p.position}</span>}</div>
            {refEl}
          </div>
          <div className="cap-ref-row">
            <span className="cap-edit-line">
              <span className="cap-lbl salary-hdr">Salary</span>
              <span className="salary-edit inline-final">
                <span className="pre">$</span>
                <input autoFocus type="text" inputMode="decimal" value={editVal}
                       onChange={(e) => setEditVal(e.target.value)}
                       onKeyDown={(e) => { if (e.key === "Enter") onConfirm(); if (e.key === "Escape") onCancel(); }}
                       onFocus={(e) => e.target.select()}
                       aria-label={`Final salary for ${p.name}`} />
                {mFieldShowM(editVal) && <span className="pre" style={{ fontSize: 12 }}>M</span>}
              </span>
            </span>
          </div>
          {/* Advice rides above the Years bar (mirrors CapSheetRow's option layout). */}
          {multiYear && <span className={`cap-advice ${tone}`}>{advice.reason}</span>}
        </div>
        {/* Years bar gets its OWN grid row below the photo (matches CapSheetRow) so
            the per-year list (row 3) and Option/✓✗ bar (row 4) never collide — the
            old layout nested ContractFields in info-col with no cap-grid, so the
            per-year steppers overlapped the Option bar (both landed on grid-row 2). */}
        {multiYear && (
          <div className="contract-bar">
            <span className="resign-hdr">Years</span>
            <div className="contract-bar-main">
              <ContractFields base={editBase} years={editYears} setYears={setEditYears}
                              maxYears={maxYears} maxRate={maxRate} calc={calc}
                              setRaisePct={setEditRaisePct} setRaises={setEditRaises}
                              setPyExpanded={setPyExpanded} hideYearsHeader />
            </div>
          </div>
        )}
        {multiYear && calc.usePerYear && editBase > 0 && editYrs > 1 && (
          <ContractPerYear calc={calc} maxRate={maxRate} setRaises={setEditRaises} />
        )}
        {multiYear ? (
          <OptionConfirmBar option={editOption} setOption={setEditOption} years={editYears}>
            <ConfirmButtons onSave={onConfirm} onCancel={onCancel} saveTitle="Confirm signing" />
          </OptionConfirmBar>
        ) : (
          <div className="cap-advice-bar resign-bar">
            <div className="resign-bar-main">
              <span className={`cap-advice ${tone}`}>{advice.reason}</span>
              <ConfirmButtons onSave={onConfirm} onCancel={onCancel} saveTitle="Confirm signing" />
            </div>
          </div>
        )}
      </div>
    );
  }

  // ---- idle: the Sign / Hold for Trade / Renounce buttons ----
  return (
    <div className={`flex-row body-row cap-moves-row ${renounced ? "decided-out" : ""}`}>
      {photoEl}
      <div className="info-col">
        <div className="player-name">{p.name}
          {p.position && <span className="pos-name">{p.position}</span>}</div>
        <div className="player-sub">
          <span className="bird-meta">{BR[p.birdRights] || "Free agent"}</span>
          {signed
            ? <span className="hold-meta"><b className="signed-amt">Re-signed {fmt$(signedSalary)}</b>{countsHold ? ` · counts ${fmt$(hold)} hold` : ""}</span>
            : <span className="hold-meta">Cap hold {fmt$(hold)}</span>}
        </div>
        <div className="cap-moves-actions">
          <div className="decisions equal three">
            <button className={signed ? "on-yes" : ""} onClick={onSign}>
              {signed && <Icon.Check className="dec-icon" />}<span className="dec-label">{signed ? "Signed" : "Sign"}</span>
            </button>
            <button className={held ? "on-neutral" : ""} onClick={onHold}>
              <span className="dec-label">Hold for Trade</span>
            </button>
            <button className={renounced ? "on-no" : ""} onClick={onRenounce}>
              <span className="dec-label">Renounce</span>
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

/* Cap Space team Step 2 "Cap Holds" — establish cap room. Each FA: Sign /
   Hold for Trade / Renounce (salary entered only on Sign) + the Room MLE
   explainer. A player opt-out is your own re-signable FA (cap hold); a
   DECLINED team option is a let-go player (no hold), so it's excluded. */
function Step2CapHolds({ buckets, state, dispatch, derived }) {
  const declinedFAs = [
    ...buckets.optsP.filter(p => ["opt-out", "kept-hold", "renounced", "signed"].includes(state.decisions[p.name]?.kind)),
    ...buckets.optsT.filter(p => ["decline", "kept-hold", "renounced", "signed"].includes(state.decisions[p.name]?.kind)),
  ].map(p => ({ ...p, offseasonStatus: "UFA", lastSalary: p.priorSeasonSalary }));
  // Order by cap hold (highest first) so opt-outs intermix by value
  // rather than being bucketed at the bottom (#2). Renounced still drop
  // to the bottom inside CapMovesTable.
  const allFAs = [...buckets.ufas, ...buckets.rfas, ...declinedFAs]
    .sort((a, b) => computeCapHold(b) - computeCapHold(a));
  const isRenounced = (p) => state.decisions[p.name]?.kind === "renounced";
  const isSigned = (p) => state.decisions[p.name]?.kind === "signed";
  const heldFAs = allFAs.filter(p => !isRenounced(p) && !isSigned(p));
  const renounced = allFAs.filter(isRenounced);
  const reSignable = allFAs.filter(p => !isSigned(p));   // held + renounced
  const room = CAP_2026.cap - derived.committed;

  function renounceAll() {
    for (const p of heldFAs) dispatch({ type: "SET_DECISION", player: p.name, decision: { kind: "renounced" } });
    if (heldFAs.length) dispatch({ type: "SET_TOAST", toast: `Renounced ${heldFAs.length} hold${heldFAs.length === 1 ? "" : "s"} to free room.` });
  }
  function holdAll() {
    for (const p of reSignable) dispatch({ type: "SET_DECISION", player: p.name, decision: { kind: "kept-hold" } });
    if (reSignable.length) dispatch({ type: "SET_TOAST", toast: `Holding ${reSignable.length} free agent${reSignable.length === 1 ? "" : "s"}.` });
  }

  return (
    <>
      {room >= 0 && (
        <div className="cap-room-banner">
          <b>{fmt$(room)}</b> in cap room remaining
        </div>
      )}

      <SectionIntro title="Free agents" count={allFAs.length}
        desc="For each: Sign (enter a salary), Hold for Trade (keep the cap charge), or Renounce (clear it & free room). Renounced players drop to the bottom; Bird rights are kept while held."
        action={
          <button className="btn" onClick={renounceAll} disabled={heldFAs.length === 0}>
            Renounce all
          </button>
        } />
      {allFAs.length > 0
        ? <CapMovesTable players={allFAs} state={state} dispatch={dispatch} />
        : <div className="apron-blurb"><span className="ico"><Icon.Check style={{ width: 16, height: 16 }} /></span><div>No free agents.</div></div>}
    </>
  );
}

/* Unified cap-sheet FA row (signed re-signs, held, renounced, and option
   players). Display = name + salary + a clickable status chip; clicking
   opens an IN-PLACE editor (salary edits where it sits, pre-selected;
   above-hold shows a locked "Cap hold" + a labelled "Salary" field;
   option players get an Opt-in/out toggle). Edit-open state is lifted to
   CommittedRosterTable so only one row edits at a time. */
function CapSheetRow({ p, state, dispatch, salary, tone, editing, editVal, setEditVal, editKind, setEditKind, dirty, onEdit, onClose, anyEditing,
                       editYears, setEditYears, editOption, setEditOption,
                       editRaisePct, setEditRaisePct, editRaises, setEditRaises,
                       pyExpanded, setPyExpanded, apronView }) {
  const salaryRef = useRef(null);
  const decision = state.decisions[p.name];
  const hold = computeCapHold(p);
  const s2627 = p.seasons?.find(s => s.season === "2026-27")?.salary || 0;
  const isPO = p.offseasonStatus === "player_option";
  const isTO = p.offseasonStatus === "team_option";
  const isOption = isPO || isTO;
  const optInKind = isPO ? "opt-in" : "exercise";
  const optOutKind = isPO ? "opt-out" : "decline";
  // Button highlights reflect the PENDING kind while editing (not committed
  // until ✓), else the committed decision.
  const effKind = editing ? editKind : decision?.kind;
  const signed = effKind === "signed";
  const heldNow = effKind === "kept-hold" || !effKind;
  const renounced = effKind === "renounced";
  const optIn = isOption && effKind === optInKind;
  // "Hold for Trade" also covers an option player who opted out but hasn't
  // picked Sign/Renounce yet (kind opt-out/decline = on cap hold).
  const heldHighlight = heldNow || (isOption && effKind === optOutKind);
  const optionSalary = p.optionSalary || s2627;
  const sb = statusBadge(p, decision);
  const photoSrc = p.nbaId ? `assets/players/${p.nbaId}.png` : null;
  const initials = p.name.split(" ").map(s => s[0]).slice(0, 2).join("");
  const committedSigned = decision?.kind === "signed";
  const signedSalary = committedSigned ? (decision.salary || 0) : 0;
  const isBirdLike = p.birdRights === "Bird" || p.birdRights === "EarlyBird";
  const aboveHold = isBirdLike && (editing ? (parseSalaryFromMField(editVal) || 0) > hold : signedSalary > hold);
  const advice = capSignClassify(p, parseSalaryFromMField(editVal) || hold);
  const advTone = advice.action === "illegal" ? "error" : advice.needsRoom ? "warn" : "info";

  // Contract fields (years / option / raises) — shown below the buttons only
  // when "Signed" is the pending decision, indented to the info column.
  const multiYear = state.multiYear, adjustRaises = state.adjustRaises;
  const maxYears = maxContractYears(p.birdRights);
  const maxRate = contractRaiseRate(p.birdRights);
  const base = parseSalaryFromMField(editVal) || 0;
  const yrs = multiYear ? editYears : 1;
  const calc = contractCalc(base, yrs, { adjustRaises, raisePct: editRaisePct, raises: editRaises, pyExpanded, maxRate });

  // Commit the pending decision (✓). Nothing is dispatched until here, so the
  // row holds its place/bucket while you pick Sign/Hold/Renounce.
  function commitPending() {
    if (editKind === "signed") {
      const n = parseSalaryFromMField(editVal);
      if (n == null || n <= 0) { onClose(); return; }
      const dec = { kind: "signed", salary: n };
      if (multiYear) {
        dec.years = editYears;
        dec.option = editYears > 1 ? editOption : null;
        if (adjustRaises && editYears > 1) {
          if (pyExpanded && editYears > 2) dec.raises = editRaises.slice(0, editYears - 1);
          else if (editRaisePct != null) dec.raisePct = editRaisePct;
        }
      } else {
        dec.years = decision?.years || 2;
      }
      if (reSignBlocked(p, { salary: dec.salary, years: dec.years || 1, raisePct: dec.raisePct, raises: dec.raises }, dispatch, state)) return;
      dispatch({ type: "SET_DECISION", player: p.name, decision: dec });
    } else if (editKind) {
      dispatch({ type: "SET_DECISION", player: p.name, decision: { kind: editKind } });
    }
    onClose();
  }

  const photoInner = (
    <>
      {photoSrc
        ? <img className="row-photo" src={photoSrc} alt="" onError={(e) => { e.currentTarget.style.display = "none"; e.currentTarget.nextSibling.style.display = "grid"; }} />
        : null}
      <span className="row-photo placeholder" style={{ display: photoSrc ? "none" : "grid" }}>{initials}</span>
    </>
  );
  // All rows use the full photo; renounced rows are simply dimmed
  // (.renounced-row opacity) rather than head-cropped.
  const photoEl = photoInner;
  // The salary box reflects the pending decision: editable only when Signing;
  // otherwise greyed at the implied figure (option salary / cap hold / $0).
  const apronMode = state.mode === "apron";
  let boxVal = editVal, boxEditable = true;
  if (signed) { boxVal = editVal; boxEditable = true; }
  else if (optIn) { boxVal = toMField(optionSalary); boxEditable = false; }
  else if (heldNow && !apronMode) { boxVal = toMField(hold); boxEditable = false; }
  else { boxVal = "0"; boxEditable = false; }   // opt-out / decline / renounce / apron-held
  const mEdit = (
    <span className={`salary-edit inline-final ${boxEditable ? "" : "is-locked"}`}>
      <span className="pre">$</span>
      <input ref={salaryRef} type="text" inputMode="decimal" value={boxVal} disabled={!boxEditable}
             onChange={(e) => { if (boxEditable) setEditVal(e.target.value); }} onFocus={(e) => { if (boxEditable) e.target.select(); }}
             onKeyDown={(e) => { if (e.key === "Enter") commitPending(); if (e.key === "Escape") onClose(); }} />
      {mFieldShowM(boxVal) && <span className="pre" style={{ fontSize: 12 }}>M</span>}
    </span>
  );
  // When the row enters/switches to Sign, focus + select the salary and scroll
  // it into view so the keyboard pops and you can just type the new amount.
  useEffect(() => {
    // Focus + select the salary on Sign; let the browser do its native
    // focus-scroll (it reveals the field above the keyboard — the behaviour
    // that worked well before). No custom scrolling.
    if (editing && boxEditable && salaryRef.current) {
      salaryRef.current.focus();
      salaryRef.current.select();
    }
  }, [editing, signed]);
  // Pencil chip stays clickable even while another row is open, so you can
  // switch editors — but it's locked (no-op) while that edit has unsaved
  // changes (must ✓/✗ first), matching the 2ways behaviour.
  const locked = anyEditing && dirty && !editing;
  const chip = (
    <button className={`cap-status-chip ${sb.kind} ${locked ? "locked" : ""}`}
            onClick={onEdit}><Icon.Pencil /> {capStatusLabel(p, decision)}</button>
  );

  // ---- display (not editing) ----
  if (!editing) {
    if (tone === "renounced") {
      return (
        <div className="flex-row body-row cap-row renounced-row">
          {photoEl}
          <div className="info-col">
            <div className="cap-line1">
              <div className="player-name">{p.name}{p.position && <span className="pos-name">{p.position}</span>}</div>
              {chip}
            </div>
          </div>
        </div>
      );
    }
    return (
      <div data-name={p.name} className="flex-row body-row cap-row">
        {photoEl}
        <div className="info-col">
          <div className="cap-line1">
            <div className="player-name">{p.name}{p.position && <span className="pos-name">{p.position}</span>}</div>
            <span className="cap-salary">
              <span className="money">{fmt$Full(salary)}</span>
              {(() => {
                const lbl = window.contractYrsLabel && window.contractYrsLabel(p, decision);
                return lbl ? <span className="contract-yrs">{lbl}</span> : null;
              })()}
            </span>
          </div>
          <div className="cap-line2">
            {chip}
            {aboveHold && !apronView && <span className="cap-note">re-sign {fmt$(signedSalary)}</span>}
          </div>
        </div>
      </div>
    );
  }

  // ---- editing (in place) ----
  // The shared .resign-edit grid (matches Build Roster free agents): photo ·
  // info-col [name, Cap hold/Option ref-row, advice, Sign/Hold/Renounce
  // buttons, Years/Total when Signed] · per-year · bottom bar (Option + ✓/✗).
  const apron = state.mode === "apron";   // no cap holds → no Hold/Renounce
  const optedOut = isOption && effKind === optOutKind;
  const showOption = signed && multiYear;
  const confirmEl = <ConfirmButtons onSave={commitPending} onCancel={onClose} saveTitle="Apply" />;
  // Apron has no cap hold — reference the option salary instead.
  const refEl = (apron || optIn)
    ? <span className="cap-locked"><span className="cap-lbl">Option</span><span className="money dim">{fmt$(optionSalary)}</span></span>
    : <span className="cap-locked"><span className="cap-lbl">Cap hold</span><span className="money dim">{fmt$(hold)}</span></span>;
  // One mode-aware advice line (cap-room talk only applies in cap mode).
  let adviceText, adviceTone = "info";
  if (optIn) adviceText = isPO ? "Opted in — plays at the option salary." : "Option exercised — stays on the books.";
  else if (apron && optedOut) adviceText = isPO ? "Opts out — becomes a free agent." : "Declined — becomes a free agent.";
  else if (apron && signed) adviceText = "Re-signs to a new contract.";
  else if (!apron && renounced) adviceText = "Renounce — clears the cap hold ($0).";
  else if (!apron && heldHighlight && !signed) adviceText = "Hold for trade — keeps the cap hold on the books.";
  else { adviceText = advice.reason; adviceTone = advTone; }
  return (
    <div className={`flex-row body-row cap-row editing resign-edit ${showOption ? "cap-grid" : ""}`}>
      {photoEl}
      <div className="info-col">
        {/* Ref (Cap hold / Option) always rides the name line; the salary box
            sits on its own line below — same in multi-year and 1-year. */}
        <div className="cap-line1">
          <div className="player-name">{p.name}{p.position && <span className="pos-name">{p.position}</span>}</div>
          {refEl}
        </div>
        <div className="cap-ref-row">
          <span className="cap-edit-line"><span className="cap-lbl salary-hdr">Salary</span>{mEdit}</span>
        </div>
        {/* When the Option row shows, the advice sits here (above the buttons);
            otherwise it rides the bottom ✓/✗ bar (see below). */}
        {showOption && <span className={`cap-advice ${adviceTone}`}>{adviceText}</span>}
        {/* One segmented control. Option players get a 4th leading "Opt in"
            choice; picking any of the other three IS opting out. Buttons set
            the PENDING kind only — nothing commits until ✓. */}
        <div className={`cap-moves-actions ${showOption ? "" : "gap-top"}`}>
          {isOption ? (
            <div className="decisions optgrp">
              <button className={`optgrp-optin ${optIn ? "on-yes" : ""}`} onClick={() => setEditKind(optInKind)}>{optIn && <Icon.Check className="dec-icon" />}<span className="dec-label">{isPO ? "Opt in" : "Exercise"}</span></button>
              <span className="optgrp-sep" aria-hidden="true" />
              <div className="optgrp-trio">
                <button className={`optgrp-out ${signed ? "on-yes" : ""}`} onClick={() => setEditKind("signed")}>{signed && <Icon.Check className="dec-icon" />}<span className="dec-label">{signed ? "Signed" : "Sign"}</span></button>
                {apron ? (
                  <button className={`optgrp-out ${optedOut ? "on-no" : ""}`} onClick={() => setEditKind(optOutKind)}><span className="dec-label">{isPO ? "Opt out" : "Decline"}</span></button>
                ) : (
                  <>
                    <button className={`optgrp-out ${heldHighlight ? "on-neutral" : ""}`} onClick={() => setEditKind("kept-hold")}><span className="dec-label">Hold</span></button>
                    <button className={`optgrp-out ${renounced ? "on-no" : ""}`} onClick={() => setEditKind("renounced")}><span className="dec-label">Renounce</span></button>
                  </>
                )}
              </div>
            </div>
          ) : (
            <div className={`decisions equal ${apron ? "two" : "three"}`}>
              <button className={signed ? "on-yes" : ""} onClick={() => setEditKind("signed")}>{signed && <Icon.Check className="dec-icon" />}<span className="dec-label">{signed ? "Signed" : "Sign"}</span></button>
              {!apron && <button className={heldHighlight ? "on-neutral" : ""} onClick={() => setEditKind("kept-hold")}><span className="dec-label">Hold for Trade</span></button>}
              <button className={renounced ? "on-no" : ""} onClick={() => setEditKind("renounced")}><span className="dec-label">Renounce</span></button>
            </div>
          )}
        </div>
      </div>
      {/* Years bar drops BELOW the photo (photo only spans the buttons) so a
          signed multi-year row doesn't stretch the photo; "Years" rides the
          photo column (col 1) like "Option" on the bottom bar. */}
      {showOption && (
        <div className="contract-bar">
          <span className="resign-hdr">Years</span>
          <div className="contract-bar-main">
            <ContractFields base={base} years={editYears} setYears={setEditYears}
                            maxYears={maxYears} maxRate={maxRate} calc={calc}
                            setRaisePct={setEditRaisePct} setRaises={setEditRaises}
                            setPyExpanded={setPyExpanded} hideYearsHeader />
          </div>
        </div>
      )}
      {showOption && calc.usePerYear && base > 0 && yrs > 1 && (
        <ContractPerYear calc={calc} maxRate={maxRate} setRaises={setEditRaises} />
      )}
      {showOption ? (
        <OptionConfirmBar option={editOption} setOption={setEditOption} years={editYears}>
          {confirmEl}
        </OptionConfirmBar>
      ) : (
        // No Option row → advice + ✓/✗ share a bottom bar, indented to the info
        // column (col 2) via the resign-bar grid so it starts right of the photo.
        <div className="cap-advice-bar resign-bar">
          <div className="resign-bar-main">
            <span className={`cap-advice ${adviceTone}`}>{adviceText}</span>
            {confirmEl}
          </div>
        </div>
      )}
    </div>
  );
}

/* Cap Space team Step 3 "Cap Moves" — the resulting cap sheet (Cap Hits) +
   room; add via Sign FA / Trades. Holds & renounced shown as their own
   read-only zones (decisions are made on the Cap Holds step). */
function Step2CapRoster({ buckets, state, dispatch, derived, openModal, openDraftPicker }) {
  const committed = buildCommittedRoster(buckets, state, false);
  const room = CAP_2026.cap - derived.committed;
  // Keep original offseasonStatus so CapSheetRow can show the opt-in/out
  // toggle for option players in the held/renounced zones.
  const declinedFAs = [
    ...buckets.optsP.filter(p => ["opt-out", "kept-hold", "renounced", "signed"].includes(state.decisions[p.name]?.kind)),
    ...buckets.optsT.filter(p => ["decline", "kept-hold", "renounced", "signed"].includes(state.decisions[p.name]?.kind)),
  ];
  const allFAs = [...buckets.ufas, ...buckets.rfas, ...declinedFAs];
  const heldFAs = allFAs
    .filter(p => { const k = state.decisions[p.name]?.kind; return k !== "signed" && k !== "renounced"; })
    .sort((a, b) => computeCapHold(b) - computeCapHold(a));
  const renouncedFAs = allFAs.filter(p => state.decisions[p.name]?.kind === "renounced");
  return (
    <>
      {room >= 0 && (
        <div className="cap-room-banner">
          <b>{fmt$(room)}</b> in cap room remaining
        </div>
      )}

      <div className="committed-head">
        <h3>Cap Hits <span className="count num">{committed.length}</span></h3>
        <div className="actions-right">
          <button className="btn" onClick={() => openModal("sign-fa")}>
            <Icon.UserPlus /> Sign FA
          </button>
          <button className="btn" onClick={() => openModal("trade")}>
            <Icon.Trade /> Trades
          </button>
        </div>
      </div>
      <CommittedRosterTable committed={committed} state={state} dispatch={dispatch}
        openDraftPicker={openDraftPicker} capSheet heldFAs={heldFAs} renouncedFAs={renouncedFAs} />
    </>
  );
}

function salaryOf(p, state, apronView) {
  const d = state.decisions[p.name];
  const s2627 = p.seasons?.find(s => s.season === "2026-27")?.salary || 0;
  if (d?.salaryOverride != null) return d.salaryOverride;   // pencil edit
  // Cap Moves counts the cap hold (not the full salary) for an above-hold
  // Bird/EB re-sign; Final Roster (apronView) shows the full contract.
  if (d?.kind === "signed") return cappedSignedSalary(p, d.salary, state.mode, apronView);
  if (d?.kind === "opt-in") return p.optionSalary || s2627;
  if (d?.kind === "exercise") return p.optionSalary || s2627;
  return s2627;
}

/* A committed-roster row with an inline salary editor (2ways-inspired):
   a fixed-width action slot so nothing shifts; while one row edits, the
   others' controls are hidden (slot kept). Draft picks: no X — the
   pencil edits the salary AND exposes the disposition options. */
function CommittedRow({ p, state, dispatch, overMax, capSheet,
                        isEditing, anyEditing, editVal, setEditVal,
                        editName, setEditName, onStart, onSave, onCancel, onUndo,
                        onResetDraft, onPickProspect,
                        editYears, setEditYears, editOption, setEditOption,
                        editRaisePct, setEditRaisePct, editRaises, setEditRaises,
                        pyExpanded, setPyExpanded, apronView, singlePage }) {
  const isAdd = p._isAddition;
  const isDraft = p._isDraft;
  const decision = state.decisions[p.name];
  const isSignedFA = !isAdd && !isDraft && decision?.kind === "signed";
  // (FA/option cap-sheet rows render via CapSheetRow, not here.)
  // #3 — a Bird/EB re-sign above the hold counts the (lower) hold in the
  // cap math (p._salary); surface the eventual full contract too.
  const decSalary = decision?.salary;
  // The "re-sign $X" note explains a cap-hold-counted salary — pointless on the
  // Final Roster (apronView) where the full contract figure is already shown.
  const countsHoldNote = isSignedFA && decSalary != null && decSalary > p._salary && !apronView;
  // a1: trade-acquired additions are NOT row-editable — their salary/contract is
  // set by the trade itself (change it in the Trade Machine). Hand-signed
  // additions, re-signed FAs, and draft picks stay editable.
  const fromTrade = isAdd && p._addition._fromTrade;
  const editable = (isAdd && !fromTrade) || isDraft || isSignedFA;
  // Undo applies to hand-made signings, not picks or trade-ins (undo via the TM).
  const canUndo = (isAdd && !fromTrade) || isSignedFA;
  const dp = isDraft ? state.draftPicks.find(d => d.id === p._draftId) : null;
  const photoSrc = isDraft
    ? (dp?.prospectPhoto || null)
    // R15: additions now carry nbaId (from _sourcePlayer), so show their
    // headshot in the roster too — not just in the old New-additions box.
    : (p.nbaId ? `assets/players/${p.nbaId}.png` : null);
  const sBadge = isAdd
    ? (fromTrade ? { kind: "traded", label: "Traded" } : { kind: "signed", label: "Signed" })
    : isDraft
    ? { kind: "draft", label: "Drafted" }
    : statusBadge(p, decision);
  const pickSub = dp?.pickNumber ? `#${dp.pickNumber} Draft Pick` : "Draft pick";

  const initials = p.name.split(" ").map(s => s[0]).slice(0, 2).join("");
  const photoEl = (isDraft && onPickProspect) ? (
    <button className="row-photo-btn" title="Choose a draft prospect" onClick={onPickProspect}>
      {photoSrc
        ? <>
            <img className="row-photo" src={photoSrc} alt="" onError={(e) => { e.currentTarget.style.display = "none"; e.currentTarget.nextSibling.style.display = "grid"; }} />
            <span className="row-photo placeholder" style={{ display: "none" }}>{initials}</span>
            <span className="draft-pick-add"><Icon.Pencil /></span>
          </>
        : <span className="row-photo placeholder draft-select" style={{ display: "grid" }}>
            <Icon.Plus /><span className="draft-select-cap">Select</span>
          </span>}
    </button>
  ) : (
    <>
      {photoSrc
        ? <img className="row-photo" src={photoSrc} alt="" onError={(e) => { e.currentTarget.style.display = "none"; e.currentTarget.nextSibling.style.display = "grid"; }} />
        : null}
      <span className="row-photo placeholder" style={{ display: photoSrc ? "none" : "grid" }}>{initials}</span>
    </>
  );

  // Editing an added FA / trade-in OR a re-signed own FA opens the SAME full
  // contract editor as the apron re-sign editor — years / final-year option /
  // raises — instead of a salary-only field. Same grid + classes, both flows.
  if (isEditing && (isAdd || isSignedFA)) {
    const base = parseSalaryFromMField(editVal) || 0;
    const BR = { Bird: "Bird Rights", EarlyBird: "Early Bird", NonBird: "Non-Bird" };
    const refLabel = isAdd ? p._addition.source : (BR[p.birdRights] || "Free agent");
    // Added FA = outside (non-Bird, 4y/5%); re-signed own FA uses its rights.
    const maxYears = isAdd ? 4 : maxContractYears(p.birdRights);
    const maxRate = isAdd ? 0.05 : contractRaiseRate(p.birdRights);
    const multiYear = state.multiYear, adjustRaises = state.adjustRaises;
    const yrs = multiYear ? editYears : 1;
    const calc = contractCalc(base, yrs, { adjustRaises, raisePct: editRaisePct, raises: editRaises, pyExpanded, maxRate });
    const inlineConfirm = !multiYear;
    const confirmEl = (
      <ConfirmButtons onSave={onSave} onCancel={onCancel} onRemove={onUndo}
                      saveTitle="Save" removeTitle={isAdd ? "Remove this signing" : "Undo this signing"} />
    );
    const refEl = <span className="cap-locked"><span className="cap-lbl">{refLabel}</span></span>;
    return (
      <div className="flex-row body-row cap-row editing resign-edit">
        {photoEl}
        <div className="info-col">
          <div className="cap-line1">
            <div className="player-name">{p.name}{p.position && <span className="pos-name">{p.position}</span>}</div>
            {refEl}
          </div>
          <div className="cap-ref-row">
            <span className="cap-edit-line">
              <span className="cap-lbl salary-hdr">Salary</span>
              <span className="salary-edit inline-final">
                <span className="pre">$</span>
                <input autoFocus type="text" inputMode="decimal" value={editVal}
                       onChange={(e) => setEditVal(e.target.value)} onFocus={(e) => e.target.select()}
                       onKeyDown={(e) => { if (e.key === "Enter") onSave(); if (e.key === "Escape") onCancel(); }} />
                {mFieldShowM(editVal) && <span className="pre" style={{ fontSize: 12 }}>M</span>}
              </span>
              {inlineConfirm && confirmEl}
            </span>
          </div>
          {multiYear && (
            <ContractFields base={base} years={editYears} setYears={setEditYears}
                            maxYears={maxYears} maxRate={maxRate} calc={calc}
                            setRaisePct={setEditRaisePct} setRaises={setEditRaises}
                            setPyExpanded={setPyExpanded} />
          )}
        </div>
        {multiYear && calc.usePerYear && base > 0 && yrs > 1 && (
          <ContractPerYear calc={calc} maxRate={maxRate} setRaises={setEditRaises} />
        )}
        {!inlineConfirm && (
          <OptionConfirmBar option={editOption} setOption={setEditOption} years={editYears}>
            {confirmEl}
          </OptionConfirmBar>
        )}
      </div>
    );
  }

  // Cap Moves cap-sheet row: salary top-right on the name line; a single
  // clickable status chip (combines the old badge + pencil) on line 2.
  if (capSheet) {
    return (
      <div data-name={p.name} className={`flex-row body-row cap-row ${isAdd ? "is-addition" : ""} ${isEditing ? "editing" : ""} ${anyEditing && !isEditing ? "other-editing" : ""} ${overMax ? "over-max" : ""}`}>
        {photoEl}
        <div className="info-col">
          <div className="cap-line1">
            {isEditing && isDraft
              ? <input className="roster-edit-name" value={editName}
                       onChange={(e) => setEditName(e.target.value)}
                       onKeyDown={(e) => { if (e.key === "Enter") onSave(); if (e.key === "Escape") onCancel(); }}
                       placeholder="Pick / prospect name" />
              : <div className="player-name">{p.name}{p.position && <span className="pos-name">{p.position}</span>}</div>}
            <span className="cap-salary">
              {isEditing
                ? <RosterEditInput value={editVal} onChange={setEditVal} onSave={onSave} onCancel={onCancel} />
                : <span className="money">{fmt$Full(p._salary)}</span>}
            </span>
          </div>
          <div className="cap-line2">
            {isEditing ? (
              <span className="cap-edit-controls">
                {isDraft && <button className="pencil" title="Reset to rookie-scale cap hold" onClick={onResetDraft}><Icon.Reset /></button>}
                {canUndo && <button className="pencil cancel" title="Undo this signing" onClick={onUndo}><Icon.Trash /></button>}
                <button className="pencil confirm" title="Save" onClick={onSave}><Icon.Check /></button>
                <button className="pencil cancel" title="Cancel" onClick={onCancel}><Icon.X /></button>
              </span>
            ) : editable ? (
              <button className={`cap-status-chip ${sBadge.kind}`} onClick={onStart}>
                <Icon.Pencil /> {isDraft ? pickSub : sBadge.label}
              </button>
            ) : (
              <span className={`cap-status-chip static ${sBadge.kind}`}>{sBadge.label}</span>
            )}
            {countsHoldNote && !isEditing && <span className="cap-note">re-sign {fmt$(decSalary)}</span>}
            {/* #2: the "N Years" lives on this line (right) so it no longer makes
                cap-line1 two-tall and push the status chip down. #3: hover reveals
                what the option/guarantee codes mean. */}
            {!isEditing && (() => {
              const lbl = window.contractYrsLabel && window.contractYrsLabel(p, isAdd ? { kind: "signed", years: (p._addition.years || 1), option: (p._addition.option || null) } : state.decisions[p.name]);
              if (!lbl) return null;
              const t = [];
              if (lbl.includes("PG")) t.push("PG = partially-guaranteed year");
              if (lbl.includes("NG")) t.push("NG = non-guaranteed year");
              if (/\dP(?!G)/.test(lbl)) t.push("P = player-option year (his call to opt in or out)");
              if (/\dT/.test(lbl)) t.push("T = team-option year (the team's call)");
              return <span className="contract-yrs cap-yrs" title={t.join("  ·  ") || undefined}>{lbl}</span>;
            })()}
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className={`flex-row body-row ${isEditing ? "editing" : ""} ${anyEditing && !isEditing ? "other-editing" : ""} ${overMax ? "over-max" : ""}`}>
      {/* photo — for a draft pick the photo IS the prospect picker */}
      {isDraft && onPickProspect ? (
        <button className="row-photo-btn" title="Choose a draft prospect" onClick={onPickProspect}>
          {photoSrc
            ? <>
                <img className="row-photo" src={photoSrc} alt="" onError={(e) => { e.currentTarget.style.display = "none"; e.currentTarget.nextSibling.style.display = "grid"; }} />
                <span className="row-photo placeholder" style={{ display: "none" }}>{initials}</span>
                <span className="draft-pick-add"><Icon.Pencil /></span>
              </>
            : <span className="row-photo placeholder draft-select" style={{ display: "grid" }}>
                <Icon.Plus /><span className="draft-select-cap">Select</span>
              </span>}
        </button>
      ) : (
        <>
          {photoSrc
            ? <img className="row-photo" src={photoSrc} alt="" onError={(e) => { e.currentTarget.style.display = "none"; e.currentTarget.nextSibling.style.display = "grid"; }} />
            : null}
          <span className="row-photo placeholder" style={{ display: photoSrc ? "none" : "grid" }}>{initials}</span>
        </>
      )}

      <div className="info-col">
        {isEditing && isDraft
          ? <input className="roster-edit-name" value={editName}
                   onChange={(e) => setEditName(e.target.value)}
                   onKeyDown={(e) => { if (e.key === "Enter") onSave(); if (e.key === "Escape") onCancel(); }}
                   placeholder="Pick / prospect name" />
          : <div className="player-name">{p.name}
              {p.position && <span className="pos-name">{p.position}</span>}</div>}
        <div className="player-sub">
          <span className={`badge ${sBadge.kind}`}><span className="dot" />{sBadge.label}</span>
          {isAdd && <span>{p._addition.years || 1}y{optionTag(p._addition.option) ? ` · ${optionTag(p._addition.option)}` : ""} · {p._addition.source}</span>}
          {isDraft && <span>{pickSub}</span>}
        </div>
        <div className="salary-inline">
          {isEditing
            ? <RosterEditInput value={editVal} onChange={setEditVal} onSave={onSave} onCancel={onCancel} />
            : <><span className="money">{fmt$Full(p._salary)}</span>
                {countsHoldNote && <span className="counts-note">re-sign {fmt$(decSalary)}</span>}
                {(() => {
                  const lbl = window.contractYrsLabel && window.contractYrsLabel(p, isAdd ? { kind: "signed", years: (p._addition.years || 1), option: (p._addition.option || null) } : state.decisions[p.name]);
                  return lbl ? <span className="contract-yrs">{lbl}</span> : null;
                })()}</>}
        </div>
      </div>

      <div className="salary-col">
        {isEditing
          ? <div className="roster-edit-cell"><RosterEditInput value={editVal} onChange={setEditVal} onSave={onSave} onCancel={onCancel} /></div>
          : <><span className="money">{fmt$Full(p._salary)}</span>
              {countsHoldNote && <span className="counts-note">re-sign {fmt$(decSalary)}</span>}
              {(() => {
                const lbl = window.contractYrsLabel && window.contractYrsLabel(p, isAdd ? { kind: "signed", years: (p._addition.years || 1), option: (p._addition.option || null) } : state.decisions[p.name]);
                return lbl ? <span className="contract-yrs">{lbl}</span> : null;
              })()}</>}
      </div>

      <div className="row-spacer" />

      <div className="decision-col roster-actions">
        {isEditing ? (
          <>
            {isDraft && (
              <button className="pencil" title="Reset to rookie-scale cap hold" onClick={onResetDraft}><Icon.Reset /></button>
            )}
            {canUndo && (
              <button className="pencil cancel" title="Undo this signing" onClick={onUndo}><Icon.Trash /></button>
            )}
            <button className="pencil confirm" title="Save" onClick={onSave}><Icon.Check /></button>
            <button className="pencil cancel" title="Cancel" onClick={onCancel}><Icon.X /></button>
          </>
        ) : editable ? (
          <span className={anyEditing ? "act-hidden" : ""}>
            <button className="pencil" title={isDraft ? "Edit pick" : "Edit signing"} onClick={onStart}><Icon.Pencil /></button>
          </span>
        ) : (
          /* invisible placeholder so salaries line up with editable rows */
          <button className="pencil ghost" tabIndex={-1} aria-hidden="true"><Icon.Pencil /></button>
        )}
      </div>
    </div>
  );
}

/* ============================================================
   Misc
   ============================================================ */
function SectionIntro({ title, desc, count, when = true, action }) {
  if (!when) return null;
  return (
    <div className="section-intro">
      <div className="section-intro-head">
        <h3>{title}{count != null && <span className="count num">{count}</span>}</h3>
        {action && <div className="section-intro-action">{action}</div>}
      </div>
      {desc && <p>{desc}</p>}
    </div>
  );
}

/* Reusable inline alert (2ways-style: flows in the layout — never an
   overlay — with a small leading symbol). tone: warn | error | info. */
function Alert({ tone = "warn", children, onDismiss }) {
  const sym = tone === "info" ? "ⓘ" : "⚠";
  return (
    <div className={`alert alert-${tone}`} role={tone === "error" ? "alert" : "status"}>
      <span className="alert-ico">{sym}</span>
      <div className="alert-body">{children}</div>
      {onDismiss && (
        <button className="alert-x" title="Dismiss" onClick={onDismiss}><Icon.X /></button>
      )}
    </div>
  );
}

/* R12 — photo cell for additions: tries to load `assets/players/{nbaId}.png`,
   falls back to initials on error. Same fallback shape as the placeholder so
   the layout doesn't shift when the image is missing. */
function AdditionPhoto({ nbaId, name }) {
  const [bad, setBad] = useState(false);
  if (bad || !nbaId) {
    const initials = (name || "?").split(" ").map(s => s[0]).slice(0, 2).join("");
    return <div className="player-photo-placeholder">{initials}</div>;
  }
  return (
    <div className="player-photo-placeholder" style={{ overflow: "hidden", padding: 0 }}>
      <img src={`assets/players/${nbaId}.png`} alt=""
           onError={() => setBad(true)}
           style={{ width: "100%", height: "100%", objectFit: "cover", objectPosition: "50% 14%" }} />
    </div>
  );
}

/* ============================================================
   Additions header section
   ============================================================ */
function AdditionsSection({ additions, dispatch, compact }) {
  return (
    <section className="section" style={{ borderColor: "var(--brand-line)", marginTop: compact ? 4 : 14 }}>
      <div className="section-head" style={{ background: "var(--brand-soft)" }}>
        <h2 style={{ color: "var(--brand)" }}>New additions</h2>
        <span className="count num">{additions.length}</span>
        <span className="desc">Recently signed or traded in.</span>
      </div>
      <table className="tbl">
        <tbody>
          {additions.map(p => (
            <tr key={p.id}>
              <td>
                <div className="player-cell">
                  {/* R12 bugfix #1: render real photo when an addition carries
                      an nbaId (trade-acquired adds now do via _sourcePlayer). */}
                  {p.nbaId
                    ? <AdditionPhoto nbaId={p.nbaId} name={p.name} />
                    : <div className="player-photo-placeholder">{p.name.split(" ").map(s => s[0]).slice(0,2).join("")}</div>}
                  <div className="player-meta">
                    <div className="player-name">{p.name}</div>
                    <div className="player-sub">
                      {p.position && <span className="pos-badge">{p.position}</span>}
                      <span>{p.years || 1}y{optionTag(p.option) ? ` · ${optionTag(p.option)}` : ""} · {p.source}</span>
                    </div>
                  </div>
                </div>
              </td>
              <td><span className="badge signed"><span className="dot" />Signed</span></td>
              <td className="num">{fmt$Full(p.salary)}</td>
              <td style={{ width: 60 }}>
                <div className="row-actions">
                  {/* R12 bugfix #2: trade-acquired additions can't be removed
                      via X — that leaves the OTHER team's outgoing decision
                      orphaned and the player vanishes from everywhere. Force
                      the user to use the TM's Undo button instead. */}
                  {p._fromTrade
                    ? <span className="from-trade-badge" title="Acquired via trade — undo from the Trade Machine to reverse">trade</span>
                    : <button className="pencil cancel" onClick={() => dispatch({ type: "REMOVE_ADDITION", id: p.id })} title="Remove"><Icon.X /></button>}
                </div>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </section>
  );
}

/* ============================================================
   Tools section
   ============================================================ */
/* Right-column "Exceptions available" panel (above the payroll chart). The
   standard exceptions a team can use, derived from its committed payroll vs the
   cap/apron lines — same rule as the Trade Machine's OptionsPanel:
     • cap space            → Room MLE
     • over cap, under A1   → Non-taxpayer MLE + BAE
     • over A1, under A2     → Taxpayer MLE
     • over A2               → none (minimums only)
   (Trade exceptions are team-specific; not wired into this panel yet.) */
/* #3: team strategy info card — shown in the right panel (above Exceptions) only
   while the on-page "Cap strategy" decision box is up and the actions haven't
   been moved here. Lines up box-to-box with the strategy box (top + end). The
   copy is placeholder team-specific context for the currently-selected strategy. */
function StrategyInfoCard({ state }) {
  const isCapDefault = (CAP_DEFAULT_TEAMS || []).includes(state.team);
  return (
    <div className="strat-info-card">
      <div className="exc-head">About this strategy</div>
      <p className="strat-info-p">
        {isCapDefault
          ? `${state.team} projects to clear meaningful room — cap space is usually the stronger path here. Renounce holds to maximize room, then use it (plus the Room MLE) to add outside free agents.`
          : `${state.team} is likely better off operating over the cap — keep Bird rights and use the MLE + trades. Cap space would mean renouncing your own free agents for room that may not beat what you can already offer.`}
      </p>
      <p className="strat-info-sub">Placeholder context — final copy will pull team-specific projections.</p>
    </div>
  );
}

function ExceptionsCard({ derived }) {
  const { cap, apron1, apron2, mle, tpmle, bae, roomMle } = CAP_2026;
  const post = derived.committed || 0;
  let items;
  if (post >= apron2)      items = [];
  else if (post >= apron1) items = [{ n: "Taxpayer MLE", a: tpmle }];
  else if (post < cap)     items = [{ n: "Room MLE", a: roomMle }];
  else                     items = [{ n: "Non-taxpayer MLE", a: mle }, { n: "Bi-annual exception", a: bae }];
  return (
    <div className="exceptions-card">
      <div className="exc-head">Exceptions available</div>
      {items.length === 0 ? (
        <div className="exc-empty">Above the 2nd apron. Minimum signings only.</div>
      ) : items.map(m => (
        <div className="exc-row" key={m.n}>
          <span className="exc-name">{m.n}</span>
          <span className="exc-amt">{fmt$(m.a)}</span>
        </div>
      ))}
    </div>
  );
}

function ToolsSection({ tools, availCount, zone, mode }) {
  return (
    <section className="section" style={{ marginTop: 22 }}>
      <div className="section-head">
        <h2>Roster-building tools</h2>
        <span className="count num">{availCount}/15 available</span>
        <span className="desc">
          {mode === "cap"
            ? "Cap-team — all tools available; hard-cap caveats only matter if you go over later."
            : "Tools fade out as you cross apron thresholds."}
        </span>
      </div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: 1, background: "var(--line)" }}>
        {tools.map(t => (
          <div key={t.n} style={{
            background: "var(--bg-surface)",
            padding: "11px 14px",
            opacity: t.avail ? 1 : 0.4,
            display: "flex", alignItems: "center", gap: 10,
          }}>
            <div style={{
              width: 22, height: 22, borderRadius: 5,
              display: "grid", placeItems: "center",
              fontSize: 11, fontWeight: 700, fontVariantNumeric: "tabular-nums",
              background: t.avail ? "var(--brand-soft)" : "var(--bg-row)",
              color: t.avail ? "var(--brand)" : "var(--text-faint)",
              border: "1px solid " + (t.avail ? "var(--brand-line)" : "var(--line)"),
            }}>{t.n}</div>
            <div style={{ flex: 1, fontSize: 12.5, color: t.avail ? "var(--text)" : "var(--text-faint)", textDecoration: t.avail ? "none" : "line-through" }}>
              {t.name}
            </div>
            {t.cap && (
              <span style={{ fontSize: 10, color: "var(--text-faint)", letterSpacing: 0.04, textTransform: "uppercase", fontWeight: 600 }}>
                {t.cap === "apron1" ? "hc 1st" : "hc 2nd"}
              </span>
            )}
          </div>
        ))}
      </div>
    </section>
  );
}

/* ============================================================
   Tweaks
   ============================================================ */
function CapMvpTweaks({ tweaks, setTweak }) {
  const TP = window.TweaksPanel;
  const TS = window.TweakSection;
  const TR = window.TweakRadio;
  const TT = window.TweakToggle;
  if (!TP) return null;
  return (
    <TP title="Tweaks">
      <TS label="Salary bar style" />
      <TR label="Style" value={tweaks.salaryBarStyle}
          options={[
            { value: "linear",  label: "Linear" },
            { value: "stacked", label: "Zones" },
          ]}
          onChange={(v) => setTweak("salaryBarStyle", v)} />

      <TS label="Density" />
      <TR label="Row height" value={tweaks.density}
          options={[
            { value: "compact",     label: "Tight" },
            { value: "comfortable", label: "Comfy" },
            { value: "roomy",       label: "Roomy" },
          ]}
          onChange={(v) => setTweak("density", v)} />

      <TS label="Team skin" />
      <TR label="Accent" value={tweaks.teamSkin}
          options={[
            { value: "off", label: "Off" },
            { value: "a",   label: "A" },
            { value: "b",   label: "B" },
          ]}
          onChange={(v) => setTweak("teamSkin", v)} />
    </TP>
  );
}

createRoot(document.getElementById("root")).render(<App />);
