/* ============================================================
   Mobile salary bar.

   ONE combined mobile design — no variants:
     • Top strip (always visible, slim)
     • Sticky one-line strip (pins to top when you scroll past)
     • Bottom thermometer card (lives at end of page, scrolled-to)

   Desktop is unchanged — uses the original SalaryBar.

   ----------------------------------------------------------------
   Top strip layout
   ----------------------------------------------------------------
   Apron view (Over-the-Cap team, or Cap-Space team's Final Roster):
     • Always-filled border meters: blue 0→cap (top), gradient green→
       yellow→red cap→max (bottom). The bars share a single dollar
       axis so the white tick can span both at the same x.
     • Three text slots dynamically sorted by dollar value so the
       committed total slides among the apron lines:
         below 1A:  [committed]  1A  2A
         between:   1A  [committed]  2A
         over 2A:   1A  2A  [committed]
     • Each slot: AMOUNT (large) + LABEL (small, below). 2ways-style.
     • Apron-line deltas use ▲ / ▼ instead of the words "over / below"
       (the carets carry that info).

   Cap view (Cap-Space team during Hold Roster steps):
     • Same height as apron bar (no bar shrinking — keeps layout
       consistent across pages).
     • Big "Cap Commitment" label + dollar amount centered.
     • Single side text: "$X cap room" (left) when under, or
       "$X over cap" (right) when over.
     • Border meters: top blue (the good zone), bottom red (the
       danger zone past cap). Same white tick spans both.

   ----------------------------------------------------------------
   Sticky strip
   ----------------------------------------------------------------
     • Appears when the user scrolls past the top strip.
     • Apron: $XXXM | ▲ $X 1A | ▼ $X 2A (sorted same way).
     • Cap:   $XXXM cap commitment · $X cap room / over cap
       (no arrows — context makes direction obvious).

   ----------------------------------------------------------------
   Bottom thermometer
   ----------------------------------------------------------------
     • 2ways-inspired: deltas on left ("+$61.5M to 2nd Apron"),
       dollar values on right ("$221.7M").
     • Apron view: shows Luxury Tax, 1st Apron, 2nd Apron, Cap line,
       plus Min Floor for context.
     • Cap view: simpler — just shows the cap line and the room/over
       distance, with an optional toggle between "payroll only" and
       "including all cap holds" for clarity.
   ============================================================ */

function useIsMobile(bp = 720) {
  const [m, setM] = useState(() =>
    (typeof window !== "undefined" && window.__forceMobile) ||
    (typeof window !== "undefined" && window.matchMedia(`(max-width: ${bp}px)`).matches));
  useEffect(() => {
    const mq = window.matchMedia(`(max-width: ${bp}px)`);
    const f = (e) => setM(!!window.__forceMobile || e.matches);
    if (mq.addEventListener) mq.addEventListener("change", f);
    else mq.addListener(f);
    const onForce = () => setM(!!window.__forceMobile || mq.matches);
    window.addEventListener("__forceMobileChange", onForce);
    return () => {
      if (mq.removeEventListener) mq.removeEventListener("change", f);
      else mq.removeListener(f);
      window.removeEventListener("__forceMobileChange", onForce);
    };
  }, [bp]);
  return m;
}

/* ============================================================
   Helpers
   ============================================================ */

// Right-edge of the bottom apron meter (and the shared axis maximum).
// Tuned so 2nd apron + a reasonable overage fit visibly.
const APRON_AXIS_MAX_M = 245;
const APRON_AXIS_MAX = APRON_AXIS_MAX_M * 1_000_000;

function totalLabelFor(state, apronView) {
  if (state.mode === "cap" && !apronView) return "Cap Commitment";
  return "Total Payroll";
}

function zoneColorForMode(committed, mode, hardCap) {
  // Used for the COMMITTED total only — always blue (logo).
  // The sentiment color story is:
  //   Blue   = committed payroll ("us")
  //   Green  = room / below an apron line
  //   Red    = over cap / over an apron line
  // Sentiment colors are applied to deltas / room values, not the total.
  return "var(--brand-blue, #4299e1)";
}

/* Three slots sorted by dollar value (ascending). The committed total
   slides among the two apron lines so the row reads left-to-right as a
   number line. */
function sortedSlots(committed, lines) {
  const { taxLine, apron1, apron2 } = CAP_2026;
  // "all" → tax + both aprons (3 threshold lines); "tax" → tax + 1st apron;
  // default "apron" → 1st + 2nd apron. Committed slides among them as a number line.
  const set = lines === "all"
    ? [{ kind: "tax",    value: taxLine, name: "Tax" },
       { kind: "apron1", value: apron1,  name: "1st Apron" },
       { kind: "apron2", value: apron2,  name: "2nd Apron" }]
    : lines === "tax"
    ? [{ kind: "tax",    value: taxLine, name: "Tax" },
       { kind: "apron1", value: apron1,  name: "1st Apron" }]
    : [{ kind: "apron1", value: apron1,  name: "1st Apron" },
       { kind: "apron2", value: apron2,  name: "2nd Apron" }];
  return [{ kind: "committed", value: committed }, ...set].sort((a, b) => a.value - b.value);
}

/* Threshold label for a slot, honoring the gear "Line labels" test toggle:
   • "word"  → "over 1st apron" / "to 2nd apron"  (ordinals plain)
   • "short" → "› 1ˢᵗ apron" / "to 2ⁿᵈ apron"     (over → "›", ordinal suffix
     as a superscript so it takes less horizontal room).
   `kind` is tax|apron1|apron2; `dir` is over|below. Returns JSX. */
function lineLabel(kind, dir, mode) {
  const short = mode === "short";
  const mini  = mode === "mini";   // [#4] terse trade-column form: "to 1A" / "› 2A" (no "apron" word)
  const word = dir === "over" ? ((short || mini) ? "›" : "over") : "to";
  if (kind === "tax") return <>{word} tax</>;
  if (mini) return <>{word} {kind === "apron1" ? "1A" : "2A"}</>;
  const n = kind === "apron1" ? "1" : "2";
  const suf = kind === "apron1" ? "st" : "nd";
  const ord = short ? <>{n}<sup className="ord">{suf}</sup></> : <>{n}{suf}</>;
  return <>{word} {ord} apron</>;
}

function deltaTo(line, committed) {
  return {
    delta: Math.abs(committed - line.value),
    dir:   committed > line.value ? "over" : "below",
  };
}

/* ============================================================
   Border meters — two separate progress bars.

   Top:    fills 0 → cap, blue. (Cap progress.)
   Bottom: fills cap → APRON_AXIS_MAX. In apron mode the fill is
           colored by zone (green → yellow → red transitions at
           apron1 / apron2). In cap mode the fill is solid red
           (the only reason it appears is you've gone over cap).
   ============================================================ */
function MobileBarMeters({ committed, mode, topFill, hardCap }) {
  const { cap, taxLine, apron1, apron2 } = CAP_2026;
  // The bottom bar's ceiling is the team's relevant ceiling: a team hard-capped
  // at the 1st apron maxes there; everyone else maxes at the 2nd apron.
  const ceiling = hardCap === "apron1" ? apron1 : apron2;
  const span = ceiling - cap;

  // TOP meter: TEAM COLOUR fill, 0 → cap (the team's identity, "us"). The
  // bottom bar owns the sentiment story, so the top can carry team colour
  // without competing. Over the ceiling the top "changes duty" → a neutral
  // GREY base + RED overtake from the left (identity stripped = penalty),
  // scaled to the SAME dollar span as the bottom bar (cap→ceiling).
  const topPct = Math.max(0, Math.min(100, (committed / cap) * 100));
  const overCeiling = committed > ceiling;
  const topBg = overCeiling
    ? "#414a5c"                                   // grey "apron jail" base
    : (topFill || "var(--brand-blue, #4299e1)");  // team gradient, else blue
  const topRedPct = overCeiling
    ? Math.max(0, Math.min(100, ((committed - ceiling) / span) * 100))
    : 0;

  // BOTTOM meter: axis cap → ceiling. Smooth green→yellow→orange→red blend with
  // the pure colours landing exactly on the meaningful CBA lines (tax / 1st apron
  // / ceiling). Faint white ticks mark tax + 1st apron. Over the ceiling (or in
  // cap mode, over the cap) the whole bottom bar goes red.
  const bottomPct = committed > cap
    ? Math.max(0, Math.min(100, ((committed - cap) / span) * 100))
    : 0;
  const taxPct = ((taxLine - cap) / span) * 100;
  const a1Pct  = ((apron1 - cap) / span) * 100;

  // NB: the faint white tick lines that marked tax / 1st apron are removed —
  // now that the strip shows tax + both aprons as sorted columns, a tick landed
  // directly under the WRONG number (the tax tick under "1st apron", etc.),
  // reading as a mislabel. The colour gradient still encodes the zones at the
  // same positions, so the meter keeps its meaning without the misleading lines.
  let zonesBg;
  if (mode === "cap" || overCeiling) {
    zonesBg = `linear-gradient(to right, var(--danger), var(--danger))`;
  } else if (ceiling === apron1) {
    // hard-capped at 1st apron: green → yellow → red across cap→1A
    zonesBg = `linear-gradient(to right,
        var(--pos) 0%, var(--warn) ${taxPct}%, var(--danger) 100%)`;
  } else {
    zonesBg = `linear-gradient(to right,
        var(--pos) 0%, var(--warn) ${taxPct}%, var(--orange) ${a1Pct}%, var(--danger) 100%)`;
  }
  const bottomSize = bottomPct > 0 ? `${(100 / bottomPct) * 100}% 100%` : "100% 100%";

  return (
    <>
      <div className="mb-meter mb-meter-top" aria-hidden="true">
        <div className="mb-meter-track" />
        <div className="mb-meter-fill"
             style={{ width: topPct + "%", background: topBg }} />
        {topRedPct > 0 && (
          <div className="mb-meter-fill mb-meter-over"
               style={{ width: topRedPct + "%", background: "var(--danger)" }} />
        )}
      </div>
      <div className="mb-meter mb-meter-bottom" aria-hidden="true">
        <div className="mb-meter-track" />
        <div className="mb-meter-fill"
             style={{ width: bottomPct + "%",
                      backgroundImage: zonesBg,
                      backgroundSize: bottomSize,
                      backgroundRepeat: "no-repeat" }} />
      </div>
    </>
  );
}

/* ============================================================
   Top strip text
   ============================================================ */

function MbApronSlot({ slot, totalLabel, committed, color, lineLabelMode }) {
  if (slot.kind === "committed") {
    return (
      <div className="mb-slot mb-slot-committed">
        <div className="mb-slot-amount num" style={{ color }}>{fmt$(committed)}</div>
        <div className="mb-slot-label">{totalLabel}</div>
      </div>
    );
  }
  const { delta, dir } = deltaTo(slot, committed);
  // [#2] Top-bar delta NUMBERS are binary: under a line → green, over ANY line →
  // red (uniform — no yellow/orange graduation here). Graduated severity still
  // lives in the meters + vertical chart; the band number just says over/under.
  const zone = dir === "over" ? "danger" : "pos";
  // Wording carries the direction — no arrows: "$5.94M over 1st apron"
  // (or the "short" test variant: "$5.94M › 1ˢᵗ apron").
  return (
    <div className={`mb-slot mb-slot-line ${dir} ${zone}`}>
      <div className="mb-slot-amount num">{fmt$(delta)}</div>
      <div className="mb-slot-label">{lineLabel(slot.kind, dir, lineLabelMode)}</div>
    </div>
  );
}

function MobileBarText({ committed, mode, hardCap, totalLabel, centered, headerLines, lineLabelMode, numColor }) {
  // [#3] The committed/payroll TOTAL is the hero number → WHITE (--payroll-num) on the
  // main bar. The Trade Machine column header passes numColor = V3 logo-blue (REG-1):
  // V3's TM salary header used the logo blue and the merge had dropped it.
  const color = numColor || "var(--payroll-num, var(--text))";
  if (mode === "cap") {
    const { cap } = CAP_2026;
    const room = cap - committed;
    const over = room < 0;
    // Side text: cap room (green, left) when under, over cap (red, right) when over.
    return (
      <div className="mb-text mb-text-cap">
        <div className="mb-cap-side mb-cap-side-left">
          {!over && (
            <>
              <div className="mb-cap-side-amount num">{fmt$(room)}</div>
              <div className="mb-cap-side-label">cap room</div>
            </>
          )}
        </div>
        <div className="mb-cap-center">
          <div className="mb-cap-amount num" style={{ color }}>{fmt$(committed)}</div>
          <div className="mb-cap-label">{totalLabel}</div>
        </div>
        <div className="mb-cap-side mb-cap-side-right">
          {over && (
            <>
              <div className="mb-cap-side-amount num">{fmt$(-room)}</div>
              <div className="mb-cap-side-label">over cap</div>
            </>
          )}
        </div>
      </div>
    );
  }
  // Round 10 #0.5a: `centered` pins the committed total in the middle, regardless
  // of dollar value — so the big number doesn't jump left/right as the trade edits.
  // Flanking lines: default [1st apron, 2nd apron]; tax-focused view (settings
  // toggle) or a team hard-capped at the 1st apron → [tax, 1st apron].
  const effLines = hardCap === "apron1" ? "tax" : (headerLines || "all");
  const sorted = sortedSlots(committed, effLines);
  const pair = sorted.filter(s => s.kind !== "committed");
  const slots = centered
    ? [pair[0], { kind: "committed", value: committed }, pair[1]]
    : sorted;
  // [#13c] Drive the grid column COUNT from the actual slots (not the global
  // data-headerlines attr): the trade machine renders 3 slots while the page may be
  // in 4-slot "all" mode → a fixed 4-col grid left an empty column + squeezed slots.
  // One column per slot; committed gets a touch more width so it never clips.
  const apronCols = slots.map(s => (s && s.kind === "committed") ? "1.3fr" : "1fr").join(" ");
  return (
    <div className="mb-text mb-text-apron" style={{ gridTemplateColumns: apronCols }}>
      {slots.map((s, i) => (
        <MbApronSlot key={s.kind} slot={s} totalLabel={totalLabel}
                     committed={committed} color={color} lineLabelMode={lineLabelMode} />
      ))}
    </div>
  );
}

/* ============================================================
   Top strip — combines meters + text
   ============================================================ */
// #0a (round 9): the top meter is tinted with the managed team's colors by default,
// or flat brand-blue if the gear picks "blue" (some teams' palettes don't have enough
// contrast on the dark bar — Spurs, Nets, etc.). Over-2A red happens inside the meter.
// Top meter is always the team's colours (no toggle). topMeterFill returns the
// team's gradient from the curated TEAM_SKINS map; falls back to blue only if a
// team has no skin. The over-ceiling grey+red override lives inside MobileBarMeters.
function topMeterFill(state) {
  const skin = (typeof window !== "undefined" && window.TEAM_SKINS && window.TEAM_SKINS[state.team]) || null;
  return skin ? `linear-gradient(90deg, ${skin[0]}, ${skin[1]})` : undefined;
}

function MobileTopStrip({ derived, state, apronView, headerLines, lineLabelMode }) {
  const { committed } = derived;
  const effMode = apronView ? "apron" : state.mode;
  const totalLabel = totalLabelFor(state, apronView);
  return (
    <section className="salary-bar-band mobile-bar-band">
      <MobileBarMeters committed={committed} mode={effMode} topFill={topMeterFill(state)} hardCap={state.hardCap} />
      <div className="mb-inner">
        <MobileBarText committed={committed} mode={effMode} hardCap={state.hardCap}
                       totalLabel={totalLabel} headerLines={headerLines} lineLabelMode={lineLabelMode} />
      </div>
    </section>
  );
}

/* ============================================================
   Compact one-line strip — rendered IN NORMAL FLOW inside the sticky
   top stack (app.jsx) when the page is scrolled, so it sits directly
   above the (shrunk) step tabs instead of floating over the header.
   Same idiom as the top strip, condensed to one line. No arrows in
   cap mode (the word "over"/"to" carries direction).
   ============================================================ */
function MobileCompactStrip({ derived, state, apronView, headerLines, lineLabelMode }) {
  const { committed } = derived;
  const effMode = apronView ? "apron" : state.mode;
  const blueColor = "var(--payroll-num, var(--text))";   // [#3] header total = WHITE (matches chart)

  // Three cells laid out left / center / right — same spatial order as the
  // full top strip, so the committed total doesn't jump as you scroll. Each
  // cell is amount-over-tiny-label (the big-bar slot format, just smaller),
  // centered in its third so nothing hugs the edges.
  let cells;
  if (effMode === "cap") {
    const { cap } = CAP_2026;
    const room = cap - committed;
    const over = room < 0;
    const committedCell = { key: "tot", cls: "committed", amount: fmt$(committed), label: "Cap" };
    const sideCell = over
      ? { key: "over", cls: "danger", amount: fmt$(-room), label: "over cap" }
      : { key: "room", cls: "pos",    amount: fmt$(room),  label: "cap room" };
    const empty = { key: "sp", cls: "empty", amount: "", label: "" };
    cells = over ? [empty, committedCell, sideCell] : [sideCell, committedCell, empty];
  } else {
    const effLines = state.hardCap === "apron1" ? "tax" : (headerLines || "all");
    cells = sortedSlots(committed, effLines).map(s => {
      if (s.kind === "committed") {
        return { key: s.kind, cls: "committed", amount: fmt$(committed), label: "Payroll" };
      }
      const { delta, dir } = deltaTo(s, committed);
      const cls = dir === "over" ? "danger" : "pos";   // [#2] binary: under any line green, over any line red
      return { key: s.kind, cls, amount: fmt$(delta), label: lineLabel(s.kind, dir, lineLabelMode) };
    });
  }

  return (
    <section className="salary-bar-band mobile-bar-band mobile-compact-strip">
      <MobileBarMeters committed={committed} mode={effMode} topFill={topMeterFill(state)} hardCap={state.hardCap} />
      {cells.map(c => (
        <span key={c.key} className={`mcs-cell ${c.cls}`}>
          {c.amount && <b className="num" style={c.cls === "committed" ? { color: blueColor } : null}>{c.amount}</b>}
          {c.label && <span className="mcs-lbl">{c.label}</span>}
        </span>
      ))}
    </section>
  );
}

/* ============================================================
   Bottom thermometer — 2ways-style with broken axis.

   Apron view: the column is split into independent regions with
   physical gaps between them, so the meaningful range (Cap → 2A)
   takes up most of the vertical space instead of being squeezed
   into the top quarter of a 0-to-max graph.

       ┌────────────┐  top
       │  over-2A   │  10%   (only when committed > 2A)
       │ ╶─ gap2 ─╴ │   3%   (only when over-2A shown)
       │            │
       │   main     │  78–91%
       │            │
       │ ╶─ gap1 ─╴ │   3%
       │   floor    │   6%
       └────────────┘  bottom

   Each region has its own dollar→y mapping. Color segments and
   threshold lines are clipped to the region they fall in.

   Cap view: simpler two-region layout (floor + main); the column
   turns blue→red at the cap line.
   ============================================================ */

// Minimum payroll floor: 90% of cap. Teams are required to spend at least
// this much, so the column below it is "nobody's been there" territory.
function getMinFloor() {
  return Math.round(CAP_2026.cap * 0.90);
}

/* Build the broken-axis layout. Returns: regions[], floor, main, over2A,
   showOver2A. Each region has yBottom/yTop (column %) and dollarMin/Max. */
function buildBrokenAxis(committed, opts = {}) {
  const { cap, apron2 } = CAP_2026;
  const minFloor = getMinFloor();
  const onlyCapInMain = !!opts.onlyCapInMain;  // cap-mode variant

  // In apron view, the over-2A region is ALWAYS present so the column's
  // vertical layout stays consistent across states — when committed is
  // below 2A, the over-2A pill simply sits at the top, empty. In cap
  // view there's no over-2A region at all.
  const showOver2A = !onlyCapInMain;
  // Whether the committed marker actually falls into the over-2A region
  // (used to decide marker placement; doesn't affect layout).
  const committedInOver2A = !onlyCapInMain && committed > apron2;

  // Region heights (% of column).
  // Apron view always reserves space for the gap + over-2A pill so the
  // layout doesn't jump when committed crosses 2A. Cap view skips both.
  const H_FLOOR  = 21;   // [#5] tripled (was 7) — the "below cap" base in both modes
  const H_GAP1   = 3;
  const H_GAP2   = showOver2A ? 3 : 0;
  const H_OVER2A = showOver2A ? 9 : 0;
  const H_MAIN   = 100 - H_FLOOR - H_GAP1 - H_GAP2 - H_OVER2A;

  const floor = {
    yBottom: 0,
    yTop: H_FLOOR,
    dollarMin: 0,
    dollarMax: minFloor,
  };

  // Main region dollar range:
  //   - cap mode: minFloor → max(cap, committed+pad)
  //   - apron mode: minFloor → 2A (over-2A region handles anything above)
  let mainDollarMax;
  if (onlyCapInMain) {
    mainDollarMax = Math.max(cap * 1.10, committed * 1.06);
  } else {
    mainDollarMax = apron2;
  }
  const main = {
    yBottom: H_FLOOR + H_GAP1,
    yTop: H_FLOOR + H_GAP1 + H_MAIN,
    dollarMin: minFloor,
    dollarMax: mainDollarMax,
  };

  const over2A = showOver2A ? {
    yBottom: H_FLOOR + H_GAP1 + H_MAIN + H_GAP2,
    yTop: 100,
    dollarMin: apron2,
    // Top of over-2A region: a touch above committed so the marker isn't
    // pinned to the very top of the column.
    dollarMax: Math.max(committed * 1.04, apron2 * 1.08),
  } : null;

  const regions = [floor, main, over2A].filter(Boolean);
  return { regions, floor, main, over2A, showOver2A, minFloor };
}

/* Convert a dollar value to a y-percent in the column. Returns null if
   the value falls in a gap (shouldn't happen for our thresholds). */
function dollarToY(value, layout) {
  for (const r of layout.regions) {
    if (value >= r.dollarMin && value <= r.dollarMax) {
      const t = (value - r.dollarMin) / (r.dollarMax - r.dollarMin);
      return r.yBottom + t * (r.yTop - r.yBottom);
    }
  }
  return null;
}

/* Build color segments grouped per region, with positions RELATIVE to
   each region (0–100% within the region). Used by the new pill-style
   layout where each region is its own rounded container with
   overflow:hidden, and color segments live inside. */
function buildSegmentsByRegion(committed, zones, layout) {
  return layout.regions.map(r => {
    const segments = [];
    const span = r.dollarMax - r.dollarMin;
    for (const z of zones) {
      const zEnd = Math.min(committed, z.endDollar);
      if (zEnd <= z.startDollar) continue;
      const cs = Math.max(z.startDollar, r.dollarMin);
      const ce = Math.min(zEnd, r.dollarMax);
      if (ce <= cs) continue;
      segments.push({
        color: z.color,
        bottomPct: ((cs - r.dollarMin) / span) * 100,
        topPct:    ((ce - r.dollarMin) / span) * 100,
      });
    }
    return { region: r, segments };
  });
}

/* Build color-segment rectangles, clipped to each region.
   `zones` is an array of {startDollar, endDollar, color} in dollar space;
   each zone is intersected with each region to produce one or more
   absolutely-positioned column-segment boxes. */
function buildColorSegments(committed, zones, layout) {
  const segments = [];
  for (const z of zones) {
    const zEnd = Math.min(committed, z.endDollar);
    if (zEnd <= z.startDollar) continue;
    for (const r of layout.regions) {
      const cs = Math.max(z.startDollar, r.dollarMin);
      const ce = Math.min(zEnd, r.dollarMax);
      if (ce <= cs) continue;
      const yB = dollarToY(cs, layout);
      const yT = dollarToY(ce, layout);
      segments.push({ color: z.color, yBottom: yB, yTop: yT });
    }
  }
  return segments;
}

/* Anchor the TOPMOST item at its true y and push lower items DOWN if
   they're too close. This preserves visual order and keeps the topmost
   labels from being shoved off the top of the graph (apron-view labels
   cluster heavily at the top). */
function staggerY(items, minGap) {
  const sorted = items.slice().sort((a, b) => b.y - a.y);
  for (let i = 1; i < sorted.length; i++) {
    const above = sorted[i - 1];
    if (above.y - sorted[i].y < minGap) {
      sorted[i] = { ...sorted[i], y: above.y - minGap };
    }
  }
  return sorted;
}

function MobileBottomThermometer({ derived, state, apronView }) {
  const { committed, apronTotal, unlikely, rosterCount } = derived;
  const { cap } = CAP_2026;
  const effMode = apronView ? "apron" : state.mode;
  const totalLabel = totalLabelFor(state, apronView);

  // Selector (cap mode only) — UI shell for "payroll only" vs
  // "including holds". The actual committed number is already what
  // the app is tracking; this is a label affordance for now.
  const [capView, setCapView] = useState("withHolds");

  if (effMode === "cap") {
    return <CapBottomThermometer committed={committed} cap={cap}
                                 totalLabel={totalLabel}
                                 apronTotal={apronTotal} rosterCount={rosterCount}
                                 view={capView} setView={setCapView} />;
  }
  return <ApronBottomThermometer committed={committed} totalLabel={totalLabel}
                                 apronTotal={apronTotal} unlikely={unlikely}
                                 rosterCount={rosterCount} team={state.team} />;
}

function ApronBottomThermometer({ committed, totalLabel, apronTotal, unlikely = 0, rosterCount = 0, team }) {
  const { cap, apron1, apron2, taxLine } = CAP_2026;

  // R4: unlikely-bonus toggle. Only offered when the rostered players carry
  // unlikely incentives (Σ unlikely > 0). The DEFAULT "Apron" view paints the
  // true apron figure (committed + unlikely, the stricter apron-with-unlikely
  // picture); the "Tax" view drops to the likely-only `committed`. The TAX bill
  // always stays on `committed` (likely incentives only) per design. (State
  // stays keyed "likely" for the committed-only branch to minimize churn.)
  const [bonusView, setBonusView] = useState("apron");
  const hasUnlikely = unlikely > 0;
  const effCommitted = (hasUnlikely && bonusView === "apron")
    ? committed + unlikely : committed;

  // Luxury tax bill (apron view only) — computed on `committed`, never the
  // toggle's effective value. Guarded: undefined engine / first paint → null.
  const taxR = (window.Engine && committed > CAP_2026.taxLine)
    ? window.Engine.luxuryTax(committed, window.Engine.CBA_2026_27,
        { repeater: (window.REPEATER_TEAMS && window.REPEATER_TEAMS.has(team)) }) : null;
  // Salary-floor flag — on the holds-free apronTotal. Suppressed below a real
  // roster (a near-empty roster legitimately sits under the floor).
  const floorR = window.Engine
    ? window.Engine.belowMinSalaryFloor(apronTotal, window.Engine.CBA_2026_27) : null;
  const showFloor = rosterCount >= 14 && floorR && floorR.below;

  const layout = buildBrokenAxis(effCommitted);

  // Color zones in dollar space — each gets clipped per region.
  // Matches the top-strip gradient: blue 0→cap, then green→yellow→orange→red
  // anchored to the real CBA lines (tax / 1st apron / 2nd apron).
  const blue   = "var(--brand-blue, #4299e1)";
  const green  = "var(--pos)";
  const yellow = "var(--warn)";
  const orange = "var(--orange)";
  const red    = "var(--danger)";
  const zones = [
    { startDollar: 0,         endDollar: cap,          color: blue   },
    { startDollar: cap,       endDollar: taxLine,      color: green  },
    { startDollar: taxLine,   endDollar: apron1,       color: yellow },
    { startDollar: apron1,    endDollar: apron2,       color: orange },
    { startDollar: apron2,    endDollar: effCommitted, color: red    },
  ];
  const segmentsByRegion = buildSegmentsByRegion(effCommitted, zones, layout);

  // Threshold lines that get a dashed marker + side-rail labels.
  const lines = [
    { key: "cap",    name: "Cap Line",   value: cap    },
    { key: "tax",    name: "Luxury Tax", value: taxLine },
    { key: "apron1", name: "1st Apron",  value: apron1 },
    { key: "apron2", name: "2nd Apron",  value: apron2 },
  ];

  // Auto-stagger side-rail labels (cap/tax/1A/2A all live in the main
  // region so they can crowd at the top).
  const labelMinGap = 11;
  const railItems = lines.map(l => {
    const { delta, dir } = deltaTo(l, effCommitted);
    return { ...l, yLine: dollarToY(l.value, layout), delta, dir };
  });
  const sorted = [...railItems].sort((a, b) => a.yLine - b.yLine);
  const staggered = staggerY(sorted.map(r => ({ ...r, y: r.yLine })), labelMinGap);
  const yLabel = Object.fromEntries(staggered.map(r => [r.key, r.y]));

  // Min-Floor right-rail entry: tells the user there IS a minimum spend
  // requirement, anchored at the top of the floor region. No left counterpart
  // (a team's committed total is always above floor in practice).
  const floorY = layout.floor.yTop;

  // Committed marker position. May fall in floor / main / over-2A.
  const committedY = dollarToY(effCommitted, layout);

  return (
    <section className="mobile-thermo-card" id="mobile-thermo-anchor">
      <div className="mt-head">
        <h3 className="mt-title">{totalLabel}</h3>
        <div className="mt-bigNum num" style={{ color: "var(--payroll-num, var(--text))" }}>{fmt$Full(committed)}</div>
        {taxR && (
          <div className="mt-stat">
            <div className="mt-stat-num num">{fmt$(taxR.tax)}</div>
            <div className="mt-stat-label">Luxury tax</div>
          </div>
        )}
        {showFloor && (
          <span className="mt-floor-badge">Below floor · −{fmt$(floorR.shortfall)}</span>
        )}
      </div>
      {hasUnlikely && (
        <div className="mb-bonus-toggle-wrap">
          <div className="cap-view-tabs">
            <button className={bonusView === "apron" ? "on" : ""}
                    onClick={() => setBonusView("apron")}>Apron</button>
            <button className={bonusView === "likely" ? "on" : ""}
                    onClick={() => setBonusView("likely")}>Tax</button>
          </div>
          <span className="sb-bonus-info" tabIndex={0} role="img"
                aria-label="What counts toward the apron versus the luxury tax"
                title="Apron counts salary, likely incentives, and unlikely incentives (the rare, hard-to-hit ones). The luxury tax is projected on salary and likely incentives only — an unlikely incentive is taxed only if it's actually earned.">ⓘ</span>
        </div>
      )}
      <div className="mt-body">
        <div className="mt-graph">
          {/* Left rail — deltas */}
          <div className="mt-rail mt-rail-left">
            {railItems.map(r => {
              const sign = r.dir === "over" ? "−" : "+";
              const cls  = r.dir === "over" ? "over" : "below";
              return (
                <div key={r.key} className={`mt-rail-item ${cls}`}
                     style={{ bottom: yLabel[r.key] + "%" }}>
                  <div className="mt-rail-num num">
                    <b>{sign}{fmt$(r.delta)}</b>
                  </div>
                  <div className="mt-rail-name">
                    {r.dir === "over" ? "over " : "to "}{r.name}
                  </div>
                </div>
              );
            })}
          </div>

          {/* Center column — per-region pill containers, then overlays */}
          <div className="mt-col">
            {/* Per-region pill containers. Each has rounded corners and
                overflow:hidden so color segments inside get clipped to
                the pill shape. */}
            {segmentsByRegion.map(({ region, segments }, i) => (
              <div key={"region-" + i} className={"mt-region" + (segments.length ? "" : " is-empty")}
                   style={{
                     bottom: region.yBottom + "%",
                     height: (region.yTop - region.yBottom) + "%",
                   }}>
                {segments.map((s, j) => (
                  <div key={j} className="mt-col-seg"
                       style={{
                         background: s.color,
                         bottom: s.bottomPct + "%",
                         height: Math.max(0, s.topPct - s.bottomPct) + "%",
                       }} />
                ))}
              </div>
            ))}

            {/* Threshold dashes — drawn ABOVE everything for visibility */}
            {lines.map(l => {
              const y = dollarToY(l.value, layout);
              if (y == null) return null;
              return (
                <div key={l.key} className={`mt-th mt-th-${l.key}`}
                     style={{ bottom: y + "%" }}>
                  <div className="mt-th-rule" />
                </div>
              );
            })}

            {/* "Here" cap marker at committed */}
            {committedY != null && (
              <div className="mt-here" style={{ bottom: committedY + "%" }}>
                <div className="mt-here-cap" />
              </div>
            )}
          </div>

          {/* Right rail — values */}
          <div className="mt-rail mt-rail-right">
            {/* Min Floor row — no left counterpart */}
            <div className="mt-rail-item mt-rail-floor"
                 style={{ bottom: floorY + "%" }}>
              <div className="mt-rail-num num">{fmt$(layout.minFloor)}</div>
              <div className="mt-rail-name">Min Floor</div>
            </div>
            {railItems.map(r => (
              <div key={r.key} className="mt-rail-item"
                   style={{ bottom: yLabel[r.key] + "%" }}>
                <div className="mt-rail-num num">{fmt$(r.value)}</div>
                <div className="mt-rail-name">{r.name}</div>
              </div>
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

function CapBottomThermometer({ committed, cap, totalLabel, view, setView, apronTotal, rosterCount = 0 }) {
  const { taxLine, apron1, apron2 } = CAP_2026;   // [#6] zone lines above the cap
  const room = cap - committed;
  const over = room < 0;
  const layout = buildBrokenAxis(committed, { onlyCapInMain: true });

  // Salary-floor flag (both views) — on the holds-free apronTotal, gated so a
  // near-empty roster doesn't alarm. No tax bill here: tax is apron-view only.
  const floorR = window.Engine
    ? window.Engine.belowMinSalaryFloor(apronTotal, window.Engine.CBA_2026_27) : null;
  const showFloor = rosterCount >= 14 && floorR && floorR.below;

  const blue = "var(--chart-floor, var(--brand-blue, #4299e1))";   /* [#5] floor colour (locked to logo blue) */
  const blueLite = "color-mix(in oklab, var(--chart-floor, #3b6fd4), #fff 30%)";  /* [#6] brighter blue the middle blends toward */
  const green  = "var(--pos)";     /* [#6] over-cap zones, mirroring the apron chart */
  const yellow = "var(--warn)";
  const orange = "var(--orange)";
  const red  = "var(--danger)";

  // Floor segment colour only (the MAIN region is painted by the #6 gradient below).
  const zones = [
    { startDollar: 0,   endDollar: cap,       color: blue },
    { startDollar: cap, endDollar: committed, color: red  },
  ];
  const segmentsByRegion = buildSegmentsByRegion(committed, zones, layout);

  const capY       = dollarToY(cap, layout);
  const committedY = dollarToY(committed, layout);
  const floorY     = layout.floor.yTop;

  return (
    <section className="mobile-thermo-card cap" id="mobile-thermo-anchor">
      <div className="mt-head">
        <h3 className="mt-title">{totalLabel}</h3>
        <div className="mt-bigNum num" style={{ color: "var(--payroll-num, var(--text))" }}>{fmt$Full(committed)}</div>
        {showFloor && (
          <span className="mt-floor-badge">Below floor · −{fmt$(floorR.shortfall)}</span>
        )}
      </div>
      <div className="cap-view-tabs">
        <button className={view === "withHolds" ? "on" : ""}
                onClick={() => setView("withHolds")}>Including holds</button>
        <button className={view === "payroll" ? "on" : ""}
                onClick={() => setView("payroll")}>Payroll only</button>
      </div>
      <div className="mt-body">
        <div className="mt-graph cap-graph">
          {/* Left rail */}
          <div className="mt-rail mt-rail-left">
            <div className={`mt-rail-item ${over ? "over" : "below"}`}
                 style={{ bottom: capY + "%" }}>
              <div className="mt-rail-num num">
                <b>{over ? "−" : "+"}{fmt$(Math.abs(room))}</b>
              </div>
              <div className="mt-rail-name">
                {over ? "over cap" : "cap room"}
              </div>
            </div>
          </div>

          {/* Center column — pill regions + overlays */}
          <div className="mt-col">
            {segmentsByRegion.map(({ region, segments }, i) => {
              // [#6] Cap view: the MAIN region BLENDS — deep floor-blue brightening up
              // to the cap, then (crisp at the cap line) GREEN→yellow→orange→red above
              // it, like the apron chart. Floor (0→min) stays solid blue. So a just-
              // over-cap team reads green; only deep-apron reads red.
              const isMain = region === layout.main;
              let mainFill = null;
              if (isMain) {
                const rMin = region.dollarMin, rspan = region.dollarMax - region.dollarMin;
                // UNclamped stops: tax/apron keep their true dollar positions even above
                // the visible region top, so the over-cap hue is accurate.
                const pctU = v => ((v - rMin) / rspan) * 100;
                const fillPct = Math.max(0, Math.min(100, ((Math.min(committed, region.dollarMax) - rMin) / rspan) * 100));
                if (fillPct > 0) {
                  const capP = Math.max(0, pctU(cap));
                  const grad = `linear-gradient(to top, ${blue} 0%, ${blueLite} ${capP}%, ${green} ${capP}%, ${yellow} ${pctU(taxLine)}%, ${orange} ${pctU(apron1)}%, ${red} ${pctU(apron2)}%)`;
                  mainFill = (
                    <div className="mt-col-seg" style={{
                      bottom: 0, height: fillPct + "%",
                      backgroundImage: grad,
                      backgroundSize: `100% ${(100 / fillPct) * 100}%`,
                      backgroundPosition: "bottom", backgroundRepeat: "no-repeat",
                    }} />
                  );
                }
              }
              const filled = isMain ? !!mainFill : segments.length > 0;
              return (
                <div key={"region-" + i} className={"mt-region" + (filled ? "" : " is-empty")}
                     style={{
                       bottom: region.yBottom + "%",
                       height: (region.yTop - region.yBottom) + "%",
                     }}>
                  {isMain ? mainFill : segments.map((s, j) => (
                    <div key={j} className="mt-col-seg"
                         style={{
                           background: s.color,
                           bottom: s.bottomPct + "%",
                           height: Math.max(0, s.topPct - s.bottomPct) + "%",
                         }} />
                  ))}
                </div>
              );
            })}
            <div className={`mt-th mt-th-cap`} style={{ bottom: capY + "%" }}>
              <div className="mt-th-rule" />
            </div>
            {committedY != null && (
              <div className="mt-here" style={{ bottom: committedY + "%" }}>
                <div className="mt-here-cap" />
              </div>
            )}
          </div>

          {/* Right rail */}
          <div className="mt-rail mt-rail-right">
            <div className="mt-rail-item mt-rail-floor"
                 style={{ bottom: floorY + "%" }}>
              <div className="mt-rail-num num">{fmt$(layout.minFloor)}</div>
              <div className="mt-rail-name">Min Floor</div>
            </div>
            <div className="mt-rail-item" style={{ bottom: capY + "%" }}>
              <div className="mt-rail-num num">{fmt$(cap)}</div>
              <div className="mt-rail-name">Cap Line</div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

/* ============================================================
   Salary bar wrapper. app.jsx places this inside the sticky top stack
   (between the header and the step tabs). At the top of the page it's
   the full top strip; once scrolled it collapses to the compact
   one-liner (which then sits right above the shrunk step tabs).
   The bottom thermometer is rendered separately by app.jsx at the end
   of the page (so it ends up below the roster naturally).
   ============================================================ */
function MobileBar({ derived, state, apronView, scrolled, headerLines, lineLabelMode }) {
  return scrolled
    ? <MobileCompactStrip derived={derived} state={state} apronView={apronView} headerLines={headerLines} lineLabelMode={lineLabelMode} />
    : <MobileTopStrip derived={derived} state={state} apronView={apronView} headerLines={headerLines} lineLabelMode={lineLabelMode} />;
}

Object.assign(window, {
  useIsMobile,
  zoneColorForMode,
  totalLabelFor,
  sortedSlots,
  deltaTo,
  MobileBarMeters,
  MobileBarText,
  MobileTopStrip,
  MobileBottomThermometer,
  MobileCompactStrip,
  MobileBar,
});
