/* Live savings widget — Mike's idea, adapted from a similar counter he had built for The Solar
 * Collaborative (bottom-right corner: kWh generated and dollars saved since you arrived, ticking
 * live off the number of installed systems). This is the same shape for Basalt: since you've
 * been on the site, roughly what your own hypervisor costs, and what Basalt would cost instead,
 * ticking in real time off the visitor's own assumptions.
 *
 * Mike, first review pass: the headline pairing was "spent" beside "you'd have saved" — since
 * Basalt was priced at a flat share of the incumbent spend at the time, those two numbers landed
 * within a few cents of each other and read as a redundant, confusing pair ("they can do the math
 * themselves"). The pairing is now the two actual costs, spent versus what the same time would
 * cost on Basalt — no derived savings figure at all. He also asked for the same live number
 * extrapolated to a month and a year, not just "since you arrived."
 *
 * Basalt pricing, 16 Sept, HourPlus call, Dylan: "very simple. It's $800 a year per server until
 * you get to 100 servers, and then you call us." Replaces the earlier ~3%-of-incumbent-spend
 * placeholder (SW_BASALT_RATE) with that real, settled number — see SW_BASALT_PRICE_PER_SERVER_
 * YEAR and SW_BASALT_SERVER_CAP below. Every file here loads as its own <script> into one shared
 * global scope (no bundler, no modules — see build_pages.py), so every top-level name is prefixed
 * sw/Sw/SW_ to stay clear of pages.jsx's and calculator.jsx's own `usd`/`num`/`PLATFORMS` — two
 * top-level `const`s with the same name in different <script> tags on the same page throw, they
 * don't shadow.
 *
 * Persisted to localStorage (no server to hold a real session) so the count keeps climbing
 * across pages and across return visits, the way a browser tab's own "time on site" would.
 * "Run the full numbers" hands the same assumptions to run-the-numbers.html as query params —
 * see calculator.jsx's own HANDOFF — so the number a visitor was just watching keeps going
 * instead of resetting to blank.
 */

const SW_BASALT_PRICE_PER_SERVER_YEAR = 800;
const SW_BASALT_SERVER_CAP = 100;
const SW_DEFAULT_SERVERS = 48;
const SW_STATE_KEY = 'basalt-savings-state';
const SW_YEAR_SECONDS = 365.25 * 24 * 3600;

/* Mike, wide-monitor review: questioned why Nutanix came out to ~$4M/year against VMware's own
   $245,760 for the identical 48-servers/128-cores estate — a 16x gap that contradicts Dylan's
   own team's read of the competitive landscape (9/4 call: "I just heard from somebody, actually
   Chris's organization, that they're something like 10% cheaper than VMware"). The old $649/core
   default was a real, sourced industry-benchmark figure for Nutanix's own NCI Pro + Production
   Support edition, but at this estate size it was never going to land anywhere near "~10%
   cheaper" once multiplied by the same 128-cores-per-server assumption VMware uses — the two
   numbers describe different things, not different platforms. Replaced with a rate derived from
   that 9/4 comment (VMware's own $40/core x ~0.9), which at least keeps Nutanix and VMware
   comparable at the same estate size.

   Refined again 15 Sept — Triq V2 walkthrough: Luigi gave a more specific figure this time —
   "it's 93% as expensive [as VMware]... they're seven percent cheaper than VMware now, that's
   like a thing they just did" (Nutanix's own recent price increase). $37/core is 92.5% of
   VMware's own $40, matching that "93%"/"seven percent cheaper" framing more closely than the
   old ~90% estimate. Still flagged in review.jsx (RC) as a derived estimate, not a confirmed
   rate — Mike asked to check Dylan's calls specifically because this number needs real client
   conversations to firm up, not a bigger benchmark search.

   Mike also asked that Proxmox, Hyper-V/Azure Local and Scale Computing be priced as their own
   managed/support tier, not the free or open-core edition — real numbers pulled 14 Sept from
   each vendor's own published pricing:
   - Proxmox: Standard support tier is EUR 550/socket/year (Basic is EUR 370, Premium EUR 1,100)
     — Standard is the first tier with remote SSH support, which is what "managed" means in
     practice. ~$600/year at current EUR/USD.
   - Azure Local (the Hyper-V successor Microsoft actually sells today): the host service itself
     is Microsoft's own published $10/physical-core/month pay-as-you-go rate = $120/core/year.
     Windows Server guest licensing is priced and billed separately, and per the site's own
     existing copy most Hyper-V estates already hold that license through an existing agreement —
     so it's deliberately not folded into this rate.
   - Scale Computing: publishes Standard-tier pricing at $189/core/year on a 5-year term — priced
     per core, not per node, which is why this platform's own `unit` changed from 'node' to
     'core' here (StorMagic's own SvSAN, the other half of this comparison, still licensed by
     storage capacity rather than core or node count, so it wasn't folded into a single rate
     either). Kept for the record — Scale Computing and StorMagic are gone from SW_PLATFORMS
     as of 15 Sept, see the note directly below. */
/* Mike, 15 Sept — Triq V2 walkthrough (Dylan/Donnie/Luigi): "pull out Scale Computing and
   StorMagic... add Oracle Linux," and separately, "pull off Hyper-V and Azure Local... add Red
   Hat." Hyper-V/Azure Local and Scale/StorMagic are gone; Red Hat (OpenShift Virtualization) and
   Oracle Linux (Oracle KVM/OLVM) take their place. Rates for the two new entries are derived
   placeholders, same discipline as Nutanix's own figure below — Red Hat's real pricing is
   bundled into an OpenShift subscription tier rather than a clean per-core list price (Donnie:
   "super complicated, super expensive"), and Oracle doesn't publish a per-socket OLVM rate
   separately from Oracle Linux Premier Support (Dylan: "fine, cheap, but has the same management
   problem everybody else does"). Both need an actual client conversation or a published list
   price before they're anything more than illustrative. */
/* Mike, 16 Sept review (first pass): "every one of these platforms has two incumbent problems —
   a management problem... and the pricing on most of them is extremely expensive. Proxmox solves
   the pricing problem, but it doesn't solve the management problem... we have to adjust our
   actual cost calculator to show the estimated management costs." First implementation folded a
   flat, invented $/server/year figure straight into the total. His own follow-up review the same
   day pushed back on that: "I am not actually seeing what you employ... your variable does not
   include the number of management hours per year... you probably have to go out and do some
   research and find out how much management hours are per platform... and then what the average
   salary... wage per hour is. Don't disappoint me." Two different numbers now, not one invented
   one:

   ADMIN_HOURLY_WAGE is real, not illustrative — U.S. Bureau of Labor Statistics, Occupational
   Outlook Handbook, "Network and Computer Systems Administrators": median wage $47.66/hour (May
   2025, bls.gov). That's base wage, not what an hour of that person actually costs an employer —
   BLS's own Employer Costs for Employee Compensation release (Dec 2025 reference period,
   published March 2026) reports wages and salaries as 70.1% of total private-industry
   compensation, benefits the remaining 29.9%. $47.66 / 0.701 ≈ $68/hour fully loaded. Both
   figures are BLS's own; the division is the only arithmetic here, not an estimate.

   SW_PLATFORMS' own `mgmtHoursPerServerYear` is the piece that has no public benchmark to cite —
   confirmed by an actual search pass (BLS, IDC-style industry reports, DISA STIG documentation)
   before falling back to this: there is no published "hours per year to manage a VMware host"
   statistic, the way there is a wage statistic. What IS sourced is Donnie's own account on this
   call of what one piece of that work actually costs: "you have to apply a hypervisor stig and
   an application stig and a database stig to every single vCenter, and scan them every year...
   applying a VMware stig takes two [to] three days." Three STIGs x ~2.5 days x 8 hours = 60
   hours/year per managed host, for STIG application and its annual rescan alone — not counting
   ordinary patching, capacity planning, or config-drift work the same call also named. 60 is
   therefore a floor, stated as one, not a full accounting: the baseline for platforms whose
   management model Luigi and Donnie described as fundamentally the same (VMware, Nutanix, HPE,
   Oracle Linux — "that is a direct property of how they're built... there is no way to get
   around it"), with Proxmox and Red Hat scaled up from that same floor for the specific,
   named reasons those two calls give (below). Flagged in review.jsx (RC/QE/SC/TC/WC) as an
   illustrative estate-level estimate needing a real client conversation, same as every other
   derived rate in this file — the wage multiplying it is the only part of this that's a citation
   rather than a judgment call.
     - vmware/nutanix/hpe/oraclelinux: 60 hrs/server/year — the STIG-cycle floor above.
     - proxmox: 75 hrs/server/year — the same floor, plus Donnie's specific complaint that a
       constrained or remote Proxmox cluster needs someone to physically show up, or a VPN/
       firewall exception that becomes its own attack surface.
     - redhat: 105 hrs/server/year — the same floor, plus the Kubernetes/container-platform
       complexity OpenShift Virtualization adds on top, needing Red-Hat-certified specialists to
       run ("triple down on management overhead," per Luigi).
   Basalt's own side isn't a separate per-platform number — SW_BASALT_MGMT_HOURS_RATE below is a
   flat 10% of whichever incumbent's hours are on screen, standing in for "significantly reduced,"
   the site's own agreed wording (not "eliminated" — see home.jsx's own ControlPlane note). */
/* Mike, 16 Sept, HourPlus call: "the cost calculator affects only three, which are the high cost
   ones" — VMware, Nutanix, and Red Hat, the three featured competitors (see nav.jsx's own note).
   Proxmox, HPE Morpheus, and Oracle Linux stay in this array — their own compare pages still
   preset a live calculator to them (QE, WC) — but drop out of `SW_FEATURED_PLATFORMS`, which is
   what the calculator's own dropdown (Run the numbers, the corner widget) actually lists. */
const SW_PLATFORMS = [
  { value: 'vmware',      label: 'VMware',                    unit: 'core',   rate: 40,   perServer: 128, mgmtHoursPerServerYear: 60 },
  { value: 'nutanix',     label: 'Nutanix',                    unit: 'core',   rate: 37,   perServer: 128, mgmtHoursPerServerYear: 60 },
  { value: 'proxmox',     label: 'Proxmox',                    unit: 'socket', rate: 600,  perServer: 2,   mgmtHoursPerServerYear: 75 },
  { value: 'redhat',      label: 'Red Hat OpenShift Virtualization', unit: 'core', rate: 500, perServer: 128, mgmtHoursPerServerYear: 105 },
  { value: 'hpe',         label: 'HPE Morpheus VM Essentials', unit: 'socket', rate: 600,  perServer: 2,   mgmtHoursPerServerYear: 60 },
  { value: 'oraclelinux', label: 'Oracle Linux (OLVM)',        unit: 'socket', rate: 200,  perServer: 2,   mgmtHoursPerServerYear: 60 },
];
const SW_FEATURED_PLATFORMS = SW_PLATFORMS.filter(p => ['vmware', 'nutanix', 'redhat'].includes(p.value));
/* BLS OOH "Network and Computer Systems Administrators" ($47.66/hr median, May 2025) divided by
   BLS ECEC's own wages-as-share-of-total-compensation figure (70.1%, Dec 2025 reference period) —
   see SW_PLATFORMS' own comment above for both citations. Rounded to a whole dollar. */
const ADMIN_HOURLY_WAGE = 68;
const SW_BASALT_MGMT_HOURS_RATE = 0.1;

const swUsd = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
const swUsdWhole = n => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
const swNum = v => { const n = parseFloat(v); return isFinite(n) && n >= 0 ? n : null; };

/* Mike, 16 Sept, HourPlus call: after the featured set shrank to three platforms, a visitor whose
   browser had already persisted a non-featured one (Proxmox, HPE, Oracle Linux — from before this
   change, or from a preset Compare page's own local state leaking in) would load into a state the
   corner widget's own dropdown can't display: the header and table still show that platform's
   numbers, but the <select> — which only lists the featured three — silently falls back to
   showing its first option instead, so the visible dropdown and the visible numbers disagree.
   Reset to the first featured platform's own defaults in that case, the same way a first-ever
   visit already does below. */
function swLoad() {
  try {
    const raw = JSON.parse(localStorage.getItem(SW_STATE_KEY) || 'null');
    if (raw && typeof raw.firstVisit === 'number' && SW_FEATURED_PLATFORMS.some(p => p.value === raw.platform)) return raw;
  } catch (e) {}
  const p0 = SW_FEATURED_PLATFORMS[0];
  return { firstVisit: Date.now(), platform: p0.value, servers: SW_DEFAULT_SERVERS, perServer: p0.perServer, rate: p0.rate };
}
function swSave(state) {
  try { localStorage.setItem(SW_STATE_KEY, JSON.stringify(state)); } catch (e) {}
}

const swFieldLabel = { font: '10.5px/1 var(--font-body)', color: 'rgba(237,239,245,.5)', letterSpacing: '.01em' };
const swFieldInput = {
  font: '13px/1 var(--font-body)', padding: '7px 9px', borderRadius: 3,
  border: '1px solid rgba(237,239,245,.24)', background: 'rgba(255,255,255,.06)', color: '#F0F1FB',
  width: '100%', boxSizing: 'border-box',
};
const swTableHead = { font: 'var(--weight-bold) 10.5px/1.2 var(--font-body)', color: 'rgba(237,239,245,.5)', textAlign: 'right' };
const swTableLabel = { font: '12px/1.3 var(--font-body)', color: 'rgba(237,239,245,.68)' };
const swTableVal = (accent) => ({ font: (accent ? 'var(--weight-bold) ' : '') + '13px/1.3 var(--font-body)', color: accent ? 'var(--lapis-300)' : '#F0F1FB', textAlign: 'right', whiteSpace: 'nowrap' });

/* Mike, wide-monitor review: the corner widget's own small type (sw* above) is right for a
   floating box in the corner of the screen, but this same table gets embedded full-size on Run
   the numbers and Pricing & licensing — there it read as "paragraph three or four," noticeably
   smaller than the rest of the page's own body text. Large variants of the same styles, used
   only by LiveCostBox's own `large` mode, so the corner widget itself is untouched. */
const lcFieldLabel = { font: '13px/1 var(--font-body)', color: 'rgba(237,239,245,.6)', letterSpacing: '.01em' };
const lcFieldInput = {
  font: '16px/1 var(--font-body)', padding: '11px 13px', borderRadius: 4,
  border: '1px solid rgba(237,239,245,.24)', background: 'rgba(255,255,255,.06)', color: '#F0F1FB',
  width: '100%', boxSizing: 'border-box',
};
const lcTableHead = { font: 'var(--weight-bold) 13px/1.2 var(--font-body)', color: 'rgba(237,239,245,.5)', textAlign: 'right' };
const lcTableLabel = { font: 'var(--type-body)', color: 'rgba(237,239,245,.72)' };
const lcTableVal = (accent) => ({ font: (accent ? 'var(--weight-bold) ' : '') + '22px/1.3 var(--font-display)', color: accent ? 'var(--lapis-300)' : '#F0F1FB', textAlign: 'right', whiteSpace: 'nowrap' });

/* The live cost table + editable assumptions, shared between the corner widget's own expanded
   panel, Run the numbers (calculator.jsx), and Pricing & licensing (pages.jsx's PricingModel) —
   one implementation of the math and the markup instead of three. `large` swaps in the page-scale
   type above; `footer` is a render prop so each caller supplies its own call to action (or none)
   below the fields without this component needing to know what that action is. */
/* Pulled out of LiveCostBox so a caller that only needs the annual figures (calculator.jsx's
   TeamGetsBack, reallocating the yearly delta into "what your team gets back") doesn't have to
   duplicate the platform lookup and the incumbent/Basalt math a second time.

   Mike, 16 Sept review: "the total amount has to incorporate management costs... it's not just
   about the licensing," then, same day, on the first pass at that: "I am not actually seeing
   what you employ... your variable does not include the number of management hours per year."
   `mgmtHoursYear` is now a real, visible number (LiveCostBox renders it as its own row, not just
   folded silently into a bigger total) — SW_PLATFORMS' own per-server hours estimate, times
   servers, times ADMIN_HOURLY_WAGE (both defined just above, with their own sourcing). licenseYear
   and mgmtYear are kept separate from the annualIncumbent total so a caller can show the split,
   not just the bigger number.

   Basalt's own total now carries the same two components, not just license — SW_BASALT_MGMT_
   HOURS_RATE (10% of the incumbent's own hours, standing in for "significantly reduced," not
   eliminated) times the same wage. Small next to the incumbent's, but a real, visible number
   instead of an implicit zero — Basalt's own management story is "much less," not "none."

   Basalt's own license line is now the real, settled price — $800/server/year, Dylan, 16 Sept —
   not a percentage of whatever the incumbent happens to charge. Past SW_BASALT_SERVER_CAP (100
   servers) that flat rate no longer applies ("then you call us"), so basaltLicenseYear (and the
   Basalt total built on it) comes back null rather than a number nobody quoted; basaltCustomQuote
   tells a caller when to show "Call us" instead of a dash for "no data." */
function swCompute(state) {
  const platform = SW_PLATFORMS.find(p => p.value === state.platform) || SW_PLATFORMS[0];
  const hasRate = state.rate != null && state.rate > 0 && state.servers > 0 && state.perServer > 0;
  const licenseYear = hasRate ? state.servers * state.perServer * state.rate : null;
  const mgmtHoursYear = hasRate ? state.servers * platform.mgmtHoursPerServerYear : null;
  const mgmtYear = mgmtHoursYear != null ? mgmtHoursYear * ADMIN_HOURLY_WAGE : null;
  const annualIncumbent = licenseYear != null && mgmtYear != null ? licenseYear + mgmtYear : null;
  const basaltCustomQuote = hasRate && state.servers > SW_BASALT_SERVER_CAP;
  const basaltLicenseYear = hasRate && !basaltCustomQuote ? state.servers * SW_BASALT_PRICE_PER_SERVER_YEAR : null;
  const basaltMgmtHoursYear = mgmtHoursYear != null ? mgmtHoursYear * SW_BASALT_MGMT_HOURS_RATE : null;
  const basaltMgmtYear = basaltMgmtHoursYear != null && !basaltCustomQuote ? basaltMgmtHoursYear * ADMIN_HOURLY_WAGE : null;
  const basaltYear = basaltLicenseYear != null && basaltMgmtYear != null ? basaltLicenseYear + basaltMgmtYear : null;
  const savingsYear = annualIncumbent != null && basaltYear != null ? annualIncumbent - basaltYear : null;
  return {
    platform, licenseYear, mgmtHoursYear, mgmtYear, annualIncumbent,
    basaltLicenseYear, basaltMgmtHoursYear, basaltMgmtYear, basaltYear, savingsYear, basaltCustomQuote,
  };
}

function LiveCostBox({ state, setState, now, large, heading, headingStyle, footer, platformOptions = SW_PLATFORMS }) {
  const { platform, licenseYear, mgmtHoursYear, mgmtYear, annualIncumbent, basaltMgmtHoursYear, basaltMgmtYear, basaltYear, basaltCustomQuote } = swCompute(state);
  const elapsedSeconds = Math.max(0, (now - state.firstVisit) / 1000);
  /* Basalt's own ticking figures used to be derived from the INCUMBENT's ticking number and a
     flat percentage — that broke once Basalt's total picked up a second component (management
     hours) at a different rate than license, and it can't work at all now that Basalt's license
     is a flat $/server rate with its own 100-server cap rather than a share of whatever the
     incumbent charges. Each side derives its own ticking figures from its own annual total. */
  const incumbentPerSecond = annualIncumbent != null ? annualIncumbent / SW_YEAR_SECONDS : null;
  const incumbentNow = incumbentPerSecond != null ? incumbentPerSecond * elapsedSeconds : null;
  const incumbentMonth = annualIncumbent != null ? annualIncumbent / 12 : null;
  const basaltPerSecond = basaltYear != null ? basaltYear / SW_YEAR_SECONDS : null;
  const basaltNow = basaltPerSecond != null ? basaltPerSecond * elapsedSeconds : null;
  const basaltMonth = basaltYear != null ? basaltYear / 12 : null;

  const setPlatform = (value) => {
    const p = SW_PLATFORMS.find(x => x.value === value) || SW_PLATFORMS[0];
    setState(s => ({ ...s, platform: value, perServer: p.perServer, rate: p.rate }));
  };
  const unitWord = platform.unit === 'core' ? 'Cores' : platform.unit === 'socket' ? 'Sockets' : 'Nodes';

  const { SectionHeader } = DS;
  const fieldLabel = large ? lcFieldLabel : swFieldLabel;
  const fieldInput = large ? lcFieldInput : swFieldInput;
  const tableHead = large ? lcTableHead : swTableHead;
  const tableLabel = large ? lcTableLabel : swTableLabel;
  const tableVal = large ? lcTableVal : swTableVal;

  return (
    <React.Fragment>
      <SectionHeader tone="inverse" align="center" title={heading || ('What ' + platform.label + ' is costing your organization')}
        style={{ marginBottom: large ? 30 : 14, maxWidth: 'none', ...headingStyle }} />
      {/* Mike, 16 Sept, second pass: "the 2,880 hours... are on a line, and your management
          hours per year text... is sitting three or four pixels above [it]." The label column
          (12px/13px depending on mode) and the value columns (13px, or 22px in `large` mode) sit
          in the same grid row at different font sizes with no shared alignment rule, so each
          span's own text sat wherever its own stretched block happened to place it — never on a
          common line. `alignItems: 'baseline'` puts every cell in a row on the same text
          baseline regardless of its own font size, the standard fix for exactly this. */}
      <div style={{ display: 'grid', gridTemplateColumns: 'auto auto auto', gap: large ? '10px 28px' : '7px 16px', marginBottom: large ? 26 : 14, justifyContent: large ? 'center' : undefined, alignItems: 'baseline' }}>
        <span />
        <span style={tableHead}>{platform.label}</span>
        <span style={tableHead}>Basalt</span>
        <span style={tableLabel}>Since you arrived</span>
        <span style={tableVal(false)}>{incumbentNow != null ? swUsd(incumbentNow) : '-'}</span>
        <span style={tableVal(true)}>{basaltNow != null ? swUsd(basaltNow) : (basaltCustomQuote ? 'Call us' : '-')}</span>
        <span style={tableLabel}>Per month</span>
        <span style={tableVal(false)}>{incumbentMonth != null ? swUsdWhole(incumbentMonth) : '-'}</span>
        <span style={tableVal(true)}>{basaltMonth != null ? swUsdWhole(basaltMonth) : (basaltCustomQuote ? 'Call us' : '-')}</span>
        <span style={tableLabel}>Per year</span>
        <span style={tableVal(false)}>{annualIncumbent != null ? swUsdWhole(annualIncumbent) : '-'}</span>
        <span style={tableVal(true)}>{basaltYear != null ? swUsdWhole(basaltYear) : (basaltCustomQuote ? 'Call us' : '-')}</span>
        {/* Mike, 16 Sept — second pass, same day: "I am not actually seeing what you employ...
            your variable does not include the number of management hours per year." The dollar
            total above already counted management overhead after the first pass, but only as a
            bigger number with the hours baked in and invisible — this row made the hours
            themselves visible, in both the compact widget and the full page.

            Mike, 16 Sept, HourPlus call, reverses the unit: "I think the money is more important
            here... your entire right column is all one unit, it's all dollars." Management cost
            now shows as a dollar figure (mgmtYear/basaltMgmtYear, the same numbers already folded
            into the Per year total above) instead of hours, so every row in this table is a
            dollar amount — the underlying hours haven't gone anywhere, they're still named
            explicitly in the sentence just below. */}
        <span style={tableLabel}>Management cost/year</span>
        <span style={tableVal(false)}>{mgmtYear != null ? swUsdWhole(mgmtYear) : '-'}</span>
        <span style={tableVal(true)}>{basaltMgmtYear != null ? swUsdWhole(basaltMgmtYear) : (basaltCustomQuote ? 'Call us' : '-')}</span>
      </div>

      {/* Mike, 16 Sept review: "we can be honest... because it incorporates management cost and
          management overhead as something, but then we can really nail it on this... when
          somebody is sitting there, they're actually able to see, holy cow, this is really,
          really expensive based on my people and my time." Spells out the hours x wage the row
          above states as a number — shown only in the full-page `large` mode, where there's room
          for the sentence rather than just the row. */}
      {large && mgmtYear != null && (
        <p style={{ font: 'var(--type-body)', color: 'rgba(237,239,245,.6)', textAlign: 'center', margin: '-14px 0 20px' }}>
          Of that {swUsdWhole(annualIncumbent)}/year, {swUsdWhole(licenseYear)} is {platform.label}'s
          own license and {swUsdWhole(mgmtYear)} is {Math.round(mgmtHoursYear).toLocaleString()} hours
          of estimated management overhead at ${ADMIN_HOURLY_WAGE}/hour, fully loaded (U.S. Bureau of
          Labor Statistics), not just the sticker price.
        </p>
      )}

      <div style={{ borderTop: '1px solid rgba(237,239,245,.14)', paddingTop: large ? 24 : 12, marginBottom: large ? 28 : 14 }}>
        <p style={{ font: large ? 'var(--type-body)' : '11px/1.4 var(--font-body)', color: 'rgba(237,239,245,.5)', margin: large ? '0 0 18px' : '0 0 10px' }}>
          Your own assumptions, live, not a quote for either platform. Basalt is $800 per server,
          per year, up to 100 servers; above that it's a custom quote (see Pricing &amp; licensing).
        </p>
        <div style={{ display: 'grid', gridTemplateColumns: large ? '1fr 1fr' : 'repeat(auto-fit, minmax(160px, 1fr))', gap: large ? 18 : 8 }}>
          <label style={{ display: 'grid', gap: large ? 6 : 4 }}>
            <span style={fieldLabel}>Compared against</span>
            <select value={state.platform} onChange={e => setPlatform(e.target.value)} style={fieldInput}>
              {platformOptions.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
            </select>
          </label>
          <label style={{ display: 'grid', gap: large ? 6 : 4 }}>
            <span style={fieldLabel}>Servers</span>
            <input type="number" min="0" value={state.servers}
              onChange={e => setState(s => ({ ...s, servers: swNum(e.target.value) ?? 0 }))} style={fieldInput} />
          </label>
          <label style={{ display: 'grid', gap: large ? 6 : 4 }}>
            <span style={fieldLabel}>{unitWord}/server</span>
            <input type="number" min="0" value={state.perServer}
              onChange={e => setState(s => ({ ...s, perServer: swNum(e.target.value) ?? 0 }))} style={fieldInput} />
          </label>
          <label style={{ display: 'grid', gap: large ? 6 : 4 }}>
            <span style={fieldLabel}>{platform.label}, per {platform.unit}/year</span>
            <input type="number" min="0" placeholder={platform.rate == null ? 'no published rate: enter yours' : ''}
              value={state.rate == null ? '' : state.rate}
              onChange={e => setState(s => ({ ...s, rate: swNum(e.target.value) }))} style={fieldInput} />
          </label>
        </div>
      </div>

      {footer && footer({ incumbentNow })}
    </React.Fragment>
  );
}

/* Mike, 14 Sept: the Compare pages' own cost calculator was missing the "see how this number was
   reached" step entirely — Run the numbers has it, the Compare pages didn't. Rather than duplicate
   the inputs/gate/sent step logic a second time (calculator.jsx had it hand-rolled inline), it's
   pulled out here so both callers share one flow, one "sent" copy, and one transition — a fix to
   one shows up in the other automatically. Also addresses the second half of that same note: the
   step swap "looks and feels like you end up refreshing the whole page" — nothing here ever
   navigates (no page load, `step` is just local state), but an instant, same-height swap between
   very differently-shaped content reads as a hiccup rather than an in-place update. `key={step}`
   plus the `lcf-step` fade (site.css) gives each step a brief settle-in instead of popping in
   whole, and the outer `minHeight` keeps the section from visibly resizing under the visitor as
   they move between the three steps. */
function LiveCostFlow({ state, setState, now, heading, platformOptions = SW_PLATFORMS }) {
  const { SectionHeader, Button } = DS;
  const [step, setStep] = React.useState('inputs'); // 'inputs' | 'gate' | 'sending' | 'sent' | 'error'
  const [first, setFirst] = React.useState('');
  const [last, setLast] = React.useState('');
  const [email, setEmail] = React.useState('');
  const [sendError, setSendError] = React.useState('');

  const submitReport = async (e) => {
    e.preventDefault();
    setStep('sending');
    setSendError('');
    const c = swCompute(state);
    try {
      const res = await fetch('/api/send-report', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          firstName: first, lastName: last, email,
          platformValue: c.platform.value, platformLabel: c.platform.label, servers: state.servers, perServer: state.perServer,
          unitWord: c.platform.unit === 'core' ? 'Cores' : c.platform.unit === 'socket' ? 'Sockets' : 'Nodes',
          rate: state.rate,
          licenseYear: c.licenseYear, mgmtYear: c.mgmtYear, annualIncumbent: c.annualIncumbent,
          mgmtHoursYear: c.mgmtHoursYear, adminHourlyWage: ADMIN_HOURLY_WAGE,
          basaltLicenseYear: c.basaltLicenseYear, basaltMgmtYear: c.basaltMgmtYear,
          basaltYear: c.basaltYear, savingsYear: c.savingsYear, basaltCustomQuote: c.basaltCustomQuote,
        }),
      });
      if (!res.ok) throw new Error('send failed');
      setStep('sent');
    } catch (err) {
      setSendError("That didn't go through. Email sending isn't fully turned on yet. Try again shortly, or reach us directly from the Contact page.");
      setStep('error');
    }
  };

  return (
    <div style={{ minHeight: 460 }}>
      <div key={step} className="lcf-step">
        {step === 'inputs' && (
          <LiveCostBox state={state} setState={setState} now={now} large heading={heading} platformOptions={platformOptions}
            footer={({ incumbentNow }) => (
              <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
                <Button size="md" disabled={incumbentNow == null} onClick={() => setStep('gate')}>See how this number was reached</Button>
              </div>
            )} />
        )}

        {step === 'gate' && (
          <React.Fragment>
            <SectionHeader tone="inverse" align="center" title="Where should we send the details?"
              lead="Every rate and multiplier that produced the number you just saw, laid out so you can take it to finance and argue with it. One email, no sequence: we do not make you book a call to get it."
              style={{ marginBottom: 30, maxWidth: 'none' }} />
            <form onSubmit={submitReport} style={{ display: 'grid', gap: 14, maxWidth: 460, margin: '0 auto' }}>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
                <label style={{ display: 'grid', gap: 6 }}>
                  <span style={lcFieldLabel}>First name</span>
                  <input value={first} onChange={e => setFirst(e.target.value)} style={lcFieldInput} required />
                </label>
                <label style={{ display: 'grid', gap: 6 }}>
                  <span style={lcFieldLabel}>Last name</span>
                  <input value={last} onChange={e => setLast(e.target.value)} style={lcFieldInput} required />
                </label>
              </div>
              <label style={{ display: 'grid', gap: 6 }}>
                <span style={lcFieldLabel}>Work email</span>
                <input type="email" placeholder="you@yourcompany.com" value={email} onChange={e => setEmail(e.target.value)} style={lcFieldInput} required />
              </label>
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 18, marginTop: 10 }}>
                <button type="button" onClick={() => setStep('inputs')}
                  style={{ background: 'none', border: 0, cursor: 'pointer', font: 'var(--type-body)', color: 'rgba(237,239,245,.6)', textDecoration: 'underline' }}>
                  Back
                </button>
                <Button type="submit" size="md">Send me the details</Button>
              </div>
            </form>
          </React.Fragment>
        )}

        {step === 'sending' && (
          <div style={{ textAlign: 'center' }}>
            <SectionHeader tone="inverse" align="center" title="Sending…"
              lead="Putting the numbers together and sending them your way."
              style={{ maxWidth: 'none' }} />
          </div>
        )}

        {step === 'sent' && (
          <div style={{ textAlign: 'center' }}>
            {/* Mike: the old "In the launch build this sends..." copy read like a bug report,
                not a confirmation — "something catchy that is nice." Splits the two jobs: a short,
                warm headline plus one confident sentence. Now that this actually calls
                /api/send-report, there's no staging caveat to add underneath any more. */}
            <SectionHeader tone="inverse" align="center" title="Sent: check your inbox"
              lead={'The full breakdown behind that number, every rate and multiplier, is on its way to ' + (email || 'your inbox') + '. Take it to finance and argue with it.'}
              style={{ maxWidth: 'none' }} />
          </div>
        )}

        {step === 'error' && (
          <div style={{ textAlign: 'center' }}>
            <SectionHeader tone="inverse" align="center" title="Couldn't send that"
              lead={sendError}
              style={{ maxWidth: 'none', marginBottom: 20 }} />
            <Button size="md" onClick={() => setStep('gate')}>Try again</Button>
          </div>
        )}
      </div>
    </div>
  );
}

/* The one component every page mounts (see site/nav.jsx's SiteFoot) so it appears site-wide
   without touching every page component individually. Fixed positioning means where it sits in
   the DOM doesn't matter — only that it renders once per page. */
function SavingsWidget() {
  const { Button } = DS;
  const [state, setState] = React.useState(swLoad);
  const [open, setOpen] = React.useState(false);
  const [now, setNow] = React.useState(Date.now());

  React.useEffect(() => {
    const id = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(id);
  }, []);
  React.useEffect(() => { swSave(state); }, [state]);

  /* Was its own inline copy of this math (pre-16 Sept) — duplicated swCompute's exact formula
     instead of calling it, which meant the management-overhead variable added there (see
     SW_PLATFORMS' own comment) would have silently not applied here. Calling the shared function
     is both the reuse fix and the correctness fix: one implementation, so the corner widget's own
     total is never quietly out of step with Run the numbers or a Compare page's calculator. */
  const { platform, annualIncumbent, basaltYear, mgmtYear, basaltMgmtYear, basaltCustomQuote } = swCompute(state);
  const elapsedSeconds = Math.max(0, (now - state.firstVisit) / 1000);
  /* Each side derives its own ticking figures from its own annual total now, not the incumbent's
     scaled by a flat percentage — see LiveCostBox's own comment on why that broke once Basalt's
     total picked up a second component (management hours) at a different rate than license, and
     why it can't work at all now that Basalt's license is a flat $/server rate with its own cap. */
  const incumbentPerSecond = annualIncumbent != null ? annualIncumbent / SW_YEAR_SECONDS : null;
  const incumbentNow = incumbentPerSecond != null ? incumbentPerSecond * elapsedSeconds : null;
  const incumbentMonth = annualIncumbent != null ? annualIncumbent / 12 : null;
  const basaltPerSecond = basaltYear != null ? basaltYear / SW_YEAR_SECONDS : null;
  const basaltNow = basaltPerSecond != null ? basaltPerSecond * elapsedSeconds : null;
  const basaltMonth = basaltYear != null ? basaltYear / 12 : null;

  const setPlatform = (value) => {
    const p = SW_PLATFORMS.find(x => x.value === value) || SW_PLATFORMS[0];
    setState(s => ({ ...s, platform: value, perServer: p.perServer, rate: p.rate }));
  };

  const runHref = 'run-the-numbers.html?platform=' + encodeURIComponent(state.platform)
    + '&servers=' + encodeURIComponent(state.servers)
    + '&cores=' + encodeURIComponent(state.perServer)
    + (state.rate != null ? '&rate=' + encodeURIComponent(state.rate) : '');

  const unitWord = platform.unit === 'core' ? 'Cores' : platform.unit === 'socket' ? 'Sockets' : 'Nodes';

  return (
    /* Mike: a fixed 300px box left a lot of dead air on the right of every table row whenever
       the numbers (or the platform name) didn't need that much width — VMware's short figures
       especially. width:'fit-content' lets the box hug whichever is actually widest (the panel's
       own maxWidth-capped content, or the trigger's own text when collapsed) instead of always
       claiming the same fixed footprint. */
    /* Design/dev director review, 21 Sept: at a 375px viewport, this fixed corner widget had no
       responsive handling at all — collapsed, it sat directly on top of in-flow CTAs and body
       copy (confirmed overlapping the homepage's "Plan a controlled evaluation" button and mid-
       sentence text on the Oil & Gas page); expanded, the panel's own 320px maxWidth against a
       375px viewport left it covering nearly the whole screen with no visible way to close it
       besides tapping the now-hidden trigger underneath. `.sw-panel` picks up a max-height/scroll
       rule at the same 620px breakpoint every other mobile layout fix on the site uses (site.css),
       and the header row below now carries an explicit ✕ that's reachable even when the panel
       fills the viewport. */
    <div className="sw-widget" style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 60, width: 'fit-content', maxWidth: 'calc(100vw - 32px)' }}>
      {open && (
        /* Mike, 24 Sept: "when Red Hat OpenShift is selected... it goes too far to the left...
           you need to anchor it on the right side, so the box in the bottom right doesn't move at
           all." .sw-widget sizes itself to its widest child's preferred width, which grows with
           the platform label (e.g. "Red Hat OpenShift Virtualization" vs. "VMware") even though
           this panel is capped at 320px — as a plain block child with no width/alignment rule of
           its own, it defaulted to the container's LEFT edge, so a wider trigger label dragged the
           whole panel left with it. marginLeft: 'auto' pins the panel's own right edge to the
           container's right edge instead — the one edge that's actually fixed at 16px from the
           viewport — so it holds still regardless of which platform is selected. */
        <div className="sw-panel" style={{ background: 'var(--surface-inverse)', borderRadius: 6, padding: '18px 18px 16px', marginBottom: 12,
          maxWidth: 320, marginLeft: 'auto', boxShadow: '0 20px 44px -16px rgba(0,0,0,.55)', border: '1px solid rgba(237,239,245,.14)' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 12, marginBottom: 14 }}>
            <div style={{ font: 'var(--weight-bold) 14px/1.3 var(--font-display)', color: '#F0F1FB' }}>
              What {platform.label} is costing your organization
            </div>
            <button onClick={() => setOpen(false)} aria-label="Close"
              style={{ flex: 'none', background: 'none', border: 'none', color: 'rgba(237,239,245,.6)', font: '16px/1 var(--font-body)', cursor: 'pointer', padding: 4 }}>
              ✕
            </button>
          </div>
          {/* auto, not 1fr: a stretched label column was the actual source of the empty gap
              between "Since you arrived" and its own number — each column now sizes to its own
              widest cell instead of the label column claiming whatever space the numbers don't
              need. */}
          {/* Same baseline fix as LiveCostBox's own table above (see its comment) — the compact
              widget's label/value cells sit at different font sizes too (12px vs 13px). */}
          <div style={{ display: 'grid', gridTemplateColumns: 'auto auto auto', gap: '7px 16px', marginBottom: 14, alignItems: 'baseline' }}>
            <span />
            <span style={swTableHead}>{platform.label}</span>
            <span style={swTableHead}>Basalt</span>
            <span style={swTableLabel}>Since you arrived</span>
            <span style={swTableVal(false)}>{incumbentNow != null ? swUsd(incumbentNow) : '-'}</span>
            <span style={swTableVal(true)}>{basaltNow != null ? swUsd(basaltNow) : (basaltCustomQuote ? 'Call us' : '-')}</span>
            <span style={swTableLabel}>Per month</span>
            <span style={swTableVal(false)}>{incumbentMonth != null ? swUsdWhole(incumbentMonth) : '-'}</span>
            <span style={swTableVal(true)}>{basaltMonth != null ? swUsdWhole(basaltMonth) : (basaltCustomQuote ? 'Call us' : '-')}</span>
            <span style={swTableLabel}>Per year</span>
            <span style={swTableVal(false)}>{annualIncumbent != null ? swUsdWhole(annualIncumbent) : '-'}</span>
            <span style={swTableVal(true)}>{basaltYear != null ? swUsdWhole(basaltYear) : (basaltCustomQuote ? 'Call us' : '-')}</span>
            {/* Mike, 16 Sept, HourPlus call: "the money is more important... your entire right
                column is all one unit, it's all dollars" — same fix as LiveCostBox's own table. */}
            <span style={swTableLabel}>Mgmt cost/year</span>
            <span style={swTableVal(false)}>{mgmtYear != null ? swUsdWhole(mgmtYear) : '-'}</span>
            <span style={swTableVal(true)}>{basaltMgmtYear != null ? swUsdWhole(basaltMgmtYear) : (basaltCustomQuote ? 'Call us' : '-')}</span>
          </div>
          <div style={{ borderTop: '1px solid rgba(237,239,245,.14)', paddingTop: 12, marginBottom: 14 }}>
            <p style={{ font: '11px/1.4 var(--font-body)', color: 'rgba(237,239,245,.5)', margin: '0 0 10px' }}>
              Your own assumptions, live, not a quote for either platform. Basalt is $800 per
              server, per year, up to 100 servers; above that it's a custom quote (see Pricing &amp; licensing).
            </p>
            <div style={{ display: 'grid', gap: 8 }}>
              <label style={{ display: 'grid', gap: 4 }}>
                <span style={swFieldLabel}>Compared against</span>
                <select value={state.platform} onChange={e => setPlatform(e.target.value)} style={swFieldInput}>
                  {SW_FEATURED_PLATFORMS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
                </select>
              </label>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
                <label style={{ display: 'grid', gap: 4 }}>
                  <span style={swFieldLabel}>Servers</span>
                  <input type="number" min="0" value={state.servers}
                    onChange={e => setState(s => ({ ...s, servers: swNum(e.target.value) ?? 0 }))} style={swFieldInput} />
                </label>
                <label style={{ display: 'grid', gap: 4 }}>
                  <span style={swFieldLabel}>{unitWord}/server</span>
                  <input type="number" min="0" value={state.perServer}
                    onChange={e => setState(s => ({ ...s, perServer: swNum(e.target.value) ?? 0 }))} style={swFieldInput} />
                </label>
              </div>
              <label style={{ display: 'grid', gap: 4 }}>
                <span style={swFieldLabel}>{platform.label}, per {platform.unit}/year</span>
                <input type="number" min="0" placeholder={platform.rate == null ? 'no published rate: enter yours' : ''}
                  value={state.rate == null ? '' : state.rate}
                  onChange={e => setState(s => ({ ...s, rate: swNum(e.target.value) }))} style={swFieldInput} />
              </label>
            </div>
          </div>
          <div className="sw-run-btn">
            <Button size="sm" href={runHref} style={{ display: 'block', width: '100%' }}>Run the full numbers</Button>
          </div>
        </div>
      )}
      <div className="sw-trigger">
        {/* Mike: "spent" was losing its own descender — that span's `overflow: hidden` (added
            for a truncation case that width:'fit-content' above now makes unnecessary) was
            clipping the 'p' along with anything else that would have overflowed. Plain text,
            no clipping. */}
        {/* Mike: the label sat flush left with the chevron pinned flush right by its own
            margin-left: auto — any slack from the box growing wider than this text needs (the
            panel above open on a long platform name) showed up as a gap splitting the two apart,
            not as space to their left. site.css's .sw-trigger rule handles the grouping now
            (justify-content: flex-end on the button itself); an auto margin here would still
            grab the slack for itself and override that, so the chevron goes back to a plain
            flex item with just its own gap from the label. */}
        {/* Mike, 14 Sept: "HPE Morpheus and Scale Computing... seem to cause it to bump out
            more." The wrapper above is `right`-anchored and does grow leftward correctly (a
            static check confirms the right edge stays put at every platform), but Button's own
            drawn SVG face is sized off a ResizeObserver reading of this exact element — swapping
            to a longer label mid-render can leave that SVG drawn for a stale, narrower width for
            a frame before it catches up, which is what actually reads as a "bump" rather than a
            clean resize. `key={platform.value}` forces a full remount on every platform switch so
            there's never a stale-sized drawing left over to catch up from. */}
        {/* Mike, 15 Sept: "the numbers since someone arrived are so small... go with the $ value
            representing this month, that will be bigger." incumbentNow only accrues a few cents
            to a few dollars across a real visit — too small to land as a headline figure. The
            monthly incumbent spend is the same number the table already calls "Per month," just
            promoted to the one figure shown collapsed; the full breakdown (including "since you
            arrived") still lives in the expanded panel below. */}
        <Button key={platform.value} variant="primary" size="md" onClick={() => setOpen(o => !o)} style={{ display: 'block', width: '100%' }}>
          <span>{incumbentMonth != null ? swUsdWhole(incumbentMonth) + '/mo on ' + platform.label : 'See what you’re spending'}</span>
          <span style={{ flex: 'none', display: 'inline-block', transition: 'transform .2s', transform: open ? 'rotate(180deg)' : 'none' }}>▲</span>
        </Button>
      </div>
    </div>
  );
}
