// financial/src/tax.jsx — the Tax section.
//
// Two questions, one engine:
//
//   THIS YEAR   where does the year actually land, and what is still
//               reachable before Dec 31 (payroll) and Apr 15 (IRA/HSA)?
//   NEXT YEAR   what should the elections BE, in dollars and in payroll
//               percentages, and where does each dollar come from — regular
//               pay, the February bonus, or the employer?
//
// Every number on this page comes from lib/taxPlanner. Nothing is computed in
// the JSX: the tab is a view, and the model is testable (see
// functions/__tests__/taxPlanner.test.js). When a figure here looks wrong the
// fix belongs in the lib, or the whole point of having one is lost.
//
// The inputs are derived first and shown as inputs — the projection is only
// as good as "what does he actually earn", and a page that hides its
// assumptions behind a confident number is worse than one that shows them.
//
// Slices run in their own Babel scope; src/shared.jsx runs first and
// publishes window.FinanceShared.

const { useState, useEffect, useContext, useMemo, useRef, useCallback } = React;
const { DataContext, useToast, callFn, fmtCur, USER_PROFILE, calcAge, MarkdownView } = window.FinanceShared;
const { ResponsiveContainer, ComposedChart, LineChart, Line, Bar, XAxis, YAxis,
        CartesianGrid, Tooltip: RTooltip, Legend, ReferenceLine, ReferenceArea } = window.Recharts;

// Two series, both validated against this app's chart surface (#0f1520) for
// lightness, chroma, CVD separation and contrast — see the dataviz validator.
// Blue is always the converted path and orange always the untouched one; the
// pairing never swaps, because colour follows the entity and not its rank.
const SERIES = { convert: '#3987e5', dont: '#d95926' };
const AXIS = { stroke: '#334155', tick: { fill: '#64748b', fontSize: 10 } };
const compact = (n) => {
  const v = Math.abs(Number(n) || 0);
  const sign = n < 0 ? '-' : '';
  if (v >= 1e6) return `${sign}$${(v / 1e6).toFixed(v >= 1e7 ? 0 : 1)}M`;
  if (v >= 1e3) return `${sign}$${Math.round(v / 1e3)}k`;
  return `${sign}$${Math.round(v)}`;
};

const pct1 = (n) => `${(Number(n) || 0).toFixed(1)}%`;
const signed = (n) => (n >= 0 ? `+${fmtCur(n)}` : `−${fmtCur(Math.abs(n))}`);

// ── Small presentational pieces ───────────────────────────────────────────
function Stat({ label, value, sub, color, hint }) {
  return (
    <div className="card-sm" title={hint || ''}>
      <div className="label" style={{marginBottom:4}}>{label}</div>
      <div className="val-md" style={{color: color || '#e2e8f0'}}>{value}</div>
      {sub && <div style={{fontSize:11,color:'#64748b',marginTop:4}}>{sub}</div>}
    </div>
  );
}

function Chip({ children, tone = 'slate' }) {
  const tones = {
    slate:  {bg:'rgba(100,116,139,0.15)', fg:'#94a3b8', bd:'rgba(100,116,139,0.35)'},
    green:  {bg:'rgba(16,185,129,0.12)',  fg:'#10b981', bd:'rgba(16,185,129,0.35)'},
    amber:  {bg:'rgba(245,158,11,0.12)',  fg:'#f59e0b', bd:'rgba(245,158,11,0.35)'},
    red:    {bg:'rgba(239,68,68,0.12)',   fg:'#ef4444', bd:'rgba(239,68,68,0.35)'},
    blue:   {bg:'rgba(59,130,246,0.12)',  fg:'#3b82f6', bd:'rgba(59,130,246,0.35)'},
  };
  const t = tones[tone] || tones.slate;
  return (
    <span style={{display:'inline-block',padding:'2px 7px',borderRadius:4,fontSize:10,fontWeight:600,
      background:t.bg,color:t.fg,border:`1px solid ${t.bd}`,whiteSpace:'nowrap'}}>{children}</span>
  );
}

function Row({ label, value, indent, strong, color, note }) {
  return (
    <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',gap:12,
      padding:'4px 0',paddingLeft:indent ? 14 : 0,fontSize: indent ? 11 : 12}}>
      <span style={{color: indent ? '#94a3b8' : '#64748b'}}>{label}{note && <span style={{color:'#475569'}}> · {note}</span>}</span>
      <strong style={{color: color || (strong ? '#e2e8f0' : '#cbd5e1'), fontWeight: strong ? 700 : 500, whiteSpace:'nowrap'}}>{value}</strong>
    </div>
  );
}

function NumField({ label, value, onChange, prefix, suffix, hint, step }) {
  return (
    <div>
      <div className="label" style={{marginBottom:3,fontSize:10}}>{label}</div>
      <div style={{display:'flex',alignItems:'center',gap:4}}>
        {prefix && <span style={{fontSize:11,color:'#64748b'}}>{prefix}</span>}
        <input type="number" step={step || 1} value={value ?? 0}
          onChange={e => onChange(e.target.value === '' ? 0 : Number(e.target.value))} style={{flex:1}}/>
        {suffix && <span style={{fontSize:11,color:'#64748b'}}>{suffix}</span>}
      </div>
      {hint && <div style={{fontSize:10,color:'#475569',marginTop:3}}>{hint}</div>}
    </div>
  );
}

// A number that can be genuinely ABSENT. NumField coerces null to 0, which
// is fine for "how much did you contribute" and wrong for a rate: 0% core is
// a real plan term, and rendering "nobody told us" as 0 is how a blank became
// indistinguishable from a decision. Empty string in, null out.
function OptNumField({ label, value, onChange, prefix, suffix, hint, step, placeholder, assumed }) {
  return (
    <div>
      <div className="label" style={{marginBottom:3,fontSize:10,display:'flex',gap:6,alignItems:'center'}}>
        <span>{label}</span>
        {assumed && <Chip tone="amber">assumed</Chip>}
      </div>
      <div style={{display:'flex',alignItems:'center',gap:4}}>
        {prefix && <span style={{fontSize:11,color:'#64748b'}}>{prefix}</span>}
        <input type="number" step={step || 1} placeholder={placeholder || 'not set'}
          value={value == null || value === '' ? '' : value}
          onChange={e => onChange(e.target.value === '' ? null : Number(e.target.value))}
          style={{flex:1, borderColor: assumed ? 'rgba(245,158,11,0.4)' : undefined}}/>
        {suffix && <span style={{fontSize:11,color:'#64748b'}}>{suffix}</span>}
      </div>
      {hint && <div style={{fontSize:10,color:'#475569',marginTop:3}}>{hint}</div>}
    </div>
  );
}

function TextField({ label, value, onChange, hint, type }) {
  return (
    <div>
      <div className="label" style={{marginBottom:3,fontSize:10}}>{label}</div>
      <input type={type || 'text'} value={value || ''} onChange={e => onChange(e.target.value || null)} style={{width:'100%'}}/>
      {hint && <div style={{fontSize:10,color:'#475569',marginTop:3}}>{hint}</div>}
    </div>
  );
}

function SelectField({ label, value, onChange, options, hint }) {
  return (
    <div>
      <div className="label" style={{marginBottom:3,fontSize:10}}>{label}</div>
      <select value={value} onChange={e => onChange(e.target.value)}>
        {options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
      </select>
      {hint && <div style={{fontSize:10,color:'#475569',marginTop:3}}>{hint}</div>}
    </div>
  );
}

function CheckField({ label, checked, onChange, hint }) {
  return (
    <label style={{display:'flex',alignItems:'flex-start',gap:8,fontSize:11,color:checked?'#3b82f6':'#94a3b8',
      padding:'7px 10px',borderRadius:6,cursor:'pointer',
      background:checked?'rgba(59,130,246,0.12)':'#161b22',border:`1px solid ${checked?'rgba(59,130,246,0.4)':'#1e2a3a'}`}}>
      <input type="checkbox" checked={!!checked} onChange={e=>onChange(e.target.checked)} style={{margin:'2px 0 0',width:'auto'}}/>
      <span>{label}{hint && <span style={{display:'block',color:'#475569',marginTop:2}}>{hint}</span>}</span>
    </label>
  );
}

const GRID = {display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(170px,1fr))',gap:12};
const SUB = (label) => (
  <div style={{fontSize:10,fontWeight:600,color:'#94a3b8',textTransform:'uppercase',letterSpacing:0.5,margin:'4px 0 10px'}}>{label}</div>
);

// ── Bracket ladder ────────────────────────────────────────────────────────
// Same visual language as the Strategy tab so the two pages agree on sight.
// Rendered from whatever bracket table the plan used, so an ESTIMATED next-year
// table draws its own (estimated) ladder rather than silently reusing this
// year's lines.
function BracketLadder({ brackets, taxable, marginalRate }) {
  const COLORS = ['#10b981','#3b82f6','#8b5cf6','#f59e0b','#f97316','#ef4444','#dc2626'];
  return (
    <div>
      {brackets.map((b, i) => {
        const active = taxable > b.min && (b.max == null || taxable <= b.max);
        const span = (b.max == null ? taxable + 100000 : b.max) - b.min;
        const fill = b.min > taxable ? 0 : Math.min(1, (Math.min(taxable, b.max == null ? taxable : b.max) - b.min) / span);
        const color = COLORS[i] || '#dc2626';
        return (
          <div key={i} style={{marginBottom:5}}>
            <div style={{display:'flex',justifyContent:'space-between',fontSize:10,marginBottom:2,
              color: active ? color : '#64748b'}}>
              <span style={{fontWeight: active ? 700 : 400}}>{b.rate}%{active ? ' ← you' : ''}</span>
              <span>{fmtCur(b.min)} – {b.max ? fmtCur(b.max) : '∞'}</span>
            </div>
            <div className="progress-bar">
              <div className="progress-fill" style={{width:`${fill*100}%`,background:color,opacity:active?1:0.4}}/>
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ── Capacity table ────────────────────────────────────────────────────────
function CapacityTable({ capacity, showPerPaycheck }) {
  return (
    <div style={{overflowX:'auto'}}>
      <table>
        <thead>
          <tr>
            <th>Vehicle</th>
            <th style={{textAlign:'right'}}>Limit</th>
            <th style={{textAlign:'right'}}>On track for</th>
            <th style={{textAlign:'right'}}>Room left</th>
            {showPerPaycheck && <th style={{textAlign:'right'}}>Per paycheck</th>}
            <th style={{textAlign:'right'}}>Deadline</th>
          </tr>
        </thead>
        <tbody>
          {capacity.map(r => (
            <tr key={r.key}>
              <td>
                <div style={{color:'#e2e8f0',fontWeight:600}}>{r.label}</div>
                <div className="progress-bar" style={{margin:'5px 0 4px',maxWidth:220}}>
                  <div className="progress-fill" style={{width:`${r.pctComplete}%`,
                    background: r.remaining <= 0 ? '#10b981' : (r.fits ? '#3b82f6' : '#f59e0b')}}/>
                </div>
                {r.banked != null && r.banked < r.contributed && (
                  <div style={{fontSize:10,color:'#475569'}}>{fmtCur(r.banked)} withheld so far · the rest comes from the current election</div>
                )}
                {r.note && <div style={{fontSize:10,color:'#475569',maxWidth:420}}>{r.note}</div>}
                {r.blocked && <div style={{fontSize:10,color:'#f59e0b',maxWidth:420,marginTop:2}}>⚠ {r.blocked}</div>}
              </td>
              <td style={{textAlign:'right',color:'#94a3b8'}}>{fmtCur(r.limit)}</td>
              <td style={{textAlign:'right',color:'#e2e8f0'}}>{fmtCur(r.contributed)}</td>
              <td style={{textAlign:'right',color: r.remaining > 0 ? '#f59e0b' : '#10b981',fontWeight:600}}>
                {r.remaining > 0 ? fmtCur(r.remaining) : '✓ full'}
              </td>
              {showPerPaycheck && (
                <td style={{textAlign:'right',color:'#94a3b8'}}>
                  {r.remaining <= 0 ? '—' : (r.perPaycheck != null
                    ? <span>{fmtCur(r.perPaycheck)}<span style={{color:'#475569'}}> · {(r.pctOfPay||0).toFixed(0)}%</span></span>
                    : <span style={{color:'#475569'}}>lump</span>)}
                </td>
              )}
              <td style={{textAlign:'right'}}>
                <Chip tone={r.deadline === 'yearEnd' ? 'amber' : 'blue'}>{r.deadlineLabel}</Chip>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

// ── Action list ───────────────────────────────────────────────────────────
function ActionList({ actions }) {
  const CAT = {
    withholding:'#ef4444', retirement:'#10b981', roth:'#3b82f6', health:'#8b5cf6',
    family:'#f59e0b', bracket:'#f97316', employer:'#10b981',
  };
  if (!actions.length) return <div className="empty-state">Nothing outstanding — every lever this model knows about is already pulled.</div>;
  return (
    <div style={{display:'flex',flexDirection:'column',gap:10}}>
      {actions.map((a, i) => {
        const color = CAT[a.category] || '#64748b';
        return (
          <div key={a.id} style={{border:'1px solid #1e2a3a',borderLeft:`3px solid ${color}`,borderRadius:8,padding:'12px 14px',background:'rgba(255,255,255,0.015)'}}>
            <div style={{display:'flex',alignItems:'center',gap:8,flexWrap:'wrap',marginBottom:6}}>
              <span style={{width:20,height:20,borderRadius:'50%',background:'#161b22',color:color,
                display:'flex',alignItems:'center',justifyContent:'center',fontSize:10,fontWeight:700,flexShrink:0}}>{i+1}</span>
              <span style={{fontSize:13,fontWeight:600,color:'#e2e8f0'}}>{a.title}</span>
              <Chip tone={a.deadline === 'yearEnd' ? 'amber' : a.deadline === 'filing' ? 'blue' : 'slate'}>
                {a.deadline === 'yearEnd' ? 'by Dec 31' : a.deadline === 'filing' ? 'by Apr 15' : 'open enrollment'}
              </Chip>
              {a.savings > 0 && <Chip tone="green">saves {fmtCur(a.savings)}</Chip>}
            </div>
            <div style={{fontSize:12,color:'#94a3b8',lineHeight:1.65}}>{a.detail}</div>
            {a.warning && (
              <div style={{marginTop:8,padding:'8px 10px',background:'rgba(245,158,11,0.1)',
                border:'1px solid rgba(245,158,11,0.3)',borderRadius:6,fontSize:11,color:'#fbbf24',lineHeight:1.6}}>
                {a.warning}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}

// ── HSA vs healthcare FSA ─────────────────────────────────────────────────
// Its own card because it is the household's one genuinely EXCLUSIVE benefit
// choice, and the exclusion is invisible from either side: his FSA is elected
// in his employer's portal, her HDHP in the State's, and neither system knows
// the other exists. A general-purpose healthcare FSA can reimburse a spouse's
// expenses, which makes it disqualifying coverage for that spouse's HSA
// (Rev. Rul. 2004-45) — so one election silently voids the other.
function HealthDecisionCard({ health, decision, value, year }) {
  if (health.hsaOwner === 'none' && health.healthcareFsaKind === 'none') return null;
  const blocked = health.hsaOwner !== 'none' && !health.hsaEligible;
  const side = (o, win) => (
    <div style={{flex:1,minWidth:260,padding:'14px 16px',borderRadius:8,
      background: win ? 'rgba(16,185,129,0.07)' : '#0f1520',
      border:`1px solid ${win ? 'rgba(16,185,129,0.35)' : '#1e2a3a'}`}}>
      <div style={{display:'flex',alignItems:'center',gap:8,marginBottom:10,flexWrap:'wrap'}}>
        <span style={{fontSize:12,fontWeight:600,color: win ? '#10b981' : '#e2e8f0'}}>{o.label}</span>
        {win && <Chip tone="green">better by {fmtCur(Math.abs(decision.delta))}</Chip>}
      </div>
      <Row label="Contribution" value={fmtCur(o.contribution)} strong/>
      {o.employerMoney > 0 && <Row label="Employer money" value={fmtCur(o.employerMoney)} indent color="#10b981"/>}
      <Row label={`Tax saved at ${o.ratePct.toFixed(1)}%`} value={fmtCur(o.taxSaving)} indent/>
      {o.forfeiture > 0 && <Row label="Forfeited if unspent" value={`− ${fmtCur(o.forfeiture)}`} indent color="#ef4444"/>}
      {o.premiumDelta > 0 && <Row label="Extra premium" value={`− ${fmtCur(o.premiumDelta)}`} indent color="#ef4444"/>}
      <hr className="divider" style={{margin:'8px 0'}}/>
      <Row label="Net value" value={fmtCur(o.netValue)} strong color={win ? '#10b981' : '#e2e8f0'}/>
      <ul style={{margin:'10px 0 0',paddingLeft:16,fontSize:11,color:'#64748b',lineHeight:1.6}}>
        {o.notes.map((n, i) => <li key={i} style={{marginBottom:3}}>{n}</li>)}
      </ul>
    </div>
  );
  return (
    <div className="card" style={{marginBottom:16,borderColor: blocked ? 'rgba(245,158,11,0.4)' : '#1e2a3a'}}>
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',flexWrap:'wrap',gap:8,marginBottom:6}}>
        <div style={{fontWeight:600}}>HSA or healthcare FSA — {year} can only have one</div>
        <Chip tone="amber">open-enrollment decision, both employers</Chip>
      </div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:14,lineHeight:1.65}}>
        A <strong style={{color:'#94a3b8'}}>general-purpose</strong> healthcare FSA can reimburse either spouse's
        expenses, so it counts as other health coverage for both of you and disqualifies an HSA on
        <strong style={{color:'#94a3b8'}}> either</strong> side — no matter which employer runs which plan. Neither
        benefits portal checks the other. A <strong style={{color:'#94a3b8'}}>limited-purpose</strong> FSA
        (dental and vision only) is HSA-compatible by design and keeps part of the benefit; ask whether it is offered.
      </div>
      {blocked && (
        <div style={{marginBottom:14,padding:'9px 11px',background:'rgba(245,158,11,0.1)',
          border:'1px solid rgba(245,158,11,0.3)',borderRadius:6,fontSize:11,color:'#fbbf24',lineHeight:1.6}}>
          Right now the HSA is blocked — {health.hsaBlockedBy}.
          {health.hsaExcess > 0 && <> {fmtCur(health.hsaExcess)} of stated HSA contributions would be
            <strong> excess contributions</strong>, taxed at 6% a year until withdrawn.</>}
        </div>
      )}
      <div style={{display:'flex',gap:12,flexWrap:'wrap'}}>
        {side(decision.keepFsa, decision.winner === 'fsa')}
        {side(decision.takeHsa, decision.winner === 'hsa')}
      </div>
      {value.spouseFicaMarginalPct > value.ficaMarginalPct && (
        <div style={{marginTop:12,padding:'9px 11px',background:'rgba(59,130,246,0.08)',
          border:'1px solid rgba(59,130,246,0.25)',borderRadius:6,fontSize:11,color:'#94a3b8',lineHeight:1.65}}>
          <strong style={{color:'#3b82f6'}}>Which paycheck matters.</strong> Your wages pass the Social Security wage
          base partway through the year, so a pre-tax dollar taken from your paycheck in December saves
          {' '}{value.ficaMarginalPct.toFixed(2)}% in payroll tax. Hers never reach the base, so the same dollar taken
          from her paycheck saves {value.spouseFicaMarginalPct.toFixed(2)}% — a
          {' '}{(value.spouseFicaMarginalPct - value.ficaMarginalPct).toFixed(2)} point difference on every dollar,
          which is why an HSA on her State plan is worth more than the identical HSA on yours would be. It only
          applies to money routed through payroll: a bank transfer into the HSA gets the income-tax deduction and
          none of this.
        </div>
      )}
      <div style={{marginTop:10,fontSize:11,color:'#475569',lineHeight:1.6}}>{decision.caveat}</div>
    </div>
  );
}

// ── Tax returns ───────────────────────────────────────────────────────────
//
// Upload a filed 1040, review what was read off it, then save. Three
// deliberate choices:
//
//   The PDF is never stored. A 1040 carries both SSNs, the kids' SSNs and
//   bank details; none of that is needed to compute a safe harbour, and the
//   dozen numbers that are needed carry no identifiers at all.
//
//   Nothing saves automatically. The extraction is shown next to the
//   arithmetic checks the server ran on it, and a human presses save. A
//   misread line 24 is indistinguishable from a correct one once it is in the
//   database and feeding the safe-harbour target.
//
//   Blank fields stay blank. A null means "not readable" and is fixable by
//   asking; a plausible substitute is not.
// A capital loss carryforward drains at $3,000 a year against ordinary income
// and nothing else touches it — including, and this is the expensive wrong
// guess, a Roth conversion. Realising GAINS is the only thing that drains it,
// and losses offset gains with no cap at all.
//
// The card exists because the two numbers live in different places and nobody
// puts them side by side: the carryforward is on a worksheet inside last
// year's return, the unrealised gain is on a brokerage screen.
function GainHarvestCard({ harvest, gainsRate, capitalLoss, year }) {
  if (!harvest) return null;
  const h = harvest;
  const nothing = h.harvest <= 0;
  const leftAfter = Math.max(0, h.unspent - h.harvest);
  return (
    <div className="card" style={{marginBottom:16}}>
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',flexWrap:'wrap',gap:8,marginBottom:6}}>
        <div style={{fontWeight:600}}>Draining the capital loss carryforward</div>
        {gainsRate && (
          <Chip tone="blue">
            gains taxed at {pct1(gainsRate.combinedPct)}
            {gainsRate.niitPct > 0 ? ` · ${gainsRate.federalPct}% + NIIT + DE` : ` · ${gainsRate.federalPct}% + DE`}
          </Chip>
        )}
      </div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:12,lineHeight:1.65}}>
        Losses offset capital <em>gains</em> dollar for dollar with no cap. Only ordinary income is rationed,
        to {fmtCur(3000)} a year — a figure unindexed since 1978. So a conversion, a bonus or a raise is
        sheltered by none of this; realising a gain is.
      </div>

      <Row label="Carryforward" value={fmtCur(h.carryforward)} strong/>
      {h.alreadyRealised > 0 && <Row label="Less gains already realised this year" value={`−${fmtCur(h.alreadyRealised)}`} indent/>}
      <Row label={`Held back for the ${year} ordinary deduction`} value={`−${fmtCur(h.reserved)}`} indent
        note="worth more here than against a gain"/>
      <Row label="Gain that can be realised at zero tax" value={fmtCur(h.headroom)} strong color="#10b981"/>
      <Row label="Unrealised gain on file in taxable accounts" value={fmtCur(h.available)} indent
        note={h.coverage.complete ? null : `${pct1(h.coverage.pct)} of holdings priced`}/>
      <hr className="divider" style={{margin:'10px 0'}}/>
      <Row label="Harvest this year" value={fmtCur(h.harvest)} strong color={nothing ? '#94a3b8' : '#10b981'}/>
      <Row label="Tax avoided on that gain" value={fmtCur(h.taxAvoided)} indent/>
      <Row label={`Still carrying forward into ${year + 1}`} value={fmtCur(leftAfter)} indent/>

      {!nothing && h.positions.length > 0 && (
        <div style={{marginTop:12}}>
          <div style={{fontSize:11,color:'#94a3b8',marginBottom:6,fontWeight:600}}>Sell, largest gain first</div>
          <div style={{display:'flex',flexDirection:'column',gap:6}}>
            {h.positions.map((pos, i) => (
              <div key={`${pos.ticker || pos.name}-${i}`}
                style={{display:'flex',gap:10,alignItems:'baseline',flexWrap:'wrap',
                  padding:'7px 10px',borderRadius:6,background:'#0f1520',border:'1px solid #1e2a3a'}}>
                <span style={{minWidth:64,fontWeight:700,color:'#e2e8f0',fontSize:12}}>{pos.ticker || pos.name}</span>
                <span style={{fontSize:11,color:'#94a3b8'}}>
                  sell {fmtCur(pos.sellValue)} to realise <strong style={{color:'#10b981'}}>{fmtCur(pos.harvestGain)}</strong> of gain
                </span>
                {pos.partial && <Chip tone="amber">partial — {pct1((pos.harvestGain / pos.gain) * 100)} of the position</Chip>}
                {pos.account && <span style={{fontSize:10,color:'#475569',marginLeft:'auto'}}>{pos.account}</span>}
              </div>
            ))}
          </div>
        </div>
      )}

      {!nothing && (
        <div style={{marginTop:12,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.7,
          background:'rgba(16,185,129,0.08)',border:'1px solid rgba(16,185,129,0.25)',color:'#94a3b8'}}>
          <strong style={{color:'#10b981'}}>There is no wash-sale rule on gains.</strong>{' '}
          §1091 disallows repurchased <em>losses</em> only, so each position can be bought back the same minute —
          same ticker, same share count. Market exposure never changes; the basis steps up permanently, which is
          real tax saved on a future sale rather than a timing shuffle.
          <div style={{marginTop:6}}>
            The last {fmtCur(h.reserved)} stays put on purpose: aimed at ordinary income it is worth{' '}
            <strong style={{color:'#e2e8f0'}}>{fmtCur(h.reservedWorthAsOrdinary)}</strong> at {pct1(h.ordinaryRatePct)},
            against <strong style={{color:'#e2e8f0'}}>{fmtCur(h.reservedWorthAsGains)}</strong> aimed at a gain.
            It is the most valuable part of the carryforward.
          </div>
        </div>
      )}

      {nothing && (
        <div style={{marginTop:12,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.7,
          background:'rgba(100,116,139,0.1)',border:'1px solid #1e2a3a',color:'#94a3b8'}}>
          {h.headroom <= 0
            ? `The carryforward is at or under the ${fmtCur(3000)} cap, so it is fully used by this year's ordinary deduction — there is nothing left to harvest against.`
            : `No unrealised gain in a taxable account to harvest. Gains inside a 401(k), IRA, Roth, HSA or 529 are not capital gains at all — selling in there does nothing for the carryforward.`}
        </div>
      )}

      <div style={{marginTop:10,fontSize:10.5,color:'#475569',lineHeight:1.7}}>
        Two things to confirm at the broker before selling, neither of which is in any feed this app reads:
        which lots are <strong>long-term</strong> (short-term gain nets against short-term loss first, and the
        character of the carryforward matters), and that the <strong>trade date</strong> falls inside {year} —
        settlement in January still counts for {year}, but a trade on Jan 2 does not.
        {!h.coverage.complete && (
          <> Cost basis is missing on {fmtCur(h.coverage.unknownBasisValue)} of holdings; those are excluded rather
          than counted as pure gain, so the harvestable figure above is a floor.</>
        )}
      </div>
    </div>
  );
}

// Payroll tax is three separate taxes with three different bases, and the
// single total hides the one that changes behaviour: the Social Security cap
// is PER EARNER. A household line cannot say that one paycheck stopped paying
// 6.2% in September while the other never will — which is exactly what
// decides whose payroll a §125 dollar should come off.
//
// It also explains the line's stubbornness. FICA wages are gross less §125
// only, so 401(k)/403(b) deferrals and the §414(h) pension pick-up are all
// taxable here. Deferring more moves federal and Delaware and barely touches
// this.
function PayrollBreakdown({ fica, year, estimated }) {
  if (!fica || !fica.earners) return null;
  const r = fica.rates || {};
  return (
    <div className="card" style={{marginBottom:16}}>
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',flexWrap:'wrap',gap:8,marginBottom:6}}>
        <div style={{fontWeight:600}}>Payroll tax, in its three parts</div>
        <Chip tone={estimated ? 'amber' : 'slate'}>
          {estimated ? `${year} wage base estimated` : `${year} wage base ${fmtCur(fica.wageBase)}`}
        </Chip>
      </div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:12,lineHeight:1.65}}>
        Your half only — each employer pays a matching {r.socialSecurityPct}% + {r.medicarePct}% that appears nowhere on
        this page. The base is gross pay less <strong style={{color:'#94a3b8'}}>§125 items only</strong> (premiums, FSA,
        HSA through payroll). 401(k) and 403(b) deferrals are payroll-taxable going in, and so is the §414(h) pension
        pick-up, which is federally exempt but not exempt here — which is why this line barely moves when pre-tax
        contributions rise.
      </div>

      <div style={{fontSize:10,fontWeight:600,color:'#94a3b8',textTransform:'uppercase',letterSpacing:0.5,marginBottom:6}}>
        Social Security · {r.socialSecurityPct}% · capped per earner, not per household
      </div>
      {fica.earners.map(e => (
        <div key={e.key} style={{marginBottom:8,padding:'8px 10px',borderRadius:6,background:'#0f1520',border:'1px solid #1e2a3a'}}>
          <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',gap:10,flexWrap:'wrap'}}>
            <span style={{color:'#e2e8f0',fontWeight:600,fontSize:12}}>{e.label}</span>
            <span style={{display:'flex',gap:6,alignItems:'center'}}>
              <Chip tone={e.measured ? 'green' : 'amber'}>{e.measured ? 'measured from payslips' : 'modelled from stated salary'}</Chip>
              {e.capped && <Chip tone="blue">cap reached</Chip>}
            </span>
          </div>
          <div style={{fontSize:11,color:'#94a3b8',marginTop:5,lineHeight:1.7}}>
            {fmtCur(e.wages)} of payroll-taxable wages
            {e.cafeteriaExcluded > 0 && <> (gross less {fmtCur(e.cafeteriaExcluded)} of §125)</>}
            {' · '}
            {e.capped
              ? <>charged on the first {fmtCur(e.socialSecurityWages)} — {fmtCur(e.overCapWages)} above the cap pays no Social Security at all</>
              : <>{fmtCur(e.roomToCap)} of headroom before the cap</>}
          </div>
          <div style={{display:'flex',justifyContent:'space-between',marginTop:5,fontSize:12}}>
            <span style={{color:'#64748b'}}>{fmtCur(e.socialSecurityWages)} × {r.socialSecurityPct}%</span>
            <strong style={{color:'#e2e8f0'}}>{fmtCur(e.socialSecurity)}</strong>
          </div>
        </div>
      ))}

      <hr className="divider" style={{margin:'10px 0'}}/>
      <Row label={`Medicare · ${r.medicarePct}% on all ${fmtCur(fica.combinedWages)} of wages, no cap`}
        value={fmtCur(fica.medicare)}/>
      <Row label={`Additional Medicare · ${r.additionalMedicarePct}% over ${fmtCur(fica.additionalMedicareThreshold)} combined`}
        value={fica.additionalMedicareApplies ? fmtCur(fica.additionalMedicare) : '—'}
        note={fica.additionalMedicareApplies
          ? `on ${fmtCur(fica.additionalMedicareBase)}`
          : `${fmtCur(fica.additionalMedicareThreshold - fica.combinedWages)} below the threshold`}/>
      <hr className="divider" style={{margin:'10px 0'}}/>
      <Row label="Payroll tax" value={fmtCur(fica.total)} strong color="#ef4444"/>
      <Row label="Effective rate on payroll-taxable wages"
        value={pct1(fica.combinedWages > 0 ? (fica.total / fica.combinedWages) * 100 : 0)} indent
        note={`against a headline ${(r.socialSecurityPct + r.medicarePct).toFixed(2)}%`}/>

      {fica.additionalMedicareApplies && (
        <div style={{marginTop:10,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.65,
          background:'rgba(100,116,139,0.1)',border:'1px solid #1e2a3a',color:'#94a3b8'}}>
          The {fmtCur(fica.additionalMedicareThreshold)} threshold is <strong style={{color:'#e2e8f0'}}>joint</strong> and
          has not been indexed since 2013 — but payroll withholds the extra {r.additionalMedicarePct}% once a
          <em> single</em> job passes $200,000. A two-income household therefore settles the difference on the return
          in one direction or the other most years; it is a projection input, not a surprise.
        </div>
      )}
      {fica.earners.some(e => !e.measured) && (
        <div style={{marginTop:10,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.65,
          background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.3)',color:'#fbbf24'}}>
          {fica.earners.filter(e => !e.measured).map(e => e.label).join(' and ')}
          {' '}sits on a stated salary rather than statements. The cap turns on the exact wage figure, so a salary that
          misses stipends, supplements or a mid-year step moves this line directly — and a modelled paycheck also
          means the withholding beside it is modelled from a naive W-4, which under-withholds two-income households
          by design. Upload those payslips and both stop being estimates.
        </div>
      )}
    </div>
  );
}

// WHEN to defer, which the funding table never answered. Two forces pull
// opposite ways — front-loading buys market time, and without an annual
// true-up it forfeits the match on every check after the cap is hit — so the
// true-up flag decides it. The two effects are shown in separate columns on
// purpose: a forfeited match is arithmetic and the growth edge is a forecast,
// and putting them in one "total" would let an assumed return talk someone
// out of guaranteed money.
function DeferralTiming({ sched, year }) {
  if (!sched || !sched.target) return null;
  const rec = sched.strategies.find(st => st.key === sched.recommended);
  const shown = sched.strategies.filter(st => !st.duplicateOf);
  const dupes = sched.strategies.filter(st => st.duplicateOf);
  return (
    <div className="card" style={{marginBottom:16}}>
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',flexWrap:'wrap',gap:8,marginBottom:6}}>
        <div style={{fontWeight:600}}>Phasing the 401(k) across {year} — incentive vs salary, early vs level</div>
        <Chip tone={sched.matchTrueUp ? 'green' : 'amber'}>
          {sched.matchTrueUp ? 'match trued up annually' : 'NO true-up'}
        </Chip>
      </div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:12,lineHeight:1.65}}>
        Same {fmtCur(sched.target)} either way — this is only about which checks it comes off.
        {sched.bonusMonth
          ? ` The incentive lands on check ${sched.bonusPeriod} of ${sched.periods} (month ${sched.bonusMonth}), which is early enough to matter.`
          : ' No incentive month is set, so the incentive is assumed to land on the first check.'}
      </div>

      <div className="table-wrap">
        <table>
          <thead>
            <tr>
              <th>Strategy</th>
              <th style={{textAlign:'right'}}>From incentive</th>
              <th style={{textAlign:'right'}}>From salary</th>
              <th style={{textAlign:'right'}}>Heaviest regular check</th>
              <th style={{textAlign:'right'}}>Match earned</th>
              <th style={{textAlign:'right'}}>Growth edge</th>
            </tr>
          </thead>
          <tbody>
            {shown.map(st => {
              const isRec = st.key === sched.recommended;
              return (
                <tr key={st.key} style={isRec ? {background:'rgba(16,185,129,0.07)'} : null}>
                  <td style={{color:'#e2e8f0',fontWeight:600}}>
                    {st.label} {isRec && <Chip tone="green">recommended</Chip>}
                    <div style={{fontSize:10,color:'#475569',marginTop:2}}>{st.note}</div>
                    {!st.reachesTarget && (
                      <div style={{fontSize:10,color:'#f59e0b',marginTop:2}}>
                        ⚠ falls {fmtCur(st.shortfall)} short at a {sched.maxDeferralPct}% per-check ceiling
                      </div>
                    )}
                  </td>
                  <td style={{textAlign:'right',color:'#94a3b8'}}>{st.fromBonus > 0 ? fmtCur(st.fromBonus) : '—'}</td>
                  <td style={{textAlign:'right',color:'#94a3b8'}}>{st.fromSalary > 0 ? fmtCur(st.fromSalary) : '—'}</td>
                  <td style={{textAlign:'right',color:'#94a3b8'}}>
                    {st.heaviestRegularCheck > 0 ? <>{fmtCur(st.heaviestRegularCheck)}<div style={{fontSize:10,color:'#475569'}}>{st.heaviestRegularPct}% of the check</div></> : '—'}
                  </td>
                  <td style={{textAlign:'right',color: st.matchForfeited > 0 ? '#ef4444' : '#10b981',fontWeight:600}}>
                    {fmtCur(st.matchEarned)}
                    {st.matchForfeited > 0 && <div style={{fontSize:10,color:'#ef4444'}}>−{fmtCur(st.matchForfeited)} forfeited</div>}
                  </td>
                  <td style={{textAlign:'right',color:'#64748b'}}>{fmtCur(st.growthEdge)}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      {dupes.length > 0 && (
        <div style={{fontSize:10,color:'#475569',marginTop:6}}>
          {dupes.map(d => `"${d.label}" is the same schedule as "${(sched.strategies.find(x => x.key === d.duplicateOf) || {}).label}" at this incentive size`).join('; ')} — not shown twice.
        </div>
      )}

      {rec && (
        <div style={{marginTop:12,padding:'10px 12px',borderRadius:6,fontSize:11.5,lineHeight:1.7,
          background:'rgba(16,185,129,0.08)',border:'1px solid rgba(16,185,129,0.25)',color:'#94a3b8'}}>
          <strong style={{color:'#10b981'}}>{rec.label}.</strong> {sched.reason}
          <div style={{marginTop:6}}>
            In payroll terms: elect{' '}
            {rec.fromBonus > 0 && <><strong style={{color:'#e2e8f0'}}>{rec.bonusPct}%</strong> on the incentive ({fmtCur(rec.fromBonus)})</>}
            {rec.fromBonus > 0 && rec.fromSalary > 0 && ' and '}
            {rec.fromSalary > 0 && <><strong style={{color:'#e2e8f0'}}>{rec.heaviestRegularPct}%</strong> on regular pay ({fmtCur(rec.heaviestRegularCheck)} a check)</>}
            {rec.fromSalary <= 0 && ', and nothing at all off the monthly budget'}.
            {rec.capReachedMonth && <> The §402(g) cap is reached in month {rec.capReachedMonth}.</>}
          </div>
          {rec.fromSalary > 0 && (
            <div style={{marginTop:6,color:'#64748b'}}>
              A regular paycheck shrinks by about {fmtCur(rec.heaviestRegularNetCost)} at the heaviest point of this
              schedule — the deferral costs less than its face amount, because {pct1(sched.marginalRatePct)} of it would
              have gone to tax anyway.
            </div>
          )}
        </div>
      )}

      <div style={{marginTop:10,fontSize:10.5,color:'#475569',lineHeight:1.7}}>
        {sched.growthCaveat}
        {!sched.bonusDeferralAllowed && ' Your plan is set to exclude the incentive from deferral, so the incentive-first strategies are unavailable — check the SPD, because plenty of plans allow it.'}
        {' '}The per-check ceiling is taken as {sched.maxDeferralPct}%; if payroll allows more, front-loading reaches the cap sooner still.
      </div>
    </div>
  );
}

// Roth conversions on the break-even framing rather than the bracket
// comparison. The folk rule — convert if your future rate will be higher —
// is wrong in the expensive direction: it ignores that paying the tax from a
// TAXABLE account moves money out of an account that is taxed every year into
// one that never is, which is worth something regardless of any rate change.
// See lib/rothConversion.js for the algebra and the Vanguard check.
function ConversionAnalysis({ analysis, conversion, year }) {
  if (!analysis) return null;
  const { outside, inside, verdict: v, windows, sizing, dragPreset, expectedFutureRatePct } = analysis;
  const good = v.call === 'convert';
  const soon = v.call === 'convertLater';
  const tone = good ? 'green' : soon || v.call === 'close' ? 'amber' : 'slate';
  const bg = good ? 'rgba(16,185,129,0.08)' : soon || v.call === 'close' ? 'rgba(245,158,11,0.1)' : 'rgba(100,116,139,0.1)';
  const bd = good ? 'rgba(16,185,129,0.25)' : soon || v.call === 'close' ? 'rgba(245,158,11,0.3)' : '#1e2a3a';
  const qualityTone = { best: 'green', good: 'green', fair: 'amber', conflicted: 'red', poor: 'slate' };
  const qualityLabel = {
    best: 'best window', good: 'the window you have', fair: 'workable, with a catch',
    conflicted: 'cheap on tax, expensive on subsidy', poor: 'expensive',
  };

  return (
    <div className="card" style={{marginBottom:16}}>
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',flexWrap:'wrap',gap:8,marginBottom:6}}>
        <div style={{fontWeight:600}}>Roth conversions — the break-even rate, and when to act</div>
        <Chip tone={tone}>{v.headline}</Chip>
      </div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:14,lineHeight:1.65}}>
        "Convert if your future rate is higher" is the wrong test, and it is wrong in the direction that costs money.
        Paying the tax from a <strong style={{color:'#94a3b8'}}>taxable</strong> account moves dollars out of an account
        taxed every year into one that never is — worth something on its own, whatever happens to rates. So the future
        rate at which converting breaks even sits <em>below</em> today's rate.
      </div>

      <div className="grid-2" style={{gap:12,marginBottom:12}}>
        <div style={{padding:'10px 12px',borderRadius:6,background:'#0f1520',border:'1px solid rgba(16,185,129,0.25)'}}>
          <div className="label" style={{marginBottom:4,fontSize:10}}>Pay the tax from savings</div>
          <div className="val-md" style={{color:'#10b981'}}>{outside.betrPct}%</div>
          <div style={{fontSize:11,color:'#64748b',marginTop:4,lineHeight:1.6}}>
            break-even future rate — <strong style={{color:'#10b981'}}>{outside.advantagePct} points below</strong> the
            {' '}{outside.currentRatePct}% you would pay converting today
          </div>
        </div>
        <div style={{padding:'10px 12px',borderRadius:6,background:'#0f1520',border:'1px solid rgba(239,68,68,0.25)'}}>
          <div className="label" style={{marginBottom:4,fontSize:10}}>Withhold the tax from the IRA</div>
          <div className="val-md" style={{color:'#ef4444'}}>{inside.betrPct}%</div>
          <div style={{fontSize:11,color:'#64748b',marginTop:4,lineHeight:1.6}}>
            {inside.penaltyApplies
              ? <>worse than a wash — under 59½ the withheld dollars are themselves a distribution and carry the 10% penalty</>
              : <>exactly today's rate. The entire advantage comes from moving money out of a taxed account, and withholding moves none</>}
          </div>
        </div>
      </div>

      <div style={{padding:'10px 12px',borderRadius:6,fontSize:11.5,lineHeight:1.7,marginBottom:12,
        background:bg,border:`1px solid ${bd}`,color:'#94a3b8'}}>
        <strong style={{color: good ? '#10b981' : soon || v.call === 'close' ? '#fbbf24' : '#e2e8f0'}}>{v.headline}.</strong>{' '}
        {v.detail}
      </div>

      {sizing && (
        <>
          <Row label={`Room to the top of the ${conversion.ceilingRate}% bracket`} value={fmtCur(conversion.room)} strong/>
          <Row label="Blended rate if that room were filled" value={pct1(sizing.blendedPct)} indent
            note={`marginal is ${pct1(sizing.marginalPct)} — the first dollar's rate, not the whole conversion's`}/>
          <Row label="Tax on it, federal + Delaware" value={fmtCur(sizing.totalCost)} indent/>
        </>
      )}

      <div style={{marginTop:16,paddingTop:16,borderTop:'1px solid #1e2a3a'}}>
        <ConversionValueChart lifetime={analysis.lifetime} amount={analysis.amount}/>
        <RunwayChart ladder={analysis.ladder} windows={windows}/>
        <DrainChart drain={analysis.drain} retireAge={windows.retireAge} rmdAge={windows.rmdAge}/>
      </div>

      <div style={{marginTop:18}}>
        <div style={{fontSize:10,fontWeight:600,color:'#94a3b8',textTransform:'uppercase',letterSpacing:0.5,marginBottom:8}}>
          Now vs later — {windows.runwayYears} years of runway before RMDs decide for you
        </div>
        <div style={{display:'flex',flexDirection:'column',gap:8}}>
          {windows.windows.map(w => (
            <div key={w.key} style={{padding:'8px 10px',borderRadius:6,background:'#0f1520',
              border:`1px solid ${w.quality === 'best' || w.quality === 'good' ? 'rgba(16,185,129,0.3)'
                : w.quality === 'conflicted' ? 'rgba(239,68,68,0.3)' : '#1e2a3a'}`}}>
              <div style={{display:'flex',gap:10,alignItems:'baseline',flexWrap:'wrap'}}>
                <span style={{minWidth:74,fontWeight:700,color:'#e2e8f0',fontSize:12}}>
                  {w.toAge ? `Age ${w.fromAge}–${w.toAge}` : `Age ${w.fromAge}+`}
                </span>
                <span style={{fontSize:12,color:'#e2e8f0',fontWeight:600}}>{w.label}</span>
                <Chip tone={qualityTone[w.quality]}>{qualityLabel[w.quality]}</Chip>
              </div>
              <div style={{fontSize:11,color:'#64748b',marginTop:5,lineHeight:1.7}}>
                <strong style={{color:'#94a3b8'}}>Rate driven by:</strong> {w.rateDriver}. {w.why}
              </div>
            </div>
          ))}
        </div>
      </div>

      {conversion.carryforwardNote && (
        <div style={{marginTop:12,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.65,
          background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.3)',color:'#fbbf24'}}>
          {conversion.carryforwardNote}
        </div>
      )}

      <div style={{marginTop:12,fontSize:10.5,color:'#475569',lineHeight:1.7}}>
        Break-even assumes {outside.growthPct}% growth over {outside.horizonYears} years, {outside.taxDragPct}% annual
        tax drag on the account paying the bill ({dragPreset ? dragPreset.label.toLowerCase() : 'custom'}
        {dragPreset ? ` — ${dragPreset.detail}` : ''}), and {outside.ltcgRatePct}% on liquidating it. A future rate of
        {' '}{expectedFutureRatePct}% is your own assumption from Assumptions, not a forecast. Basis is deliberately
        absent from the break-even: carried through both scenarios it cancels — it lowers the dollar cost and clears
        the pro-rata contamination on future backdoor Roths, but it does not move the rate at which converting pays.
      </div>
    </div>
  );
}

// A shared tooltip body, so every chart on this page reads the same way.
function VizTooltip({ active, payload, label, labelFmt, rows }) {
  if (!active || !payload || !payload.length) return null;
  return (
    <div style={{background:'#0b0f17',border:'1px solid #1e2a3a',borderRadius:6,padding:'8px 10px',fontSize:11,lineHeight:1.7}}>
      <div style={{color:'#e2e8f0',fontWeight:700,marginBottom:4}}>{labelFmt ? labelFmt(label, payload) : label}</div>
      {(rows ? rows(payload) : payload.map(p => ({ key: p.dataKey, name: p.name, value: fmtCur(p.value), color: p.color }))).map((r, i) => (
        <div key={r.key || i} style={{display:'flex',justifyContent:'space-between',gap:14}}>
          <span style={{color:'#94a3b8',display:'flex',alignItems:'center',gap:6}}>
            {r.color && <span style={{width:8,height:8,borderRadius:2,background:r.color,display:'inline-block'}}/>}
            {r.name}
          </span>
          <strong style={{color:'#e2e8f0'}}>{r.value}</strong>
        </div>
      ))}
    </div>
  );
}

// THE FIFTEEN-YEAR PICTURE. Both paths hold the same dollars in the same
// investments; the only difference is which wrapper the growth happens inside
// and who has already been paid. Converting starts BEHIND by the whole tax
// bill — it has paid the IRS and bought nothing yet — and the crossover is
// where compounding in a wrapper that is never taxed overtakes that head
// start. Drawing it is the only way to answer "how long until this pays off",
// which is the question a break-even RATE cannot address.
function ConversionValueChart({ lifetime, amount }) {
  if (!lifetime || !lifetime.rows.length) return null;
  const data = lifetime.rows;
  const cross = lifetime.crossoverYear;
  return (
    <div>
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',flexWrap:'wrap',gap:8,marginBottom:4}}>
        <div style={{fontSize:12,fontWeight:600,color:'#e2e8f0'}}>
          After-tax value of {fmtCur(amount)} converted, both ways
        </div>
        <div style={{display:'flex',gap:12,fontSize:10.5,color:'#94a3b8'}}>
          <span style={{display:'flex',alignItems:'center',gap:5}}>
            <span style={{width:10,height:2,background:SERIES.convert,display:'inline-block'}}/>Convert now
          </span>
          <span style={{display:'flex',alignItems:'center',gap:5}}>
            <span style={{width:10,height:2,background:SERIES.dont,display:'inline-block'}}/>Leave it alone
          </span>
        </div>
      </div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:8,lineHeight:1.6}}>
        Same dollars, same investments — only the wrapper differs. Converting starts {fmtCur(Math.abs(data[0].advantage))} behind,
        because the tax is paid on day one and has bought nothing yet.
      </div>
      <div style={{height:220}}>
        <ResponsiveContainer width="100%" height="100%">
          <LineChart data={data} margin={{top:6,right:12,left:0,bottom:0}}>
            <CartesianGrid stroke="#1e2a3a" strokeDasharray="2 4" vertical={false}/>
            <XAxis dataKey="year" {...AXIS} tickLine={false}
              label={{ value: 'years from now', position: 'insideBottomRight', offset: -2, fill: '#475569', fontSize: 10 }}/>
            <YAxis {...AXIS} tickLine={false} width={54} tickFormatter={compact}/>
            <RTooltip content={<VizTooltip labelFmt={(l) => `Year ${l}`}
              rows={(pl) => {
                const d = pl[0] && pl[0].payload;
                return [
                  { key: 'c', name: 'Convert now', value: fmtCur(d.convert), color: SERIES.convert },
                  { key: 'd', name: 'Leave it alone', value: fmtCur(d.dont), color: SERIES.dont },
                  { key: 'a', name: d.advantage >= 0 ? 'Ahead by' : 'Behind by', value: fmtCur(Math.abs(d.advantage)) },
                ];
              }}/>}/>
            {cross != null && (
              <ReferenceLine x={cross} stroke="#475569" strokeDasharray="3 3"
                label={{ value: `breaks even, year ${cross}`, fill: '#94a3b8', fontSize: 10, position: 'top' }}/>
            )}
            <Line type="monotone" dataKey="dont" stroke={SERIES.dont} strokeWidth={2} dot={false} name="Leave it alone"/>
            <Line type="monotone" dataKey="convert" stroke={SERIES.convert} strokeWidth={2} dot={false} name="Convert now"/>
          </LineChart>
        </ResponsiveContainer>
      </div>
      <div style={{marginTop:8,fontSize:11,lineHeight:1.7,color:'#94a3b8'}}>
        {lifetime.crosses
          ? <>Converting is behind for <strong style={{color:'#e2e8f0'}}>{cross} years</strong> and ahead after — by{' '}
              <strong style={{color:'#10b981'}}>{fmtCur(lifetime.finalAdvantage)}</strong> at year {lifetime.years}.
              That is the whole trade: a bill today for growth that is never taxed again.</>
          : <>It never breaks even inside {lifetime.years} years — still{' '}
              <strong style={{color:'#f59e0b'}}>{fmtCur(Math.abs(lifetime.finalAdvantage))}</strong> behind at the end.
              Either the horizon is too short or the rate today is too high; the runway below is where that changes.</>}
        {lifetime.dragAvoided > 0 && (
          <> Along the way the taxable account hands over <strong style={{color:'#e2e8f0'}}>{fmtCur(lifetime.dragAvoided)}</strong> in
          annual tax on its own dividends in the leave-it-alone path — the leak nobody sees on a statement, and the reason
          the break-even is not simply a bracket comparison.</>
        )}
      </div>
    </div>
  );
}

// THE RUNWAY. Headroom is the space between the income a year already has and
// the top of the bracket being filled, which is why a large raise does not
// merely cost more tax — it CLOSES the window. Drawing income and headroom as
// one stacked bar against the bracket line makes that a picture rather than a
// sentence: the bar reaching the line means there is no room left.
function RunwayChart({ ladder, windows }) {
  if (!ladder || !ladder.rows.length) return null;
  const data = ladder.rows;
  const bands = (windows.windows || [])
    .filter(w => w.key !== 'rmd')
    .map(w => ({ ...w, from: Math.max(w.fromAge, data[0].age), to: Math.min(w.toAge == null ? data[data.length-1].age : w.toAge, data[data.length-1].age + 1) }))
    .filter(w => w.to > w.from);
  return (
    <div style={{marginTop:18}}>
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',flexWrap:'wrap',gap:8,marginBottom:4}}>
        <div style={{fontSize:12,fontWeight:600,color:'#e2e8f0'}}>
          Every year to {windows.rmdAge}: income, and the room left under the {ladder.ceilingRate}% ceiling
        </div>
        <div style={{display:'flex',gap:12,fontSize:10.5,color:'#94a3b8'}}>
          <span style={{display:'flex',alignItems:'center',gap:5}}>
            <span style={{width:10,height:10,borderRadius:2,background:SERIES.dont,display:'inline-block'}}/>Taxable income
          </span>
          <span style={{display:'flex',alignItems:'center',gap:5}}>
            <span style={{width:10,height:10,borderRadius:2,background:SERIES.convert,display:'inline-block'}}/>Room to convert
          </span>
        </div>
      </div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:8,lineHeight:1.6}}>
        Where the bar reaches the line there is no room left. A raise does not just raise the tax bill — it removes the option.
      </div>
      <div style={{height:230}}>
        <ResponsiveContainer width="100%" height="100%">
          <ComposedChart data={data} margin={{top:14,right:12,left:0,bottom:0}}>
            <CartesianGrid stroke="#1e2a3a" strokeDasharray="2 4" vertical={false}/>
            {bands.map(b => (
              <ReferenceArea key={b.key} x1={b.from} x2={b.to} ifOverflow="extendDomain"
                fill={b.quality === 'poor' ? '#64748b' : b.quality === 'conflicted' ? '#ef4444' : '#10b981'}
                fillOpacity={b.quality === 'poor' ? 0.05 : 0.09}
                label={{ value: b.key === 'working' ? 'working' : b.key === 'gap' ? 'retired'
                           : b.key === 'aca' ? 'ACA cliff years' : 'Medicare watching',
                         fill: '#475569', fontSize: 9, position: 'insideTop' }}/>
            ))}
            <XAxis dataKey="age" {...AXIS} tickLine={false}
              label={{ value: 'age', position: 'insideBottomRight', offset: -2, fill: '#475569', fontSize: 10 }}/>
            <YAxis {...AXIS} tickLine={false} width={54} tickFormatter={compact}/>
            <RTooltip content={<VizTooltip labelFmt={(l, pl) => {
                const d = pl[0] && pl[0].payload;
                return `Age ${l} · ${d.year}`;
              }}
              rows={(pl) => {
                const d = pl[0] && pl[0].payload;
                return [
                  { key: 'i', name: 'Taxable income', value: fmtCur(d.taxableIncome), color: SERIES.dont },
                  { key: 'h', name: d.overCeiling ? 'Over the ceiling' : 'Room to convert',
                    value: d.overCeiling ? 'none' : fmtCur(d.headroom), color: SERIES.convert },
                  ...(d.headroom > 0 ? [{ key: 't', name: `Tax to fill it (${d.blendedPct}%)`, value: fmtCur(d.tax) }] : []),
                ];
              }}/>}/>
            {ladder.ceiling != null && (
              <ReferenceLine y={ladder.ceiling} stroke="#94a3b8" strokeDasharray="4 4"
                label={{ value: `top of ${ladder.ceilingRate}%`, fill: '#94a3b8', fontSize: 10, position: 'right' }}/>
            )}
            <Bar dataKey="taxableIncome" stackId="a" fill={SERIES.dont} name="Taxable income"/>
            <Bar dataKey="headroom" stackId="a" fill={SERIES.convert} name="Room to convert" radius={[3,3,0,0]}/>
          </ComposedChart>
        </ResponsiveContainer>
      </div>
      <div style={{marginTop:8,fontSize:11,lineHeight:1.7,color:'#94a3b8'}}>
        {ladder.blockedYears > 0 && (
          <><strong style={{color:'#f59e0b'}}>{ladder.blockedYears} of these years have no room at all</strong> — income
          clears the {ladder.ceilingRate}% ceiling outright, so there is nothing to convert into. </>
        )}
        Across the whole runway there is <strong style={{color:'#e2e8f0'}}>{fmtCur(ladder.totalHeadroom)}</strong> of
        headroom, costing <strong style={{color:'#e2e8f0'}}>{fmtCur(ladder.totalTax)}</strong> to use in full. Filling
        every year is rarely right — it is the shape that matters, and where the shape opens up.
      </div>
    </div>
  );
}

// CAN THE RUNWAY DRAIN THE PILE. The plan is fifteen years at roughly a
// bracket a year; the balance compounds for the fifteen years BEFORE that
// window opens and keeps compounding during it. Those two quantities do not
// automatically meet, and the line not reaching zero is the whole finding —
// which is fixable now and not fixable then.
function DrainChart({ drain, retireAge, rmdAge }) {
  if (!drain || !drain.rows.length) return null;
  return (
    <div style={{marginTop:18}}>
      <div style={{fontSize:12,fontWeight:600,color:'#e2e8f0',marginBottom:4}}>
        Traditional balance, and whether {retireAge}–{rmdAge} can empty it
      </div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:8,lineHeight:1.6}}>
        Still contributing until {retireAge}, then converting {fmtCur(drain.conversionPerYear)} a year. The shaded band is
        the conversion window.
      </div>
      <div style={{height:210}}>
        <ResponsiveContainer width="100%" height="100%">
          <ComposedChart data={drain.rows} margin={{top:14,right:12,left:0,bottom:0}}>
            <CartesianGrid stroke="#1e2a3a" strokeDasharray="2 4" vertical={false}/>
            <ReferenceArea x1={retireAge} x2={rmdAge} ifOverflow="extendDomain" fill="#10b981" fillOpacity={0.08}
              label={{ value: 'converting', fill:'#475569', fontSize: 9, position: 'insideTop' }}/>
            <XAxis dataKey="age" {...AXIS} tickLine={false}
              label={{ value: 'age', position: 'insideBottomRight', offset: -2, fill:'#475569', fontSize: 10 }}/>
            <YAxis {...AXIS} tickLine={false} width={54} tickFormatter={compact}/>
            <RTooltip content={<VizTooltip labelFmt={(l) => `Age ${l}`} rows={(pl) => {
              const d = pl[0] && pl[0].payload;
              return [
                { key: 'b', name: 'Traditional balance', value: fmtCur(d.balance), color: SERIES.dont },
                ...(d.converted > 0 ? [{ key: 'c', name: 'Converted this year', value: fmtCur(d.converted), color: SERIES.convert }] : []),
              ];
            }}/>}/>
            <Bar dataKey="converted" fill={SERIES.convert} name="Converted" radius={[3,3,0,0]}/>
            <Line type="monotone" dataKey="balance" stroke={SERIES.dont} strokeWidth={2} dot={false} name="Traditional balance"/>
          </ComposedChart>
        </ResponsiveContainer>
      </div>
      <div style={{marginTop:8,padding:'10px 12px',borderRadius:6,fontSize:11.5,lineHeight:1.75,
        background: drain.drained ? 'rgba(16,185,129,0.08)' : 'rgba(245,158,11,0.1)',
        border:`1px solid ${drain.drained ? 'rgba(16,185,129,0.25)' : 'rgba(245,158,11,0.3)'}`,color:'#94a3b8'}}>
        {drain.drained ? (
          <><strong style={{color:'#10b981'}}>The runway clears it — empty at {drain.emptiedAtAge}.</strong>{' '}
            Nothing is forced out at {rmdAge}, and the marginal pre-tax dollar today is still doing its job.</>
        ) : (
          <>
            <strong style={{color:'#fbbf24'}}>Fifteen years does not empty it.</strong>{' '}
            The balance reaches <strong style={{color:'#e2e8f0'}}>{fmtCur(drain.balanceAtRetirement)}</strong> at {retireAge}
            {' '}and <strong style={{color:'#e2e8f0'}}>{fmtCur(drain.leftAtRmd)}</strong> is still there at {rmdAge}, forcing a
            first RMD of about <strong style={{color:'#e2e8f0'}}>{fmtCur(drain.firstRmdApprox)}</strong> on top of pension and
            Social Security.
            <div style={{marginTop:6}}>
              Two ways to close it, and only one of them exists after you retire.
              Convert <strong style={{color:'#e2e8f0'}}>{fmtCur(drain.neededPerYear)}</strong> a year instead of{' '}
              {fmtCur(drain.conversionPerYear)} — {fmtCur(drain.shortfallPerYear)} more, which almost certainly means
              accepting a higher bracket than the one you were filling. Or defer about{' '}
              <strong style={{color:'#e2e8f0'}}>{fmtCur(drain.deferLessPerYear)}</strong> a year LESS pre-tax between now
              and {retireAge}, routing it to Roth and after-tax instead.
            </div>
            <div style={{marginTop:6,color:'#64748b'}}>
              This is not "stop deferring". The match is free money and the deduction is real at today's rate. It is that
              the <em>marginal</em> dollar — the elective above the match, and the mega-backdoor — now reads better as Roth
              than as traditional, because the pile is already larger than the runway can drain. A residue is not a
              failure either: it is taxed at whatever RMDs plus pension produce, and this number is the size of that
              exposure while there is still time to change it.
            </div>
          </>
        )}
      </div>
    </div>
  );
}

// ── Deduction ledger ──────────────────────────────────────────────────────
// Charitable gifts (and the odd medical/other deductible) recorded when they
// happen, one Firestore doc per calendar year, so filing season reads a
// total instead of reconstructing one from bank statements. All arithmetic
// and the substantiation flags live in lib/taxDeductions; this is the view.
//
// Suggestions come from the Monarch feed and are CONFIRMED, never auto-added
// — Monarch's "donation" can be a raffle ticket or a race entry. A confirmed
// row keeps the transaction id and a dismissed one is remembered on the year
// doc, so neither ever comes back as a suggestion.
function DeductionLedgerCard({ taxDeductions, saveTaxDeductions, transactions, currentYear, plan }) {
  const { show, Toast } = useToast();
  const TD = window.TaxDeductions;

  const localToday = () => {
    const d = new Date();
    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  };

  // The year being edited is the card's own state, not the page's tab: in
  // February you are still logging January gifts AND assembling last year's
  // total for the preparer.
  const storedYears = TD.yearsWithItems(taxDeductions);
  const yearOptions = [...new Set([currentYear, currentYear - 1, ...storedYears])].sort((a, b) => b - a);
  const [ledgerYear, setLedgerYear] = useState(currentYear);

  const yearDoc = (taxDeductions || {})[String(ledgerYear)] || {};
  const items = Array.isArray(yearDoc.items) ? yearDoc.items : [];
  const dismissed = Array.isArray(yearDoc.dismissed) ? yearDoc.dismissed : [];
  const rows = useMemo(() => TD.itemsForYear(items, ledgerYear), [items, ledgerYear]);
  const summary = useMemo(() => TD.summarizeYear(items, ledgerYear), [items, ledgerYear]);

  // Dedupe must see every year's rows — a December gift confirmed under one
  // year would otherwise be re-suggested when the picker sits on the other.
  const allItems = useMemo(() => Object.values(taxDeductions || {})
    .flatMap((d) => (Array.isArray(d.items) ? d.items : [])), [taxDeductions]);
  const candidates = useMemo(() => TD.findTransactionCandidates({ transactions, items: allItems, year: ledgerYear })
    .filter((c) => !c.txId || !dismissed.includes(c.txId)), [transactions, allItems, ledgerYear, dismissed]);

  const blankDraft = { date: localToday(), amount: '', org: '', category: 'charity_cash', receipt: false, note: '' };
  const [draft, setDraft] = useState(blankDraft);
  const setD = (k) => (v) => setDraft((d) => ({ ...d, [k]: v }));

  // True on success — a failed write must not fall through to the success
  // toast or clear the form the user would need to retry from.
  const persist = async (nextItems, nextDismissed) => {
    try { await saveTaxDeductions(ledgerYear, { items: nextItems, dismissed: nextDismissed ?? dismissed }); return true; }
    catch (e) { show(e.message, 'error'); return false; }
  };

  const addDraft = async () => {
    const item = TD.normalizeItem({ ...draft, amount: Number(draft.amount) });
    if (!item) { show('A gift needs a date and a positive amount', 'error'); return; }
    if (TD.yearOf(item) !== ledgerYear) { show(`That date is not in ${ledgerYear} — switch the year picker first`, 'error'); return; }
    if (!(await persist([...items, item]))) return;
    setDraft({ ...blankDraft, date: draft.date });
    show(`Added ${fmtCur(item.amount)} — ${ledgerYear} total ${fmtCur(TD.summarizeYear([...items, item], ledgerYear).total)}`);
  };

  const confirmCandidate = async (c) => {
    const item = TD.normalizeItem({ date: c.date, amount: c.amount, org: c.org,
      category: 'charity_cash', txId: c.txId, source: 'monarch' });
    if (!item) return;
    if (!(await persist([...items, item]))) return;
    show(`Added ${c.org} · ${fmtCur(c.amount)}`);
  };

  const dismissCandidate = async (c) => {
    if (!c.txId) return;
    await persist(items, [...dismissed, c.txId]);
  };

  const removeItem = async (item) => {
    if (!window.confirm(`Remove ${item.org || 'this entry'} · ${fmtCur(item.amount)} from the ${ledgerYear} ledger?`)) return;
    await persist(items.filter((it) => it.id !== item.id));
  };

  const toggleReceipt = async (item) => {
    await persist(items.map((it) => (it.id === item.id ? { ...it, receipt: !it.receipt } : it)));
  };

  const catLabel = (key) => (TD.CATEGORIES.find((c) => c.key === key) || {}).label || key;
  const std = ledgerYear === currentYear && plan ? plan.federal.stdDeduction : window.TaxConstants.forYear(ledgerYear).mfj.stdDeduction;

  return (
    <div className="card" style={{marginBottom:16}}>
      {Toast}
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',flexWrap:'wrap',gap:10,marginBottom:6}}>
        <div style={{fontWeight:600,fontSize:14}}>Deduction ledger — giving &amp; other write-offs</div>
        <div style={{display:'flex',background:'#0d1117',border:'1px solid #1e2a3a',borderRadius:8,overflow:'hidden'}}>
          {yearOptions.map((y) => (
            <button key={y} onClick={() => setLedgerYear(y)} style={{background: ledgerYear===y ? '#10b981' : 'transparent',
              color: ledgerYear===y ? '#fff' : '#64748b', fontWeight:600, fontSize:11, borderRadius:0, padding:'6px 12px'}}>{y}</button>
          ))}
        </div>
      </div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:14,lineHeight:1.65}}>
        Log gifts when they happen and April is a read-out. The {ledgerYear} standard deduction (MFJ) is
        <strong style={{color:'#94a3b8'}}> {fmtCur(std)}</strong> — you deduct giving on Schedule A only if the whole of
        it (state and local taxes, mortgage interest, gifts) beats that number{ledgerYear === currentYear && plan
          ? <span> (this year's plan currently takes the <strong style={{color:'#94a3b8'}}>{plan.federal.usingItemized ? 'itemized' : 'standard'}</strong> deduction)</span>
          : null}. Track it either way: the total is what a bunching or donor-advised-fund decision is made from, and the
        receipts are required records even in a standard-deduction year.
      </div>

      {/* Year totals */}
      <div className="grid-4" style={{marginBottom:12}}>
        <Stat label={`${ledgerYear} charitable total`} value={fmtCur(summary.charitableTotal)} color="#10b981"
          sub={`${fmtCur(summary.charitableCash)} cash · ${fmtCur(summary.charitableNonCash)} goods`}/>
        <Stat label="All tracked deductions" value={fmtCur(summary.total)}
          sub={`${summary.count} entr${summary.count === 1 ? 'y' : 'ies'}`}/>
        <Stat label="Medical / dental" value={fmtCur(summary.byCategory.medical)}
          sub="only the part above 7.5% of AGI counts"/>
        <Stat label="vs standard deduction" value={`${std > 0 ? Math.round((summary.total / std) * 100) : 0}%`}
          sub={`itemizing needs the whole of Schedule A over ${fmtCur(std)}`}/>
      </div>

      {/* Substantiation — say it while the paperwork is still obtainable */}
      {(summary.missingReceipt.length > 0 || summary.needsForm8283) && (
        <div style={{marginBottom:12,padding:'8px 10px',borderRadius:6,background:'rgba(245,158,11,0.08)',
          border:'1px solid rgba(245,158,11,0.35)',fontSize:11,color:'#e2e8f0',lineHeight:1.6}}>
          {summary.missingReceipt.length > 0 && (
            <div>
              <strong style={{color:'#f59e0b'}}>{summary.missingReceipt.length} gift{summary.missingReceipt.length===1?'':'s'} of $250+
              without an acknowledgment letter</strong> ({summary.missingReceipt.map((it)=>it.org||'unnamed').join(', ')}) —
              the IRS requires a written acknowledgment from the charity for any single gift of $250 or more, and it must
              be obtained before filing. Ask now; tick the receipt box when it arrives.
            </div>
          )}
          {summary.needsForm8283 && (
            <div style={{marginTop: summary.missingReceipt.length ? 6 : 0}}>
              <strong style={{color:'#f59e0b'}}>Non-cash gifts exceed $500</strong> — the return adds Form 8283
              (description, dates, and how the fair-market value was set).
            </div>
          )}
        </div>
      )}

      {/* Monarch suggestions */}
      {candidates.length > 0 && (
        <div style={{marginBottom:12}}>
          <div className="label" style={{marginBottom:6}}>Looks like giving in your transactions — confirm the real gifts</div>
          <div style={{display:'grid',gap:4}}>
            {candidates.slice(0, 8).map((c) => (
              <div key={c.txId || `${c.date}|${c.amount}`} style={{display:'flex',gap:10,alignItems:'center',flexWrap:'wrap',
                padding:'6px 10px',borderRadius:6,background:'#0f1520',border:'1px solid #1e2a3a',fontSize:12}}>
                <span style={{minWidth:86,color:'#64748b',fontVariantNumeric:'tabular-nums'}}>{c.date}</span>
                <span style={{flex:1,minWidth:140,color:'#e2e8f0'}}>{c.org}
                  {c.categoryName && <span style={{color:'#475569'}}> · {c.categoryName}</span>}</span>
                <strong style={{color:'#e2e8f0',fontVariantNumeric:'tabular-nums'}}>{fmtCur(c.amount)}</strong>
                <button className="btn-primary" style={{padding:'4px 10px',fontSize:11}} onClick={() => confirmCandidate(c)}>Add</button>
                {c.txId && <button style={{padding:'4px 8px',fontSize:11,background:'transparent',color:'#64748b',
                  border:'1px solid #1e2a3a',borderRadius:6}} title="Not a gift — don't suggest again"
                  onClick={() => dismissCandidate(c)}>✕</button>}
              </div>
            ))}
            {candidates.length > 8 && <div style={{fontSize:11,color:'#475569'}}>+{candidates.length - 8} more after these are handled</div>}
          </div>
          <div style={{fontSize:10,color:'#475569',marginTop:4}}>
            A race entry, raffle ticket, or anything you got something back for is not deductible — dismiss those.
          </div>
        </div>
      )}

      {/* The ledger */}
      {rows.length > 0 ? (
        <div style={{overflowX:'auto',marginBottom:12}}>
          <table>
            <thead>
              <tr><th>Date</th><th>Organization</th><th>Category</th>
                <th style={{textAlign:'right'}}>Amount</th><th>Receipt</th><th></th></tr>
            </thead>
            <tbody>
              {rows.map((it) => (
                <tr key={it.id}>
                  <td style={{whiteSpace:'nowrap',color:'#94a3b8'}}>{it.date}</td>
                  <td style={{color:'#e2e8f0'}}>{it.org || '—'}
                    {it.note && <span style={{color:'#475569'}}> · {it.note}</span>}
                    {it.source === 'monarch' && <span style={{marginLeft:6}}><Chip tone="blue">from feed</Chip></span>}</td>
                  <td style={{fontSize:11,color:'#64748b'}}>{catLabel(it.category)}</td>
                  <td style={{textAlign:'right',fontVariantNumeric:'tabular-nums',color:'#e2e8f0'}}>{fmtCur(it.amount)}</td>
                  <td>
                    <button onClick={() => toggleReceipt(it)} title="Toggle: acknowledgment letter / receipt on file"
                      style={{background:'transparent',border:'none',cursor:'pointer',padding:0}}>
                      <Chip tone={it.receipt ? 'green' : (it.amount >= 250 && (it.category==='charity_cash'||it.category==='charity_noncash') ? 'amber' : 'slate')}>
                        {it.receipt ? 'on file' : 'none'}
                      </Chip>
                    </button>
                  </td>
                  <td><button style={{background:'transparent',border:'none',color:'#64748b',cursor:'pointer',fontSize:12}}
                    title="Remove entry" onClick={() => removeItem(it)}>✕</button></td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ) : (
        <div style={{marginBottom:12,fontSize:12,color:'#475569'}}>Nothing logged for {ledgerYear} yet.</div>
      )}

      {/* Add a row */}
      <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(130px,1fr))',gap:10,alignItems:'end'}}>
        <div>
          <div className="label" style={{marginBottom:3,fontSize:10}}>Date</div>
          <input type="date" value={draft.date} onChange={(e) => setD('date')(e.target.value)} style={{width:'100%'}}/>
        </div>
        <NumField label="Amount" value={draft.amount === '' ? 0 : draft.amount} onChange={setD('amount')} prefix="$"/>
        <TextField label="Organization" value={draft.org} onChange={setD('org')}/>
        <SelectField label="Category" value={draft.category} onChange={setD('category')}
          options={window.TaxDeductions.CATEGORIES.map((c) => [c.key, c.label])}/>
        <div>
          <div className="label" style={{marginBottom:3,fontSize:10}}>Receipt on file</div>
          <input type="checkbox" checked={draft.receipt} onChange={(e) => setD('receipt')(e.target.checked)}/>
        </div>
        <button className="btn-primary" style={{padding:'8px 14px',fontSize:12}} onClick={addDraft}>Add entry</button>
      </div>
      <div style={{fontSize:10,color:'#475569',marginTop:6}}>
        Goods (Goodwill runs, gear donations) go in at thrift-shop fair-market value, not what you paid.
        Employer-matched gifts: log only your half — the match is the employer's deduction.
      </div>
    </div>
  );
}

function TaxReturnsCard({ returns, onSave, onDelete, currentYear }) {
  const { show, Toast } = useToast();
  const [busy, setBusy] = useState(false);
  const [draft, setDraft] = useState(null);      // { year, parsed, warnings, fileName }
  const fileRef = useRef(null);

  const upload = async (file) => {
    if (!file) return;
    // The callable takes base64 in the request body; Cloud Functions caps a
    // callable payload at 10MB and base64 inflates by a third, so a big scan
    // is refused here with a useful instruction rather than as a 413.
    if (file.size > 7 * 1024 * 1024) {
      show('That file is over 7MB — upload just the 1040 pages, or a smaller scan', 'error');
      return;
    }
    setBusy(true);
    try {
      const base64 = await new Promise((resolve, reject) => {
        const r = new FileReader();
        r.onload = () => resolve(String(r.result).split(',')[1]);
        r.onerror = reject;
        r.readAsDataURL(file);
      });
      const res = await callFn('parseTaxReturn', {
        fileBase64: base64, mimeType: file.type || 'application/pdf', fallbackYear: currentYear - 1,
      });
      setDraft({ ...res, fileName: file.name });
      show(`Read ${res.year} — review before saving`);
    } catch (e) { show(e.message, 'error'); }
    setBusy(false);
    if (fileRef.current) fileRef.current.value = '';
  };

  const FIELDS = [
    ['totalTax', 'Total tax', '1040 line 24'],
    ['agi', 'Adjusted gross income', 'line 11'],
    ['taxableIncome', 'Taxable income', 'line 15'],
    ['federalWithheld', 'Federal withheld', 'line 25d'],
    ['estimatedPayments', 'Estimated payments', 'line 26'],
    ['deductionAmount', 'Deduction taken', 'line 12'],
    ['childTaxCredit', 'Child tax credit', 'line 19'],
    ['capitalGainOrLoss', 'Capital gain / loss', 'line 7'],
  ];

  const years = Object.keys(returns || {}).map(Number).sort((a, b) => b - a);

  return (
    <div className="card" style={{marginBottom:16}}>
      {Toast}
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',flexWrap:'wrap',gap:10,marginBottom:6}}>
        <div style={{fontWeight:600,fontSize:14}}>Filed tax returns</div>
        <div style={{display:'flex',gap:8,alignItems:'center'}}>
          <input ref={fileRef} type="file" accept="application/pdf,image/*" style={{display:'none'}}
            onChange={e => upload(e.target.files && e.target.files[0])}/>
          <button className="btn-primary" style={{padding:'7px 14px',fontSize:12}} disabled={busy}
            onClick={() => fileRef.current && fileRef.current.click()}>
            {busy ? <span className="spinner"/> : 'Upload a 1040'}
          </button>
        </div>
      </div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:14,lineHeight:1.65}}>
        A filed return carries four numbers this page cannot get anywhere else: last year's
        <strong style={{color:'#94a3b8'}}> total tax</strong> (which unlocks the cheaper safe-harbour target),
        <strong style={{color:'#94a3b8'}}> Form 8606 basis</strong> (non-deductible IRA money that must not be taxed
        twice, and which appears on no statement), the
        <strong style={{color:'#94a3b8'}}> capital-loss carryforward</strong>, and whether you itemized.
        The PDF itself is read and discarded — only these figures are stored.
      </div>

      {draft && (
        <div style={{marginBottom:16,border:'1px solid rgba(59,130,246,0.35)',borderRadius:8,padding:'14px 16px',background:'rgba(59,130,246,0.05)'}}>
          <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',flexWrap:'wrap',gap:10,marginBottom:10}}>
            <div style={{fontWeight:600,fontSize:13}}>
              {draft.year} return — read from {draft.fileName}
              {draft.parsed.filingStatus && <span style={{marginLeft:8}}><Chip tone={draft.parsed.filingStatus === 'mfj' ? 'slate' : 'amber'}>{String(draft.parsed.filingStatus).toUpperCase()}</Chip></span>}
            </div>
            <div style={{display:'flex',gap:8}}>
              <button className="btn-secondary" style={{fontSize:11,padding:'5px 10px'}} onClick={() => setDraft(null)}>Discard</button>
              <button className="btn-primary" style={{fontSize:11,padding:'5px 12px'}}
                onClick={async () => {
                  try { await onSave(draft.year, draft.parsed); setDraft(null); show(`Saved ${draft.year}`); }
                  catch (e) { show(e.message, 'error'); }
                }}>Save {draft.year}</button>
            </div>
          </div>

          {(draft.warnings || []).length > 0 && (
            <div style={{marginBottom:12,padding:'9px 11px',background:'rgba(245,158,11,0.1)',
              border:'1px solid rgba(245,158,11,0.3)',borderRadius:6,fontSize:11,color:'#fbbf24',lineHeight:1.6}}>
              <div style={{fontWeight:600,marginBottom:4}}>Checks the numbers did not pass — look at these before saving:</div>
              {draft.warnings.map((w, i) => <div key={i}>· {w}</div>)}
            </div>
          )}

          <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fill,minmax(200px,1fr))',gap:10}}>
            {FIELDS.map(([k, label, line]) => (
              <div key={k} style={{background:'#0f1520',border:'1px solid #1e2a3a',borderRadius:6,padding:'8px 10px'}}>
                <div style={{fontSize:10,color:'#64748b'}}>{label} <span style={{color:'#334155'}}>· {(draft.parsed.sources || {})[k] || line}</span></div>
                <div style={{fontSize:14,fontWeight:700,color: draft.parsed[k] == null ? '#475569' : '#e2e8f0'}}>
                  {draft.parsed[k] == null ? 'not readable' : fmtCur(draft.parsed[k])}
                </div>
              </div>
            ))}
          </div>

          {draft.parsed.form8606 && (
            <div style={{marginTop:10,padding:'9px 11px',background:'rgba(16,185,129,0.07)',border:'1px solid rgba(16,185,129,0.25)',borderRadius:6,fontSize:11,color:'#94a3b8',lineHeight:1.6}}>
              <strong style={{color:'#10b981'}}>Form 8606 found.</strong> Basis carried forward:
              {' '}<strong style={{color:'#e2e8f0'}}>{draft.parsed.form8606.totalBasis == null ? 'not readable' : fmtCur(draft.parsed.form8606.totalBasis)}</strong>.
              That is money already taxed once — it comes off the pro-rata calculation so a backdoor conversion is not taxed on it again.
            </div>
          )}
          {draft.parsed.scheduleD && (draft.parsed.scheduleD.shortTermCarryforward != null || draft.parsed.scheduleD.longTermCarryforward != null) && (
            <div style={{marginTop:10,padding:'9px 11px',background:'rgba(59,130,246,0.08)',border:'1px solid rgba(59,130,246,0.25)',borderRadius:6,fontSize:11,color:'#94a3b8',lineHeight:1.6}}>
              <strong style={{color:'#3b82f6'}}>Capital loss carryforward.</strong>
              {' '}Short-term {fmtCur(draft.parsed.scheduleD.shortTermCarryforward || 0)}, long-term {fmtCur(draft.parsed.scheduleD.longTermCarryforward || 0)} — deductible at $3,000/yr against ordinary income, or in full against realised gains.
            </div>
          )}
          {draft.parsed.notes && (
            <div style={{marginTop:10,fontSize:11,color:'#64748b',lineHeight:1.6}}>Note from the read: {draft.parsed.notes}</div>
          )}
        </div>
      )}

      {years.length > 0 ? (
        <div style={{overflowX:'auto'}}>
          <table>
            <thead><tr>
              <th>Year</th><th style={{textAlign:'right'}}>AGI</th><th style={{textAlign:'right'}}>Taxable</th>
              <th style={{textAlign:'right'}}>Total tax</th><th style={{textAlign:'right'}}>Effective</th>
              <th style={{textAlign:'right'}}>Deduction</th><th style={{textAlign:'right'}}>8606 basis</th><th/>
            </tr></thead>
            <tbody>
              {years.map(y => {
                const r = returns[y] || {};
                const eff = r.agi > 0 && r.totalTax != null ? (r.totalTax / r.agi) * 100 : null;
                return (
                  <tr key={y}>
                    <td style={{color:'#e2e8f0',fontWeight:600}}>{y}</td>
                    <td style={{textAlign:'right',color:'#94a3b8'}}>{r.agi == null ? '—' : fmtCur(r.agi)}</td>
                    <td style={{textAlign:'right',color:'#94a3b8'}}>{r.taxableIncome == null ? '—' : fmtCur(r.taxableIncome)}</td>
                    <td style={{textAlign:'right',color:'#e2e8f0',fontWeight:600}}>{r.totalTax == null ? '—' : fmtCur(r.totalTax)}</td>
                    <td style={{textAlign:'right',color:'#94a3b8'}}>{eff == null ? '—' : pct1(eff)}</td>
                    <td style={{textAlign:'right',color:'#64748b'}}>{r.deductionKind || '—'}</td>
                    <td style={{textAlign:'right',color:'#64748b'}}>
                      {r.form8606 && r.form8606.totalBasis != null ? fmtCur(r.form8606.totalBasis) : '—'}
                    </td>
                    <td style={{textAlign:'right'}}>
                      <button className="btn-secondary" style={{fontSize:10,padding:'3px 8px'}}
                        onClick={() => { if (window.confirm(`Delete the stored ${y} return figures?`)) onDelete(y); }}>Delete</button>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      ) : (
        <div style={{padding:'20px',textAlign:'center',color:'#475569',fontSize:12}}>
          No returns on file. Upload last year's 1040 and the safe-harbour target stops being a guess.
        </div>
      )}
    </div>
  );
}

// ── AI brief ──────────────────────────────────────────────────────────────
// The brief is asked to explain and sequence the numbers this page already
// computed — it is never asked to derive them. That split is deliberate: the
// arithmetic is tested, the prose is not.
function AiBrief({ plan, year, kind, stored, onStored }) {
  const { show, Toast } = useToast();
  const [text, setText] = useState(stored || '');
  const [loading, setLoading] = useState(false);
  useEffect(() => { setText(stored || ''); }, [stored, year]);
  const run = async () => {
    setLoading(true);
    try {
      const r = await callFn('generateTaxYearPlan', { year, kind, plan });
      setText(r.insights);
      if (onStored) onStored(r.insights);
    } catch (e) { show(e.message, 'error'); }
    setLoading(false);
  };
  return (
    <div className="card">
      {Toast}
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',flexWrap:'wrap',gap:10,marginBottom:14}}>
        <div>
          <div style={{fontWeight:600,fontSize:14}}>Advisor brief — {year}</div>
          <div style={{fontSize:11,color:'#64748b',marginTop:2}}>
            Claude reads the computed plan above and writes the sequencing, the trade-offs and the watch-outs. It does not recompute the numbers.
          </div>
        </div>
        <button className="btn-primary" onClick={run} disabled={loading} style={{padding:'8px 16px'}}>
          {loading ? <span className="spinner"/> : (text ? 'Regenerate' : 'Generate brief')}
        </button>
      </div>
      {text ? <MarkdownView text={text}/> : (
        <div style={{padding:'24px 20px',textAlign:'center',color:'#475569',fontSize:12}}>
          Generate a written brief for {year} from the plan above.
        </div>
      )}
    </div>
  );
}

// ── The section ───────────────────────────────────────────────────────────
function Tax() {
  const { compensation, payslipsByYear, retirementConfig, accounts, kidsPlan,
          taxConfig, saveTaxConfig, taxReturns, saveTaxReturn, deleteTaxReturn,
          taxDeductions, saveTaxDeductions, transactions,
          spousePayslipsByYear, equityConfig, holdings, saveRetirementConfig } = useContext(DataContext);
  const { show, Toast } = useToast();

  const today = useMemo(() => new Date(), []);
  const currentYear = today.getFullYear();
  const nextYear = currentYear + 1;
  const [yearTab, setYearTab] = useState('current');
  const year = yearTab === 'current' ? currentYear : nextYear;

  // ── Derived inputs ──────────────────────────────────────────────────────
  const yourAge = calcAge(USER_PROFILE.birthday, new Date(year, 11, 31));
  const spouseAge = calcAge(USER_PROFILE.spouseBirthday, new Date(year, 11, 31));

  const comp = compensation[String(currentYear)] || compensation[currentYear] || {};
  const prevComp = compensation[String(currentYear - 1)] || {};
  const payslips = useMemo(() => (payslipsByYear && payslipsByYear[String(currentYear)]) || [], [payslipsByYear, currentYear]);
  const ytd = comp.ytd || null;
  // Hers get exactly the same treatment when they are on file.
  const spousePayslips = useMemo(
    () => (spousePayslipsByYear && spousePayslipsByYear[String(currentYear)]) || [],
    [spousePayslipsByYear, currentYear]);
  const spouseYtd = comp.spouseYtd || null;

  // Qualifying children for the child tax credit: under 17 at the END of the
  // tax year. Counting today's age quietly keeps a credit for a kid who turns
  // 17 in November — a $2,200 error, in the direction of an over-stated refund.
  const kidsUnder17 = useMemo(() => {
    const list = Array.isArray(kidsPlan?.kids) ? kidsPlan.kids : [];
    if (!list.length) return null; // no kid data → fall back to the config field
    return list.filter(k => typeof k.dob === 'string' && k.dob
      && calcAge(k.dob, new Date(year, 11, 31)) < 17).length;
  }, [kidsPlan, year]);

  // Pre-tax IRA balances drive the pro-rata warning. Read from linked accounts
  // by default — the balance that ruins a backdoor Roth is exactly the one
  // nobody remembers they have.
  const buckets = useMemo(() => window.TaxPlanner.accountTaxBuckets(accounts || []), [accounts]);

  const rc = retirementConfig || {};

  // The employer's rates are the one input on this page that exists nowhere
  // the app can read — not a payslip, not a statement, not a return. Passed
  // through UNRESOLVED (nulls intact) so taxPlanner.employerRates can tell
  // "he entered this" from "nobody ever did" and label the difference. The
  // old code resolved them here with `!= null ? x : 3`, which erased that
  // distinction before the model ever saw it.
  // Nothing is defaulted here — not even the service milestone. A step the
  // config has not been told about is not a step, and seeding one would
  // re-commit the exact sin this change exists to undo.
  const employerConfig = useMemo(() => ({
    matchPct: rc.employerMatchPct,
    corePct: rc.employerCorePct,
    coreSalaryCap: rc.employerCoreSalaryCap,
    serviceStartDate: rc.employerServiceStartDate,
    coreStepYears: rc.employerCoreStepYears,
    coreStepPct: rc.employerCoreStepPct,
  }), [rc]);
  // Employer rates live in the Retirement config, because Retirement and
  // Strategy compute from the same numbers. Editing them here writes THERE —
  // a second copy on the tax doc would let the three tabs disagree, which is
  // the one thing the data model is built to prevent.
  const saveEmployer = async (patch) => {
    try { await saveRetirementConfig({ ...rc, ...patch }); show('Employer rates saved'); }
    catch (e) { show(e.message, 'error'); }
  };

  // ── What the filed returns supply ───────────────────────────────────────
  // A stored return WINS over the typed assumption for the fields it covers,
  // because it is the filed document and the assumption was a placeholder for
  // it. Everything it does not cover falls through to the config untouched —
  // a return with an unreadable line 24 must not overwrite a number the user
  // entered by hand off the same page.
  const filed = useMemo(() => {
    const byYear = taxReturns || {};
    const prior = byYear[String(currentYear - 1)] || byYear[currentYear - 1] || null;
    const num = (v) => (typeof v === 'number' && isFinite(v) ? v : null);
    const basisOf = (r, who) => {
      const f = r && r.form8606;
      if (!f || f.totalBasis == null) return null;
      // A return with two 8606s tags whose it is; a single one belongs to the
      // filer unless it says otherwise.
      if (who === 'spouse') return f.spouse === 'spouse' ? num(f.totalBasis) : null;
      return f.spouse === 'spouse' ? null : num(f.totalBasis);
    };
    const carryforward = (r) => {
      const d = r && r.scheduleD;
      if (!d) return null;
      const st = num(d.shortTermCarryforward), lt = num(d.longTermCarryforward);
      if (st == null && lt == null) return null;
      return Math.abs(st || 0) + Math.abs(lt || 0);
    };
    return {
      year: prior ? currentYear - 1 : null,
      priorYearTotalTax: num(prior && prior.totalTax),
      priorYearAgi: num(prior && prior.agi),
      itemized: prior && prior.deductionKind === 'itemized' ? num(prior.deductionAmount) : null,
      capitalLossCarryforward: carryforward(prior),
      yourBasis: basisOf(prior, 'you'),
      spouseBasis: basisOf(prior, 'spouse'),
      hasAny: !!prior,
    };
  }, [taxReturns, currentYear]);


  // Delaware pension pick-up: SEPP employee contributions are excluded from
  // federal wages under §414(h)(2) but NOT from payroll tax. The rate depends
  // on when she was hired (3% pre-2012, 5% after), on compensation above
  // $6,000 — defaulted from her hire date and editable, because a wrong
  // default here moves taxable income by thousands.
  const spouseSalary = Number(rc.wifeSalary) || 118094;
  const pensionRate = (() => {
    const hire = rc.wifeHireDate ? new Date(rc.wifeHireDate) : null;
    return hire && hire.getFullYear() < 2012 ? 3 : 5;
  })();
  const defaultPensionPickup = Math.max(0, Math.round((spouseSalary - 6000) * pensionRate / 100));

  // ── Config ──────────────────────────────────────────────────────────────
  const DEFAULTS = {
    // Health accounts are a HOUSEHOLD object, not a flag on one earner: the
    // HSA may sit on her State plan while the FSA that would disqualify it
    // sits on his. Defaults describe the status quo — his employer offers a
    // general-purpose healthcare FSA and no HDHP.
    health: {
      hsaOwner: 'none',              // 'none' | 'you' | 'spouse'
      hsaCoverage: 'family',
      hsaContribution: 0,
      hsaViaPayroll: true,
      employerHsaContribution: 0,
      healthcareFsaKind: 'general',  // 'none' | 'general' | 'limited'
      healthcareFsa: 0,
      healthcareFsaOwner: 'you',
      dependentCareFsa: 0,
      dependentCareFsaOwner: 'spouse',
    },
    hdhpPremiumDelta: 0, fsaForfeitureRisk: 0,
    otherIncome: 0, itemizedDeductions: 0, stateItemizedDeductions: 0,
    capitalLossCarryforward: 0, capitalGains: 0,
    // A lump's own column can be empty because the parse folded it elsewhere.
    // Stating the month it lands settles it when the arithmetic cannot.
    bonusPaidMonth: 0, rsuPaidMonth: 0,
    iraBasisYou: 0, iraBasisSpouse: 0,
    kids: 2, priorYearTotalTax: 0, priorYearAgi: 0,
    spouseFederalWithholding: 0, spouseStateWithholding: 0,
    spousePensionPickup: defaultPensionPickup,
    electiveIsRoth: false, spouseElectiveIsRoth: false,
    afterTax401k: 0, iraContribution: 0, spouseIraContribution: 0,
    preTaxIraYou: 0, preTaxIraSpouse: 0, useAccountIraBalance: true,
    matchTrueUp: true, bonusDeferralAllowed: true, maxDeferralPct: 75,
    expectedRetirementRate: 22, conversionCeilingRate: 24,
    // Only ever used to size the front-loading advantage, which is labelled
    // a modelled figure everywhere it appears.
    assumedReturnPct: 7,
    // Break-even inputs. The drag preset is the biggest lever on the answer,
    // so it is a named choice rather than a number nobody can source.
    conversionDragPreset: 'moderate', conversionTaxDragPct: null,
    conversionHorizonYears: 25,
    // What the household expects to be taxed ON in retirement — pension plus
    // whatever is drawn. It sets how much bracket room a conversion has in
    // the years that matter, so it is an input rather than a guess.
    retirementTaxableIncome: 120000,
    retireAge: 0, marketplaceCoverageBeforeMedicare: true,
    healthPremium: 0,
    elective401k: null, spouse403b: null,   // null = follow the projection
    next: {
      raisePct: 3, bonusPct: null, rsuAmount: null, spouseRaisePct: 2.5,
      inflationPct: 2.4, elective401k: null, spouse403b: null,
      // A salary is an annual rate; what a calendar year PAYS depends on when
      // the raise lands. His is the first February check, hers is September
      // with the school year.
      raiseEffectiveMonth: 2, spouseRaiseEffectiveMonth: 9,
      hsa: null, healthcareFsa: null, dependentCareFsa: null, healthcareFsaKind: null,
      iraContribution: null, spouseIraContribution: null, afterTax401k: 0,
    },
  };
  const [cfg, setCfg] = useState(DEFAULTS);
  const [dirty, setDirty] = useState(false);
  const hydrated = useRef(false);
  useEffect(() => {
    if (hydrated.current) return;
    if (!taxConfig && !retirementConfig) return;   // nothing loaded yet
    hydrated.current = true;
    setCfg(c => ({
      ...c, ...(taxConfig || {}),
      next: { ...c.next, ...((taxConfig || {}).next || {}) },
      health: { ...c.health, ...((taxConfig || {}).health || {}) },
    }));
  }, [taxConfig, retirementConfig]);

  const set = (k) => (v) => { setCfg(c => ({ ...c, [k]: v })); setDirty(true); };
  const setNext = (k) => (v) => { setCfg(c => ({ ...c, next: { ...c.next, [k]: v } })); setDirty(true); };
  const setHealth = (k) => (v) => { setCfg(c => ({ ...c, health: { ...c.health, [k]: v } })); setDirty(true); };

  // Filed figures beat typed ones, field by field — a return with an
  // unreadable line 24 must not blank out a number typed off that same page.
  const priorYearTotalTax = filed.priorYearTotalTax != null ? filed.priorYearTotalTax : cfg.priorYearTotalTax;
  const priorYearAgi = filed.priorYearAgi != null ? filed.priorYearAgi : cfg.priorYearAgi;
  const itemizedDeductions = filed.itemized != null ? filed.itemized : cfg.itemizedDeductions;
  const capitalLossCarryforward = filed.capitalLossCarryforward != null
    ? filed.capitalLossCarryforward : cfg.capitalLossCarryforward;

  const [briefs, setBriefs] = useState({});
  const save = async () => {
    try { await saveTaxConfig({ ...cfg }); setDirty(false); show('Assumptions saved'); }
    catch (e) { show(e.message, 'error'); }
  };

  // ── Constants for each year ─────────────────────────────────────────────
  // Next year's IRS numbers do not exist in August. `projectConstants`
  // inflates the published table with the statutory rounding increments and
  // marks every value estimated; the chips on screen are not decoration.
  const published = window.TaxConstants.forYear(year);
  const constants = useMemo(() => {
    if (!published.stale) return published;
    return window.TaxPlanner.projectConstants(published, year, { inflationPct: cfg.next.inflationPct });
  }, [published, year, cfg.next.inflationPct]);

  // ── This year's plan ────────────────────────────────────────────────────
  const projection401k = useMemo(() => {
    if (!payslips.length) return null;
    const k = window.TaxConstants.forYear(currentYear).k401;
    return window.TaxPlanner.projectPayslipYear({
      payslips, ytd, year: currentYear, asOf: today,
      expected: { ...comp, bonusPaidMonth: cfg.bonusPaidMonth, rsuPaidMonth: cfg.rsuPaidMonth },
      // Payroll stops at the §402(g) limit; the run rate has to stop with it,
      // or the default election reads as a number the plan cannot produce.
      caps: { retirement401k: k.employee + window.TaxPlanner.catchUpFor(
        calcAge(USER_PROFILE.birthday, new Date(currentYear, 11, 31)), k) },
    });
  }, [payslips, ytd, currentYear, today, comp]);

  // The full-year deferral the CURRENT election lands on, unless the user has
  // overridden it to model a change. Defaulting to the cap would flatter the
  // tax estimate by pretending a decision has already been taken.
  const elective401kNow = cfg.elective401k != null ? cfg.elective401k
    : (projection401k ? Math.round(projection401k.projected.retirement401k) : Number(rc.annualContribution) || 0);
  const spouse403bNow = cfg.spouse403b != null ? cfg.spouse403b : (Number(rc.wife403bContrib) || 0);

  const healthPremiumAnnual = cfg.healthPremium || (projection401k ? Math.round(projection401k.projected.healthInsurance) : 0);

  const currentPlan = useMemo(() => window.TaxPlanner.buildYearPlan({
    year: currentYear, asOf: today, constants: window.TaxConstants.forYear(currentYear), mode: 'actual',
    payslips, ytd,
    expected: { ...comp, bonusPaidMonth: cfg.bonusPaidMonth, rsuPaidMonth: cfg.rsuPaidMonth },
    you: {
      age: calcAge(USER_PROFILE.birthday, new Date(currentYear, 11, 31)),
      base: comp.baseSalary || 0, bonus: comp.bonus || 0, rsu: comp.rsu || 0,
      elective401k: elective401kNow, electiveIsRoth: cfg.electiveIsRoth,
      afterTax401k: cfg.afterTax401k,
      healthPremium: healthPremiumAnnual,
      iraContribution: cfg.iraContribution,
    },
    spouse: {
      age: calcAge(USER_PROFILE.spouseBirthday, new Date(currentYear, 11, 31)),
      salary: spouseSalary, elective403b: spouse403bNow, electiveIsRoth: cfg.spouseElectiveIsRoth,
      pensionContribution: cfg.spousePensionPickup,
      federalWithholding: cfg.spouseFederalWithholding, stateWithholding: cfg.spouseStateWithholding,
      iraContribution: cfg.spouseIraContribution,
      payslips: spousePayslips, ytd: spouseYtd, expected: { baseSalary: spouseSalary },
    },
    employer: { ...employerConfig, matchTrueUp: cfg.matchTrueUp },
    household: {
      kids: kidsUnder17 != null ? kidsUnder17 : cfg.kids,
      otherIncome: cfg.otherIncome, itemizedDeductions,
      stateItemizedDeductions: cfg.stateItemizedDeductions,
      priorYearTotalTax, priorYearAgi,
      capitalLossCarryforward, capitalGains: cfg.capitalGains,
      expectedRetirementRate: cfg.expectedRetirementRate,
      conversionCeilingRate: cfg.conversionCeilingRate,
      health: cfg.health,
      hdhpPremiumDelta: cfg.hdhpPremiumDelta, fsaForfeitureRisk: cfg.fsaForfeitureRisk,
    },
    accounts: accounts || [],
    // Only this year's plan gets holdings. Gain harvesting is a Dec 31 move
    // against positions that exist today; feeding the same unrealised gains
    // to next year's plan would offer to sell them twice.
    holdings: holdings || [],
    iraBalances: {
      ...(cfg.useAccountIraBalance
        ? { yours: buckets.preTaxIra, spouse: cfg.preTaxIraSpouse }
        : { yours: cfg.preTaxIraYou, spouse: cfg.preTaxIraSpouse }),
      // Form 8606 basis — the only place it exists is the filed return.
      yourBasis: filed.yourBasis != null ? filed.yourBasis : cfg.iraBasisYou,
      spouseBasis: filed.spouseBasis != null ? filed.spouseBasis : cfg.iraBasisSpouse,
    },
  }), [currentYear, today, payslips, ytd, comp, elective401kNow, spouse403bNow, healthPremiumAnnual,
       cfg, spouseSalary, rc, employerConfig, kidsUnder17, accounts, buckets, filed, spousePayslips, spouseYtd, holdings,
       priorYearTotalTax, priorYearAgi, itemizedDeductions, capitalLossCarryforward]);

  // ── Next year's plan ────────────────────────────────────────────────────
  // Income is assumption-driven: base grows by the raise, the bonus follows
  // its historical percentage of base unless overridden, and the RSU carries
  // forward. All three are shown as editable inputs — the plan is only as
  // good as the raise it assumes.
  const bonusPctOfBase = comp.baseSalary > 0 && comp.bonus > 0 ? (comp.bonus / comp.baseSalary) * 100 : 35;
  // What next year actually PAYS, not the headline rate — the raise only
  // reaches the checks that fall after it takes effect.
  const currentPlanCadencePerYear = currentPlan.cadence.perYear;
  const nextBaseBlend = window.TaxPlanner.blendedAnnualPay({
    currentAnnual: comp.baseSalary || 0, raisePct: cfg.next.raisePct,
    effectiveMonth: cfg.next.raiseEffectiveMonth,
    periodsPerYear: (currentPlanCadencePerYear || 24),
  });
  const nextBase = nextBaseBlend.annual;
  const nextBonus = Math.round(nextBase * ((cfg.next.bonusPct != null ? cfg.next.bonusPct : bonusPctOfBase) / 100));
  // Next year's RSU income is what VESTS next year, which is not what was
  // granted this year: grants vest 50% at two years and 50% at three, so this
  // year's award is income two and three years out. Carrying `comp.rsu` forward
  // taxed a 2026 grant as 2027 income when it actually releases in 2028/2029 —
  // and it double-counted, because that same award is already in 2026's total
  // comp. The vesting schedule in Compensation knows the answer; it is used
  // only when it actually covers next year (past its horizon the grants have
  // not been made, and a zero there would be a claim rather than a gap).
  const nextVest = useMemo(() => {
    const grants = (equityConfig && equityConfig.grants) || [];
    if (!grants.length) return null;
    const v = window.RsuVesting.vestForYear(grants, nextYear, {
      asOf: window.RsuVesting.toISO(today),
      sharePrice: equityConfig.sharePrice,
    });
    return v.covered && v.value != null ? v : null;
  }, [equityConfig, nextYear, today]);
  const nextRsu = cfg.next.rsuAmount != null ? cfg.next.rsuAmount
    : nextVest ? Math.round(nextVest.value) : Math.round(comp.rsu || 0);
  const nextSpouseBlend = window.TaxPlanner.blendedAnnualPay({
    currentAnnual: spouseSalary, raisePct: cfg.next.spouseRaisePct,
    effectiveMonth: cfg.next.spouseRaiseEffectiveMonth,
    periodsPerYear: (currentPlanCadencePerYear || 24),
  });
  const nextSpouseSalary = nextSpouseBlend.annual;

  const nextLimits = useMemo(() => {
    const pub = window.TaxConstants.forYear(nextYear);
    return pub.stale ? window.TaxPlanner.projectConstants(pub, nextYear, { inflationPct: cfg.next.inflationPct }) : pub;
  }, [nextYear, cfg.next.inflationPct]);

  const nextElective = cfg.next.elective401k != null ? cfg.next.elective401k
    : nextLimits.k401.employee + window.TaxPlanner.catchUpFor(yourAge, nextLimits.k401);
  const nextSpouse403b = cfg.next.spouse403b != null ? cfg.next.spouse403b
    : nextLimits.k401.employee + window.TaxPlanner.catchUpFor(spouseAge, nextLimits.k401);
  // Next year's health elections default to "the same shape as this year, at
  // next year's limits" — deliberately NOT to "max everything", because the
  // HSA and the FSA are mutually exclusive and a default that funds both
  // would model a household that cannot exist.
  const nextHsa = cfg.next.hsa != null ? cfg.next.hsa
    : (cfg.health.hsaOwner !== 'none'
        ? Math.max(0, (cfg.health.hsaCoverage === 'single' ? nextLimits.hsa.single : nextLimits.hsa.family)
            - (cfg.health.employerHsaContribution || 0))
        : 0);
  const nextHealth = {
    ...cfg.health,
    hsaContribution: nextHsa,
    healthcareFsaKind: cfg.next.healthcareFsaKind != null ? cfg.next.healthcareFsaKind : cfg.health.healthcareFsaKind,
    healthcareFsa: cfg.next.healthcareFsa != null ? cfg.next.healthcareFsa : cfg.health.healthcareFsa,
    dependentCareFsa: cfg.next.dependentCareFsa != null ? cfg.next.dependentCareFsa : cfg.health.dependentCareFsa,
  };
  const nextIra = cfg.next.iraContribution != null ? cfg.next.iraContribution : nextLimits.ira.limit;
  const nextSpouseIra = cfg.next.spouseIraContribution != null ? cfg.next.spouseIraContribution : nextLimits.ira.limit;

  const nextPlan = useMemo(() => window.TaxPlanner.buildYearPlan({
    year: nextYear, asOf: today, constants: nextLimits, mode: 'planned', payslips,
    you: {
      age: calcAge(USER_PROFILE.birthday, new Date(nextYear, 11, 31)),
      base: nextBase, bonus: nextBonus, rsu: nextRsu,
      elective401k: nextElective, electiveIsRoth: cfg.electiveIsRoth,
      afterTax401k: cfg.next.afterTax401k,
      healthPremium: healthPremiumAnnual, iraContribution: nextIra,
    },
    spouse: {
      age: calcAge(USER_PROFILE.spouseBirthday, new Date(nextYear, 11, 31)),
      salary: nextSpouseSalary, elective403b: nextSpouse403b, electiveIsRoth: cfg.spouseElectiveIsRoth,
      pensionContribution: Math.round(Math.max(0, (nextSpouseSalary - 6000) * pensionRate / 100)),
      iraContribution: nextSpouseIra,
    },
    employer: { ...employerConfig, matchTrueUp: cfg.matchTrueUp },
    household: {
      kids: kidsUnder17 != null ? kidsUnder17 : cfg.kids,
      otherIncome: cfg.otherIncome, itemizedDeductions,
      stateItemizedDeductions: cfg.stateItemizedDeductions,
      capitalLossCarryforward: Math.max(0, (capitalLossCarryforward || 0) - (currentPlan.capitalLoss.deduction || 0)),
      capitalGains: cfg.capitalGains,
      // Next year's safe harbour is measured against THIS year's tax, which
      // this page has already projected — so the target is known before the
      // year starts, which is the whole reason the rule is useful.
      priorYearTotalTax: Math.round(currentPlan.federal.tax),
      priorYearAgi: Math.round(currentPlan.income.agi),
      expectedRetirementRate: cfg.expectedRetirementRate,
      conversionCeilingRate: cfg.conversionCeilingRate,
      health: nextHealth,
      hdhpPremiumDelta: cfg.hdhpPremiumDelta, fsaForfeitureRisk: cfg.fsaForfeitureRisk,
    },
    accounts: accounts || [],
    iraBalances: {
      ...(cfg.useAccountIraBalance
        ? { yours: buckets.preTaxIra, spouse: cfg.preTaxIraSpouse }
        : { yours: cfg.preTaxIraYou, spouse: cfg.preTaxIraSpouse }),
      // Form 8606 basis — the only place it exists is the filed return.
      yourBasis: filed.yourBasis != null ? filed.yourBasis : cfg.iraBasisYou,
      spouseBasis: filed.spouseBasis != null ? filed.spouseBasis : cfg.iraBasisSpouse,
    },
  }), [nextYear, today, nextLimits, payslips, nextBase, nextBonus, nextRsu, nextElective, nextSpouse403b,
       nextHsa, nextHealth, nextIra, nextSpouseIra, nextSpouseSalary, healthPremiumAnnual, cfg, rc, kidsUnder17,
       accounts, buckets, currentPlan, pensionRate, yourAge, spouseAge, filed, employerConfig,
       itemizedDeductions, capitalLossCarryforward]);

  // How each next-year target gets FUNDED. A limit is a number; a funding plan
  // is an instruction to payroll.
  const funding = useMemo(() => {
    const fp = window.TaxPlanner.fundingPlan;
    const periods = nextPlan.cadence.perYear;
    return {
      elective: fp({ base: nextBase, bonus: nextBonus, rsu: nextRsu, target: nextElective, periods,
        bonusDeferralAllowed: cfg.bonusDeferralAllowed, maxDeferralPct: cfg.maxDeferralPct, matchTrueUp: cfg.matchTrueUp }),
      afterTax: fp({ base: nextBase, bonus: nextBonus, target: nextPlan.employer.megaRoom, periods,
        bonusDeferralAllowed: cfg.bonusDeferralAllowed, maxDeferralPct: cfg.maxDeferralPct, matchTrueUp: cfg.matchTrueUp }),
      spouse: fp({ base: nextSpouseSalary, bonus: 0, target: nextSpouse403b, periods,
        bonusDeferralAllowed: false, maxDeferralPct: cfg.maxDeferralPct, matchTrueUp: true }),
    };
  }, [nextPlan, nextBase, nextBonus, nextRsu, nextElective, nextSpouse403b, nextSpouseSalary, cfg]);

  // WHEN the deferral comes off, which the funding table does not answer.
  // Next year only: phasing is a decision you make at open enrollment, and
  // by the time the current year is half gone most of it has been made for
  // you by the checks that already ran.
  const deferralTiming = useMemo(() => window.TaxPlanner.deferralSchedule({
    periods: nextPlan.cadence.perYear,
    basePerCheck: nextBase / nextPlan.cadence.perYear,
    bonus: nextBonus,
    bonusMonth: cfg.bonusPaidMonth,
    target: nextElective,
    matchPct: nextPlan.employer.rates.matchPct,
    matchTrueUp: cfg.matchTrueUp,
    maxDeferralPct: cfg.maxDeferralPct,
    bonusDeferralAllowed: cfg.bonusDeferralAllowed,
    marginalRatePct: nextPlan.value.deferral.combinedPct,
    returnPct: cfg.assumedReturnPct,
  }), [nextPlan, nextBase, nextBonus, nextElective, cfg]);

  // ── Roth conversions, on the break-even framing ─────────────────────────
  // Computed for whichever year is on screen: the current rate is the blended
  // rate on a conversion that fills the bracket, NOT the marginal rate, since
  // any conversion worth doing climbs brackets as it goes.
  const conversionAnalysis = useMemo(() => {
    const RC = window.RothConversion;
    const plan0 = yearTab === 'current' ? currentPlan : nextPlan;
    const cons = yearTab === 'current' ? window.TaxConstants.forYear(currentYear) : nextLimits;
    const drag = RC.DRAG_PRESETS.find(d => d.key === cfg.conversionDragPreset) || RC.DRAG_PRESETS[1];
    const dragPct = cfg.conversionTaxDragPct != null ? cfg.conversionTaxDragPct : drag.pct;
    const sizing = RC.blendedConversionRate({
      amount: plan0.conversion.room,
      taxableIncome: plan0.federal.taxableIncome,
      brackets: cons.mfj.brackets,
      stateRatePct: plan0.state.marginalRate,
      bracketTax: window.TaxPlanner.bracketTax,
    });
    // The rate that belongs in the break-even is the rate actually paid on
    // the conversion. Falling back to the marginal rate only when there is no
    // room to size against.
    const currentRatePct = sizing ? sizing.blendedPct
      : plan0.federal.marginalRatePct + plan0.state.marginalRate;
    const shared = {
      currentRatePct,
      horizonYears: Math.max(0, (cfg.conversionHorizonYears != null ? cfg.conversionHorizonYears : 25)),
      growthPct: cfg.assumedReturnPct,
      taxDragPct: dragPct,
      ltcgRatePct: plan0.gainsRate ? plan0.gainsRate.combinedPct : 21.6,
    };
    const outside = RC.betr({ ...shared, payTaxFrom: 'outside' });
    const inside = RC.betr({ ...shared, payTaxFrom: 'inside', underAge59Half: yourAge < 59.5 });
    const windows = RC.conversionWindows({
      yourAge,
      // 0 (or unset) means "follow the Retirement tab" — a retirement age of
      // zero is not a thing anyone means to enter.
      retireAge: Number(cfg.retireAge) || rc.retireAge || 65,
      // Retiring before Medicare means buying cover, and a conversion is ACA
      // MAGI in full — so this flag decides whether the best-looking years on
      // the runway are actually the most expensive ones.
      marketplaceCoverage: cfg.marketplaceCoverageBeforeMedicare,
      birthYear: new Date(USER_PROFILE.birthday).getFullYear(),
      // Her Delaware pension starts the day she stops teaching, so the "gap
      // years" this household actually gets are not the empty ones the
      // standard advice is written for.
      pensionAtRetirement: (Number(rc.wifePensionAnnual) || 0) > 0 || !!rc.wifeUseMa30Scale,
    });
    // The picture behind the rate. Sized on the bracket headroom, because
    // that is the conversion actually on the table this year — a round
    // $100,000 would be a number nobody could act on.
    const amount = Math.max(0, Math.round(plan0.conversion.room)) || 100000;
    const lifetime = window.RothConversion.lifetimeValue({
      amount, currentRatePct, futureRatePct: cfg.expectedRetirementRate,
      growthPct: cfg.assumedReturnPct, taxDragPct: dragPct,
      ltcgRatePct: shared.ltcgRatePct, horizonYears: shared.horizonYears,
    });
    // Income is stated per phase rather than modelled from a growth curve:
    // this year and next are computed, and retirement is an assumption the
    // household can see and change.
    const ladder = window.RothConversion.runwayLadder({
      startYear: currentYear, startAge: yourAge, windows,
      brackets: cons.mfj.brackets, ceilingRate: plan0.conversion.ceilingRate,
      stateRatePct: plan0.state.marginalRate, bracketTax: window.TaxPlanner.bracketTax,
      taxableIncomeNow: currentPlan.federal.taxableIncome,
      taxableIncomeNextYear: nextPlan.federal.taxableIncome,
      workingGrowthPct: cfg.next.raisePct,
      retirementIncome: cfg.retirementTaxableIncome,
    });
    // Can the runway drain the pile? The lever this exposes — defer less
    // pre-tax NOW — only exists during the working years, which is exactly
    // why it is computed fifteen years before the window opens.
    const cheapRows = ladder.rows.filter(r => r.age >= windows.retireAge);
    const typicalConversion = cheapRows.length
      ? Math.round(cheapRows.reduce((t, r) => t + r.headroom, 0) / cheapRows.length) : 0;
    const drain = window.RothConversion.drainProjection({
      traditionalBalance: (buckets ? buckets.preTaxIra + buckets.preTaxWorkplace : 0) || cfg.preTaxIraYou,
      annualPreTaxAdditions: plan0.preTax.total,
      startAge: yourAge, retireAge: windows.retireAge, rmdAge: windows.rmdAge,
      growthPct: cfg.assumedReturnPct,
      conversionPerYear: typicalConversion,
    });
    return {
      outside, inside, sizing, dragPreset: drag, windows, lifetime, ladder, amount, drain,
      expectedFutureRatePct: cfg.expectedRetirementRate,
      // The verdict is told WHERE on the runway we are, so "converting wins"
      // can never sit above a window labelled expensive without explaining
      // itself.
      verdict: RC.verdict({
        betrPct: outside.betrPct,
        expectedFutureRatePct: cfg.expectedRetirementRate,
        bestWindow: windows.bestWindow,
        currentWindow: windows.windows.find(w => w.fromAge <= yourAge && (w.toAge == null || yourAge < w.toAge)),
        noCheapWindow: windows.noCheapWindow,
      }),
    };
  }, [yearTab, currentPlan, nextPlan, currentYear, nextLimits, cfg, yourAge, rc]);

  const plan = yearTab === 'current' ? currentPlan : nextPlan;

  // ── Render ──────────────────────────────────────────────────────────────
  const staleDays = currentPlan.throughDate
    ? Math.round((today - new Date(currentPlan.throughDate)) / 86400000) : null;

  // Which statements are actually inconsistent. "Your totals are wrong" is
  // true and useless; naming the four dates is something you can act on.
  const anomalies = useMemo(
    () => (payslips.length && window.PayslipColumns
      ? window.PayslipColumns.componentAnomalies(payslips) : []),
    [payslips]
  );

  return (
    <div className="page">
      {Toast}
      <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',flexWrap:'wrap',gap:12,marginBottom:20}}>
        <div className="page-title" style={{marginBottom:0}}>Tax</div>
        <div style={{display:'flex',gap:8,alignItems:'center'}}>
          {dirty && <button className="btn-primary" style={{fontSize:11,padding:'6px 12px'}} onClick={save}>Save assumptions</button>}
          <div style={{display:'flex',background:'#0d1117',border:'1px solid #1e2a3a',borderRadius:8,overflow:'hidden'}}>
            {[['current',`${currentYear} — this year`],['next',`${nextYear} — plan`]].map(([k,label]) => (
              <button key={k} onClick={()=>setYearTab(k)} style={{background: yearTab===k ? '#10b981' : 'transparent',
                color: yearTab===k ? '#fff' : '#64748b', fontWeight:600, fontSize:11, borderRadius:0, padding:'8px 14px'}}>{label}</button>
            ))}
          </div>
        </div>
      </div>

      {/* A projection built on an input that contradicts itself is not a
          projection. This is the loudest thing on the page on purpose: the
          numbers below it are the stated package, not a reading of payroll. */}
      {!currentPlan.dataTrust.ok && (
        <div className="card" style={{marginBottom:16,borderColor:'#ef4444',background:'rgba(239,68,68,0.06)'}}>
          <div style={{display:'flex',alignItems:'center',gap:8,marginBottom:8,flexWrap:'wrap'}}>
            <span style={{fontSize:18}}>⚠</span>
            <span style={{fontWeight:700,color:'#ef4444',fontSize:14}}>The payslip totals for {currentYear} do not add up</span>
          </div>
          <div style={{fontSize:12,color:'#e2e8f0',lineHeight:1.7}}>
            {currentPlan.dataTrust.reason}.
          </div>
          <div style={{fontSize:11,color:'#94a3b8',lineHeight:1.7,marginTop:8}}>
            The component lines on a payslip — base, bonus, RSU — must add up to that payslip's gross. When they add to
            more, they were read from the year-to-date column beside the period column, and summing them across the year
            produces a number several times too large. Base, bonus and RSU below therefore come from the compensation
            row; tax withheld, deferrals and premiums are separate columns and still come from payroll.
          </div>
          {anomalies.length > 0 && (
            <div style={{marginTop:10}}>
              <div style={{fontSize:11,color:'#e2e8f0',fontWeight:600,marginBottom:5}}>
                {anomalies.filter(a=>a.estimated).length} statement{anomalies.filter(a=>a.estimated).length===1?'':'s'} reported
                a figure that is neither a period nor a year-to-date value — an annual salary rate, most likely. The gross on
                {anomalies.filter(a=>a.estimated).length===1?' it is':' those are'} trustworthy, so
                {anomalies.filter(a=>a.estimated).length===1?' it has':' they have'} been described by that instead:
              </div>
              <div style={{display:'grid',gap:3}}>
                {anomalies.filter(a=>a.estimated).slice(0,8).map((a,i)=>(
                  <div key={i} style={{display:'flex',gap:12,fontSize:11,color:'#94a3b8',fontVariantNumeric:'tabular-nums'}}>
                    <span style={{minWidth:90,color:'#e2e8f0'}}>{a.payDate || 'no date'}</span>
                    <span>gross {fmtCur(a.gross)}</span>
                    <span style={{color:'#f59e0b'}}>read {fmtCur(a.reportedComponents)}</span>
                    <span style={{color:'#64748b'}}>→ base taken as {fmtCur(a.gross)}</span>
                  </div>
                ))}
              </div>
              {anomalies.some(a=>a.repaired) && (
                <div style={{fontSize:11,color:'#64748b',marginTop:6}}>
                  {anomalies.filter(a=>a.repaired).length} other statement{anomalies.filter(a=>a.repaired).length===1?' was':'s were'} unwound
                  from their own arithmetic and need no attention.
                </div>
              )}
            </div>
          )}
        </div>
      )}

      {/* The projection disagreeing with the stated package is evidence that
          something is being read wrong, and it is the only check that comes
          from outside the payslips themselves. */}
      {currentPlan.packageCheck.divergent && (
        <div className="card" style={{marginBottom:16,borderColor:'#f59e0b',background:'rgba(245,158,11,0.06)'}}>
          <div style={{display:'flex',alignItems:'center',gap:8,marginBottom:6,flexWrap:'wrap'}}>
            <span style={{fontSize:16}}>⚠</span>
            <span style={{fontWeight:700,color:'#f59e0b',fontSize:13}}>This does not match your compensation row</span>
          </div>
          <div style={{fontSize:12,color:'#e2e8f0',lineHeight:1.7}}>{currentPlan.packageCheck.message}</div>
          <div style={{fontSize:11,color:'#94a3b8',lineHeight:1.7,marginTop:8}}>
            Compare the banked column below against a recent payslip's year-to-date figures. If the banked side is right and
            the year-end is wrong, the run rate is at fault; if the banked side is already too big, a statement is being
            counted twice.
          </div>
        </div>
      )}

      {/* Freshness — a projection built on stale payslips is stale, and the
          page says so rather than presenting month-old data as today's. */}
      <div className="card-sm" style={{marginBottom:16,display:'flex',gap:14,flexWrap:'wrap',alignItems:'center',fontSize:11,color:'#64748b'}}>
        <span>Your payslips: <strong style={{color:'#e2e8f0'}}>{payslips.length}</strong> in {currentYear}</span>
        <span>Hers: <strong style={{color: spousePayslips.length ? '#e2e8f0' : '#f59e0b'}}>{spousePayslips.length || 'none'}</strong>
          {!spousePayslips.length && ' — her side is modelled until you upload them'}</span>
        {currentPlan.throughDate && <span>through <strong style={{color:'#e2e8f0'}}>{currentPlan.throughDate}</strong></span>}
        <span>cadence <strong style={{color:'#e2e8f0'}}>{currentPlan.cadence.label}</strong> ({currentPlan.cadence.perYear}/yr)</span>
        <span><strong style={{color:'#e2e8f0'}}>{currentPlan.periodsRemaining}</strong> paycheck{currentPlan.periodsRemaining===1?'':'s'} left this year</span>
        {staleDays != null && staleDays > 45 && <Chip tone="amber">last payslip {staleDays} days old — upload the recent ones for a sharper projection</Chip>}
        {!payslips.length && <Chip tone="amber">no payslips for {currentYear} — projecting from the compensation row instead</Chip>}
        {constants.estimated && <Chip tone="amber">{year} IRS numbers are estimated (+{cfg.next.inflationPct}% from {constants.estimatedFrom})</Chip>}
      </div>

      {/* ── Stat row ──────────────────────────────────────────────────── */}
      <div className="grid-4" style={{marginBottom:16}}>
        <Stat label={`${year} household income`} value={fmtCur(plan.income.householdGross)}
          sub={`AGI ${fmtCur(plan.income.agi)} after ${fmtCur(plan.preTax.total)} pre-tax`}/>
        <Stat label="Total tax" value={fmtCur(plan.total.tax)} color="#ef4444"
          sub={`${pct1(plan.total.effectiveOnGrossPct)} of gross · fed ${fmtCur(plan.federal.tax)} · DE ${fmtCur(plan.state.tax)} · payroll ${fmtCur(plan.fica.total)}`}/>
        <Stat label="Marginal rate" value={pct1(plan.value.deferral.combinedPct)} color="#f59e0b"
          sub={`${plan.federal.marginalRatePct}% federal + ${plan.state.marginalRate}% Delaware — what one more pre-tax dollar saves`}/>
        <Stat label={yearTab === 'current' ? 'April balance' : 'Balance if W-4s are left alone'}
          value={signed(plan.withholding.federalBalance)}
          color={plan.withholding.federalBalance > 0 ? '#ef4444' : '#10b981'}
          sub={yearTab === 'current'
            ? (plan.withholding.federalBalance > 0 ? 'federal — you will owe' : 'federal — refund')
            : 'both W-4s modelled as filed with no multiple-jobs adjustment'}/>
      </div>

      {yearTab === 'current' ? (
        <CurrentYear plan={currentPlan} year={currentYear} comp={comp} prevComp={prevComp}
          cfg={cfg} set={set} setHealth={setHealth} buckets={buckets} pensionRate={pensionRate}
          defaultPensionPickup={defaultPensionPickup} elective401kNow={elective401kNow}
          spouse403bNow={spouse403bNow} kidsUnder17={kidsUnder17} spouseSalary={spouseSalary}
          returns={taxReturns} onSaveReturn={saveTaxReturn} onDeleteReturn={deleteTaxReturn} filed={filed}
          taxDeductions={taxDeductions} saveTaxDeductions={saveTaxDeductions} transactions={transactions}
          briefs={briefs} setBriefs={setBriefs}
          employerConfig={employerConfig} saveEmployer={saveEmployer}
          conversionAnalysis={conversionAnalysis}/>
      ) : (
        <NextYear deferralTiming={deferralTiming} plan={nextPlan} current={currentPlan} year={nextYear} limits={nextLimits}
          cfg={cfg} set={set} setNext={setNext} setHealth={setHealth} funding={funding}
          nextBase={nextBase} nextBonus={nextBonus} nextRsu={nextRsu} nextVest={nextVest} nextSpouseSalary={nextSpouseSalary}
          nextBaseBlend={nextBaseBlend} nextSpouseBlend={nextSpouseBlend}
          bonusPctOfBase={bonusPctOfBase} briefs={briefs} setBriefs={setBriefs}/>
      )}
    </div>
  );
}

// ── This year ─────────────────────────────────────────────────────────────
function CurrentYear({ plan, year, comp, cfg, set, setHealth, buckets, pensionRate, defaultPensionPickup,
                       elective401kNow, spouse403bNow, kidsUnder17, spouseSalary,
                       returns, onSaveReturn, onDeleteReturn, filed, briefs, setBriefs,
                       taxDeductions, saveTaxDeductions, transactions,
                       employerConfig, saveEmployer, conversionAnalysis }) {
  const p = plan;
  const det = p.projection ? p.projection.detail : null;
  const limits = window.TaxConstants.forYear(year);

  const incomeRows = [
    ['Base salary', 'baseSalary', p.income.yourBase],
    ['Bonus / IC', 'bonus', p.income.yourBonus],
    ['RSU', 'rsu', p.income.yourRSU],
    ['Other comp', 'otherComp', p.income.yourOther],
  ];

  return (
    <>
      {/* Where the year lands */}
      <div className="grid-2" style={{marginBottom:16}}>
        <div className="card">
          <div style={{fontWeight:600,marginBottom:4}}>Where {year} finishes</div>
          <div style={{fontSize:11,color:'#64748b',marginBottom:12,lineHeight:1.6}}>
            Recurring pay is projected from the median regular paycheck. The bonus and the RSU vest are
            <strong style={{color:'#94a3b8'}}> carried at what was actually paid</strong> — annualizing a February
            lump would invent two more of them and move the household two brackets.
          </div>
          {p.projection && p.projection.rateShifts && p.projection.rateShifts.baseSalary && (
            <div style={{fontSize:11,color:'#94a3b8',marginBottom:12,lineHeight:1.65,padding:'8px 10px',
              background:'rgba(59,130,246,0.08)',border:'1px solid rgba(59,130,246,0.25)',borderRadius:6}}>
              <strong style={{color:'#3b82f6'}}>Your pay rate stepped this year</strong> — from
              {' '}{fmtCur(p.projection.rateShifts.baseSalary.from)} to {fmtCur(p.projection.rateShifts.baseSalary.to)} a
              paycheck ({p.projection.rateShifts.baseSalary.pct.toFixed(1)}%). The rest of the year is projected at the
              current rate; the year's median would sit between the two and price every remaining paycheck at a rate
              nobody is being paid.
            </div>
          )}
          {p.spouseProjection && p.spouseProjection.rateShifts && p.spouseProjection.rateShifts.baseSalary && (
            <div style={{fontSize:11,color:'#94a3b8',marginBottom:12,lineHeight:1.65,padding:'8px 10px',
              background:'rgba(59,130,246,0.08)',border:'1px solid rgba(59,130,246,0.25)',borderRadius:6}}>
              <strong style={{color:'#3b82f6'}}>Her pay rate stepped this year</strong> — from
              {' '}{fmtCur(p.spouseProjection.rateShifts.baseSalary.from)} to
              {' '}{fmtCur(p.spouseProjection.rateShifts.baseSalary.to)} a paycheck. A school-year contract changes every
              September, so her calendar year is two rates and the remainder is projected at the current one.
            </div>
          )}
          <table>
            <thead><tr><th>Line</th><th style={{textAlign:'right'}}>Banked</th><th style={{textAlign:'right'}}>Still to come</th><th style={{textAlign:'right'}}>Year end</th></tr></thead>
            <tbody>
              {incomeRows.map(([label, key, projected]) => {
                const d = det ? det[key] : null;
                return (
                  <tr key={key}>
                    <td>
                      {label}
                      {d && <span style={{marginLeft:6}}><Chip tone={d.kind === 'lump' ? 'blue' : 'slate'}>
                        {d.kind === 'lump' ? (d.basis === 'paid' ? 'paid' : d.basis === 'planned' ? 'planned' : 'none') : 'run rate'}
                      </Chip></span>}
                    </td>
                    <td style={{textAlign:'right',color:'#94a3b8'}}>{d ? fmtCur(d.ytd) : '—'}</td>
                    <td style={{textAlign:'right',color:'#94a3b8'}}>{d ? fmtCur(d.remaining) : '—'}</td>
                    <td style={{textAlign:'right',color:'#e2e8f0',fontWeight:600}}>{fmtCur(projected)}</td>
                  </tr>
                );
              })}
              <tr>
                <td style={{color:'#e2e8f0',fontWeight:600}}>Your gross</td>
                <td style={{textAlign:'right',color:'#94a3b8'}}>{det ? fmtCur(det.grossPay.ytd) : '—'}</td>
                <td style={{textAlign:'right',color:'#94a3b8'}}>{det ? fmtCur(det.grossPay.remaining) : '—'}</td>
                <td style={{textAlign:'right',color:'#10b981',fontWeight:700}}>{fmtCur(p.income.yourGross)}</td>
              </tr>
              <tr>
                <td>Spouse salary
                  <span style={{marginLeft:6}}>
                    <Chip tone={p.spouseMeasured ? 'slate' : 'amber'}>{p.spouseMeasured ? 'run rate' : 'stated'}</Chip>
                  </span>
                </td>
                <td style={{textAlign:'right',color:'#94a3b8'}}>
                  {p.spouseProjection ? fmtCur(p.spouseProjection.detail.grossPay.ytd) : '—'}
                </td>
                <td style={{textAlign:'right',color:'#94a3b8'}}>
                  {p.spouseProjection ? fmtCur(p.spouseProjection.detail.grossPay.remaining) : '—'}
                </td>
                <td style={{textAlign:'right',color:'#e2e8f0',fontWeight:600}}>{fmtCur(p.income.spouseGross)}</td>
              </tr>
            </tbody>
          </table>
        </div>

        <div className="card">
          <div style={{fontWeight:600,marginBottom:12}}>The {year} tax bill</div>
          <Row label="Household gross" value={fmtCur(p.income.householdGross)} strong/>
          <Row label="401(k) / 403(b) pre-tax" value={`− ${fmtCur(p.preTax.yourElectivePreTax + p.preTax.spouseElectivePreTax)}`} indent/>
          <Row label="Health premiums, FSA, HSA, pension pick-up" value={`− ${fmtCur(p.preTax.yourOther + p.preTax.spouseOther)}`} indent/>
          <Row label="Adjusted gross income" value={fmtCur(p.income.agi)} strong/>
          <Row label={p.federal.usingItemized ? 'Itemized deductions' : 'Standard deduction (MFJ)'} value={`− ${fmtCur(p.federal.deduction)}`} indent/>
          <Row label="Taxable income" value={fmtCur(p.federal.taxableIncome)} strong/>
          <hr className="divider" style={{margin:'10px 0'}}/>
          <Row label="Federal tax before credits" value={fmtCur(p.federal.grossTax)}/>
          <Row label={`Child tax credit (${p.federal.childTaxCredit.gross ? Math.round(p.federal.childTaxCredit.gross / (limits.credits.childTaxCredit || 1)) : 0} kids)`}
            value={`− ${fmtCur(p.federal.childTaxCredit.credit)}`} indent
            note={p.federal.childTaxCredit.phaseOut > 0 ? `${fmtCur(p.federal.childTaxCredit.phaseOut)} phased out` : null}/>
          <Row label="Federal" value={fmtCur(p.federal.tax)} strong color="#ef4444"/>
          <Row label="Delaware" value={fmtCur(p.state.tax)} strong color="#ef4444"
            note={`${p.state.marginalRate}% top rate, ${fmtCur(p.state.credits)} personal credits`}/>
          <Row label="Payroll (SS + Medicare)" value={fmtCur(p.fica.total)} strong color="#ef4444"
            note={p.value.socialSecurity.observed ? 'SS stopped — read from payslips'
              : p.fica.socialSecurityCapped ? 'SS capped (projected)' : 'broken out below'}/>
          <hr className="divider" style={{margin:'10px 0'}}/>
          <Row label="Total tax" value={fmtCur(p.total.tax)} strong color="#ef4444"/>
          <Row label="Effective rate on gross" value={pct1(p.total.effectiveOnGrossPct)}/>
        </div>
      </div>

      <PayrollBreakdown fica={p.fica} year={year} estimated={p.constants.estimated}/>

      {/* Brackets + withholding */}
      <div className="grid-2" style={{marginBottom:16}}>
        <div className="card">
          <div style={{fontWeight:600,marginBottom:12}}>Federal brackets — MFJ {limits.year}</div>
          <BracketLadder brackets={limits.mfj.brackets} taxable={p.federal.taxableIncome} marginalRate={p.federal.marginalRatePct}/>
          <div style={{marginTop:12,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.6,
            background: p.federal.marginalRatePct >= 32 ? 'rgba(239,68,68,0.1)' : 'rgba(16,185,129,0.08)',
            border: `1px solid ${p.federal.marginalRatePct >= 32 ? 'rgba(239,68,68,0.3)' : 'rgba(16,185,129,0.25)'}`}}>
            {p.federal.marginalRatePct >= 32 ? (
              <span style={{color:'#fca5a5'}}>Taxable income is <strong>{fmtCur(p.federal.overThe24Line)}</strong> over the 24%/32% line.
                Every pre-tax dollar back under it is worth <strong>{pct1(p.value.deferral.combinedPct)}</strong>.</span>
            ) : (
              <span style={{color:'#94a3b8'}}>Headroom before the 32% bracket: <strong style={{color:'#10b981'}}>{fmtCur(p.federal.roomTo32)}</strong>.
                A Roth election instead of traditional spends that headroom; a deferral widens it.</span>
            )}
          </div>
        </div>

        <div className="card">
          <div style={{fontWeight:600,marginBottom:4}}>Withholding &amp; the safe harbour</div>
          <div style={{fontSize:11,color:'#64748b',marginBottom:12,lineHeight:1.6}}>
            Two jobs each withhold as though theirs were the household's only income — both grant the full standard
            deduction and both start again at 10%. That gap is structural, not a mistake.
          </div>
          <Row label="Federal tax projected" value={fmtCur(p.federal.tax)} strong/>
          <Row label="Withheld — you (from payslips)" value={fmtCur(p.withholding.yourFederal)} indent/>
          <Row label={`Withheld — spouse ${p.withholding.spouseFederalMeasured ? '(from her payslips)'
            : p.withholding.spouseFederalModelled ? '(modelled)' : '(entered)'}`}
            value={fmtCur(p.withholding.spouseFederal)} indent/>
          <Row label="Balance in April" value={signed(p.withholding.federalBalance)} strong
            color={p.withholding.federalBalance > 0 ? '#ef4444' : '#10b981'}/>
          <Row label="Delaware balance" value={signed(p.withholding.stateBalance)}
            color={p.withholding.stateBalance > 0 ? '#f59e0b' : '#10b981'}/>
          <hr className="divider" style={{margin:'10px 0'}}/>
          <Row label={`Safe harbour target (${p.withholding.harbor.priorYearKnown ? `lower of 90% this year / ${p.withholding.harbor.priorYearPct}% last year` : '90% of this year — no prior-year tax entered'})`}
            value={fmtCur(p.withholding.harbor.required)}/>
          <div style={{marginTop:10,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.6,
            background: p.withholding.harbor.safe ? 'rgba(16,185,129,0.08)' : 'rgba(239,68,68,0.1)',
            border: `1px solid ${p.withholding.harbor.safe ? 'rgba(16,185,129,0.25)' : 'rgba(239,68,68,0.3)'}`,
            color: p.withholding.harbor.safe ? '#10b981' : '#fca5a5'}}>
            {p.withholding.harbor.safe
              ? 'Inside the safe harbour — any April balance is payable without an underpayment penalty.'
              : `Short by ${fmtCur(p.withholding.harbor.shortfall)}. ${p.periodsRemaining > 0
                  ? `Add ${fmtCur(p.withholding.harbor.shortfall / p.periodsRemaining)} per remaining paycheck on W-4 line 4(c) — withholding is treated as paid evenly across the year, an estimated payment is not.`
                  : 'No paychecks left — a Q4 estimated payment by Jan 15 limits, but does not erase, the penalty.'}`}
          </div>
          {/* Social Security, measured. This is the only figure on the page
              that checks one of the app's own constants against reality. */}
          {p.value.socialSecurity.status !== 'unknown' && (
            <div style={{marginTop:10,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.65,
              background: p.value.socialSecurity.observed ? 'rgba(16,185,129,0.08)' : 'rgba(100,116,139,0.1)',
              border:`1px solid ${p.value.socialSecurity.observed ? 'rgba(16,185,129,0.25)' : '#1e2a3a'}`,color:'#94a3b8'}}>
              <strong style={{color: p.value.socialSecurity.observed ? '#10b981' : '#94a3b8'}}>
                Social Security: {p.value.socialSecurity.observed ? 'stopped' : 'still withholding'}.
              </strong>{' '}
              {p.value.socialSecurity.evidence}.
              {p.value.socialSecurity.observed && (
                <> Every further pre-tax dollar off <em>your</em> paycheck therefore saves
                  {' '}<strong style={{color:'#e2e8f0'}}>{p.value.ficaMarginalPct.toFixed(2)}%</strong> in payroll tax,
                  against <strong style={{color:'#e2e8f0'}}>{p.value.spouseFicaMarginalPct.toFixed(2)}%</strong> off hers —
                  measured from the payslips, not assumed from the wage base.</>
              )}
              {p.value.socialSecurity.impliedWageBase != null && (
                <div style={{marginTop:6,color: p.value.socialSecurity.agreesWithConstant ? '#64748b' : '#f59e0b'}}>
                  {p.value.socialSecurity.agreesWithConstant
                    ? `The ${fmtCur(p.value.socialSecurity.ytdWithheld)} withheld implies a wage base of ${fmtCur(p.value.socialSecurity.impliedWageBase)}, which matches the ${fmtCur(p.value.socialSecurity.constantWageBase)} in the table — the constant is confirmed by your own payroll.`
                    : `⚠ The ${fmtCur(p.value.socialSecurity.ytdWithheld)} withheld implies a wage base of ${fmtCur(p.value.socialSecurity.impliedWageBase)}, but the table says ${fmtCur(p.value.socialSecurity.constantWageBase)}. Payroll is more likely right than the table — check a payslip against the IRS figure and fix lib/taxConstants.`}
                </div>
              )}
              {p.value.socialSecurity.status === 'zeros-unexplained' && (
                <div style={{marginTop:6,color:'#f59e0b'}}>
                  Checks with gross pay and no Social Security usually mean an RSU vest or an off-cycle statement,
                  not the cap — so the full rate is still being charged. If these really are post-cap checks, the
                  year-to-date Social Security on them is lower than it should be and the payslip is worth a look.
                </div>
              )}
            </div>
          )}

          {p.withholding.dualIncomeGap > 1000 && (
            <div style={{marginTop:8,fontSize:11,color:'#94a3b8',lineHeight:1.6}}>
              Two naive W-4s would under-withhold this household by <strong style={{color:'#f59e0b'}}>{fmtCur(p.withholding.dualIncomeGap)}</strong> —
              that is what the Step 2 checkbox on the W-4 exists to fix.
            </div>
          )}
        </div>
      </div>

      {/* Capacity */}
      <div className="card" style={{marginBottom:16}}>
        <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',flexWrap:'wrap',gap:8,marginBottom:6}}>
          <div style={{fontWeight:600}}>What is still reachable in {year}</div>
          <div style={{fontSize:11,color:'#64748b'}}>{p.periodsRemaining} paycheck{p.periodsRemaining===1?'':'s'} left · {p.cadence.label}</div>
        </div>
        <div style={{fontSize:11,color:'#64748b',marginBottom:12,lineHeight:1.6}}>
          "On track for" is where the current election lands by December, not what has been withheld so far.
          Anything on a <Chip tone="amber">Dec 31</Chip> deadline has to clear payroll before the last run of the year;
          <Chip tone="blue">Apr 15</Chip> items can still be funded from a bank account after the year closes.
        </div>
        <CapacityTable capacity={p.capacity} showPerPaycheck/>
      </div>

      <DeductionLedgerCard taxDeductions={taxDeductions} saveTaxDeductions={saveTaxDeductions}
        transactions={transactions} currentYear={year} plan={plan}/>

      <TaxReturnsCard returns={returns} onSave={onSaveReturn} onDelete={onDeleteReturn} currentYear={year}/>

      {/* Actions */}
      <div className="card" style={{marginBottom:16}}>
        <div style={{fontWeight:600,marginBottom:4}}>Do these before the deadlines</div>
        <div style={{fontSize:11,color:'#64748b',marginBottom:14}}>
          Ranked by what the move is worth, priced at {pct1(p.value.deferral.combinedPct)} for deferrals and
          {' '}{pct1(p.value.cafeteria.combinedPct)} for anything that also escapes payroll tax.
        </div>
        <ActionList actions={p.actions}/>
      </div>

      {/* Deadlines */}
      <div className="card" style={{marginBottom:16}}>
        <div style={{fontWeight:600,marginBottom:12}}>Calendar</div>
        <div style={{display:'flex',flexDirection:'column',gap:8}}>
          {p.deadlines.filter(d => d.daysAway > -30).map(d => (
            <div key={d.key} style={{display:'flex',gap:12,alignItems:'baseline',flexWrap:'wrap',
              padding:'8px 10px',borderRadius:6,background:'#0f1520',border:'1px solid #1e2a3a'}}>
              <span style={{minWidth:92,fontWeight:600,color:'#e2e8f0',fontSize:12}}>{d.date}</span>
              <Chip tone={d.daysAway < 0 ? 'slate' : d.daysAway < 60 ? 'amber' : 'green'}>
                {d.daysAway < 0 ? 'passed' : `${d.daysAway}d`}
              </Chip>
              <span style={{fontSize:12,color:'#e2e8f0',fontWeight:600}}>{d.label}</span>
              <span style={{fontSize:11,color:'#64748b',flex:1,minWidth:200}}>{d.what}</span>
            </div>
          ))}
        </div>
      </div>

      {/* Roth / conversions */}
      <div className="grid-2" style={{marginBottom:16}}>
        <div className="card">
          <div style={{fontWeight:600,marginBottom:12}}>Roth conversions</div>
          <Row label={`Room to the top of the ${p.conversion.ceilingRate}% bracket`} value={fmtCur(p.conversion.room)} strong/>
          {p.conversion.taxCost != null && <Row label="Tax if the room were filled" value={fmtCur(p.conversion.taxCost)} indent/>}
          <div style={{marginTop:10,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.65,
            background: p.conversion.worthwhile ? 'rgba(16,185,129,0.08)' : 'rgba(100,116,139,0.1)',
            border:'1px solid #1e2a3a',color:'#94a3b8'}}>
            {p.conversion.note}
            <div style={{marginTop:6,color:'#64748b'}}>
              This is the bracket comparison. The break-even analysis below is the one that decides it.
            </div>
          </div>
        </div>
        <div className="card">
          <div style={{fontWeight:600,marginBottom:12}}>Backdoor Roth — the pro-rata check</div>
          <Row label="Pre-tax IRA balance (yours)" value={fmtCur(p.proRata.you.preTaxIraBalance)} strong
            color={p.proRata.you.clean ? '#10b981' : '#f59e0b'}/>
          {!p.proRata.you.clean && (
            <>
              <Row label="Taxable share of a conversion" value={pct1(p.proRata.you.taxableFraction * 100)} indent/>
              <Row label="Tax cost on this year's contribution" value={fmtCur(p.proRata.you.taxCost)} indent/>
            </>
          )}
          <div style={{marginTop:10,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.65,
            background: p.proRata.you.clean ? 'rgba(16,185,129,0.08)' : 'rgba(245,158,11,0.1)',
            border:`1px solid ${p.proRata.you.clean ? 'rgba(16,185,129,0.25)' : 'rgba(245,158,11,0.3)'}`,
            color: p.proRata.you.clean ? '#10b981' : '#fbbf24'}}>
            {p.proRata.you.clean
              ? 'No pre-tax IRA money — a backdoor Roth converts clean. Keep it that way: a 401(k) rolled to an IRA later would contaminate every future conversion.'
              : `${p.proRata.you.remedy}. §408(d)(2) reads the Dec 31 balance across every traditional, SEP and SIMPLE IRA you own — not the balance on conversion day.`}
          </div>
          {buckets && (
            <div style={{marginTop:10,fontSize:11,color:'#64748b',lineHeight:1.6}}>
              From linked accounts: {fmtCur(buckets.preTaxWorkplace)} workplace pre-tax · {fmtCur(buckets.preTaxIra)} IRA pre-tax ·
              {' '}{fmtCur(buckets.roth)} Roth · {fmtCur(buckets.taxable)} taxable
            </div>
          )}
        </div>
      </div>

      <ConversionAnalysis analysis={conversionAnalysis} conversion={p.conversion} year={year}/>

      <GainHarvestCard harvest={p.gainHarvest} gainsRate={p.gainsRate} capitalLoss={p.capitalLoss} year={year}/>

      {/* Health sits at the bottom, next to the fields that drive it. It is a
          once-a-year open-enrollment decision, not something read on the way
          to the tax bill, and it was pushing the ranked actions below the
          fold. */}
      <HealthDecisionCard health={p.health} decision={p.healthDecision} value={p.value} year={year}/>

      <Assumptions cfg={cfg} set={set} setHealth={setHealth} comp={comp} elective401kNow={elective401kNow} spouse403bNow={spouse403bNow}
        pensionRate={pensionRate} defaultPensionPickup={defaultPensionPickup} kidsUnder17={kidsUnder17}
        spouseSalary={spouseSalary} buckets={buckets} plan={p} filed={filed}
        employerConfig={employerConfig} saveEmployer={saveEmployer}/>

      <div style={{marginTop:16}}>
        <AiBrief plan={briefPayload({ ...p, conversionAnalysis })} year={year} kind="currentYear"
          stored={briefs[year]} onStored={t => setBriefs(b => ({...b, [year]: t}))}/>
      </div>
    </>
  );
}

// ── Next year ─────────────────────────────────────────────────────────────
function NextYear({ plan, current, year, limits, cfg, set, setNext, setHealth, funding, deferralTiming,
                    nextBase, nextBonus, nextRsu, nextVest, nextSpouseSalary, nextBaseBlend, nextSpouseBlend,
                    bonusPctOfBase, briefs, setBriefs }) {
  const p = plan;
  const delta = (a, b) => a - b;

  const compare = [
    ['Household gross', current.income.householdGross, p.income.householdGross],
    ['Pre-tax contributions', current.preTax.total, p.preTax.total],
    ['Taxable income', current.federal.taxableIncome, p.federal.taxableIncome],
    ['Federal tax', current.federal.tax, p.federal.tax],
    ['Delaware tax', current.state.tax, p.state.tax],
    ['Payroll tax', current.fica.total, p.fica.total],
    ['Total tax', current.total.tax, p.total.tax],
  ];

  const fundingRows = [
    ['401(k) elective', funding.elective, nextBase, nextBonus],
    ['After-tax 401(k) → Roth', funding.afterTax, nextBase, nextBonus],
    ["Spouse 403(b)", funding.spouse, nextSpouseSalary, 0],
  ].filter(([, f]) => f.target > 0);

  return (
    <>
      <div className="card" style={{marginBottom:16}}>
        <div style={{display:'flex',justifyContent:'space-between',alignItems:'baseline',flexWrap:'wrap',gap:8,marginBottom:6}}>
          <div style={{fontWeight:600}}>{year} assumptions</div>
          {limits.estimated && <Chip tone="amber">IRS limits estimated from {limits.estimatedFrom} — replace them in lib/taxConstants when the Rev. Proc. lands (late Oct) and the retirement limits follow (Nov)</Chip>}
        </div>
        <div style={{fontSize:11,color:'#64748b',marginBottom:14,lineHeight:1.6}}>
          Next year is planned, not measured — every number below is an input. The elections that come out the other
          side have to be made at <strong style={{color:'#94a3b8'}}>open enrollment</strong>, which is the real deadline
          for the FSA, the HSA and the payroll deferral percentage.
        </div>
        {SUB('Income')}
        <div style={GRID}>
          <NumField label="Raise on base" value={cfg.next.raisePct} onChange={setNext('raisePct')} suffix="%" step={0.5}
            hint={`→ ${fmtCur(nextBase)} paid${nextBaseBlend.shortfallVsHeadline > 0
              ? ` (rate ${fmtCur(nextBaseBlend.headlineRate)}, less ${fmtCur(nextBaseBlend.shortfallVsHeadline)} — ${nextBaseBlend.checksAtOldRate} checks still at the old rate)` : ''}`}/>
          <SelectField label="Raise takes effect" value={String(cfg.next.raiseEffectiveMonth || 1)}
            onChange={v=>setNext('raiseEffectiveMonth')(Number(v))}
            options={[['1','January'],['2','February'],['3','March'],['4','April'],['5','May'],['6','June'],['7','July'],['8','August'],['9','September'],['10','October'],['11','November'],['12','December']]}
            hint="the first paycheck at the new rate"/>
          <NumField label="Bonus target" value={cfg.next.bonusPct != null ? cfg.next.bonusPct : Math.round(bonusPctOfBase*10)/10}
            onChange={setNext('bonusPct')} suffix="% of base" step={0.5} hint={`→ ${fmtCur(nextBonus)}`}/>
          <NumField label="RSU vesting" value={cfg.next.rsuAmount != null ? cfg.next.rsuAmount : nextRsu}
            onChange={setNext('rsuAmount')} prefix="$"
            hint={nextVest
              ? `${nextVest.shares} sh vesting in ${nextVest.year} — supplemental wages, 22% withheld`
              : 'vests are supplemental wages — 22% withholding'}/>
          <NumField label="Spouse raise" value={cfg.next.spouseRaisePct} onChange={setNext('spouseRaisePct')} suffix="%" step={0.5}
            hint={`→ ${fmtCur(nextSpouseSalary)} paid${nextSpouseBlend.shortfallVsHeadline > 0
              ? ` (rate ${fmtCur(nextSpouseBlend.headlineRate)}; ${nextSpouseBlend.checksAtOldRate} of ${nextSpouseBlend.periodsPerYear} checks are the old contract)` : ''}`}/>
          <SelectField label="Her raise takes effect" value={String(cfg.next.spouseRaiseEffectiveMonth || 9)}
            onChange={v=>setNext('spouseRaiseEffectiveMonth')(Number(v))}
            options={[['1','January'],['7','July'],['8','August'],['9','September'],['10','October']]}
            hint="a school-year contract changes in September, so most of the calendar year is the old one"/>
          <NumField label="Limit inflation assumption" value={cfg.next.inflationPct} onChange={setNext('inflationPct')} suffix="%" step={0.1}
            hint="drives the estimated IRS limits"/>
        </div>
        <div style={{height:14}}/>
        {SUB('Planned elections')}
        <div style={GRID}>
          <NumField label="Your 401(k)" value={cfg.next.elective401k != null ? cfg.next.elective401k : plan.preTax.yourElective}
            onChange={setNext('elective401k')} prefix="$" hint={`cap ${fmtCur(limits.k401.employee)}${limits.estimated ? ' (est)' : ''}`}/>
          <NumField label="Spouse 403(b)" value={cfg.next.spouse403b != null ? cfg.next.spouse403b : plan.preTax.spouseElective}
            onChange={setNext('spouse403b')} prefix="$" hint="its own §402(g) limit"/>
          <NumField label="After-tax 401(k)" value={cfg.next.afterTax401k} onChange={setNext('afterTax401k')} prefix="$"
            hint={`§415(c) room ${fmtCur(p.employer.megaRoom)}${p.employer.provisional ? ' — provisional: it is the limit less deferrals less EMPLOYER money, and the employer rates are placeholders' : ''}`}/>
          <NumField label="HSA (employee)" value={plan.health.hsaStated} onChange={setNext('hsa')} prefix="$"
            hint={plan.health.hsaOwner === 'none'
              ? 'nobody on an HDHP — set the owner below'
              : `cap ${fmtCur(plan.health.hsaLimit)}${plan.health.employerHsaSeed > 0 ? ` less ${fmtCur(plan.health.employerHsaSeed)} employer seed` : ''}${plan.health.hsaEligible ? '' : ' — currently blocked'}`}/>
          <NumField label="Healthcare FSA" value={plan.health.healthcareFsa} onChange={setNext('healthcareFsa')} prefix="$"
            hint={`cap ${fmtCur(limits.fsa ? limits.fsa.healthcare : 0)}`}/>
          <SelectField label="Healthcare FSA type" value={plan.health.healthcareFsaKind} onChange={setNext('healthcareFsaKind')}
            options={[['none','Not elected'],['general','General purpose'],['limited','Limited purpose (dental/vision)']]}
            hint="general-purpose blocks an HSA for both spouses"/>
          <NumField label="Dependent Care FSA" value={plan.health.dependentCareFsa} onChange={setNext('dependentCareFsa')} prefix="$"
            hint={`cap ${fmtCur(limits.fsa ? limits.fsa.dependentCareMFJ : 0)} — raised by OBBBA`}/>
          <NumField label="Your IRA" value={cfg.next.iraContribution != null ? cfg.next.iraContribution : limits.ira.limit}
            onChange={setNext('iraContribution')} prefix="$" hint="backdoor — non-deductible then converted"/>
          <NumField label="Spouse IRA" value={cfg.next.spouseIraContribution != null ? cfg.next.spouseIraContribution : limits.ira.limit}
            onChange={setNext('spouseIraContribution')} prefix="$"/>
        </div>
      </div>

      {/* Funding plan — the instruction to payroll */}
      <div className="card" style={{marginBottom:16}}>
        <div style={{fontWeight:600,marginBottom:4}}>How each target gets funded</div>
        <div style={{fontSize:11,color:'#64748b',marginBottom:14,lineHeight:1.6}}>
          A limit is a number; this is the instruction. Regular pay and the bonus are different instruments — the bonus
          can absorb a large deferral in one check, and it withholds at the 22% supplemental rate, which is
          <strong style={{color:'#94a3b8'}}> below this household's {p.federal.marginalRatePct}% marginal rate</strong>.
          That difference is why bonus cash quietly builds an April balance.
          {cfg.matchTrueUp === false && <span> With no employer true-up, the deferral must run to the last paycheck of the year or the match on the skipped checks is forfeited.</span>}
        </div>
        <div style={{overflowX:'auto'}}>
          <table>
            <thead>
              <tr>
                <th>Target</th><th style={{textAlign:'right'}}>Amount</th>
                <th style={{textAlign:'right'}}>From salary</th><th style={{textAlign:'right'}}>From bonus</th>
                <th style={{textAlign:'right'}}>Payroll election</th><th style={{textAlign:'right'}}>Per paycheck</th>
              </tr>
            </thead>
            <tbody>
              {fundingRows.map(([label, f]) => (
                <tr key={label}>
                  <td style={{color:'#e2e8f0',fontWeight:600}}>{label}
                    {label.startsWith('After-tax') && p.employer.provisional && (
                      <div style={{fontSize:10,color:'#f59e0b'}}>⚠ 415(c) room rests on placeholder employer rates</div>
                    )}
                    {!f.feasible && <div style={{fontSize:10,color:'#f59e0b'}}>⚠ {fmtCur(f.shortfall)} cannot be funded from pay at a {cfg.maxDeferralPct}% cap</div>}
                  </td>
                  <td style={{textAlign:'right',color:'#e2e8f0'}}>{fmtCur(f.target)}</td>
                  <td style={{textAlign:'right',color:'#94a3b8'}}>{fmtCur(f.fromBase)}</td>
                  <td style={{textAlign:'right',color:'#94a3b8'}}>{f.fromBonus > 0 ? fmtCur(f.fromBonus) : '—'}</td>
                  <td style={{textAlign:'right',color:'#10b981',fontWeight:600}}>
                    {f.basePct.toFixed(1)}% of pay{f.bonusPct > 0 ? ` + ${f.bonusPct.toFixed(0)}% of bonus` : ''}
                  </td>
                  <td style={{textAlign:'right',color:'#94a3b8'}}>{fmtCur(f.perPaycheck)}</td>
                </tr>
              ))}
              <tr>
                <td style={{color:'#e2e8f0',fontWeight:600}}>Employer match + core
                  {p.employer.provisional && <div style={{fontSize:10,color:'#f59e0b'}}>⚠ placeholder rates</div>}
                </td>
                <td style={{textAlign:'right',color: p.employer.provisional ? '#f59e0b' : '#10b981'}}>{fmtCur(p.employer.total)}</td>
                {/* "Free money" is a claim, and it must not be made in the one
                    case the number is a guess — that pairing is what put a
                    5% core on a 4% plan and called the result free money. */}
                <td colSpan={4} style={{color:'#64748b',fontSize:11}}>
                  {p.employer.provisional
                    ? `${p.employer.rates.matchPct}% match + ${p.employer.rates.corePct}% core are placeholders — enter your real rates in Assumptions on the ${p.year - 1} tab`
                    : `funded by the employer, not by you — ${p.employer.rates.matchPct}% match + ${p.employer.rates.corePct}% core`}
                </td>
              </tr>
            </tbody>
          </table>
        </div>
        <div style={{marginTop:12,display:'flex',gap:10,flexWrap:'wrap'}}>
          <CheckField label="Bonus deferral allowed" checked={cfg.bonusDeferralAllowed} onChange={set('bonusDeferralAllowed')}
            hint="some plans exclude bonus from deferral entirely — check the SPD"/>
          <CheckField label="Employer trues up the match annually" checked={cfg.matchTrueUp} onChange={set('matchTrueUp')}
            hint="without a true-up, hitting the cap early forfeits match"/>
        </div>
      </div>

      {/* Side by side */}
      <div className="grid-2" style={{marginBottom:16}}>
        <div className="card">
          <div style={{fontWeight:600,marginBottom:12}}>{year} vs {current.year}</div>
          <table>
            <thead><tr><th>Line</th><th style={{textAlign:'right'}}>{current.year}</th><th style={{textAlign:'right'}}>{year}</th><th style={{textAlign:'right'}}>Δ</th></tr></thead>
            <tbody>
              {compare.map(([label, a, b]) => (
                <tr key={label}>
                  <td>{label}</td>
                  <td style={{textAlign:'right',color:'#94a3b8'}}>{fmtCur(a)}</td>
                  <td style={{textAlign:'right',color:'#e2e8f0',fontWeight:600}}>{fmtCur(b)}</td>
                  <td style={{textAlign:'right',color: delta(b,a) > 0 ? '#f59e0b' : '#10b981'}}>{signed(delta(b,a))}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
        <div className="card">
          <div style={{fontWeight:600,marginBottom:12}}>Estimated {year} limits</div>
          <Row label="401(k) / 403(b) elective" value={fmtCur(limits.k401.employee)}/>
          <Row label="Total §415(c) (all sources)" value={fmtCur(limits.k401.total415c)}/>
          <Row label="IRA" value={fmtCur(limits.ira.limit)}/>
          <Row label="HSA family" value={fmtCur(limits.hsa.family)}/>
          <Row label="Healthcare FSA" value={fmtCur(limits.fsa ? limits.fsa.healthcare : 0)}/>
          <Row label="Dependent Care FSA" value={fmtCur(limits.fsa ? limits.fsa.dependentCareMFJ : 0)}/>
          <Row label="Standard deduction (MFJ)" value={fmtCur(limits.mfj.stdDeduction)}/>
          <Row label="Social Security wage base" value={fmtCur(limits.fica.socialSecurityWageBase)}/>
          <Row label="Roth IRA phase-out starts" value={fmtCur(limits.mfj.rothPhaseOut.start)}/>
          <Row label="Gift exclusion (529 per-donor)" value={fmtCur(limits.gift.annualExclusion)}
            note={`superfund 5× = ${fmtCur(limits.gift.annualExclusion * 5)} per donor, per child`}/>
          {limits.estimated && (
            <div style={{marginTop:10,padding:'9px 11px',background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.3)',
              borderRadius:6,fontSize:11,color:'#fbbf24',lineHeight:1.6}}>
              Estimated at +{limits.inflationPct}% from {limits.estimatedFrom}, rounded to the increments the statute
              actually uses ($500 for deferrals, $1,000 for §415(c), $50 for HSA, $300 for the wage base). Plan against
              them, but re-check in November before the elections are locked.
            </div>
          )}
        </div>
      </div>

      <DeferralTiming sched={deferralTiming} year={year}/>

      <PayrollBreakdown fica={p.fica} year={year} estimated={limits.estimated}/>

      <div className="card" style={{marginBottom:16}}>
        <div style={{fontWeight:600,marginBottom:4}}>{year} capacity at the planned elections</div>
        <div style={{fontSize:11,color:'#64748b',marginBottom:12}}>Per-paycheck figures assume the full {p.cadence.perYear} pay periods.</div>
        <CapacityTable capacity={p.capacity} showPerPaycheck/>
      </div>

      <div className="card" style={{marginBottom:16}}>
        <div style={{fontWeight:600,marginBottom:4}}>What to change for {year}</div>
        <div style={{fontSize:11,color:'#64748b',marginBottom:14}}>
          Priced at {pct1(p.value.deferral.combinedPct)} combined federal + Delaware on the projected {year} income.
          Elections marked <Chip tone="amber">by Dec 31</Chip> have to be in place before the first payroll of the year.
        </div>
        <ActionList actions={p.actions}/>
      </div>

      <HealthDecisionCard health={plan.health} decision={plan.healthDecision} value={plan.value} year={year}/>

      <AiBrief plan={briefPayload(p)} year={year} kind="nextYear"
        stored={briefs[year]} onStored={t => setBriefs(b => ({...b, [year]: t}))}/>
    </>
  );
}

// ── Assumptions editor ────────────────────────────────────────────────────
// Deliberately at the BOTTOM of the current-year page and not behind a modal:
// the projection is only as good as these, and they should be visible under
// the numbers they produced.
function Assumptions({ cfg, set, setHealth, elective401kNow, spouse403bNow, pensionRate, defaultPensionPickup,
                       kidsUnder17, spouseSalary, buckets, plan, filed, employerConfig, saveEmployer }) {
  const f = filed || {};
  // The employer rates save to the RETIREMENT config, not the tax one, so
  // they get their own draft and their own button rather than riding on the
  // page's Save. Two destinations behind one button is how a user learns not
  // to trust either.
  const [emp, setEmp] = useState(employerConfig);
  const [empDirty, setEmpDirty] = useState(false);
  useEffect(() => { setEmp(employerConfig); setEmpDirty(false); }, [employerConfig]);
  const setEmpField = (k) => (v) => { setEmp(e => ({ ...e, [k]: v })); setEmpDirty(true); };
  const rates = plan.employer.rates || { sources: {}, assumed: false };
  const src = rates.sources || {};
  // A field a filed return supplies is shown as read-only with its source. An
  // input that silently ignores what you type into it is worse than no input.
  const Filed = ({ label, value, hint }) => (
    <div>
      <div className="label" style={{marginBottom:3,fontSize:10}}>{label}</div>
      <div style={{padding:'8px 12px',background:'rgba(16,185,129,0.07)',border:'1px solid rgba(16,185,129,0.25)',
        borderRadius:8,color:'#e2e8f0',display:'flex',justifyContent:'space-between',alignItems:'center',gap:8}}>
        <span>{fmtCur(value)}</span><Chip tone="green">filed {f.year}</Chip>
      </div>
      <div style={{fontSize:10,color:'#475569',marginTop:3}}>{hint}</div>
    </div>
  );
  return (
    <div className="card">
      <div style={{fontWeight:600,marginBottom:4}}>Assumptions behind these numbers</div>
      <div style={{fontSize:11,color:'#64748b',marginBottom:14,lineHeight:1.6}}>
        Income, payslips, employer match and spouse salary come from Compensation and Retirement. Everything below is
        specific to tax and lives here. Blank means zero, and zero is used as stated — nothing is silently guessed.
        Fields marked <Chip tone="green">filed</Chip> come from an uploaded return and win over anything typed.
      </div>

      {SUB('Contributions this year')}
      <div style={GRID}>
        <NumField label="Your 401(k) full year" value={elective401kNow} onChange={set('elective401k')} prefix="$"
          hint="defaults to where the current election lands"/>
        <NumField label="Spouse 403(b) full year" value={spouse403bNow} onChange={set('spouse403b')} prefix="$"/>
        <NumField label="After-tax 401(k)" value={cfg.afterTax401k} onChange={set('afterTax401k')} prefix="$"/>
        <NumField label="Your IRA" value={cfg.iraContribution} onChange={set('iraContribution')} prefix="$"/>
        <NumField label="Spouse IRA" value={cfg.spouseIraContribution} onChange={set('spouseIraContribution')} prefix="$"/>
      </div>

      <div style={{height:16}}/>
      {SUB("Employer contributions — the numbers nothing else can check")}
      <div style={{fontSize:11,color:'#64748b',marginBottom:10,lineHeight:1.65}}>
        These live in your SPD and nowhere else — employer money is not a payroll deduction, so no payslip,
        statement or return carries it. A blank here is not zero: it is <Chip tone="amber">assumed</Chip>, and
        everything downstream says so. One percentage point moves the total by about
        {' '}<strong style={{color:'#e2e8f0'}}>{fmtCur(Math.round((plan.income.yourBase || 0) / 100))}</strong> a
        year — and it moves twice, because 415(c) room is the limit less your deferrals less employer money, so
        over-stating the employer <em>under</em>-states how much after-tax you are allowed to add.
        Saved to the Retirement config, so Retirement and Strategy change with it.
      </div>
      <div style={GRID}>
        <OptNumField label="Employer match" value={emp.matchPct} onChange={setEmpField('matchPct')} suffix="%" step={0.5}
          assumed={src.matchPct === 'assumed'}
          hint={`of pay, dollar for dollar up to this much deferred${src.matchPct === 'assumed' ? ` — running on ${rates.matchPct}%` : ''}`}/>
        <OptNumField label="Employer core (non-elective)" value={emp.corePct} onChange={setEmpField('corePct')} suffix="%" step={0.5}
          assumed={src.corePct === 'assumed'}
          hint={`paid whether or not you defer${src.corePct === 'assumed' ? ` — running on ${rates.coreBasePct}%` : ''}`}/>
        <OptNumField label="Core salary cap" value={emp.coreSalaryCap} onChange={setEmpField('coreSalaryCap')} prefix="$" step={5000}
          assumed={src.coreSalaryCap === 'assumed'}
          hint="core is paid on pay up to here, not on all of it"/>
        <TextField label="Service start date" value={emp.serviceStartDate} onChange={setEmpField('serviceStartDate')} type="date"
          hint="only needed if the core steps with service"/>
        <OptNumField label="Core steps after" value={emp.coreStepYears} onChange={setEmpField('coreStepYears')} suffix="yrs" step={1}
          hint="e.g. 20 if you are on the lower rate until twenty years' service"/>
        <OptNumField label="…and becomes" value={emp.coreStepPct} onChange={setEmpField('coreStepPct')} suffix="%" step={0.5}
          hint="blank = no step modelled; the rate is held flat rather than guessed"/>
      </div>
      {rates.step && (
        <div style={{marginTop:10,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.65,
          background:'rgba(59,130,246,0.08)',border:'1px solid rgba(59,130,246,0.25)',color:'#94a3b8'}}>
          {rates.step.serviceYearsAtYearEnd != null && (
            <>You reach {rates.step.years} years on <strong style={{color:'#e2e8f0'}}>{rates.step.date}</strong>
              {' '}({rates.step.serviceYearsAtYearEnd} years' service at the end of {plan.year}). </>
          )}
          {rates.coreBlended
            ? <>It lands inside {plan.year}, so the core is blended: {rates.step.checksAtOldRate} paychecks at {rates.coreBasePct}%
                and the rest at {rates.step.pct}%, giving <strong style={{color:'#e2e8f0'}}>{rates.corePct}%</strong> for the year.</>
            : rates.step.reachedBy
              ? <>The step has already happened, so {plan.year} is at the full {rates.corePct}%.</>
              : <>{plan.year} is still at {rates.corePct}% — the step is {rates.step.yearsAway} years away.</>}
        </div>
      )}
      {rates.stepUnpriced && (
        <div style={{marginTop:10,padding:'9px 11px',borderRadius:6,fontSize:11,lineHeight:1.65,
          background:'rgba(245,158,11,0.1)',border:'1px solid rgba(245,158,11,0.3)',color:'#fbbf24'}}>
          A step at {rates.stepYears} years' service is recorded, but not what the rate becomes — so every projection
          here and on Retirement holds the core flat at {rates.coreBasePct}%. Fill in "…and becomes" and the step
          prices itself, including the year it lands.
        </div>
      )}
      <div style={{marginTop:10}}>
        <button className={empDirty ? 'btn-primary' : 'btn'} disabled={!empDirty}
          onClick={() => saveEmployer(emp)} style={{opacity: empDirty ? 1 : 0.5}}>
          {empDirty ? 'Save employer rates' : 'Employer rates saved'}
        </button>
      </div>

      <div style={{height:16}}/>
      {SUB('Household')}
      <div style={GRID}>
        <NumField label="Other income" value={cfg.otherIncome} onChange={set('otherIncome')} prefix="$"
          hint="interest, dividends, realised gains"/>
        {f.itemized != null
          ? <Filed label="Itemized deductions" value={f.itemized} hint="Schedule A on the filed return"/>
          : <NumField label="Itemized deductions" value={cfg.itemizedDeductions} onChange={set('itemizedDeductions')} prefix="$"
              hint={`0 = take the ${fmtCur(plan.federal.stdDeduction)} standard`}/>}
        {f.capitalLossCarryforward != null
          ? <Filed label="Capital loss carryforward" value={f.capitalLossCarryforward} hint="Schedule D carryover worksheet"/>
          : <NumField label="Capital loss carryforward" value={cfg.capitalLossCarryforward} onChange={set('capitalLossCarryforward')} prefix="$"
              hint="deductible $3,000/yr against ordinary income"/>}
        <NumField label="Realised capital gains this year" value={cfg.capitalGains} onChange={set('capitalGains')} prefix="$"
          hint="a carryforward offsets these in full, before the $3,000 cap applies"/>
        <SelectField label="Bonus paid in" value={String(cfg.bonusPaidMonth || 0)}
          onChange={v=>set('bonusPaidMonth')(Number(v))} options={[['0','Work it out from the payslips'],['1','January'],['2','February'],['3','March'],['4','April'],['5','May'],['6','June'],['7','July'],['8','August'],['9','September'],['10','October'],['11','November'],['12','December']]}
          hint="a bonus parsed into the base-pay column leaves its own column empty — say the month and it stops being guessed at"/>
        <SelectField label="RSU delivered in" value={String(cfg.rsuPaidMonth || 0)}
          onChange={v=>set('rsuPaidMonth')(Number(v))} options={[['0','Work it out from the payslips'],['1','January'],['2','February'],['3','March'],['4','April'],['5','May'],['6','June'],['7','July'],['8','August'],['9','September'],['10','October'],['11','November'],['12','December']]}
          hint="a vest often arrives on its own statement that never gets uploaded"/>
        <NumField label="Delaware itemized" value={cfg.stateItemizedDeductions} onChange={set('stateItemizedDeductions')} prefix="$"
          hint="federal itemized less state income tax paid"/>
        {kidsUnder17 != null ? (
          <div>
            <div className="label" style={{marginBottom:3,fontSize:10}}>Kids under 17</div>
            <div style={{padding:'8px 12px',background:'#0f1520',border:'1px solid #1e2a3a',borderRadius:8,color:'#e2e8f0'}}>{kidsUnder17}</div>
            <div style={{fontSize:10,color:'#475569',marginTop:3}}>from the Kids tab, aged as of Dec 31 — edit the birthdays there</div>
          </div>
        ) : (
          <NumField label="Kids under 17" value={cfg.kids} onChange={set('kids')}
            hint="no kid records on the Kids tab — enter manually"/>
        )}
        <NumField label="Health premiums (annual)" value={cfg.healthPremium} onChange={set('healthPremium')} prefix="$"
          hint="0 = read from payslips"/>
        <NumField label="Expected retirement rate" value={cfg.expectedRetirementRate} onChange={set('expectedRetirementRate')} suffix="%"
          hint="drives Roth vs traditional"/>
        <NumField label="Assumed return" value={cfg.assumedReturnPct} onChange={set('assumedReturnPct')} suffix="%" step={0.5}
          hint="only sizes the front-loading advantage — never a saving, always labelled modelled"/>
        <SelectField label="Account paying a conversion's tax"
          value={cfg.conversionDragPreset} onChange={set('conversionDragPreset')}
          options={window.RothConversion.DRAG_PRESETS.map(d => [d.key, `${d.label} (${d.pct}% drag)`])}
          hint="the single biggest lever on the break-even rate — the more that account is taxed each year, the more converting wins"/>
        <NumField label="Years until the money is spent" value={cfg.conversionHorizonYears} onChange={set('conversionHorizonYears')} suffix="yrs"
          hint="a longer horizon widens the conversion advantage"/>
        <NumField label="Taxable income in retirement" value={cfg.retirementTaxableIncome} onChange={set('retirementTaxableIncome')} prefix="$"
          hint="pension plus what you draw — it sets how much bracket room the good years actually have"/>
        <NumField label="Retire at" value={cfg.retireAge} onChange={set('retireAge')} suffix="yrs"
          hint="the conversion runway is this age to your RMD age — 0 follows the Retirement tab"/>
      </div>

      <div style={{height:16}}/>
      {SUB('Withholding')}
      <div style={GRID}>
        {f.priorYearTotalTax != null
          ? <Filed label="Last year's total tax" value={f.priorYearTotalTax} hint="1040 line 24 — unlocks the cheaper safe-harbour target"/>
          : <NumField label="Last year's total tax (1040 line 24)" value={cfg.priorYearTotalTax} onChange={set('priorYearTotalTax')} prefix="$"
              hint="unlocks the cheaper safe-harbour target — or upload the return above"/>}
        {f.priorYearAgi != null
          ? <Filed label="Last year's AGI" value={f.priorYearAgi} hint="1040 line 11 — over $150k means the target is 110%"/>
          : <NumField label="Last year's AGI" value={cfg.priorYearAgi} onChange={set('priorYearAgi')} prefix="$"
              hint="over $150k → the target is 110%"/>}
        <NumField label="Spouse federal withheld" value={cfg.spouseFederalWithholding} onChange={set('spouseFederalWithholding')} prefix="$"
          hint="0 = modelled from a naive W-4"/>
        <NumField label="Spouse Delaware withheld" value={cfg.spouseStateWithholding} onChange={set('spouseStateWithholding')} prefix="$"/>
        <NumField label={`Spouse pension pick-up (${pensionRate}% over $6k)`} value={cfg.spousePensionPickup} onChange={set('spousePensionPickup')} prefix="$"
          hint={`§414(h)(2) — federal-exempt, payroll-taxable. Default ${fmtCur(defaultPensionPickup)} on ${fmtCur(spouseSalary)}`}/>
      </div>

      <div style={{height:16}}/>
      {SUB('Accounts & plan features')}
      <div style={GRID}>
        <NumField label="Pre-tax IRA — yours" value={cfg.useAccountIraBalance ? buckets.preTaxIra : cfg.preTaxIraYou}
          onChange={set('preTaxIraYou')} prefix="$" hint={cfg.useAccountIraBalance ? 'read from linked accounts' : 'entered manually'}/>
        <NumField label="Pre-tax IRA — spouse" value={cfg.preTaxIraSpouse} onChange={set('preTaxIraSpouse')} prefix="$"
          hint="pro-rata is per taxpayer, not per household"/>
        {f.yourBasis != null
          ? <Filed label="IRA basis — yours (Form 8606)" value={f.yourBasis} hint="line 14 — already-taxed money, excluded from the pro-rata cost"/>
          : <NumField label="IRA basis — yours (Form 8606 line 14)" value={cfg.iraBasisYou} onChange={set('iraBasisYou')} prefix="$"
              hint="non-deductible contributions already taxed once — upload a return to fill this in"/>}
        {f.spouseBasis != null
          ? <Filed label="IRA basis — spouse (Form 8606)" value={f.spouseBasis} hint="line 14 on her 8606"/>
          : <NumField label="IRA basis — spouse (Form 8606)" value={cfg.iraBasisSpouse} onChange={set('iraBasisSpouse')} prefix="$"/>}
        <NumField label="Max payroll deferral %" value={cfg.maxDeferralPct} onChange={set('maxDeferralPct')} suffix="%"
          hint="the ceiling your plan allows per paycheck"/>
      </div>
      <div style={{marginTop:12,display:'flex',gap:10,flexWrap:'wrap'}}>
        <CheckField label="Your 401(k) is elected Roth" checked={cfg.electiveIsRoth} onChange={set('electiveIsRoth')}
          hint="no current-year deduction"/>
        <CheckField label="Spouse 403(b) is elected Roth" checked={cfg.spouseElectiveIsRoth} onChange={set('spouseElectiveIsRoth')}/>
        <CheckField label="Use linked-account IRA balance" checked={cfg.useAccountIraBalance} onChange={set('useAccountIraBalance')}
          hint={`currently ${fmtCur(buckets.preTaxIra)}`}/>
        <CheckField label="Employer trues up the match" checked={cfg.matchTrueUp} onChange={set('matchTrueUp')}/>
        <CheckField label="Buying our own health cover before Medicare"
          checked={cfg.marketplaceCoverageBeforeMedicare} onChange={set('marketplaceCoverageBeforeMedicare')}
          hint="retiring before 65 on marketplace cover: a conversion is ACA MAGI, and the 400% cliff is back for 2026"/>
      </div>
      <div style={{height:16}}/>
      {SUB('Health accounts — the household ones')}
      <div style={{fontSize:11,color:'#64748b',marginBottom:10,lineHeight:1.6}}>
        These four fields decide each other. An HSA needs someone on an HDHP; a general-purpose healthcare FSA on
        <em> either</em> side cancels it for <em>both</em>. Which payroll each one runs through changes the payroll-tax
        saving, because his wages pass the Social Security cap during the year and hers do not.
      </div>
      <div style={GRID}>
        <SelectField label="Who is on an HDHP?" value={cfg.health.hsaOwner} onChange={setHealth('hsaOwner')}
          options={[['none','Neither of us'],['you','Me (my employer)'],['spouse','My wife (State of Delaware)']]}
          hint="the HSA belongs to whoever carries the high-deductible plan"/>
        <SelectField label="HDHP coverage tier" value={cfg.health.hsaCoverage} onChange={setHealth('hsaCoverage')}
          options={[['family','Family'],['single','Self-only']]}
          hint={`family limit ${fmtCur(plan.health.hsaLimit)} — one limit for the household, not one each`}/>
        <NumField label="HSA contribution (employee)" value={cfg.health.hsaContribution} onChange={setHealth('hsaContribution')} prefix="$"
          hint={plan.health.hsaEligible ? 'pre-tax through payroll' : 'blocked — see the decision card above'}/>
        <NumField label="Employer HSA contribution" value={cfg.health.employerHsaContribution} onChange={setHealth('employerHsaContribution')} prefix="$"
          hint="free money, but it counts against the same limit"/>
        <SelectField label="Healthcare FSA type" value={cfg.health.healthcareFsaKind} onChange={setHealth('healthcareFsaKind')}
          options={[['none','Not elected'],['general','General purpose (HCSA)'],['limited','Limited purpose (dental/vision)']]}
          hint="only the general-purpose flavour blocks an HSA"/>
        <NumField label="Healthcare FSA amount" value={cfg.health.healthcareFsa} onChange={setHealth('healthcareFsa')} prefix="$"/>
        <SelectField label="Healthcare FSA payroll" value={cfg.health.healthcareFsaOwner} onChange={setHealth('healthcareFsaOwner')}
          options={[['you','My paycheck'],['spouse',"My wife's paycheck"]]}/>
        <NumField label="Dependent Care FSA" value={cfg.health.dependentCareFsa} onChange={setHealth('dependentCareFsa')} prefix="$"
          hint="does not affect HSA eligibility"/>
        <SelectField label="Dependent Care payroll" value={cfg.health.dependentCareFsaOwner} onChange={setHealth('dependentCareFsaOwner')}
          options={[['spouse',"My wife's paycheck"],['you','My paycheck']]}/>
        <NumField label="HDHP premium difference" value={cfg.hdhpPremiumDelta} onChange={set('hdhpPremiumDelta')} prefix="$"
          hint="annual extra cost of the HDHP vs today's plan — folded into the comparison"/>
        <NumField label="FSA dollars likely unspent" value={cfg.fsaForfeitureRisk} onChange={set('fsaForfeitureRisk')} prefix="$"
          hint="honest forfeiture estimate; the carryover is netted off"/>
      </div>
      <div style={{marginTop:12,display:'flex',gap:10,flexWrap:'wrap'}}>
        <CheckField label="HSA funded through payroll" checked={cfg.health.hsaViaPayroll} onChange={setHealth('hsaViaPayroll')}
          hint="a bank transfer gets the income-tax deduction but not the payroll-tax saving"/>
      </div>

    </div>
  );
}

// What the AI brief receives. Deliberately a SUBSET: the model gets the
// computed conclusions and the inputs behind them, not the raw payslip series
// — there is nothing it could do with 26 rows of gross pay that this file has
// not already done, and every extra token is latency the user waits through.
function briefPayload(plan) {
  return {
    year: plan.year, mode: plan.mode, asOf: plan.asOf,
    constants: plan.constants,
    income: plan.income,
    preTax: plan.preTax,
    federal: { ...plan.federal, childTaxCredit: plan.federal.childTaxCredit },
    state: { tax: plan.state.tax, marginalRate: plan.state.marginalRate, taxable: plan.state.taxable },
    fica: plan.fica,
    health: plan.health,
    healthDecision: plan.healthDecision,
    total: plan.total,
    withholding: plan.withholding,
    value: plan.value,
    capacity: plan.capacity.map(r => ({
      key: r.key, label: r.label, limit: r.limit, contributed: r.contributed,
      remaining: r.remaining, perPaycheck: r.perPaycheck, deadline: r.deadline, fits: r.fits, note: r.note,
    })),
    employer: plan.employer,
    conversion: plan.conversion,
    proRata: plan.proRata,
    capitalLoss: plan.capitalLoss,
    // The break-even analysis, computed. The model is told the answer and
    // forbidden from re-deriving it — see rule 9 in the prompt.
    conversionAnalysis: plan.conversionAnalysis || null,
    gainsRate: plan.gainsRate,
    // Sent computed, like everything else here: the model is told what the
    // harvest IS, never asked to work out how a carryforward nets.
    gainHarvest: plan.gainHarvest,
    buckets: plan.buckets,
    periodsRemaining: plan.periodsRemaining,
    deadlines: plan.deadlines,
    actions: plan.actions,
  };
}

window.FinanceViews = Object.assign(window.FinanceViews || {}, { Tax });
