// Vantage — Pulse: the information arms. Three sentiment dials (Fear &
// Greed, VIX, put/call) with interpretation a market participant can act
// on, and a news stream filtered to the whole tape or just your book.
// Any arm whose source is down says so — no stale needles.

const VG_FG_ZONES = [
  { to: 25, label: 'extreme fear', color: '#f87171' },
  { to: 45, label: 'fear', color: '#fb923c' },
  { to: 55, label: 'neutral', color: '#8f8a9e' },
  { to: 75, label: 'greed', color: '#86efac' },
  { to: 100, label: 'extreme greed', color: '#4ade80' },
];
const vgFgZone = s => VG_FG_ZONES.find(z => s <= z.to) ?? VG_FG_ZONES[2];

// Semicircular gauge, 0–100. Zones as arc segments, needle at the score.
function VgGauge({ score, size = 190 }) {
  const w = size, h = size * 0.58, cx = w / 2, cy = h - 6, r = w / 2 - 10;
  const angle = s => Math.PI * (1 - s / 100); // 0 → left (π), 100 → right (0)
  const pt = (a, rad) => [cx + rad * Math.cos(a), cy - rad * Math.sin(a)];
  const arc = (from, to, rad, width, color, opacity) => {
    const [x1, y1] = pt(angle(from), rad);
    const [x2, y2] = pt(angle(to), rad);
    return <path key={`${from}-${to}`} d={`M ${x1} ${y1} A ${rad} ${rad} 0 0 1 ${x2} ${y2}`}
      fill="none" stroke={color} strokeWidth={width} strokeLinecap="butt" opacity={opacity} />;
  };
  const segs = [];
  let start = 0;
  for (const z of VG_FG_ZONES) {
    segs.push(arc(start + 0.5, z.to - 0.5, r, 9, z.color, score != null && score > start && score <= z.to ? 1 : 0.28));
    start = z.to;
  }
  const [nx, ny] = score != null ? pt(angle(score), r - 13) : [cx, cy];
  return (
    <svg width={w} height={h + 8} style={{ display: 'block', overflow: 'visible' }}>
      {segs}
      {score != null && (
        <g>
          <line x1={cx} y1={cy} x2={nx} y2={ny} stroke={VG.ink} strokeWidth="1.6" strokeLinecap="round" />
          <circle cx={cx} cy={cy} r="3.4" fill={VG.ink} />
        </g>
      )}
      <text x={12} y={h + 6} fill={VG.ink4} fontSize="8.5" style={{ letterSpacing: '0.1em' }}>FEAR</text>
      <text x={w - 12} y={h + 6} fill={VG.ink4} fontSize="8.5" textAnchor="end" style={{ letterSpacing: '0.1em' }}>GREED</text>
    </svg>
  );
}

function VgPulseTile({ label, labelHref, actions, children, blurb, tip }) {
  return (
    <div style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
        {labelHref ? (
          <a href={labelHref} target="_blank" rel="noopener noreferrer"
            style={{ ...vgS.caps, color: VG.ink3, borderBottom: `1px dotted ${VG.accent}` }}
            title="open the source">{label} ↗</a>
        ) : (
          <span style={vgS.caps}>{label}</span>
        )}
        <span style={{ flex: 1 }} />
        {actions}
      </div>
      <div style={{ flex: 1 }}>{children}</div>
      <VgBlurb text={blurb} />
      <VgTip text={tip} />
    </div>
  );
}

function VgUnreachable({ what }) {
  return <div style={{ color: VG.ink3, fontSize: 12.5, padding: '18px 0' }}>{what} unreachable right now — returns automatically.</div>;
}

// A tile-shaped placeholder for a feed still in flight — distinct from
// "unreachable" so a slow arm reads as loading, not failed.
function VgLoading({ what }) {
  return <div style={{ background: VG.tile, borderRadius: 18, padding: 18, color: VG.ink4, fontSize: 13, ...vgS.serif }}>reading {what}…</div>;
}

function VgFearGreedTile({ fg, blurb, onNote }) {
  const CNN_URL = 'https://www.cnn.com/markets/fear-and-greed';
  if (!fg) return <VgPulseTile label="fear & greed · cnn" labelHref={CNN_URL}><VgUnreachable what="CNN index" /></VgPulseTile>;
  const zone = vgFgZone(fg.score);
  const deltas = [
    ['prev close', fg.prevClose], ['1 wk', fg.prevWeek],
    ['1 mo', fg.prevMonth], ['1 yr', fg.prevYear],
  ].filter(([, v]) => v != null);
  return (
    <VgPulseTile label="fear & greed · cnn" labelHref={CNN_URL}
      actions={<VgNoteBtn onClick={() => onNote({ kind: 'signal', key: 'fear-greed', label: 'Fear & Greed', snapshot: { score: fg.score, rating: fg.rating } })} />}
      blurb={blurb}
      tip="extremes are often contrarian — the crowd tends to be wrong at the edges, though extremes can deepen before they turn.">
      <div style={{ display: 'flex', gap: 18, alignItems: 'center', flexWrap: 'wrap' }}>
        <div style={{ position: 'relative' }}>
          <VgGauge score={fg.score} />
          <div style={{ position: 'absolute', left: 0, right: 0, bottom: 12, textAlign: 'center' }}>
            <div style={{ ...vgS.serif, ...vgS.num, fontSize: 30, color: zone.color, lineHeight: 1 }}>{Math.round(fg.score)}</div>
            <div style={{ ...vgS.caps, color: zone.color }}>{fg.rating || zone.label}</div>
          </div>
        </div>
        <div style={{ flex: 1, minWidth: 180 }}>
          {/* Horizontal delta strip — the reference points in one row. */}
          <div style={{ display: 'flex', gap: 0, marginBottom: 8 }}>
            {deltas.map(([lbl, v], i) => (
              <div key={lbl} style={{
                flex: 1, textAlign: 'center', padding: '2px 8px',
                borderLeft: i > 0 ? `1px solid ${VG.rule}` : 'none',
              }}>
                <div style={{ ...vgS.caps, color: VG.ink4, fontSize: 9 }}>{lbl}</div>
                <div style={{ ...vgS.num, fontSize: 15, fontWeight: 600, color: vgFgZone(v).color }}>{Math.round(v)}</div>
              </div>
            ))}
          </div>
          {fg.history?.length > 5 && (
            <VgChartX data={fg.history.map(p => p.y)}
              labels={fg.history.map(p => p.t ? new Date(p.t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '')}
              color={zone.color} h={64} fmt={v => Math.round(v)} caption="~90 days · hover for history" />
          )}
        </div>
      </div>
    </VgPulseTile>
  );
}

function VgVixTile({ vix, blurb, onLive, onNote }) {
  if (!vix) return <VgPulseTile label="vix · implied volatility"><VgUnreachable what="VIX data" /></VgPulseTile>;
  const chg = vix.prevClose ? vix.level / vix.prevClose - 1 : null;
  const bandColor = { calm: '#4ade80', normal: VG.ink3, elevated: '#fb923c', high: '#f87171' }[vix.band?.key] || VG.ink3;
  return (
    <VgPulseTile label="vix · implied volatility"
      actions={
        <React.Fragment>
          <VgLiveBtn onClick={() => onLive({ choices: VG_TV.VIX, label: 'VIX' })} />
          <VgNoteBtn onClick={() => onNote({ kind: 'signal', key: 'vix', label: 'VIX', snapshot: { level: vix.level, band: vix.band?.key } })} />
        </React.Fragment>
      }
      blurb={blurb}
      tip={vix.band ? `${vix.band.key} regime — ${vix.band.note}` : null}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
        <span style={{ ...vgS.serif, ...vgS.num, fontSize: 34, color: bandColor, lineHeight: 1 }}>{vix.level.toFixed(2)}</span>
        {/* Rising vol is a risk event, not a gain — color is inverted. */}
        {chg != null && <span style={{ ...vgS.num, fontSize: 12.5, color: vgDelta(-chg) }}>{vgPct(chg)} today</span>}
        {vix.band && <VgTag color={bandColor}>{vix.band.key}</VgTag>}
      </div>
      {vix.series && (
        <div style={{ marginTop: 10 }}>
          <VgChartX data={vix.series} color={bandColor} h={72} caption="3 months of closes · hover for history" />
        </div>
      )}
      <div style={{ fontSize: 11.5, color: VG.ink3, marginTop: 8 }}>
        markets are pricing a ±{vix.impliedMonthlyMovePct}% S&P move over the next month.
      </div>
    </VgPulseTile>
  );
}

// The put/call chart ALWAYS shows. No embeddable live feed exists for the
// ratio (TradingView's USI:PC is view-on-site only), so Vantage records its
// own reading daily and charts the accumulated history.
function VgPcHistory({ history }) {
  const data = (history || []).map(r => r.v);
  if (data.length >= 2) {
    return (
      <div style={{ marginTop: 12 }}>
        <VgChartX data={data} labels={(history || []).map(r => r.d)} color={VG.accent}
          h={64} fmt={v => v.toFixed(2)}
          caption={`vantage's own daily log · ${data.length} days and growing`} />
      </div>
    );
  }
  return (
    <div style={{ fontSize: 11, color: VG.ink4, marginTop: 10, lineHeight: 1.5 }}>
      no free historical source exists for this ratio, so Vantage logs its own reading once a day —
      {data.length === 1 ? ' first point recorded today; the chart draws itself from here.' : ' the chart begins with the first recorded day.'}
    </div>
  );
}

function VgPutCallTile({ pc, pcHistory, blurb, onNote }) {
  if (!pc) {
    return (
      <VgPulseTile label="put / call · options flow">
        <VgUnreachable what="options data" />
        <VgPcHistory history={pcHistory} />
      </VgPulseTile>
    );
  }
  const bandColor = { fearful: '#f87171', neutral: VG.ink3, greedy: '#4ade80' }[pc.band?.key] || VG.ink3;
  const totalVol = pc.putVol + pc.callVol;
  return (
    <VgPulseTile label={`put / call · ${pc.symbol.toLowerCase()} volume`}
      actions={<VgNoteBtn onClick={() => onNote({ kind: 'signal', key: 'put-call', label: 'Put/Call', snapshot: { ratio: pc.ratio, oiRatio: pc.oiRatio } })} />}
      blurb={blurb}
      tip={pc.band ? pc.band.note : null}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
        <span style={{ ...vgS.serif, ...vgS.num, fontSize: 34, color: bandColor, lineHeight: 1 }}>
          {pc.ratio == null ? '—' : pc.ratio.toFixed(2)}
        </span>
        {pc.band && <VgTag color={bandColor}>{pc.band.key}</VgTag>}
        {pc.oiRatio != null && <span style={{ ...vgS.num, fontSize: 11.5, color: VG.ink3 }}>open interest {pc.oiRatio.toFixed(2)}</span>}
      </div>
      {totalVol > 0 && (
        <div style={{ marginTop: 12 }}>
          <div style={{ display: 'flex', height: 8, borderRadius: 999, overflow: 'hidden', background: VG.rule }}>
            <div style={{ width: `${pc.putVol / totalVol * 100}%`, background: '#f87171', opacity: 0.85 }} />
            <div style={{ flex: 1, background: '#4ade80', opacity: 0.85 }} />
          </div>
          <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10.5, color: VG.ink3, marginTop: 4, ...vgS.num }}>
            <span>puts {(pc.putVol / 1e3).toFixed(0)}k</span>
            <span>calls {(pc.callVol / 1e3).toFixed(0)}k</span>
          </div>
        </div>
      )}
      <VgPcHistory history={pcHistory} />
      <div style={{ fontSize: 10.5, color: VG.ink4, marginTop: 8 }}>
        nearest expiries, delayed volume · 1.0 = puts match calls
      </div>
    </VgPulseTile>
  );
}

// ── The board: indices + sector rotation ──────────────────────────────────
// "Are the indices reacting?" and "is news hitting one sector harder?" —
// the divergence between sectors IS the answer, so the readout line leads.

const VG_MONTH_SHORT = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];

function VgBoard({ markets, blurb, onLive, onNote }) {
  const [openSector, setOpenSector] = React.useState(null);   // sector label whose stories are expanded
  const [sectorNews, setSectorNews] = React.useState({});     // label -> rows | 'loading' | 'error'
  const toggleSector = (label) => {
    if (openSector === label) return setOpenSector(null);
    setOpenSector(label);
    if (!sectorNews[label]) {
      setSectorNews(m => ({ ...m, [label]: 'loading' }));
      vgGet(`/vantage/api/sector-news?sector=${encodeURIComponent(label)}`)
        .then(d => setSectorNews(m => ({ ...m, [label]: d.items || [] })))
        .catch(() => setSectorNews(m => ({ ...m, [label]: 'error' })));
    }
  };
  if (!markets) return null;
  const anyIdx = markets.indices?.some(i => i.price != null);
  const anySec = markets.sectors?.some(s => s.dayPct != null);
  const monthName = VG_MONTH_SHORT[new Date().getMonth()];
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', marginBottom: 14 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
        <span style={vgS.caps}>the board</span>
        <span style={{ fontSize: 10.5, color: VG.ink4 }}>click an index for its live chart</span>
        <span style={{ flex: 1 }} />
        <VgNoteBtn onClick={() => onNote({ kind: 'signal', key: 'board', label: 'The board', snapshot: { readout: markets.readout?.key ?? null, spyDayPct: markets.spyDayPct } })} />
      </div>
      {!anyIdx && !anySec ? (
        <div style={{ color: VG.ink3, fontSize: 12.5 }}>index data unreachable right now — returns automatically.</div>
      ) : (
        <React.Fragment>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))', gap: 10, marginBottom: 14 }}>
            {markets.indices.map(i => (
              <div key={i.symbol} role="button" tabIndex={0} className="vg-row"
                onClick={() => onLive({ choices: VG_TV[i.symbol] || [[i.symbol, i.label]], label: i.label })}
                onKeyDown={e => { if (e.key === 'Enter') onLive({ choices: VG_TV[i.symbol] || [[i.symbol, i.label]], label: i.label }); }}
                style={{ padding: '6px 8px', margin: '-6px -8px', borderRadius: 10 }}>
                <div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
                  <span style={{ ...vgS.caps, color: VG.ink4 }}>{i.label}</span>
                  <span style={{ fontSize: 9, color: VG.accent2 }}>↗</span>
                </div>
                <div style={{ ...vgS.num, fontSize: 17, fontWeight: 600, color: VG.ink, marginTop: 2 }}>
                  {i.price == null ? '—' : i.kind === 'yield' ? `${i.price.toFixed(2)}%` : i.price.toLocaleString('en-US', { maximumFractionDigits: 0 })}
                </div>
                <div style={{ ...vgS.num, fontSize: 11.5, color: vgDelta(i.kind === 'yield' ? null : i.dayPct) }}>
                  {i.kind === 'yield' ? (i.dayPct == null ? '' : `${vgPct(i.dayPct)} chg`) : vgPct(i.dayPct)}
                </div>
                {i.spark && <div style={{ marginTop: 4, maxWidth: 120 }}><VgLine series={[i.spark]} colors={[vgDelta(i.dayPct)]} w={110} h={20} /></div>}
              </div>
            ))}
          </div>
          {anySec && (
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', columnGap: 22, rowGap: 8 }}>
              {markets.sectors.map(s => {
                const mag = Math.min(Math.abs(s.dayPct ?? 0) / 0.02, 1);
                return (
                  <div key={s.symbol} style={{ fontSize: 11.5 }}>
                    <div style={{ display: 'grid', gridTemplateColumns: '92px 1fr 78px', gap: 8, alignItems: 'center' }}>
                      <span style={{ color: VG.ink3 }}>{s.label.toLowerCase()}</span>
                      <span style={{ position: 'relative', height: 4, background: VG.rule, borderRadius: 999, overflow: 'hidden' }}>
                        <span style={{
                          position: 'absolute', top: 0, bottom: 0,
                          left: (s.dayPct ?? 0) >= 0 ? '50%' : `${50 - mag * 50}%`,
                          width: `${mag * 50}%`,
                          background: vgDelta(s.dayPct), borderRadius: 999,
                        }} />
                      </span>
                      <span style={{ ...vgS.num, textAlign: 'right', color: vgDelta(s.dayPct) }}>
                        {vgPct(s.dayPct)}
                        {s.relSpy != null && <span style={{ color: VG.ink4, marginLeft: 5 }}>{s.relSpy >= 0 ? '+' : ''}{(s.relSpy * 100).toFixed(1)} vs spy</span>}
                      </span>
                    </div>
                    {/* Context line: is news touching this sector, and is this
                        month usually strong or slow for it? */}
                    {(s.newsCount > 0 || s.monthAvg != null) && (
                      <div style={{ display: 'flex', gap: 10, marginTop: 2, marginLeft: 0, fontSize: 10, color: VG.ink4, ...vgS.num }}>
                        {s.newsCount > 0 && (
                          <button onClick={() => toggleSector(s.label)} title="see the tagged headlines and their sentiment" style={{
                            appearance: 'none', border: 0, background: 'transparent', cursor: 'pointer',
                            color: VG.accent2, fontFamily: 'inherit', fontSize: 10, padding: 0, ...vgS.num,
                          }}>◈ {s.newsCount} tagged stor{s.newsCount === 1 ? 'y' : 'ies'} · 36h {openSector === s.label ? '▴' : '▾'}</button>
                        )}
                        {s.monthAvg != null && (
                          <span>
                            {monthName} usually {s.monthAvg >= 0 ? '+' : ''}{(s.monthAvg * 100).toFixed(1)}%
                            {s.monthHitRate != null ? ` (${Math.round(s.monthHitRate * 100)}% of yrs)` : ''}
                          </span>
                        )}
                      </div>
                    )}
                    {openSector === s.label && (
                      <div style={{ margin: '4px 0 6px', display: 'flex', flexDirection: 'column', gap: 3 }}>
                        {sectorNews[s.label] === 'loading' && <span style={{ fontSize: 11, color: VG.ink4 }}>pulling the stories…</span>}
                        {sectorNews[s.label] === 'error' && <span style={{ fontSize: 11, color: VG.ink4 }}>stories unreachable right now.</span>}
                        {Array.isArray(sectorNews[s.label]) && sectorNews[s.label].length === 0 && (
                          <span style={{ fontSize: 11, color: VG.ink4 }}>no tagged headlines carry this sector in 7 days.</span>
                        )}
                        {Array.isArray(sectorNews[s.label]) && sectorNews[s.label].map((h, hi) => (
                          <div key={hi} style={{ display: 'flex', gap: 7, alignItems: 'baseline', fontSize: 11.5 }}>
                            <span title={h.s > 0 ? 'read as positive' : h.s < 0 ? 'read as negative' : 'neutral/unclear'}
                              style={{ color: h.s > 0 ? VG.up : h.s < 0 ? VG.down : VG.ink4, width: 10, textAlign: 'center' }}>
                              {h.s > 0 ? '▲' : h.s < 0 ? '▼' : '·'}
                            </span>
                            <a href={h.link} target="_blank" rel="noreferrer" style={{ color: VG.ink2, textDecoration: 'none', lineHeight: 1.4 }}>{h.title}</a>
                            <span style={{ ...vgS.caps, fontSize: 8, color: VG.ink4 }}>{h.source}</span>
                          </div>
                        ))}
                      </div>
                    )}
                  </div>
                );
              })}
            </div>
          )}
          {markets.readout && (
            <div style={{ ...vgS.serif, fontSize: 13, color: VG.accent2, marginTop: 12, lineHeight: 1.45 }}>{markets.readout.note}</div>
          )}
          <VgBlurb text={blurb} />
        </React.Fragment>
      )}
    </section>
  );
}

// ── Chatter: conversation coverage over the accumulated corpus ────────────
// "Is this topic being talked about more or less?" answered from OUR logged
// headlines — the corpus grows every time the news arm fetches.

function VgChatter({ chatter, onChanged, onAnalyze }) {
  const [draft, setDraft] = React.useState('');
  const [err, setErr] = React.useState(null);
  const [openTerm, setOpenTerm] = React.useState(null);     // term whose headlines are expanded
  const [headlines, setHeadlines] = React.useState({});     // term -> rows | 'loading' | 'error'
  if (!chatter) return null;
  const valid = draft.trim().length >= 2 && draft.trim().length <= 60;
  const watch = async term => {
    try {
      setErr(null);
      await vgSend('/vantage/api/topic', 'POST', { term });
      setDraft(''); onChanged();
    } catch (ex) { setErr(ex.message); }
  };
  const remove = async id => {
    try { setErr(null); await vgSend(`/vantage/api/topic/${id}`, 'DELETE'); onChanged(); }
    catch (ex) { setErr(ex.message); }
  };
  // The drill-down: a sparkline says HOW MUCH — clicking the topic shows WHAT.
  const toggleHeadlines = term => {
    if (openTerm === term) { setOpenTerm(null); return; }
    setOpenTerm(term);
    if (!headlines[term]) {
      setHeadlines(h => ({ ...h, [term]: 'loading' }));
      vgGet(`/vantage/api/chatter/headlines?term=${encodeURIComponent(term)}`)
        .then(d => setHeadlines(h => ({ ...h, [term]: d.headlines })))
        .catch(() => setHeadlines(h => ({ ...h, [term]: 'error' })));
    }
  };
  const fillSeries = series => {
    const byDay = {};
    for (const r of series || []) byDay[String(r.d).slice(0, 10)] = r.n;
    return Array.from({ length: 14 }, (_, i) => {
      const d = new Date(Date.now() - (13 - i) * 86400000).toISOString().slice(0, 10);
      return byDay[d] ?? 0;
    });
  };
  const deltaChip = (recent, prior) => {
    if (recent === 0 && prior === 0) return <span style={{ ...vgS.caps, color: VG.ink4 }}>quiet</span>;
    const dir = recent > prior ? 'rising' : recent < prior ? 'fading' : 'steady';
    const c = recent > prior ? VG.up : recent < prior ? VG.down : VG.ink3;
    return <span style={{ ...vgS.num, fontSize: 11, color: c }}>{dir} · {recent} this wk vs {prior}</span>;
  };
  const watchedTerms = new Set(chatter.watched.map(w => w.term));
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', marginBottom: 14 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 10, flexWrap: 'wrap' }}>
        <span style={vgS.caps}>chatter · conversation coverage</span>
        <span style={{ fontSize: 11, color: VG.ink4 }}>
          {chatter.corpus.size.toLocaleString()} headlines in a rolling ~120-day window
          {chatter.corpus.last24h != null ? ` · ${chatter.corpus.last24h} in the last 24h (feeds + globe, ingested hourly)` : ''}
        </span>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 7, marginBottom: 12 }}>
        {chatter.watched.length === 0 && (
          <span style={{ fontSize: 12.5, color: VG.ink3 }}>
            watch a topic — a company, a person, an idea — and Vantage counts its coverage per day.
            {chatter.rising.length > 0 ? ' Or tap a rising term below to start with what the tape is already saying.' : ''}
          </span>
        )}
        {chatter.watched.map(w => (
          <React.Fragment key={w.id}>
            <div className="vg-row" onClick={() => toggleHeadlines(w.term)}
              style={{ display: 'grid', gridTemplateColumns: '140px 1fr auto auto', gap: 12, alignItems: 'center', borderRadius: 8, padding: '2px 4px', cursor: 'pointer' }}
              title="click to see the headlines behind this line">
              <span style={{ fontSize: 13, color: VG.ink, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                {openTerm === w.term ? '▾ ' : '▸ '}{w.term}
              </span>
              <span style={{ maxWidth: 240, minWidth: 0 }}><VgLine series={[fillSeries(w.series)]} colors={[VG.accent]} w={220} h={22} /></span>
              <span style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
                {deltaChip(w.recent, w.prior)}
                {onAnalyze && (
                  <button onClick={e => { e.stopPropagation(); onAnalyze(w.term); }}
                    title={`the analyst reads the actual articles behind "${w.term}"`}
                    className="vg-chip" style={{
                      appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999,
                      padding: '2px 10px', fontSize: 10.5, background: 'rgba(217,70,239,0.12)', color: VG.accent2, fontFamily: 'inherit',
                    }}>read deeper</button>
                )}
              </span>
              <button onClick={e => { e.stopPropagation(); remove(w.id); }} aria-label={`stop watching ${w.term}`} style={{
                appearance: 'none', border: 0, background: 'transparent', color: VG.ink4, cursor: 'pointer', fontSize: 12,
              }}>✕</button>
            </div>
            {openTerm === w.term && (
              <div style={{ margin: '0 0 6px 18px', display: 'flex', flexDirection: 'column', gap: 4 }}>
                {headlines[w.term] === 'loading' && <span style={{ fontSize: 11.5, color: VG.ink4 }}>pulling the headlines…</span>}
                {headlines[w.term] === 'error' && <span style={{ fontSize: 11.5, color: VG.ink4 }}>headlines unreachable — try again shortly.</span>}
                {Array.isArray(headlines[w.term]) && headlines[w.term].length === 0 && (
                  <span style={{ fontSize: 11.5, color: VG.ink4 }}>no matching headlines in 14 days — quiet is real data too.</span>
                )}
                {Array.isArray(headlines[w.term]) && headlines[w.term].map((h, i) => (
                  <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'baseline', fontSize: 12 }}>
                    <span style={{ ...vgS.num, fontSize: 10, color: VG.ink4, whiteSpace: 'nowrap' }}>{String(h.at).slice(5, 10)}</span>
                    <a href={h.link} target="_blank" rel="noreferrer" style={{ color: VG.ink2, lineHeight: 1.4 }}>{h.title}</a>
                    <span style={{ ...vgS.caps, fontSize: 8.5, color: VG.ink4 }}>{h.source}</span>
                  </div>
                ))}
              </div>
            )}
          </React.Fragment>
        ))}
      </div>
      <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: chatter.rising.length || err ? 14 : 0, flexWrap: 'wrap' }}>
        <VgInput value={draft} onChange={e => setDraft(e.target.value)} placeholder="watch a topic… (tariffs, nvidia, rate cut)"
          maxLength={60} onKeyDown={e => { if (e.key === 'Enter') valid && watch(draft.trim()); }} style={{ maxWidth: 320 }} />
        <VgBtn small kind="primary" onClick={() => watch(draft.trim())} disabled={!valid}>watch</VgBtn>
        {err && <span style={{ fontSize: 11.5, color: VG.down }}>{err}</span>}
      </div>
      {chatter.rising.length > 0 && (
        <React.Fragment>
          <div style={{ ...vgS.caps, marginBottom: 7 }}>rising in coverage — tap to watch</div>
          <div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
            {chatter.rising.map(r => {
              const already = watchedTerms.has(r.term);
              return (
                <button key={r.term} className="vg-chip" disabled={already}
                  onClick={() => !already && watch(r.term)}
                  title={already ? 'already watching' : `${r.recent} mentions this week vs ${r.prior} last — click to watch`}
                  style={{
                    ...vgS.num, fontSize: 11.5, background: VG.chip, borderRadius: 999,
                    padding: '4px 11px', color: already ? VG.ink4 : VG.ink2,
                    border: 0, cursor: already ? 'default' : 'pointer',
                  }}>
                  {already ? '✓ ' : '+ '}{r.term} <b style={{ color: VG.up }}>{r.recent}×</b><span style={{ color: VG.ink4 }}> was {r.prior}</span>
                </button>
              );
            })}
          </div>
        </React.Fragment>
      )}
    </section>
  );
}

// ── Seasonality lab ───────────────────────────────────────────────────────
// The Six Flags question, generalized: does this symbol have an annual
// rhythm? Average return + hit rate per calendar month over ~10 years.
// Presented as a weak prior, not a trading system.

const VG_MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];

function VgSeasonality({ holdings }) {
  const [symbol, setSymbol] = React.useState('');
  const [active, setActive] = React.useState(null);   // symbol we're showing
  const [data, setData] = React.useState(undefined);  // undefined idle, null loading
  const seq = React.useRef(0);                        // drop out-of-order responses
  const look = async sym => {
    const s = (sym || '').toUpperCase().trim();
    if (!s) return;
    const my = ++seq.current;
    setActive(s); setData(null);
    try {
      const d = await vgGet(`/vantage/api/seasonality?symbol=${encodeURIComponent(s)}`);
      if (seq.current === my) setData(d);
    } catch { if (seq.current === my) setData({ symbol: s, season: null }); }
  };
  const season = data?.season;
  const nowMonth = new Date().getMonth();
  const maxAbs = season ? Math.max(...season.byMonth.map(m => Math.abs(m.avg ?? 0)), 0.001) : 1;
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', marginBottom: 14 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
        <span style={vgS.caps}>seasonality lab</span>
        <span style={{ fontSize: 11, color: VG.ink4 }}>annual rhythm over ~10 years of monthly closes</span>
        <span style={{ flex: 1 }} />
        {holdings.slice(0, 6).map(h => (
          <button key={h} onClick={() => look(h)} className="vg-chip" style={{
            appearance: 'none', border: 0, cursor: 'pointer', padding: '4px 11px', borderRadius: 999,
            fontSize: 11, fontWeight: 600, background: active === h ? VG.tile2 : VG.chip,
            color: active === h ? VG.ink : VG.ink3,
          }}>{h}</button>
        ))}
        <VgInput value={symbol} onChange={e => setSymbol(e.target.value)} placeholder="any symbol… SIX, SPY"
          onKeyDown={e => { if (e.key === 'Enter') look(symbol); }} style={{ width: 140, textTransform: 'uppercase' }} />
        <VgBtn small onClick={() => look(symbol)} disabled={!symbol.trim()}>study</VgBtn>
      </div>

      {data === undefined && (
        <div style={{ color: VG.ink3, fontSize: 12.5 }}>
          pick a holding or type any symbol — does it fade after summer and run into it, like a theme park?
        </div>
      )}
      {data === null && <div style={{ ...vgS.serif, color: VG.ink3, fontSize: 14 }}>studying {active}…</div>}
      {data && !season && data !== null && (
        <div style={{ color: VG.ink3, fontSize: 12.5 }}>not enough monthly history for {active} — needs a few years of data (or the source is unreachable).</div>
      )}
      {season && (
        <React.Fragment>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: 6, alignItems: 'end', height: 96, marginBottom: 6 }}>
            {season.byMonth.map(m => {
              const h = m.avg == null ? 2 : Math.max(4, Math.abs(m.avg) / maxAbs * 76);
              const thin = m.n < 3;
              return (
                <div key={m.month} title={m.avg == null ? 'no data' : `avg ${(m.avg * 100).toFixed(1)}% · positive ${Math.round((m.hitRate ?? 0) * 100)}% of ${m.n} yrs${m.t != null ? ` · t ${m.t}${m.sig ? ' — holds up' : ''}` : ''}`}
                  style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'flex-end', height: '100%' }}>
                  <div style={{
                    width: '78%', height: h, borderRadius: 4,
                    background: m.avg == null ? VG.rule : m.avg >= 0 ? VG.up : VG.down,
                    opacity: thin ? 0.3 : 0.35 + (m.hitRate != null ? Math.abs(m.hitRate - 0.5) * 1.3 : 0),
                  }} />
                </div>
              );
            })}
          </div>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(12, 1fr)', gap: 6, marginBottom: 10 }}>
            {season.byMonth.map(m => (
              <div key={m.month} style={{ textAlign: 'center' }}>
                <div style={{ ...vgS.caps, color: m.month === nowMonth ? VG.accent : VG.ink4 }}>{VG_MONTHS[m.month]}</div>
                <div style={{ ...vgS.num, fontSize: 10, color: m.avg == null ? VG.ink4 : vgDelta(m.avg) }}>
                  {m.avg == null ? '—' : `${m.avg > 0 ? '+' : ''}${(m.avg * 100).toFixed(1)}`}
                </div>
              </div>
            ))}
          </div>
          <div style={{ fontSize: 12, color: VG.ink2 }}>
            {active} over {season.years} years:
            {season.best != null && <> strongest <b style={{ color: VG.up }}>{VG_MONTHS[season.best]}</b> ({(season.byMonth[season.best].avg * 100).toFixed(1)}% avg, positive {Math.round(season.byMonth[season.best].hitRate * 100)}% of years)</>}
            {season.worst != null && <> · weakest <b style={{ color: VG.down }}>{VG_MONTHS[season.worst]}</b> ({(season.byMonth[season.worst].avg * 100).toFixed(1)}% avg, positive {Math.round(season.byMonth[season.worst].hitRate * 100)}% of years)</>}.
          </div>
          <div style={{ ...vgS.serif, fontSize: 12.5, color: VG.accent2, marginTop: 7, lineHeight: 1.45 }}>
            {season.best != null && season.byMonth[season.best].t != null && (
              season.byMonth[season.best].sig
                ? `the ${VG_MONTHS[season.best]} pattern clears t ${season.byMonth[season.best].t} — unusually consistent for seasonality, though 12 months tested at once means one will look special by luck. `
                : Math.abs(season.byMonth[season.best].t) >= 2.5
                  ? `the ${VG_MONTHS[season.best]} edge reads t ${season.byMonth[season.best].t} — strong, but on only ${season.byMonth[season.best].n} years, too few to trust the number. `
                  : `the ${VG_MONTHS[season.best]} edge reads t ${season.byMonth[season.best].t} — statistically indistinguishable from noise at n=${season.byMonth[season.best].n}, so treat it as trivia, not a law. `
            )}
            seasonality is a weak prior, not a system — {season.samples} monthly samples, and regimes break rhythms.
            Bar opacity = consistency (hit rate); a tall faint bar is one big year, not a pattern.
          </div>
        </React.Fragment>
      )}
    </section>
  );
}

// ── Macro: the transmission chain ─────────────────────────────────────────
// Curve → credit → labor: the signals that lead economies, not just moods.
// The 2s10s curve works keyless (US Treasury); HY spreads and jobless
// claims need a free FRED key.

function VgMacroTile({ title, sub, value, fmtV, band, bandColors, series, seriesFmt, missing }) {
  if (missing) {
    return (
      <div style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', minWidth: 0 }}>
        <div style={{ ...vgS.caps, marginBottom: 8 }}>{title}</div>
        <div style={{ fontSize: 12, color: VG.ink3, lineHeight: 1.5 }}>{missing}</div>
      </div>
    );
  }
  const c = bandColors?.[band?.key] ?? VG.ink2;
  return (
    <div style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', minWidth: 0 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 6 }}>
        <span style={vgS.caps}>{title}</span>
        <span style={{ fontSize: 10, color: VG.ink4 }}>{sub}</span>
      </div>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 8, flexWrap: 'wrap' }}>
        <span style={{ ...vgS.serif, ...vgS.num, fontSize: 27, color: c, lineHeight: 1 }}>{fmtV(value)}</span>
        {band && <VgTag color={c}>{band.key}</VgTag>}
      </div>
      {series?.length > 1 && (
        <VgChartX data={series.map(r => r.v)} labels={series.map(r => r.d)} color={c} h={56} fmt={seriesFmt ?? fmtV} />
      )}
      {band?.note && <VgTip text={band.note} />}
    </div>
  );
}

function VgMacro({ macro }) {
  if (!macro) return null;
  const fredMissing = macro.fredKey
    ? (macro.fredError
      ? `FRED says: ${macro.fredError} — if it mentions the api_key, re-paste FRED_API_KEY in Railway with no quotes, spaces, or newline.`
      : 'FRED unreachable right now — returns automatically.')
    : 'needs a free FRED api key — set FRED_API_KEY (fred.stlouisfed.org, instant signup).';
  return (
    <section style={{ marginBottom: 14 }}>
      <div style={{ ...vgS.caps, margin: '0 2px 8px' }}>macro · the transmission chain <span style={{ color: VG.ink4, textTransform: 'none', letterSpacing: 0 }}>— curve → credit → labor; each link confirms whether the last one's warning is transmitting</span></div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}>
        <VgMacroTile title="2s10s yield curve" sub={macro.curve?.source === 'treasury' ? 'us treasury · keyless' : 'fred'}
          value={macro.curve?.value} fmtV={v => v == null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(2)}%`}
          band={macro.curve?.band}
          bandColors={{ inverted: '#f87171', flat: '#fb923c', normal: '#4ade80' }}
          series={macro.curve?.series}
          missing={macro.curve ? null : 'yield curve unreachable right now — returns automatically.'} />
        <VgMacroTile title="high-yield spreads" sub="ice bofa oas · fred"
          value={macro.hyOas?.value} fmtV={v => v == null ? '—' : `${(v * 100).toFixed(0)}bp`}
          band={macro.hyOas?.band}
          bandColors={{ benign: '#4ade80', watch: '#fb923c', stress: '#f87171', crisis: '#f87171' }}
          series={macro.hyOas?.series} seriesFmt={v => `${(v * 100).toFixed(0)}bp`}
          missing={macro.hyOas ? null : fredMissing} />
        <VgMacroTile title="jobless claims" sub="weekly initial · fred"
          value={macro.claims?.value} fmtV={v => v == null ? '—' : `${(v / 1000).toFixed(0)}k`}
          band={macro.claims?.band}
          bandColors={{ improving: '#4ade80', steady: VG.ink3, deteriorating: '#fb923c' }}
          series={macro.claims?.series} seriesFmt={v => `${(v / 1000).toFixed(0)}k`}
          missing={macro.claims ? null : fredMissing} />
      </div>
    </section>
  );
}

// ── The ledger: observations & outcomes ───────────────────────────────────
// Vantage's own memory. Every high-impact event, held-name earnings, and
// thread becomes a claim, scored by what the market did at T+1/5/20.
// Patterns accumulate — small n, honestly labeled.

function VgOutcomeCell({ o }) {
  if (!o) return <span style={{ ...vgS.num, fontSize: 11, color: VG.ink4 }}>…</span>;
  const v = o.focus ?? o.spx;
  return (
    <span style={{ ...vgS.num, fontSize: 11, color: vgDelta(v) }} title={o.focus != null ? `focus ${vgPct(o.focus)} · spx ${vgPct(o.spx)}` : `spx ${vgPct(o.spx)}`}>
      {vgPct(v)}
    </span>
  );
}

function VgLedger({ oracle }) {
  if (!oracle) return null;
  const claims = oracle.claims || [];
  const scored = claims.filter(c => c.outcomes && Object.keys(c.outcomes).length);
  const pending = claims.filter(c => c.status === 'pending' && !(c.outcomes && Object.keys(c.outcomes).length));
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', marginBottom: 14 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 10, flexWrap: 'wrap' }}>
        <span style={vgS.caps}>the ledger · observations & outcomes</span>
        <span style={{ fontSize: 10.5, color: VG.ink4 }}>
          every event and thread scored at t+1 / t+5 / t+20 · {oracle.snapshotCount} daily snapshot{oracle.snapshotCount === 1 ? '' : 's'} in memory
        </span>
      </div>
      {claims.length === 0 && (
        <div style={{ fontSize: 12.5, color: VG.ink3, lineHeight: 1.5 }}>
          empty ledger — as prints hit, held names report, and threads form, Vantage registers each one
          and scores it against what the market does next. Memory starts accumulating today.
        </div>
      )}
      {oracle.patterns?.length > 0 && (() => {
        const confident = oracle.patterns.filter(p => !p.provisional);
        const provisional = oracle.patterns.filter(p => p.provisional);
        return (
          <div style={{ display: 'flex', gap: 7, flexWrap: 'wrap', marginBottom: 12, alignItems: 'baseline' }}>
            {confident.map(p => (
              <span key={p.theme + p.sector} style={{
                ...vgS.num, fontSize: 11.5, background: 'rgba(217,70,239,0.1)', color: VG.accent2,
                borderRadius: 999, padding: '4px 12px',
              }} title={`sector fell in ${Math.round(p.downRate * 100)}% of cases over t+5`}>
                {p.theme}{p.sector !== 'general' ? ` × ${p.sector.toLowerCase()}` : ''} → avg t+5 {vgPct(p.avgT5)}
                {p.avgT20 != null ? ` · t+20 ${vgPct(p.avgT20)}` : ''} <b style={{ color: VG.ink3 }}>n={p.n}</b>
              </span>
            ))}
            {provisional.length > 0 && (
              <span style={{ fontSize: 11, color: VG.ink4, lineHeight: 1.5 }}
                title="one to four observations — not enough to read a direction; the average is withheld until the sample grows">
                accumulating: {provisional.map(p => `${p.theme}${p.sector !== 'general' ? `×${p.sector.toLowerCase()}` : ''} (n=${p.n})`).join(' · ')}
              </span>
            )}
          </div>
        );
      })()}
      {scored.length > 0 && (
        <div style={{ display: 'flex', flexDirection: 'column', marginBottom: pending.length ? 10 : 0 }}>
          <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 76px 56px 56px 56px', gap: 8, padding: '2px 2px 6px', ...vgS.caps, fontSize: 8.5, color: VG.ink4 }}>
            <span>claim</span><span>date</span><span>t+1</span><span>t+5</span><span>t+20</span>
          </div>
          {scored.slice(0, 10).map(c => (
            <div key={c.id} style={{
              display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 76px 56px 56px 56px', gap: 8,
              padding: '5px 2px', borderTop: `1px solid ${VG.rule}`, fontSize: 12, alignItems: 'baseline',
            }}>
              <span style={{ color: VG.ink2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                {c.label}{c.focus_symbol ? <span style={{ ...vgS.caps, color: VG.accent2, marginLeft: 6 }}>{c.focus_symbol}</span> : null}
              </span>
              <span style={{ ...vgS.num, fontSize: 10.5, color: VG.ink4 }}>{c.claim_date}</span>
              <VgOutcomeCell o={c.outcomes?.t1} />
              <VgOutcomeCell o={c.outcomes?.t5} />
              <VgOutcomeCell o={c.outcomes?.t20} />
            </div>
          ))}
        </div>
      )}
      {pending.length > 0 && (
        <div style={{ fontSize: 11, color: VG.ink4 }}>
          awaiting first score: {pending.slice(0, 4).map(c => c.label).join(' · ')}{pending.length > 4 ? ` · +${pending.length - 4} more` : ''}
        </div>
      )}
    </section>
  );
}

// ── The map: every S&P name, red or green ─────────────────────────────────
// TradingView's stock-heatmap widget IS embeddable (unlike raw index
// charts): size = market cap, color = day change, grouped by sector.
function VgHeatmap() {
  const cfg = {
    exchanges: [], dataSource: 'SPX500', grouping: 'sector',
    blockSize: 'market_cap_basic', blockColor: 'change', locale: 'en',
    symbolUrl: '', colorTheme: 'dark', hasTopBar: false,
    isDataSetEnabled: false, isZoomEnabled: true, hasSymbolTooltip: true,
    isMonoSize: false, width: '100%', height: 420,
  };
  const src = 'https://s.tradingview.com/embed-widget/stock-heatmap/?locale=en#' + encodeURIComponent(JSON.stringify(cfg));
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', marginBottom: 14 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 10, flexWrap: 'wrap' }}>
        <span style={vgS.caps}>the map · s&p 500</span>
        <span style={{ fontSize: 10.5, color: VG.ink4 }}>every name, sized by market cap, colored by day move · hover for detail, scroll to zoom</span>
      </div>
      <iframe title="S&P 500 heatmap" src={src}
        style={{ width: '100%', height: 420, border: 0, borderRadius: 12, background: '#0a0a0c' }} />
    </section>
  );
}

// ── Calendar: the month at a glance, filterable ───────────────────────────
const VG_CAL_KINDS = {
  fomc: { color: '#f87171', label: 'fomc' },
  econ: { color: '#fb923c', label: 'prints' },
  earnings: { color: '#d946ef', label: 'earnings' },
  speech: { color: '#818cf8', label: 'speeches' },
};

function VgCalendar({ events, earnings }) {
  const all = React.useMemo(() => {
    const rows = (events || []).map(e => ({
      ...e,
      kind: e.kind === 'econ' && /speak|speech|testimon/i.test(e.title) ? 'speech' : e.kind,
    }));
    for (const [sym, e] of Object.entries(earnings || {})) {
      if (e?.date) rows.push({ kind: 'earnings', title: `${sym} reports earnings`, date: e.date, symbol: sym });
    }
    return rows.filter(e => e.date);
  }, [events, earnings]);

  const now = new Date();
  const [view, setView] = React.useState({ y: now.getFullYear(), m: now.getMonth() });
  const [filter, setFilter] = React.useState('all');
  const [selDay, setSelDay] = React.useState(null);

  const filtered = all.filter(e => filter === 'all' || e.kind === filter);
  const byDay = {};
  for (const e of filtered) {
    const k = new Date(e.date).toISOString().slice(0, 10);
    (byDay[k] = byDay[k] || []).push(e);
  }
  const first = new Date(Date.UTC(view.y, view.m, 1));
  const startDow = first.getUTCDay();
  const daysIn = new Date(Date.UTC(view.y, view.m + 1, 0)).getUTCDate();
  const todayKey = new Date().toISOString().slice(0, 10);
  const monthLabel = first.toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' }).toLowerCase();
  const dayKey = d => `${view.y}-${String(view.m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
  const shift = dir => {
    setSelDay(null);
    setView(v => {
      const m = v.m + dir;
      return { y: v.y + Math.floor(m / 12), m: ((m % 12) + 12) % 12 };
    });
  };
  const listed = selDay
    ? (byDay[selDay] || [])
    : filtered.filter(e => e.date >= Date.now() - 43200000).sort((a, b) => a.date - b.date).slice(0, 6);

  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
        <span style={vgS.caps}>calendar</span>
        <span style={{ flex: 1 }} />
        <button onClick={() => shift(-1)} aria-label="previous month" style={{ appearance: 'none', border: 0, background: VG.chip, color: VG.ink3, cursor: 'pointer', borderRadius: 999, padding: '2px 9px', fontSize: 12 }}>‹</button>
        <span style={{ ...vgS.num, fontSize: 12, color: VG.ink2, minWidth: 108, textAlign: 'center' }}>{monthLabel}</span>
        <button onClick={() => shift(1)} aria-label="next month" style={{ appearance: 'none', border: 0, background: VG.chip, color: VG.ink3, cursor: 'pointer', borderRadius: 999, padding: '2px 9px', fontSize: 12 }}>›</button>
      </div>
      <div style={{ display: 'flex', gap: 5, marginBottom: 10, flexWrap: 'wrap' }}>
        {['all', ...Object.keys(VG_CAL_KINDS)].map(k => (
          <button key={k} onClick={() => { setFilter(k); setSelDay(null); }} style={{
            appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999,
            padding: '3px 10px', fontSize: 10.5, fontWeight: 600,
            background: filter === k ? VG.tile2 : VG.chip,
            color: filter === k ? (VG_CAL_KINDS[k]?.color ?? VG.ink) : VG.ink3,
          }}>{VG_CAL_KINDS[k]?.label ?? 'all'}</button>
        ))}
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3, marginBottom: 4 }}>
        {['s', 'm', 't', 'w', 't', 'f', 's'].map((d, i) => (
          <div key={i} style={{ ...vgS.caps, fontSize: 8.5, textAlign: 'center', color: VG.ink4 }}>{d}</div>
        ))}
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 3 }}>
        {Array.from({ length: startDow }).map((_, i) => <div key={'pad' + i} />)}
        {Array.from({ length: daysIn }, (_, i) => i + 1).map(d => {
          const k = dayKey(d);
          const evs = byDay[k] || [];
          const isToday = k === todayKey;
          const isSel = k === selDay;
          return (
            <button key={d} onClick={() => setSelDay(isSel ? null : k)} style={{
              appearance: 'none', border: 0, cursor: evs.length ? 'pointer' : 'default',
              borderRadius: 8, padding: '4px 0 3px', fontFamily: 'inherit',
              background: isSel ? VG.tile2 : isToday ? 'rgba(217,70,239,0.12)' : 'transparent',
              outline: isToday ? `1px solid rgba(217,70,239,0.4)` : 'none',
            }}>
              <div style={{ ...vgS.num, fontSize: 11, color: evs.length ? VG.ink : VG.ink4 }}>{d}</div>
              <div style={{ display: 'flex', gap: 2, justifyContent: 'center', minHeight: 5, marginTop: 1 }}>
                {evs.slice(0, 3).map((e, j) => (
                  <span key={j} style={{ width: 4, height: 4, borderRadius: '50%', background: VG_CAL_KINDS[e.kind]?.color ?? VG.ink3 }} />
                ))}
              </div>
            </button>
          );
        })}
      </div>
      <div style={{ marginTop: 10, borderTop: `1px solid ${VG.rule}`, paddingTop: 8, flex: 1 }}>
        <div style={{ ...vgS.caps, color: VG.ink4, marginBottom: 6 }}>
          {selDay ? new Date(selDay + 'T12:00:00Z').toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' }).toLowerCase() : 'next up'}
        </div>
        {listed.length === 0 && <div style={{ fontSize: 11.5, color: VG.ink4 }}>nothing {selDay ? 'on this day' : 'ahead'} under this filter.</div>}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
          {listed.map((e, i) => (
            <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'baseline', fontSize: 11.5 }}>
              <span style={{ width: 5, height: 5, borderRadius: '50%', background: VG_CAL_KINDS[e.kind]?.color ?? VG.ink3, flexShrink: 0, position: 'relative', top: -1 }} />
              <span style={{ color: VG.ink2, flex: 1, lineHeight: 1.35 }}>{e.title}</span>
              <span style={{ ...vgS.num, fontSize: 10, color: VG.ink4, whiteSpace: 'nowrap' }}>{vgEventDay(e.date)}</span>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

// Catalysts ahead — the merged FOMC / econ-print / earnings timeline.
// Countdown first: "what's about to move my book" is a when-question.
function vgInDays(ts) {
  const d = Math.ceil((ts - Date.now()) / 86400000);
  if (d <= 0) return 'today';
  if (d === 1) return 'tomorrow';
  return `in ${d}d`;
}
function vgEventDay(ts) {
  const d = new Date(ts);
  return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }).toLowerCase();
}

// One symbol's filing base rates, fetched once per session and folded under
// its catalyst row. Every line names its window and its sample size — three
// events are called a sketch, not a statistic.
const vgEventHistoryCache = {}; // symbol -> payload (session-lived)

function VgEventHistory({ symbol }) {
  const [data, setData] = React.useState(vgEventHistoryCache[symbol] || null);
  const [err, setErr] = React.useState(false);
  React.useEffect(() => {
    if (vgEventHistoryCache[symbol]) { setData(vgEventHistoryCache[symbol]); return; }
    let alive = true;
    vgGet(`/vantage/api/event-history?symbol=${encodeURIComponent(symbol)}`)
      .then(d => { if (alive) { vgEventHistoryCache[symbol] = d; setData(d); } })
      .catch(() => { if (alive) setErr(true); });
    return () => { alive = false; };
  }, [symbol]);
  if (err) return <div style={{ fontSize: 11.5, color: VG.ink4, padding: '4px 0 6px' }}>filing history unreachable right now.</div>;
  if (!data) return <div style={{ fontSize: 11.5, color: VG.ink4, padding: '4px 0 6px' }}>reading {symbol}'s filing history…</div>;
  if (data.unavailable) return <div style={{ fontSize: 11.5, color: VG.ink4, padding: '4px 0 6px' }}>{data.unavailable}.</div>;
  if (!data.byItem?.length) {
    return <div style={{ fontSize: 11.5, color: VG.ink4, padding: '4px 0 6px' }}>
      {data.events > 0
        ? `only ${data.events} measurable filing${data.events === 1 ? '' : 's'} in 2y — too few of any one kind to call a pattern.`
        : 'no measurable 8-K filings in the last 2y.'}
    </div>;
  }
  return (
    <div style={{ padding: '2px 0 8px' }}>
      <div style={{ ...vgS.caps, fontSize: 9, color: VG.ink4, marginBottom: 3 }}>
        what {symbol} has done before · move = close before each filing → first close after (two sessions) · 2y of 8-Ks
      </div>
      {data.byItem.slice(0, 4).map(r => (
        <div key={r.code} style={{ fontSize: 12, color: VG.ink3, lineHeight: 1.55 }}>
          <span style={{ color: VG.ink2 }}>{r.meaning || r.code}</span>
          <span style={{ ...vgS.num }}> · median {vgPct(r.medW)} · {r.upW}↑ {r.n - r.upW}↓ · worst {vgPct(r.worstW)}</span>
          {r.medDrift != null && <span style={{ ...vgS.num }}> · 5-session drift {vgPct(r.medDrift)}</span>}
          <span style={{ ...vgS.caps, fontSize: 8.5, color: r.thin ? '#fb923c' : VG.ink4, marginLeft: 6 }}>
            n={r.n}{r.thin ? ' · thin' : ''}
          </span>
        </div>
      ))}
      {data.skipped > 0 && (
        <div style={{ fontSize: 10.5, color: VG.ink4, marginTop: 2 }}>
          {data.skipped} filing{data.skipped === 1 ? '' : 's'} skipped — outside price coverage or no coded items.
        </div>
      )}
    </div>
  );
}

function VgCatalysts({ events, econAvailable, holdings }) {
  const IMPACT = { high: '#f87171', medium: '#fb923c' };
  const [pastOpen, setPastOpen] = React.useState({}); // symbol -> bool
  const shown = (events || []).filter(e => e.date > Date.now() - 6 * 3600000).slice(0, 14);
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', marginBottom: 14 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 10, flexWrap: 'wrap' }}>
        <span style={vgS.caps}>catalysts ahead</span>
        <span style={{ fontSize: 11, color: VG.ink4 }}>
          fomc pinned from the fed's published calendar{econAvailable ? ' · prints live' : ''}
        </span>
      </div>
      {shown.length === 0 && (
        <div style={{ color: VG.ink3, fontSize: 12.5, padding: '8px 0' }}>
          {econAvailable === true
            ? 'nothing on the calendar in the near window.'
            : 'live calendar unreachable — FOMC dates still pinned; prints return automatically.'}
        </div>
      )}
      <div style={{ display: 'flex', flexDirection: 'column' }}>
        {shown.map((e, i) => (
          <React.Fragment key={i}>
            <div style={{
              display: 'grid', gridTemplateColumns: '86px 76px 1fr auto', gap: 12, alignItems: 'baseline',
              padding: '7px 2px', borderBottom: 'none',
              fontSize: 12.5,
            }}>
              <span style={{ ...vgS.num, color: VG.ink3 }}>{vgEventDay(e.date)}</span>
              <span style={{ ...vgS.caps, color: e.kind === 'earnings' ? VG.accent2 : (IMPACT[e.impact] || VG.ink4) }}>
                {e.kind === 'earnings' ? e.symbol : e.kind === 'fomc' ? 'fomc' : e.impact}
              </span>
              <span style={{ color: VG.ink2, lineHeight: 1.4 }}>
                {e.title}
                {(e.forecast || e.previous) && (
                  <span style={{ ...vgS.num, fontSize: 11, color: VG.ink4, marginLeft: 8 }}>
                    {e.forecast ? `est ${e.forecast}` : ''}{e.forecast && e.previous ? ' · ' : ''}{e.previous ? `prev ${e.previous}` : ''}
                  </span>
                )}
                {e.kind === 'earnings' && holdings.includes(e.symbol) && (
                  <span style={{ ...vgS.caps, color: VG.accent2, marginLeft: 8 }}>held</span>
                )}
                {e.kind === 'earnings' && (
                  <button onClick={() => setPastOpen(p => ({ ...p, [e.symbol]: !p[e.symbol] }))} className="vg-chip" style={{
                    appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999, marginLeft: 8,
                    padding: '1px 8px', fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em',
                    background: VG.chip, color: pastOpen[e.symbol] ? VG.accent2 : VG.ink3,
                  }}>{pastOpen[e.symbol] ? 'past ▾' : 'past →'}</button>
                )}
              </span>
              <span style={{ ...vgS.num, fontSize: 11.5, color: vgInDays(e.date) === 'today' ? VG.accent : VG.ink3 }}>{vgInDays(e.date)}</span>
            </div>
            {e.kind === 'earnings' && pastOpen[e.symbol] && (
              <div style={{ margin: '0 2px 0 8px', paddingLeft: 10, borderLeft: `2px solid ${VG.rule}` }}>
                <VgEventHistory symbol={e.symbol} />
              </div>
            )}
            {i < shown.length - 1 && <div style={{ borderBottom: `1px solid ${VG.rule}` }} />}
          </React.Fragment>
        ))}
      </div>
    </section>
  );
}

function vgNewsAgo(ts) {
  if (!ts) return '';
  const m = Math.floor((Date.now() - ts) / 60000);
  if (m < 1) return 'now';
  if (m < 60) return `${m}m`;
  const h = Math.floor(m / 60);
  if (h < 24) return `${h}h`;
  return `${Math.floor(h / 24)}d`;
}

// Bold the tagged entities (companies, tickers) inside a headline so the
// who/what reads at a glance. React elements, never raw HTML.
function vgHighlight(title, tags, onResearchSym) {
  const tickers = new Set((tags?.tickers || []).map(t => String(t).toUpperCase()));
  const entities = [...(tags?.companies || []), ...(tags?.tickers || [])]
    .filter(e => e && e.length >= 2)
    .sort((a, b) => b.length - a.length);
  if (!entities.length) return title;
  const lower = title.toLowerCase();
  const marks = [];
  for (const ent of entities) {
    const idx = lower.indexOf(ent.toLowerCase());
    if (idx === -1) continue;
    if (marks.some(m => idx < m.end && idx + ent.length > m.start)) continue;
    marks.push({ start: idx, end: idx + ent.length, ent });
  }
  if (!marks.length) return title;
  marks.sort((a, b) => a.start - b.start);
  const parts = [];
  let pos = 0;
  marks.forEach((m, i) => {
    if (m.start > pos) parts.push(title.slice(pos, m.start));
    const text = title.slice(m.start, m.end);
    const sym = tickers.has(text.toUpperCase()) ? text.toUpperCase() : null;
    // A tagged ticker is a doorway: click → its dossier. Companies stay bold-only.
    parts.push(sym && onResearchSym
      ? <b key={i} className="vg-chip" title={`open the ${sym} dossier`}
          onClick={e => { e.stopPropagation(); onResearchSym(sym); }}
          style={{ color: VG.accent2, fontWeight: 600, cursor: 'pointer', textDecoration: 'underline', textDecorationColor: 'rgba(240,171,252,0.35)' }}>{text}</b>
      : <b key={i} style={{ color: VG.accent2, fontWeight: 600 }}>{text}</b>);
    pos = m.end;
  });
  if (pos < title.length) parts.push(title.slice(pos));
  return parts;
}

const VG_GICS_SHORT = {
  'information technology': 'tech', 'financials': 'financials', 'health care': 'health',
  'consumer discretionary': 'discretionary', 'consumer staples': 'staples', 'energy': 'energy',
  'industrials': 'industrials', 'utilities': 'utilities', 'materials': 'materials',
  'real estate': 'real estate', 'communication services': 'comms',
};

function VgThreads({ threads }) {
  const [open, setOpen] = React.useState(null);
  if (!threads?.length) return null;
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
      {threads.map((t, i) => (
        <div key={i} style={{ background: 'rgba(217,70,239,0.07)', border: `1px solid rgba(217,70,239,0.25)`, borderRadius: 12, padding: '8px 12px' }}>
          <button onClick={() => setOpen(open === i ? null : i)} style={{
            appearance: 'none', border: 0, background: 'transparent', cursor: 'pointer',
            display: 'flex', gap: 8, alignItems: 'baseline', width: '100%', textAlign: 'left',
            fontFamily: 'inherit', padding: 0,
          }}>
            <span style={{ color: VG.accent2, fontSize: 12 }}>⚑</span>
            <span style={{ fontSize: 12.5, color: VG.ink, fontWeight: 600 }}>
              {t.count}× {t.theme}{t.sector !== 'general' ? ` · ${t.sector.toLowerCase()}` : ''} this week
            </span>
            <span style={{ fontSize: 11, color: VG.ink3 }}>— repeated signals point somewhere. {open === i ? 'hide' : 'see the stories'}</span>
          </button>
          {open === i && (
            <div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
              {t.items.map((s, j) => (
                <a key={j} href={s.link} target="_blank" rel="noopener noreferrer"
                  style={{ fontSize: 12, color: VG.ink2, lineHeight: 1.4 }}>· {s.title}</a>
              ))}
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

function VgNews({ items, threads, holdings, onNote, onResearchSym }) {
  const [filter, setFilter] = React.useState('all');
  const shown = items.filter(it => filter === 'all' ? true
    : filter === 'book' ? (it.symbol && holdings.includes(it.symbol))
    : it.symbol === filter);
  const chips = ['all', ...(holdings.length ? ['book'] : []), ...holdings];
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
        <span style={vgS.caps}>the tape</span>
        <span style={{ flex: 1 }} />
        {chips.map(c => (
          <button key={c} onClick={() => setFilter(c)} style={{
            appearance: 'none', border: 0, cursor: 'pointer',
            padding: '4px 11px', borderRadius: 999, fontSize: 11, fontWeight: 600,
            background: filter === c ? VG.tile2 : VG.chip,
            color: filter === c ? VG.ink : VG.ink3,
          }}>{c === 'book' ? 'your book' : c.toLowerCase()}</button>
        ))}
      </div>
      <VgThreads threads={threads} />
      {shown.length === 0 && (
        <div style={{ color: VG.ink3, fontSize: 12.5, padding: '10px 0' }}>
          {items.length === 0 ? 'news feeds unreachable right now — they return automatically.' : 'nothing under this filter.'}
        </div>
      )}
      <div style={{ display: 'flex', flexDirection: 'column' }}>
        {shown.map((it, i) => {
          const secChips = (it.tags?.sectors || []).map(s => VG_GICS_SHORT[String(s).toLowerCase()] || null).filter(Boolean).slice(0, 1);
          const themeChips = (it.tags?.themes || []).slice(0, 2);
          return (
            // Fixed 104px outlet column: every headline starts at the same x
            // regardless of publisher-name length.
            <div key={i} style={{
              display: 'grid', gridTemplateColumns: '104px minmax(0, 1fr) auto auto', gap: 10,
              alignItems: 'baseline',
              padding: '8px 2px', borderBottom: i < shown.length - 1 ? `1px solid ${VG.rule}` : 'none',
              fontSize: 13,
            }}>
              <span style={{ ...vgS.caps, fontSize: 9, color: VG.ink4, overflow: 'hidden', whiteSpace: 'nowrap' }}>{it.source}</span>
              <span style={{ minWidth: 0 }}>
                {it.symbol && <span style={{ ...vgS.caps, color: VG.accent2, marginRight: 8 }}>{it.symbol}</span>}
                <a href={it.link} target="_blank" rel="noopener noreferrer"
                  style={{ color: VG.ink2, lineHeight: 1.45 }}>
                  {vgHighlight(it.title, it.tags, onResearchSym)}
                </a>
                {/* Chip hierarchy: sector = quiet outline, theme = magenta tint. */}
                {secChips.map(tag => (
                  <span key={'s' + tag} style={{
                    fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase',
                    boxShadow: `inset 0 0 0 1px ${VG.rule}`, color: VG.ink3,
                    borderRadius: 999, padding: '2px 7px', whiteSpace: 'nowrap', marginLeft: 8,
                  }}>{tag}</span>
                ))}
                {themeChips.map(tag => (
                  <span key={'t' + tag} style={{
                    fontSize: 9, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase',
                    background: 'rgba(217,70,239,0.13)', color: VG.accent2,
                    borderRadius: 999, padding: '2px 7px', whiteSpace: 'nowrap', marginLeft: 6,
                  }}>{tag}</span>
                ))}
              </span>
              <span style={{ ...vgS.num, fontSize: 11, color: VG.ink4, whiteSpace: 'nowrap' }}>{vgNewsAgo(it.at)}</span>
              <VgNoteBtn title="note this story"
                onClick={() => onNote({ kind: 'headline', key: it.link, label: it.title.slice(0, 60), snapshot: { title: it.title, source: it.source, tags: it.tags } })} />
            </div>
          );
        })}
      </div>
    </section>
  );
}

// The read — a page-level verdict so the Pulse tab opens with "the market is X
// today" instead of dumping the eye into fourteen equal tiles. Composes the
// signals already fetched (sentiment, tape, curve); degrades to nothing until
// at least one arm loads. Presentation only — every input is computed and
// tested upstream, so this stays in the view where it can't drift from fetches.
// Since yesterday — the narrative layer: snapshot-to-snapshot regime changes,
// reviews that came due, holdings out of character. Hidden when genuinely
// quiet (a filler strip is noise; silence is honest). Review lines jump to
// the journal; character lines to research.
function VgChangesStrip({ changes, onJournal, onResearchSym, onGlobe, onAnalyze }) {
  const rows = (changes?.changes || []).filter(c => c.kind !== 'first');
  if (!rows.length) return null;
  const icon = { mood: '◐', vol: '≋', breadth: '▤', tape: '⇄', curve: '∿', thread: '⛓', chatter: '❝', review: '☑', character: 'σ', globe: '🌍', news: '⚑', falsifier: '⚠', hitrate: '◔' };
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '13px 18px', marginBottom: 14, borderLeft: `2px solid ${VG.accent}` }}>
      <div style={{ ...vgS.caps, marginBottom: 8 }}>
        since yesterday{changes.prior ? ` · ${changes.prior} → ${changes.asOf}` : ''}
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
        {rows.slice(0, 7).map((c, i) => {
          const clickable = c.kind === 'review' || c.kind === 'hitrate' ? onJournal : c.kind === 'character' ? () => onResearchSym?.(c.line.split(' ')[0]) : c.kind === 'globe' ? () => onGlobe?.() : null;
          if (c.kind === 'falsifier') {
            return (
              <div key={i} style={{ display: 'flex', gap: 10, alignItems: 'baseline', fontSize: 12.5, lineHeight: 1.5 }}>
                <span style={{ color: '#fb923c', width: 14, textAlign: 'center' }}>⚠</span>
                <span style={{ minWidth: 0 }}>
                  <a href={c.link} target="_blank" rel="noreferrer" style={{ color: VG.ink, textDecoration: 'none' }}>{c.line}</a>
                  {c.source && <span style={{ ...vgS.caps, fontSize: 8.5, color: VG.ink4, marginLeft: 8 }}>{c.source}</span>}
                  <button onClick={() => onJournal?.()} className="vg-chip" style={{
                    appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999, marginLeft: 8,
                    padding: '1px 9px', fontSize: 10, background: VG.chip, color: VG.ink3, fontFamily: 'inherit',
                  }}>open the thesis →</button>
                </span>
              </div>
            );
          }
          if (c.kind === 'news') {
            return (
              <div key={i} style={{ display: 'flex', gap: 10, alignItems: 'baseline', fontSize: 12.5, lineHeight: 1.5 }}>
                <span style={{ color: c.imp >= 3 ? '#f87171' : VG.accent2, width: 14, textAlign: 'center' }}>⚑</span>
                <span style={{ minWidth: 0 }}>
                  <a href={c.link} target="_blank" rel="noreferrer" style={{ color: c.imp >= 3 ? VG.ink : VG.ink2, textDecoration: 'none' }}>{c.line}</a>
                  {c.source && <span style={{ ...vgS.caps, fontSize: 8.5, color: VG.ink4, marginLeft: 8 }}>{c.source}</span>}
                  {onAnalyze && c.topic && (
                    <button onClick={() => onAnalyze(c.topic)} className="vg-chip" style={{
                      appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999, marginLeft: 8,
                      padding: '1px 9px', fontSize: 10, background: 'rgba(217,70,239,0.12)', color: VG.accent2, fontFamily: 'inherit',
                    }}>read deeper</button>
                  )}
                </span>
              </div>
            );
          }
          return (
            <div key={i} onClick={clickable || undefined} className={clickable ? 'vg-chip' : undefined}
              style={{ display: 'flex', gap: 10, alignItems: 'baseline', fontSize: 12.5, color: VG.ink2, lineHeight: 1.5, cursor: clickable ? 'pointer' : 'default' }}>
              <span style={{ color: c.kind === 'review' ? VG.accent : VG.ink4, width: 14, textAlign: 'center' }}>{icon[c.kind] || '·'}</span>
              <span>{c.line}{clickable ? ' →' : ''}</span>
            </div>
          );
        })}
      </div>
    </section>
  );
}

// The wider sentiment board: crypto fear&greed, the VIX term structure,
// UMich consumer sentiment, and the St. Louis Fed stress index — each tile
// says plainly when its feed is unreachable or needs the FRED key.
function VgMoreSent({ more }) {
  const tile = (label, body, sub) => (
    <div style={{ background: VG.chip, borderRadius: 12, padding: '10px 12px', minWidth: 0 }}>
      <div style={vgS.caps}>{label}</div>
      <div style={{ ...vgS.serif, ...vgS.num, fontSize: 19, color: VG.ink, marginTop: 3 }}>{body}</div>
      {sub && <div style={{ fontSize: 10, color: VG.ink4, marginTop: 2, lineHeight: 1.4 }}>{sub}</div>}
    </div>
  );
  const c = more?.crypto, t = more?.vixTerm, u = more?.umich, st = more?.stress;
  return (
    <section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 10, marginBottom: 14 }}>
      {tile('crypto fear & greed · alternative.me',
        c ? `${c.score} ${c.rating}` : '—',
        c ? 'the risk appetite of the most speculative crowd' : 'feed unreachable right now')}
      {tile('vix term structure · 9d / 30d / 3m',
        t ? `${t.v9d.toFixed(1)} / ${t.v30d.toFixed(1)} / ${t.v3m.toFixed(1)}` : '—',
        t ? `${t.shape} — ${t.note}` : 'term quotes unreachable right now')}
      {tile('consumer sentiment · umich (fred)',
        u ? u.value.toFixed(1) : '—',
        u ? `monthly survey · as of ${u.at}` : 'needs the FRED key, or feed unreachable')}
      {tile('financial stress · st. louis fed (fred)',
        st ? st.value.toFixed(2) : '—',
        st ? `${st.value > 0 ? 'above' : 'below'} average stress (0 = average) · as of ${st.at}` : 'needs the FRED key, or feed unreachable')}
    </section>
  );
}

function VgPulseVerdict({ sent, markets, macro, sift }) {
  const fg = sent?.fearGreed;
  const vixK = sent?.vix?.band?.key, vixL = sent?.vix?.level;
  const tape = markets?.readout, curveK = macro?.curve?.band?.key;
  const moodWord = s => s == null ? null : s <= 25 ? 'extreme fear' : s <= 45 ? 'fear' : s <= 55 ? 'neutral' : s <= 75 ? 'greed' : 'extreme greed';
  const mood = fg?.score != null ? moodWord(fg.score) : null;
  const chips = [];
  if (mood) chips.push({ t: `${fg.rating || mood} · F&G ${Math.round(fg.score)}`, c: fg.score <= 45 ? '#fb923c' : fg.score > 55 ? VG.up : VG.ink3 });
  if (vixK) chips.push({ t: `vol ${vixK}${vixL != null ? ` ${Math.round(vixL)}` : ''}`, c: (vixK === 'high' || vixK === 'elevated') ? '#fb923c' : VG.ink3 });
  const tapePhrase = { rotation: 'rotation tape', 'risk-on': 'broad risk-on', 'risk-off': 'broad risk-off', mixed: 'mixed tape' };
  if (tape?.key && tapePhrase[tape.key]) chips.push({ t: tapePhrase[tape.key], c: tape.key === 'risk-off' ? VG.down : tape.key === 'risk-on' ? VG.up : VG.ink3 });
  if (curveK) chips.push({ t: `curve ${curveK}`, c: curveK === 'inverted' ? '#fb923c' : VG.ink3 });
  if (!chips.length && !sift?.lines?.length) return null;
  const line = tape?.note || (mood ? `${mood} sentiment${vixK ? `, ${vixK} volatility` : ''}.` : null);
  const siftLines = sift?.lines || null;
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '13px 18px', marginBottom: 14 }}>
      {/* The sift leads: 1-3 written lines summarizing EVERYTHING — triaged
          news, deltas, regimes, your names. Chips stay as the number strip;
          without a sift yet, the mechanical line stands in (honest fallback). */}
      {siftLines && (
        <div style={{ marginBottom: 9 }}>
          {siftLines.map((l, i) => (
            <div key={i} style={{ ...vgS.serif, fontSize: i === 0 ? 17 : 14.5, color: i === 0 ? VG.ink : VG.ink2, lineHeight: 1.45 }}>{l}</div>
          ))}
        </div>
      )}
      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginBottom: !siftLines && line ? 8 : 0 }}>
        <span style={vgS.caps}>the read</span>
        {chips.map((ch, i) => (
          <span key={i} style={{ ...vgS.num, fontSize: 11.5, background: VG.chip, color: ch.c, borderRadius: 999, padding: '3px 11px' }}>{ch.t}</span>
        ))}
        {siftLines && sift?.at && <span style={{ ...vgS.caps, fontSize: 8.5, color: VG.ink4 }}>sift · {Math.max(0, Math.round((Date.now() - sift.at) / 60000))}m old</span>}
      </div>
      {!siftLines && line && <div style={{ fontSize: 12.5, color: VG.ink2, lineHeight: 1.5 }}>{line}</div>}
    </section>
  );
}

// ── The analyst: deep-reads on demand ────────────────────────────────────
// Point it at a topic and it reads the actual articles — then answers: what
// is this topic CONCRETELY this week, where do named outlets agree and
// disagree, what does none of the read pieces mention, what does it touch in
// your book, and what would confirm or refute the story. Every dossier footer
// counts what couldn't be read; nothing is analyzed from headlines alone.
function VgDossier({ d }) {
  const p = d.payload || d;
  const s = p.synthesis || {};
  const H = ({ children }) => <div style={{ ...vgS.caps, color: VG.accent2, margin: '12px 0 5px' }}>{children}</div>;
  return (
    <div style={{ fontSize: 12.5, lineHeight: 1.55 }}>
      <div style={{ ...vgS.serif, fontSize: 16, color: VG.ink, lineHeight: 1.4 }}>{s.what}</div>
      {(s.angles || []).length > 0 && (
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
          {s.angles.map((a, i) => (
            <span key={i} title={a.note} style={{ ...vgS.num, fontSize: 11, background: VG.chip, borderRadius: 999, padding: '3px 10px', color: VG.ink2 }}>
              {a.angle} × {a.n}
            </span>
          ))}
        </div>
      )}
      {(s.consensus || []).length > 0 && (
        <React.Fragment>
          <H>where outlets agree</H>
          {s.consensus.map((c, i) => <div key={i} style={{ color: VG.ink2, marginBottom: 3 }}>· {c}</div>)}
        </React.Fragment>
      )}
      {(s.disagreements || []).length > 0 && (
        <React.Fragment>
          <H>where they disagree</H>
          {s.disagreements.map((x, i) => (
            <div key={i} style={{ background: VG.chip, borderRadius: 10, padding: '8px 12px', marginBottom: 6 }}>
              <div style={{ color: VG.ink, marginBottom: 3 }}>{x.point}</div>
              <div style={{ color: VG.ink2 }}><b style={{ color: VG.accent2 }}>{x.a.outlet}</b>: {x.a.says}</div>
              <div style={{ color: VG.ink2 }}><b style={{ color: VG.accent2 }}>{x.b.outlet}</b>: {x.b.says}</div>
            </div>
          ))}
        </React.Fragment>
      )}
      {(s.omissions || []).length > 0 && (
        <React.Fragment>
          <H>not being said</H>
          {s.omissions.map((o, i) => <div key={i} style={{ color: VG.ink2, marginBottom: 3 }}>· {o}</div>)}
        </React.Fragment>
      )}
      {s.bookAngle && (
        <React.Fragment>
          <H>your book</H>
          <div style={{ color: VG.ink2 }}>{s.bookAngle}</div>
        </React.Fragment>
      )}
      {(s.watchpoints || []).length > 0 && (
        <React.Fragment>
          <H>watchpoints — what would settle it</H>
          {s.watchpoints.map((w, i) => (
            <div key={i} style={{ marginBottom: 5 }}>
              <span style={{ color: VG.ink }}>{w.watch}</span>
              <span style={{ color: VG.ink4 }}> — confirms: </span><span style={{ color: VG.ink3 }}>{w.confirms}</span>
              <span style={{ color: VG.ink4 }}> · refutes: </span><span style={{ color: VG.ink3 }}>{w.refutes}</span>
            </div>
          ))}
        </React.Fragment>
      )}
      <div style={{ borderTop: `1px solid ${VG.rule}`, marginTop: 10, paddingTop: 8 }}>
        <div style={{ ...vgS.caps, color: VG.ink4, marginBottom: 4 }}>
          read {p.read?.length ?? 0} pieces · {(p.outlets || []).join(' · ')}
          {p.unreadable?.length ? ` · ${p.unreadable.length} unreadable` : ''}
          {p.spendUSD != null ? ` · $${Number(p.spendUSD).toFixed(3)}` : ''}
        </div>
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
          {(p.read || []).map((r, i) => (
            <a key={i} href={r.link} target="_blank" rel="noreferrer" style={{ fontSize: 10.5, color: VG.ink3, textDecoration: 'none' }}
              title={r.title}>{r.source} ↗</a>
          ))}
        </div>
        {p.unreadable?.length > 0 && (
          <div style={{ fontSize: 10.5, color: VG.ink4, marginTop: 4 }}>
            couldn't read: {p.unreadable.slice(0, 3).map(u => `${u.source || 'unknown'} (${u.reason})`).join(' · ')}{p.unreadable.length > 3 ? ` · +${p.unreadable.length - 3} more` : ''}
          </div>
        )}
      </div>
    </div>
  );
}

function VgAnalyst({ requestTopic, onConsumedRequest }) {
  const [dossiers, setDossiers] = React.useState(null);
  const [active, setActive] = React.useState(null);       // a dossier object
  const [topic, setTopic] = React.useState('');
  const [job, setJob] = React.useState(null);             // { jobId, topic }
  const [err, setErr] = React.useState(null);
  const pollRef = React.useRef(null);

  const loadList = React.useCallback(() => {
    vgGet('/vantage/api/dossiers').then(d => setDossiers(d.dossiers || [])).catch(() => setDossiers([]));
  }, []);
  React.useEffect(loadList, [loadList]);

  const start = React.useCallback(async (t) => {
    const term = String(t || '').trim().toLowerCase();
    if (term.length < 2) return;
    setErr(null); setActive(null); setTopic(term);
    try {
      const r = await vgSend('/vantage/api/analyze', 'POST', { topic: term });
      setJob({ jobId: r.jobId, topic: term });
    } catch (ex) { setErr(ex.message); }
  }, []);

  // Cross-section requests ("read deeper" from chatter) land here.
  React.useEffect(() => {
    if (requestTopic) { start(requestTopic); onConsumedRequest && onConsumedRequest(); }
  }, [requestTopic, start, onConsumedRequest]);

  React.useEffect(() => {
    if (!job) return;
    pollRef.current = setInterval(async () => {
      try {
        const j = await vgGet(`/vantage/api/analyze/job/${job.jobId}`);
        if (j.status === 'done') { setJob(null); setActive(j.dossier); loadList(); }
        else if (j.status === 'error') { setJob(null); setErr(j.error); }
      } catch { /* keep polling; server may be mid-restart */ }
    }, 2500);
    return () => clearInterval(pollRef.current);
  }, [job, loadList]);

  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', marginBottom: 14 }}>
      <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 10 }}>
        <VgInput value={topic} onChange={e => setTopic(e.target.value)}
          onKeyDown={e => { if (e.key === 'Enter') start(topic); }}
          placeholder="read deeply into a topic… (ai, tariffs, uranium)" style={{ flex: 1, minWidth: 220 }} />
        <VgBtn small kind="primary" onClick={() => start(topic)} disabled={!!job || topic.trim().length < 2}>
          {job ? 'reading…' : 'deep read'}
        </VgBtn>
      </div>
      {job && (
        <div style={{ ...vgS.serif, fontSize: 14, color: VG.ink3, marginBottom: 8 }}>
          the analyst is reading the articles behind “{job.topic}” — gathering, one pass per piece, then the sift. ~a minute.
        </div>
      )}
      {err && <div style={{ fontSize: 12, color: '#fb923c', marginBottom: 8 }}>the analyst reports: {err}</div>}
      {active && <VgDossier d={active} />}
      {!active && !job && dossiers?.length > 0 && (
        <React.Fragment>
          <div style={{ ...vgS.caps, color: VG.ink4, marginBottom: 5 }}>past dossiers — tap to reopen</div>
          <div style={{ display: 'flex', gap: 7, flexWrap: 'wrap' }}>
            {dossiers.map(d => (
              <button key={d.id} onClick={() => setActive(d)} className="vg-chip" style={{
                appearance: 'none', border: 0, cursor: 'pointer', borderRadius: 999,
                padding: '4px 12px', fontSize: 11.5, background: VG.chip, color: VG.ink2, fontFamily: 'inherit',
              }}>
                {d.topic} <span style={{ color: VG.ink4 }}>· {vgAgoDays(d.at) === 0 ? 'today' : `${vgAgoDays(d.at)}d ago`}</span>
              </button>
            ))}
          </div>
        </React.Fragment>
      )}
      {!active && !job && dossiers && dossiers.length === 0 && (
        <div style={{ fontSize: 12.5, color: VG.ink3 }}>
          no dossiers yet — name a topic above and the analyst reads the actual articles behind it: what it concretely is,
          where outlets disagree, and what nobody's mentioning.
        </div>
      )}
      {active && (
        <button onClick={() => setActive(null)} style={{ appearance: 'none', border: 0, background: 'transparent', color: VG.ink4, cursor: 'pointer', fontSize: 11.5, marginTop: 8, padding: 0 }}>
          ← back to the dossier shelf
        </button>
      )}
    </section>
  );
}

// ── The staff: who tends each section ────────────────────────────────────
// The dashboard is not a wall of feeds — it's a crew of small workers, each
// owning a section. This roster shows each one, what it tends, when it last
// worked, and what it actually did (its own report, never invented). Health:
// steady dot = last run fine; amber = degraded or gone quiet past its cadence;
// hollow = has not run yet this deploy (honest absence).
function VgStaff({ staff, onReload }) {
  const [running, setRunning] = React.useState(null);
  if (!staff) return <div style={{ fontSize: 12.5, color: VG.ink3, padding: '4px 0 10px' }}>meeting the crew…</div>;
  const agents = staff.agents || [];
  const RUNNABLE = { wire: 1, oracle: 1, globe: 1, sentinel: 1, alarms: 1, skeptic: 1, courier: 1, days: 1 };
  const run = key => {
    setRunning(key);
    vgSend('/vantage/api/agents/run', 'POST', { key })
      .catch(() => {})
      .finally(() => { setRunning(null); onReload && onReload(); });
  };
  const dot = h => h === 'ok' ? VG.ink3 : (h === 'degraded' || h === 'stale') ? '#fb923c' : 'transparent';
  const ring = h => h === 'quiet' ? `1px solid ${VG.ink4}` : 'none';
  const ago = a => a.agoMin == null ? 'not yet this deploy'
    : a.agoMin < 1 ? 'just now' : a.agoMin < 60 ? `${a.agoMin}m ago` : `${Math.round(a.agoMin / 60)}h ago`;
  const side = s => agents.filter(a => a.side === s);
  const group = (label, list) => (
    <div style={{ marginBottom: 10 }}>
      <div style={{ ...vgS.caps, color: VG.ink4, marginBottom: 4 }}>{label}</div>
      {list.map(a => (
        <div key={a.key} style={{
          display: 'grid', gridTemplateColumns: '10px 190px 1fr auto auto', gap: 10, alignItems: 'baseline',
          padding: '6px 0', borderBottom: `1px solid ${VG.rule}`, fontSize: 12.5,
        }}>
          <span style={{ width: 7, height: 7, borderRadius: '50%', background: dot(a.health), border: ring(a.health), alignSelf: 'center' }}
            title={a.health} />
          <span style={{ minWidth: 0 }}>
            <span style={{ ...vgS.serif, fontSize: 14.5, color: VG.ink, display: 'block' }}>{a.name}</span>
            <span style={{ fontSize: 10.5, color: VG.ink4, display: 'block' }}>{a.does}</span>
          </span>
          <span style={{ color: a.note ? VG.ink2 : VG.ink4, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
            {a.note || 'no report yet'}
          </span>
          <span style={{ ...vgS.num, fontSize: 11, color: VG.ink3 }}>{ago(a)}</span>
          {RUNNABLE[a.key]
            ? <button onClick={() => run(a.key)} disabled={running === a.key} className="vg-chip" style={{
                appearance: 'none', border: 0, cursor: running === a.key ? 'wait' : 'pointer', borderRadius: 999,
                padding: '2px 10px', fontSize: 10.5, background: VG.chip, color: running === a.key ? VG.ink4 : VG.ink3,
              }}>{running === a.key ? 'running…' : 'run now'}</button>
            : <span style={{ ...vgS.caps, fontSize: 8.5, color: VG.ink4, alignSelf: 'center' }}>on demand</span>}
        </div>
      ))}
    </div>
  );
  const u = staff.usage;
  return (
    <section style={{ background: VG.tile, borderRadius: 18, padding: '15px 18px', marginBottom: 14 }}>
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginBottom: 10, flexWrap: 'wrap' }}>
        <span style={vgS.caps}>the staff · who tends each section</span>
        <span style={{ fontSize: 10.5, color: VG.ink4 }}>every note below is the worker's own report — quiet means it hasn't run, not that all is well</span>
        <span style={{ flex: 1 }} />
        <a href="/agents" style={{ ...vgS.caps, color: VG.ink3, textDecoration: 'none' }}>the staff's home ↗</a>
      </div>
      {group('vantage', side('vantage'))}
      {group('garden', side('garden'))}
      {u && (
        <div style={{ ...vgS.caps, color: VG.ink4, marginTop: 4 }}>
          today's model spend ${Number(u.totalCostUSD || 0).toFixed(4)} · {(u.calls || []).length} call{(u.calls || []).length === 1 ? '' : 's'}
        </div>
      )}
    </section>
  );
}

function VantagePulse({ positions, catalysts, onGlobe, onJournal, onResearchSym }) {
  const [sent, setSent] = React.useState(undefined);   // undefined = loading
  const [news, setNews] = React.useState(undefined);
  const [threads, setThreads] = React.useState([]);
  const [markets, setMarkets] = React.useState(null);
  const [chatter, setChatter] = React.useState(null);
  const [insights, setInsights] = React.useState(null);
  const [macro, setMacro] = React.useState(null);
  const [oracle, setOracle] = React.useState(null);
  const [changes, setChanges] = React.useState(null);
  const [staff, setStaff] = React.useState(null);
  const [hit, setHit] = React.useState(null);                 // PM-Track hit rate (7/30/90 + divergence)
  const [analystReq, setAnalystReq] = React.useState(null);   // { topic, nonce } from other sections
  const [sift, setSift] = React.useState(null);
  const [tv, setTv] = React.useState(null);       // { tvSymbol, label }
  const [noting, setNoting] = React.useState(null); // { kind, key, label, snapshot }
  const holdings = [...new Set(positions.map(p => p.symbol))];

  const loadChatter = React.useCallback(() => {
    vgGet('/vantage/api/chatter').then(setChatter).catch(() => setChatter(null));
  }, []);
  const loadStaff = React.useCallback(() => {
    vgGet('/vantage/api/agents').then(setStaff).catch(() => setStaff(null));
  }, []);

  React.useEffect(() => {
    let alive = true;
    const load = () => {
      vgGet('/vantage/api/sentiment').then(d => alive && setSent(d)).catch(() => alive && setSent(null));
      vgGet('/vantage/api/markets').then(d => alive && setMarkets(d)).catch(() => alive && setMarkets(null));
      vgGet('/vantage/api/insights').then(d => alive && setInsights(d)).catch(() => alive && setInsights(null));
      vgGet('/vantage/api/macro').then(d => alive && setMacro(d)).catch(() => alive && setMacro(null));
      vgGet('/vantage/api/oracle').then(d => alive && setOracle(d)).catch(() => alive && setOracle(null));
      vgGet('/vantage/api/changes').then(d => alive && setChanges(d)).catch(() => alive && setChanges(null));
      vgGet('/vantage/api/pmtrack/hitrate').then(d => alive && setHit(d)).catch(() => alive && setHit(null));
      if (alive) loadStaff();
      vgGet('/vantage/api/sift').then(d => alive && setSift(d?.lines ? d : null)).catch(() => alive && setSift(null));
      // Chatter loads regardless of the news fetch outcome; the short delay
      // lets the server finish logging fresh headlines into the corpus.
      vgGet(`/vantage/api/news${holdings.length ? `?symbols=${encodeURIComponent(holdings.join(','))}` : ''}`)
        .then(d => { if (alive) { setNews(d.items); setThreads(d.threads || []); } })
        .catch(() => alive && setNews([]))
        .finally(() => { setTimeout(() => { if (alive) loadChatter(); }, 1200); });
    };
    load();
    const t = setInterval(() => { if (!document.hidden) load(); }, 5 * 60_000);
    return () => { alive = false; clearInterval(t); };
  }, [holdings.sort().join(',')]);

  const openLive = React.useCallback(cfg => setTv(cfg), []);
  const openNote = React.useCallback(cfg => setNoting(cfg), []);

  // Paint the shell immediately — every panel already tolerates an undefined
  // feed and fills in when it lands. Only the sentiment row and the wire get a
  // loading placeholder (they'd otherwise show "unreachable" mid-load); nothing
  // blocks first paint on the two slowest external arms (/sentiment, /news).
  const blurbs = insights?.blurbs ?? {};

  // ── Fold summaries: one honest line per layer, from data already in hand.
  // Absent feeds say so; nothing is invented for a closed fold. ──
  const sumSent = sent === undefined ? 'reading…'
    : sent === null ? 'sentiment feeds unreachable right now'
    : [
        sent.fearGreed?.score != null ? `fear&greed ${sent.fearGreed.score}${sent.fearGreed.rating ? ` ${sent.fearGreed.rating}` : ''}` : null,
        sent.vix?.level != null ? `vix ${sent.vix.level.toFixed(1)}${sent.vix.band?.key ? ` ${sent.vix.band.key}` : ''}` : null,
        sent.putCall?.ratio != null ? `put/call ${sent.putCall.ratio.toFixed(2)}` : null,
        sent.more?.crypto ? `crypto ${sent.more.crypto.score}` : null,
        sent.more?.vixTerm ? `term ${sent.more.vixTerm.shape}` : null,
      ].filter(Boolean).join(' · ') || 'no sentiment data returned';
  const sentNotable = ['elevated', 'high'].includes(sent?.vix?.band?.key);

  const sumMacro = !macro ? 'curve → credit → labor · open for status'
    : [
        macro.curve?.value != null ? `curve ${macro.curve.value > 0 ? '+' : ''}${macro.curve.value.toFixed(2)}%${macro.curve.band ? ` ${macro.curve.band}` : ''}` : null,
        macro.hyOas?.value != null ? `hy ${(macro.hyOas.value * 100).toFixed(0)}bp${macro.hyOas.band ? ` ${macro.hyOas.band}` : ''}` : null,
        macro.claims?.value != null ? `claims ${(macro.claims.value / 1000).toFixed(0)}k${macro.claims.band ? ` ${macro.claims.band}` : ''}` : null,
      ].filter(Boolean).join(' · ') || (macro.fredError ? 'FRED is rejecting the key — open for the fix' : 'macro feeds not returning — open for why');
  const macroNotable = macro?.curve?.band === 'inverted'
    || ['stress', 'crisis'].includes(macro?.hyOas?.band)
    || macro?.claims?.band === 'deteriorating';

  const upcoming = (catalysts?.events ?? []).filter(e => e.date > Date.now() - 6 * 3600000);
  const nextEv = upcoming[0];
  const sumCata = !catalysts ? 'reading the calendar…'
    : nextEv ? `next · ${nextEv.kind === 'earnings' ? `${nextEv.symbol} earnings` : nextEv.title} ${vgInDays(nextEv.date)}${upcoming.length > 1 ? ` · ${upcoming.length - 1} more ahead` : ''}`
    : 'nothing on the near calendar';
  const cataNotable = upcoming.some(e => e.date - Date.now() < 48 * 3600000
    && (e.kind === 'fomc' || e.impact === 'high' || (e.kind === 'earnings' && holdings.includes(e.symbol))));

  const oracleClaims = oracle?.claims || [];
  const oraclePending = oracleClaims.filter(c => c.status === 'pending' && !(c.outcomes && Object.keys(c.outcomes).length)).length;
  const sumLedger = `${oracleClaims.length - oraclePending} scored · ${oraclePending} pending`;
  // Gradual introduction: the ledger appears only once it has memory to show.
  const intro = vgFeatureIntro({ claimCount: oracleClaims.length, snapshotCount: oracle?.snapshotCount || 0, scoredCount: hit?.scoredCount || 0 });
  // Hit rate surfaces (smart-opens, wears the dot) ONLY when 7d diverges from
  // 30d by more than 15 points on enough calls — the server computes the rule.
  const hitNotable = !!hit?.divergence?.divergent;
  const sumHit = !hit ? 'reading…'
    : !hit.d30?.all?.n ? 'no scored calls yet'
    : `30d ${Math.round(hit.d30.all.rate * 100)}% (${hit.d30.all.hits}/${hit.d30.all.n})${hit.d7?.all?.n ? ` · 7d ${Math.round(hit.d7.all.rate * 100)}%` : ''}${hit.divergence?.divergent ? ' · diverging' : ''}`;

  const sumNews = news === undefined ? 'reading the wire…'
    : !news || news.length === 0 ? 'the wire returned nothing right now'
    : `${news.length} stories · ${String(news[0]?.title || '').slice(0, 90)}`;

  const analystAgent = staff?.agents?.find(a => a.key === 'analyst');
  const sumAnalyst = analystAgent?.note
    ? analystAgent.note
    : 'name a topic and it reads the actual articles — claims, disagreements, omissions';

  const staffAgents = staff?.agents || [];
  const staffReported = staffAgents.filter(a => a.at);
  const staffIssues = staffAgents.filter(a => a.health === 'degraded' || a.health === 'stale').length;
  const sumStaff = !staff ? 'the crew behind every section — meeting them…'
    : staffReported.length === 0 ? `${staffAgents.length} workers · none have reported yet this deploy`
    : `${staffReported.length} of ${staffAgents.length} reporting${staffIssues ? ` · ${staffIssues} need${staffIssues === 1 ? 's' : ''} a look` : ' · all steady'}`;

  return (
    <React.Fragment>
      {/* LEVEL 0–1, always open: the read, what changed, the planet, and the
          board. Everything deeper is a fold — one honest summary line, the
          body mounting only when opened (smart-open when it warrants it). */}
      <section style={{ display: 'flex', flexWrap: 'wrap', gap: 14, alignItems: 'stretch' }}>
        <div style={{ flex: '1 1 460px', minWidth: 0 }}>
          <VgPulseVerdict sent={sent} markets={markets} macro={macro} sift={sift} />
          <VgChangesStrip changes={changes} onJournal={onJournal} onResearchSym={onResearchSym} onGlobe={onGlobe} onAnalyze={t => setAnalystReq(r => ({ topic: t, nonce: (r?.nonce || 0) + 1 }))} />
        </div>
        <VgPulseGlobe onGlobe={onGlobe} />
      </section>
      <VgBoard markets={markets} blurb={blurbs.board} onLive={openLive} onNote={openNote} />

      <VgLayer k="sentiment" title="sentiment" summary={sumSent} notable={sentNotable} hintFirst
        byline={vgByline(staff, 'scribe', 'blurbs by')}>
        <section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 10, marginBottom: 14 }}>
          {sent === undefined ? [0, 1, 2].map(i => <VgLoading key={i} what="sentiment" />) : (
            <React.Fragment>
              <VgFearGreedTile fg={sent?.fearGreed ?? null} blurb={blurbs.fearGreed} onNote={openNote} />
              <VgVixTile vix={sent?.vix ?? null} blurb={blurbs.vix} onLive={openLive} onNote={openNote} />
              <VgPutCallTile pc={sent?.putCall ?? null} pcHistory={sent?.pcHistory ?? []} blurb={blurbs.putCall} onNote={openNote} />
            </React.Fragment>
          )}
        </section>
        {sent !== undefined && <VgMoreSent more={sent?.more} />}
      </VgLayer>

      <VgLayer k="macro" title="macro" summary={sumMacro} notable={macroNotable}>
        <VgMacro macro={macro} />
      </VgLayer>

      <VgLayer k="catalysts" title="catalysts & calendar" summary={sumCata} notable={cataNotable}>
        {/* Catalysts + chatter on the left, the filterable calendar beside them. */}
        <section style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.55fr) minmax(300px, 1fr)', gap: 14, marginBottom: 14, alignItems: 'stretch' }}>
          <div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
            {/* These cards carry their own marginBottom — no extra gap. */}
            <VgCatalysts events={catalysts?.events ?? []} econAvailable={catalysts?.econAvailable} holdings={holdings} />
            <VgChatter chatter={chatter} onChanged={loadChatter} onAnalyze={t => setAnalystReq(r => ({ topic: t, nonce: (r?.nonce || 0) + 1 }))} />
          </div>
          <VgCalendar events={catalysts?.events ?? []} earnings={catalysts?.earnings ?? {}} />
        </section>
      </VgLayer>

      <VgLayer k="heatmap" title="s&p heatmap" summary="finviz sector map — loads when opened">
        <VgHeatmap />
      </VgLayer>

      <VgLayer k="seasonality" title="seasonality" summary="monthly tendencies for your book — history, not prophecy">
        <VgSeasonality holdings={holdings} />
      </VgLayer>

      {oracle && (intro.ledger.on ? (
        <VgLayer k="ledger" title="the ledger" summary={sumLedger} introKey="ledger"
          byline={vgByline(staff, 'oracle', 'kept by')}>
          <VgLedger oracle={oracle} />
        </VgLayer>
      ) : (
        <VgLocked title="the ledger" hint={intro.ledger.hint} />
      ))}

      {hit !== null && (intro.hitRate.on ? (
        <VgLayer k="hitrate" title="hit rate" summary={sumHit} notable={hitNotable} introKey="hitrate"
          byline={vgByline(staff, 'grader', 'scored by')}>
          <VgHitRate hit={hit} />
        </VgLayer>
      ) : (
        <VgLocked title="hit rate" hint={intro.hitRate.hint} />
      ))}

      <VgLayer k="wire" title="the wire" summary={sumNews} byline={vgByline(staff, 'wire')}>
        {news === undefined
          ? <VgLoading what="the wire" />
          : <VgNews items={news ?? []} threads={threads} holdings={holdings} onNote={openNote} onResearchSym={onResearchSym} />}
      </VgLayer>

      <VgLayer k="analyst" title="the analyst" summary={sumAnalyst} notable={!!analystReq} openNonce={analystReq?.nonce}
        byline={vgByline(staff, 'analyst', 'deep reads by')}>
        <VgAnalyst requestTopic={analystReq?.topic} onConsumedRequest={() => setAnalystReq(r => r && { ...r, topic: null })} />
      </VgLayer>

      <VgLayer k="staff" title="the staff" summary={sumStaff} notable={staffIssues > 0}>
        <VgStaff staff={staff} onReload={loadStaff} />
      </VgLayer>

      {tv && <VgTVModal choices={tv.choices} label={tv.label} onClose={() => setTv(null)} />}
      {noting && (
        <VgNoteModal kind={noting.kind} subjectKey={noting.key} label={noting.label}
          snapshot={noting.snapshot} onClose={() => setNoting(null)} />
      )}
    </React.Fragment>
  );
}

window.VantagePulse = VantagePulse;
