/* CryptoPage — the crypto world (Revolut X), BETA.

   Same shape as Swings — dashboard, trades, alerts, strategies — reading its OWN tables
   (crypto_strategies / crypto_positions / crypto_orders / crypto_marks / crypto_signals /
   crypto_broker_accounts). Nothing here touches the other worlds' data. Every page carries
   the BETA banner so nobody mistakes an empty table for a broken feed: the signal sheet and
   the engine come after this. */

const CR_n = (v) => { if (v == null || v === "") return null; const x = Number(v); return isNaN(x) ? null : x; };
const CR_usd = (v, dec) => { const x = CR_n(v); if (x == null) return "—"; return (x < 0 ? "−$" : "$") + Math.abs(x).toFixed(dec == null ? 2 : dec); };
const CR_pct = (v) => { const x = CR_n(v); if (x == null) return "—"; const r = Math.round(x * 10) / 10; return (r > 0 ? "+" : "") + r.toFixed(1) + "%"; };
const CR_ago = (iso) => {
  if (!iso) return "never";
  const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
  return s < 90 ? Math.round(s) + "s ago" : s < 5400 ? Math.round(s / 60) + "m ago" : s < 172800 ? Math.round(s / 3600) + "h ago" : Math.round(s / 86400) + "d ago";
};

/* The one BETA pill, used everywhere the world is named: menu, top bar, page heads, banner. */
function CryptoBetaPill({ small, tone }) {
  const amber = tone === "amber";
  return (
    <span style={{ display: "inline-block", font: "var(--w-semibold) " + (small ? "9px" : "var(--t-2xs)") + "/1 var(--font-sans)", letterSpacing: "var(--ls-wide)",
      color: amber ? "var(--chip-entry)" : "var(--violet)", padding: small ? "2px 5px" : "3px 7px",
      border: "1px solid " + (amber ? "var(--chip-entry)" : "var(--violet-line)"), borderRadius: 999, verticalAlign: "middle" }}>BETA</span>
  );
}

function CryptoBetaBanner() {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px",
      background: "var(--chip-entry-bg)", borderRadius: "var(--radius-sm)" }}>
      <CryptoBetaPill tone="amber" />
      <span style={{ font: "var(--w-regular) var(--t-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
        Crypto on Revolut X is being built. Prices refresh every few seconds, and your positions, resting orders and balances every twenty. Manual trades only for now: no signal source, and the engine places no orders yet.
      </span>
    </div>
  );
}


/* ---------------------------------------------------------------- market sentiment
   Everything here comes from `market_snapshot`, which the bot fills from CoinMarketCap on its
   own clock. The dashboard never calls CMC: the key would have to ship in a public file, and
   the plan's 15,000 credits a month would not survive one browser tab left open. */

/* The gauge, drawn as five EQUAL bands. Drawing them at their true widths — extreme fear is
   25 points wide, neutral only 10 — made the colours lopsided and the whole thing read as a
   bucket rather than a dial. CoinMarketCap draws equal segments; so do we. */
function CR_Gauge({ value, size = 320 }) {
  const SWEEP = 180, STROKE = 10, R = 116, GAP = 3.4;
  const start = 270 - SWEEP / 2, cx = size / 2, cy = STROKE / 2 + 6 + R;
  const cols = ["#F0454B", "#F5A524", "#E2C93F", "#8FCB52", "#21C77A"];
  const P = (a, r) => [cx + r * Math.cos(a * Math.PI / 180), cy + r * Math.sin(a * Math.PI / 180)];
  const bands = cols.map((c, i) => {
    const a0 = start + i * SWEEP / 5 + GAP, a1 = start + (i + 1) * SWEEP / 5 - GAP;
    const [x0, y0] = P(a0, R), [x1, y1] = P(a1, R);
    return <path key={i} d={`M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${R} ${R} 0 0 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`}
                 stroke={c} strokeWidth={STROKE} fill="none" strokeLinecap="round" />;
  });
  const v = Math.max(0, Math.min(100, Number(value) || 0));
  const [kx, ky] = P(start + SWEEP * (v / 100), R);
  const h = cy + STROKE / 2 + 8;
  const tone = v >= 55 ? "var(--profit)" : v <= 45 ? "var(--loss)" : "var(--dryrun)";
  return (
    <div style={{ position: "relative", display: "flex", justifyContent: "center", padding: "14px 0 0", height: h + 52 }}>
      <svg viewBox={`0 0 ${size} ${h}`} width={size}
           style={{ width: "100%", maxWidth: size, height: "auto" }}>
        {bands}
        <circle cx={kx} cy={ky} r={STROKE * 0.62} fill="#FBFBFC" stroke="var(--surface-card)" strokeWidth="3" />
      </svg>
      <div style={{ position: "absolute", left: 0, right: 0, top: cy - 40, textAlign: "center",
                    font: "var(--w-light) 64px/1 var(--font-mono)", letterSpacing: "var(--ls-tight)", color: tone }}>{v}</div>
    </div>
  );
}

const CR_BANDS = [["#F0454B", "extreme fear"], ["#F5A524", "fear"], ["#E2C93F", "neutral"],
                  ["#8FCB52", "greed"], ["#21C77A", "extreme greed"]];

/* One continuous slide, the same idea as the Apple TV rail. Pauses on hover so a card can
   actually be read. */
const CR_Rail = React.memo(function CR_Rail({ coins, series }) {
  const narrow = window.NT_IS_MOBILE && window.NT_IS_MOBILE();
  // The marquee is driven by JAVASCRIPT, not a CSS animation. A CSS animation carries
  // hidden state (its start time), and both React re-renders and iOS layer recompositing
  // could silently reset that state — on the phone it read as "a few tickers, then it
  // restarts". Here the position is a pure function of the wall clock every frame:
  //   x = -((t - pausedTime) * SPEED) mod span
  // so there is no state to lose. A restart, a re-render, a remount — the next frame
  // lands exactly where an uninterrupted run would be. The IntersectionObserver and
  // hover still pause it (off-screen compositing / reading a card), with the paused
  // time subtracted so it resumes where it stopped.
  const CARD = narrow ? 168 : 236, GAP = narrow ? 8 : 12;
  // Pace it in pixels per second rather than by a fixed duration, so the speed does not
  // depend on how many coins came back. A phone shows two cards at once, so the slow
  // desktop crawl reads as stalled there.
  const SPEED = 44;                  // one pace everywhere — the desktop crawl (Gianni, 27 Aug)
  const span = ((coins && coins.length) || 0) * (CARD + GAP);
  // These hooks sit above the early return — React requires the same hooks every render.
  const railRef = React.useRef(null);
  React.useEffect(() => {
    const el = railRef.current;
    if (!el) return;
    const row = el.querySelector(".cr-row");
    if (!row || !span) return;
    if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    let alive = true, visible = true, hovered = false, base = 0, pausedAt = null;
    const paused = () => !visible || hovered;
    const tick = (now) => {
      if (!alive) return;
      if (paused()) { if (pausedAt == null) pausedAt = now; }
      else {
        if (pausedAt != null) { base += now - pausedAt; pausedAt = null; }
        const x = -((((now - base) / 1000) * SPEED) % span);
        row.style.transform = "translate3d(" + x.toFixed(1) + "px,0,0)";
      }
      requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);
    let io = null;
    if (typeof IntersectionObserver !== "undefined") {
      io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; });
      io.observe(el);
    }
    // Hover-pause ONLY where hovering is real. iOS synthesizes a mouseenter from a touch
    // and never sends the mouseleave, so on a phone one tap froze the slider for good.
    const canHover = window.matchMedia && window.matchMedia("(hover: hover)").matches;
    const over = () => { hovered = true; }, out = () => { hovered = false; };
    if (canHover) {
      el.addEventListener("mouseenter", over);
      el.addEventListener("mouseleave", out);
    }
    return () => {
      alive = false;
      if (io) io.disconnect();
      if (canHover) {
        el.removeEventListener("mouseenter", over);
        el.removeEventListener("mouseleave", out);
      }
    };
  }, [coins && coins.length, SPEED, span]);
  if (!coins || !coins.length) return null;
  const spark = (vals, up) => {
    if (!vals || vals.length < 2) return null;
    const w = 104, h = 30, lo = Math.min.apply(null, vals), hi = Math.max.apply(null, vals);
    const rng = (hi - lo) || 1;
    const d = vals.map((v, i) => `${(i * w / (vals.length - 1)).toFixed(1)} ${(h - (v - lo) / rng * h).toFixed(1)}`).join(" L ");
    return <svg viewBox={`0 0 ${w} ${h}`} width={w} height={h}>
      <path d={"M " + d} fill="none" stroke={up ? "#21C77A" : "#F0454B"} strokeWidth="1.6" vectorEffect="non-scaling-stroke" />
    </svg>;
  };
  const card = (c, k) => {
    const up = (c.d24 || 0) >= 0;
    const col = up ? "var(--profit)" : "var(--loss)";
    return (
      <div key={k} style={{ width: CARD, boxSizing: "border-box", flex: "none", background: "rgba(255,255,255,.02)",
                            border: "1px solid var(--border)", borderRadius: "var(--radius-md)",
                            padding: narrow ? "9px 10px" : "11px 13px" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
          <img src={c.logo} alt="" width="19" height="19" style={{ borderRadius: "50%", flex: "none" }} />
          <b style={{ font: "var(--w-medium) var(--t-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{c.name}</b>
          <span style={{ marginLeft: "auto", font: "var(--w-regular) var(--t-2xs)/1 var(--font-mono)", color: "var(--text-tertiary)" }}>{c.symbol}</span>
        </div>
        <div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 10, marginTop: 9 }}>
          <div>
            <span style={{ display: "block", font: "var(--w-regular) 15px/1 var(--font-mono)", fontVariantNumeric: "tabular-nums" }}>
              {CR_usd(c.price, c.price >= 100 ? 2 : 4)}</span>
            <span style={{ display: "block", marginTop: 5, font: "var(--w-medium) var(--t-2xs)/1 var(--font-mono)", color: col }}>
              {up ? "\u25B2" : "\u25BC"} {Math.abs(c.d24 || 0).toFixed(2)}%</span>
          </div>
          {narrow ? null : spark((series || {})[c.symbol], up)}
        </div>
      </div>
    );
  };
  const once = coins.map((c, i) => card(c, "a" + i));
  const twice = coins.map((c, i) => card(c, "b" + i));
  return (
    // touchAction pan-y: without it a finger landing on the marquee could not scroll the
    // page on iOS — the animated 5,000px layer swallowed the gesture.
    <div className="cr-rail" ref={railRef}
         style={{ overflow: "hidden", touchAction: "pan-y",
                  WebkitMaskImage: "linear-gradient(90deg, transparent 0, black 36px, black calc(100% - 36px), transparent 100%)",
                  maskImage: "linear-gradient(90deg, transparent 0, black 36px, black calc(100% - 36px), transparent 100%)" }}>
      <div className="cr-row" style={{ display: "flex", gap: GAP, width: "max-content", willChange: "transform" }}>{once}{twice}</div>
    </div>
  );
});

function CryptoPage({ page }) {
  const NT = window.NitroTraderDesignSystem_95e598;
  const isMobile = useIsMobile();
  // The trades table drops its widest columns (capital, market value) when its OWN container is
  // too narrow to show them all — measured with a ResizeObserver, not a viewport guess, so it is
  // correct on any screen width / sidebar state. `tradesRef` attaches to the trades page root.
  const [med, setMed] = React.useState(false);
  const _roRef = React.useRef(null);
  const tradesRef = React.useCallback((el) => {
    if (_roRef.current) { _roRef.current.disconnect(); _roRef.current = null; }
    if (el && window.ResizeObserver) {
      const ro = new ResizeObserver((ents) => { setMed((ents[0].contentRect.width || 9999) < 900); });
      ro.observe(el); _roRef.current = ro;
    }
  }, []);
  const [d, setD] = React.useState({ strategies: [], positions: [], orders: [], marks: [], signals: [], brokers: [], briefs: [], market: {}, loaded: false });
  const [openBrief, setOpenBrief] = React.useState(null);
  // Deep-link from a Notifications sentiment row: NT_OPEN_BRIEF holds the brief id to open.
  React.useEffect(() => {
    if (typeof window !== "undefined" && window.NT_OPEN_BRIEF != null) {
      setOpenBrief(window.NT_OPEN_BRIEF); window.NT_OPEN_BRIEF = null;
    }
  }, []);
  const [playVid, setPlayVid] = React.useState(null);   // sentiment panel: which video is playing
  const [playing, setPlaying] = React.useState(null);     // brief id whose player is loaded
  const [tradeFilter, setTradeFilter] = React.useState("all");
  const [chartOf, setChartOf] = React.useState(null);     // position id whose chart is open
  const [openRow, setOpenRow] = React.useState(null);     // phone: which portfolio row is expanded
  const [fngOpen, setFngOpen] = React.useState(true);     // fear & greed history chart — open by default
  const [fngHover, setFngHover] = React.useState(null);   // day index under the pointer
  const load = React.useCallback(() => {
    const db = window.NT_CLIENT;
    if (!db) return;
    Promise.all([
      db.from("crypto_strategies").select("*").order("id"),
      db.from("crypto_positions").select("*").order("opened_at", { ascending: false }).order("id", { ascending: false }).limit(200),
      db.from("crypto_orders").select("*").order("id", { ascending: false }).limit(50),
      db.from("crypto_marks").select("*"),
      db.from("crypto_signals").select("*").order("detected_at", { ascending: false }).limit(100),
      db.from("crypto_broker_accounts").select("*").order("id"),
      db.from("market_snapshot").select("kind,payload,fetched_at"),
      db.from("crypto_video_briefs").select("id,channel_name,channel_avatar,video_id,title,url,published_at,status,sentiment,sentiment_score,headline,short_title,summary_short,playbook,summary,signals,model,summarized_at,error,fng_at_publish,btc_at_publish,xrp_at_publish,reezer_score,reezer_verdict,btc_signal,xrp_signal,storm,long_read,score_reasoning,game_plan,key_levels,invalidators,views_text,duration_text").order("published_at", { ascending: false }).limit(60),
    ]).then((r) => {
      const strategies = (r[0] && r[0].data) || [];
      window.NT_HAS_CRYPTO = strategies.length > 0;
      setD({ strategies, positions: (r[1] && r[1].data) || [], orders: (r[2] && r[2].data) || [],
             marks: (r[3] && r[3].data) || [], signals: (r[4] && r[4].data) || [],
             brokers: (r[5] && r[5].data) || [],
             market: Object.fromEntries(((r[6] && r[6].data) || []).map((x) => [x.kind, x])),
             briefs: (r[7] && r[7].data) || [], loaded: true });
    }, () => {});
  }, []);
  // Every four seconds. The bot re-marks crypto every 3s and equities every 5s, so a
  // thirty-second page clock was the reason a screen full of live money sat still.
  React.useEffect(() => { load(); const t = setInterval(load, 4000); return () => clearInterval(t); }, [load]);
  React.useEffect(() => { if (window.lucide) window.lucide.createIcons(); });

  const broker = d.brokers[0] || null;
  const bs = (broker && broker.settings) || {};
  const markOf = (sym) => d.marks.filter((m) => String(m.symbol).toUpperCase() === String(sym).toUpperCase())[0] || null;
  const openPos = d.positions.filter((p) => p.status === "open");
  const closedPos = d.positions.filter((p) => p.status !== "open");
  const working = d.orders.filter((o) => o.status === "working");
  const unreal = (p) => { const m = markOf(p.symbol); if (!m) return null; if (m.unrealized_pnl != null) return CR_n(m.unrealized_pnl); const px = CR_n(m.px); return px != null ? (px - (CR_n(p.avg_price) || 0)) * (CR_n(p.qty) || 0) : null; };
  const pnlPct = (p) => { const m = markOf(p.symbol); const c = (CR_n(p.qty) || 0) * (CR_n(p.avg_price) || 0); if (!(m && c)) return null; const u = (m.unrealized_pnl != null) ? CR_n(m.unrealized_pnl) : ((CR_n(m.px) * CR_n(p.qty)) - c); return u / c * 100; };
  const realized = closedPos.reduce((a, p) => a + (CR_n(p.realized_pnl) || 0), 0);
  const unrealTotal = openPos.reduce((a, p) => a + (unreal(p) || 0), 0);

  // Same KPI card, table and empty-state styles as the Swings pages — the world must read
  // identically, only the data differs.
  const toneCol = (t) => (t === "up" ? "var(--profit)" : t === "down" ? "var(--loss)" : "var(--text-primary)");
  const cardStyle = { display: "flex", flexDirection: "column", gap: 10, padding: "18px 18px 16px", background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", boxShadow: "var(--shadow-sm)", minWidth: 0 };
  const labelStyle = { font: "var(--w-medium) var(--t-2xs)/1 var(--font-sans)", letterSpacing: "var(--ls-wide)", textTransform: "lowercase", color: "var(--text-tertiary)" };
  const valStyle = (t) => ({ font: "var(--w-light) var(--t-kpi)/1 var(--font-mono)", fontVariantNumeric: "tabular-nums", letterSpacing: "var(--ls-tight)", color: toneCol(t) });
  const subStyle = { font: "var(--w-regular) var(--t-xs)/1.3 var(--font-sans)", color: "var(--text-tertiary)" };
  // The phone card: the same information, stacked tight, to the height of a ticker card.
  const cardStyleMo = { ...cardStyle, gap: 5, padding: "10px 12px 11px" };
  const valStyleMo = (t) => ({ ...valStyle(t), font: "var(--w-light) 21px/1 var(--font-mono)" });
  const subStyleMo = { font: "var(--w-regular) var(--t-2xs)/1.3 var(--font-sans)", color: "var(--text-tertiary)" };
  const Kard = ({ label, value, sub, tone, visual }) => (
    <div style={isMobile ? cardStyleMo : cardStyle}>
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between",
                    gap: 8, minHeight: isMobile ? 0 : 30 }}>
        <span style={labelStyle}>{label}</span>
        {isMobile ? null : (visual || null)}
      </div>
      <span style={isMobile ? valStyleMo(tone) : valStyle(tone)}>{value != null ? value : "—"}</span>
      {sub ? <span style={isMobile ? subStyleMo : subStyle}>{sub}</span> : null}
    </div>
  );
  const ring = (frac) => {
    const f = Math.max(0, Math.min(1, frac || 0)), r = 11, c = 2 * Math.PI * r;
    return (
      <svg width="28" height="28" viewBox="0 0 28 28" style={{ flex: "none" }}>
        <circle cx="14" cy="14" r={r} fill="none" stroke="var(--border)" strokeWidth="3" />
        <circle cx="14" cy="14" r={r} fill="none" stroke="var(--violet)" strokeWidth="3" strokeDasharray={c} strokeDashoffset={c * (1 - f)} strokeLinecap="round" transform="rotate(-90 14 14)" />
      </svg>
    );
  };
  const th = { font: "var(--w-medium) var(--t-2xs)/1 var(--font-sans)", letterSpacing: "var(--ls-wide)", textTransform: "uppercase", color: "var(--text-tertiary)", padding: "10px 14px", textAlign: "left", whiteSpace: "nowrap" };
  const thR = { ...th, textAlign: "right" };
  const td = { font: "var(--w-regular) var(--t-sm)/1.4 var(--font-sans)", padding: "9px 14px", borderTop: "1px solid var(--row-line)", textAlign: "left", color: "var(--text-secondary)", verticalAlign: "top" };
  const tdTall = { ...td, padding: "15px 14px", verticalAlign: "middle" };
  const tdTallR = { ...tdTall, textAlign: "right" };
  const mono = { font: "var(--w-regular) var(--t-sm)/1.4 var(--font-mono)", fontVariantNumeric: "tabular-nums" };
  const pnlColor = (v) => (v == null ? "var(--text-secondary)" : v > 0 ? "var(--profit)" : v < 0 ? "var(--loss)" : "var(--text-secondary)");
  const tone = (v) => (v == null ? null : v > 0 ? "up" : v < 0 ? "down" : null);
  const empty = (text) => (
    <div style={{ padding: "40px 20px", textAlign: "center", color: "var(--text-tertiary)", font: "var(--w-regular) var(--t-sm)/1.6 var(--font-sans)" }}>{text}</div>
  );
  const pill = (txt, on) => (
    <span style={{ display: "inline-flex", alignItems: "center", gap: 6, height: 22, padding: "0 9px", borderRadius: 999, background: on ? "var(--profit-bg)" : "var(--surface-inset)", color: on ? "var(--profit)" : "var(--text-tertiary)", font: "var(--w-semibold) var(--t-2xs)/1 var(--font-sans)", letterSpacing: "var(--ls-caps)" }}>{txt}</span>
  );
  const usd4 = (v) => { const x = CR_n(v); return x == null ? "—" : "$" + x.toFixed(2); };   // prices: two decimals, always
  // Amounts: two decimals, except where that would read as nothing. 0.001 BTC at two
  // decimals is "0.00", which looks like the buy never happened — so a holding smaller
  // than half a cent's worth of a unit gets as many places as it needs, up to satoshis.
  const qty2 = (v) => {
    const x = CR_n(v);
    if (x == null) return "—";
    const a = Math.abs(x);
    if (a === 0 || a >= 0.005) return x.toFixed(2);
    const places = Math.min(8, Math.ceil(-Math.log10(a)) + 2);
    return x.toFixed(places).replace(/0+$/, "");
  };
  const coin = (sym) => String(sym || "").replace("-USD", "");
  const resting = d.orders.filter((o) => o.status === "working");

  // ---- the portfolio / trades table, Swings layout: coin · amount · capital · buy-in · price · value · p&l · %
  // The phone gets three columns and a tap, the same as Swings — but only on the dashboard's
  // portfolio. The trades page keeps its own layout, which already answers other questions.
  const posListMobile = (rows, emptyText) => (
    (rows.length + resting.length) === 0 ? empty(emptyText) : (
      <div>
        <div style={NT_MO_HEAD}>
          <div>coin</div>
          <div style={{ textAlign: "right" }}>amount</div>
          <div style={{ textAlign: "right" }}>unrealized</div>
        </div>
        {resting.map((o) => (
          <NT_MoRow key={"r" + o.id} amber sym={coin(o.symbol)} title={o.name || coin(o.symbol)}
            sub="resting at Revolut X" qtyText={o.qty == null ? "—" : qty2(o.qty)} pnl={null}
            open={openRow === "r" + o.id}
            onToggle={() => setOpenRow(openRow === "r" + o.id ? null : "r" + o.id)}
            detail={[["side", String(o.action || "").toUpperCase()],
                     ["limit", o.limit_price == null ? "market" : usd4(o.limit_price)],
                     ["time in force", o.tif || "GTC"],
                     ["filled", qty2(o.filled_qty || 0) + " / " + (o.qty == null ? "—" : qty2(o.qty))]]} />
        ))}
        {rows.map((p) => {
          const m = markOf(p.symbol) || {};
          const cost = (CR_n(p.qty) || 0) * (CR_n(p.avg_price) || 0);
          return (
            <NT_MoRow key={"p" + p.id} sym={coin(p.symbol)} title={p.name || coin(p.symbol)}
              sub={"since " + CR_ago(p.opened_at)}
              qtyText={qty2(p.qty)} pnl={unreal(p)} money={(v) => CR_usd(v)}
              pctText={pnlPct(p) == null ? "" : CR_pct(pnlPct(p))}
              open={openRow === "p" + p.id}
              onToggle={() => setOpenRow(openRow === "p" + p.id ? null : "p" + p.id)}
              detail={[["buy-in", usd4(p.avg_price)],
                       ["current price", m.px == null ? "—" : usd4(m.px)],
                       ["capital", CR_usd(cost)],
                       ["market value", m.mkt_value != null ? CR_usd(m.mkt_value)
                                        : (m.px == null ? "—" : CR_usd(CR_n(m.px) * (CR_n(p.qty) || 0)))]]} />
          );
        })}
      </div>
    )
  );

  const posTable = (rows, withStatus, emptyText, sel, narrow) => (
    isMobile && !withStatus ? posListMobile(rows, emptyText) :
    <div style={{ overflowX: "auto" }}>
      {(rows.length + (withStatus ? 0 : resting.length)) === 0 ? empty(emptyText) : (
        <table style={{ width: "100%", borderCollapse: "collapse", tableLayout: "fixed", minWidth: narrow ? 420 : (med ? 640 : 860) }}>
          <colgroup>
            {narrow
              ? <React.Fragment>
                  <col style={{ width: "34%" }} /><col style={{ width: "16%" }} /><col style={{ width: "17%" }} />
                  <col style={{ width: "16%" }} /><col style={{ width: "17%" }} />
                </React.Fragment>
              : med
              ? <React.Fragment>
                  <col style={{ width: "27%" }} /><col style={{ width: "12%" }} /><col style={{ width: "15%" }} /><col style={{ width: "15%" }} />
                  <col style={{ width: "17%" }} /><col style={{ width: "14%" }} />{withStatus ? <col style={{ width: "12%" }} /> : null}
                </React.Fragment>
              : <React.Fragment>
                  <col style={{ width: "23%" }} /><col style={{ width: "9%" }} /><col style={{ width: "11%" }} /><col style={{ width: "11%" }} />
                  <col style={{ width: "12%" }} /><col style={{ width: "12%" }} /><col style={{ width: "12%" }} /><col style={{ width: "10%" }} />{withStatus ? <col style={{ width: "10%" }} /> : null}
                </React.Fragment>}
          </colgroup>
          <thead><tr>
            <th style={th}>coin</th><th style={thR}>amount</th>{(narrow || med) ? null : <th style={thR}>capital</th>}<th style={thR}>buy-in</th>
            <th style={thR}>{narrow ? "price" : (med ? "Current" : "current price")}</th>{(narrow || med) ? null : <th style={thR}>market value</th>}<th style={thR}>{withStatus ? "p&l" : "unrealized"}</th>{narrow ? null : <th style={thR}>%</th>}{withStatus ? <th style={thR}>status</th> : null}
          </tr></thead>
          <tbody>
            {!withStatus ? resting.map((o) => (
              <tr key={"r" + o.id} style={{ background: "color-mix(in srgb, var(--dryrun) 5%, transparent)" }}>
                <td style={{ ...tdTall, borderLeft: "3px solid var(--dryrun)", color: "var(--text-primary)" }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 11 }}>
                    <span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 36, height: 36, flex: "none", borderRadius: "var(--radius-sm)", background: "var(--dryrun-bg)", border: "1px solid color-mix(in srgb, var(--dryrun) 32%, transparent)", color: "var(--dryrun)", font: "var(--w-medium) 12px/1 var(--font-mono)" }}>{coin(o.symbol)}</span>
                    <span style={{ minWidth: 0 }}>
                      <span style={{ display: "block", font: "var(--w-regular) var(--t-body)/1.2 var(--font-sans)" }}><span style={{ color: o.action === "SELL" ? "var(--loss)" : "var(--profit)", fontWeight: 500 }}>{o.action}</span>{" " + (o.qty == null ? "" : qty2(o.qty))}</span>
                      <span style={{ display: "block", marginTop: 4, font: "var(--w-regular) var(--t-2xs)/1 var(--font-sans)", color: "var(--dryrun)" }}>{isMobile ? "resting at Revolut X" : (
                        <React.Fragment>
                          {o.limit_price == null ? "market" : "limit " + usd4(o.limit_price)} · {o.tif || "GTC"} · filled {qty2(o.filled_qty || 0)}/{qty2(o.qty)}
                        </React.Fragment>
                      )}</span>
                    </span>
                  </div>
                </td>
                <td style={{ ...tdTallR, ...mono, color: "var(--text-secondary)" }}>{qty2(o.qty)}</td>
                {(narrow || med) ? null : <td style={{ ...tdTallR, ...mono, color: "var(--text-secondary)" }}>{o.limit_price != null && o.qty != null ? CR_usd(CR_n(o.limit_price) * CR_n(o.qty)) : "—"}</td>}
                <td colSpan={narrow ? 2 : (med ? 3 : 4)} style={tdTallR}></td>
                <td style={tdTallR}><span style={{ display: "inline-flex", alignItems: "center", height: 22, padding: "0 9px", borderRadius: 999, background: "var(--dryrun-bg)", color: "var(--dryrun)", font: "var(--w-semibold) var(--t-2xs)/1 var(--font-sans)", letterSpacing: "var(--ls-caps)" }}>RESTING</span></td>
              </tr>
            )) : null}
            {rows.map((p) => {
              const m = markOf(p.symbol); const open = p.status === "open";
              const qty = CR_n(open ? p.qty : (p.orig_qty || p.qty)) || 0;
              const px = open ? (m && CR_n(m.px)) : CR_n(p.exit_price);
              const cap = qty * (CR_n(p.avg_price) || 0);
              const val = (open && m && m.mkt_value != null) ? CR_n(m.mkt_value) : (px != null ? px * qty : null);
              const pnl = open ? ((m && m.unrealized_pnl != null) ? CR_n(m.unrealized_pnl) : (val != null && cap ? val - cap : null)) : CR_n(p.realized_pnl);
              const pct = (pnl != null && cap) ? (pnl / cap) * 100 : null;
              return (
                <tr key={p.id} className="nt-trow"
                    onClick={sel ? () => sel.onPick(p.id) : undefined}
                    title={sel ? "Show this position's chart" : undefined}
                    style={sel ? { cursor: "pointer",
                                   background: sel.id === p.id ? "var(--surface-inset)" : undefined,
                                   boxShadow: sel.id === p.id ? "inset 3px 0 0 var(--accent)" : undefined } : undefined}>
                  <td style={{ ...tdTall, color: "var(--text-primary)" }}>
                    <div style={{ display: "flex", alignItems: "center", gap: 11 }}>
                      <span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 36, height: 36, flex: "none", borderRadius: "var(--radius-sm)", background: "var(--violet-soft)", border: "1px solid var(--violet-line)", color: "var(--violet-400)", font: "var(--w-medium) 12px/1 var(--font-mono)" }}>{coin(p.symbol)}</span>
                      <span>
                        <span style={{ display: "block", font: "var(--w-regular) var(--t-body)/1.2 var(--font-sans)" }}>{p.name || coin(p.symbol)}</span>
                        <span style={{ display: "block", marginTop: 4, font: "var(--w-regular) var(--t-2xs)/1 var(--font-sans)", color: "var(--text-tertiary)" }}>{open ? "since " + new Date(p.opened_at).toLocaleDateString("en-US", { day: "numeric", month: "short" }) : new Date(p.opened_at).toLocaleDateString("en-US", { day: "numeric", month: "short" }) + " – " + (p.closed_at ? new Date(p.closed_at).toLocaleDateString("en-US", { day: "numeric", month: "short" }) : "")} · Revolut X</span>
                      </span>
                    </div>
                  </td>
                  <td style={{ ...tdTallR, ...mono }}>{qty2(qty)}</td>
                  {(narrow || med) ? null : <td style={{ ...tdTallR, ...mono }}>{CR_usd(cap)}</td>}
                  <td style={{ ...tdTallR, ...mono }}>{usd4(p.avg_price)}</td>
                  <td style={{ ...tdTallR, ...mono }}>{px != null ? usd4(px) : "—"}</td>
                  {(narrow || med) ? null : <td style={{ ...tdTallR, ...mono }}>{val != null ? CR_usd(val) : "—"}</td>}
                  <td style={{ ...tdTallR, ...mono, color: pnlColor(pnl) }}>
                    {pnl == null ? "—" : CR_usd(pnl)}
                    {narrow && pct != null ? (
                      <span style={{ display: "block", marginTop: 3, font: "var(--w-regular) var(--t-2xs)/1 var(--font-mono)" }}>
                        {(pct >= 0 ? "+" : "−") + Math.abs(pct).toFixed(2) + "%"}</span>
                    ) : null}
                  </td>
                  {narrow ? null : <td style={{ ...tdTallR }}>{pct == null ? <span style={{ color: "var(--text-tertiary)" }}>—</span> : <span style={{ display: "inline-flex", alignItems: "center", height: 22, padding: "0 9px", borderRadius: 999, background: pct >= 0 ? "var(--profit-bg)" : "var(--loss-bg)", color: pnlColor(pct), font: "var(--w-semibold) var(--t-2xs)/1 var(--font-mono)" }}>{CR_pct(pct)}</span>}</td>}
                  {withStatus ? <td style={tdTallR}>{pill(open ? "OPEN" : "CLOSED", open)}</td> : null}
                </tr>
              );
            })}
          </tbody>
        </table>
      )}
    </div>
  );

  // ---- KPIs, Swings set: account value · p&l · invested · open positions
  const acctValue = CR_n(bs.account_value);
  const investedCost = openPos.reduce((a, p) => a + (CR_n(p.qty) || 0) * (CR_n(p.avg_price) || 0), 0);
  const closedCost = closedPos.reduce((a, p) => a + (CR_n(p.orig_qty || p.qty) || 0) * (CR_n(p.avg_price) || 0), 0);
  const totalPnl = realized + unrealTotal;
  const investedRet = (investedCost + closedCost) > 0 ? (totalPnl / (investedCost + closedCost)) * 100 : null;
  const kpiRow = (
    <div className="nt-kpi-row">
      <Kard label="account value" value={acctValue == null ? "—" : CR_usd(acctValue, 0)}
        sub={acctValue == null ? "needs a linked broker" : (bs.available_by_ccy && bs.available_by_ccy.USD != null ? CR_usd(bs.available_by_ccy.USD, 0) + " free to trade" + (bs.reserved_usd ? " · " + CR_usd(bs.reserved_usd, 0) + " in orders" : "") : "at Revolut X")} />
      <Kard label="p&l" value={CR_usd(totalPnl, 0)} tone={tone(totalPnl)} sub={"realized " + CR_usd(realized, 0) + " · unrealized " + CR_usd(unrealTotal, 0)} />
      <Kard label="return" value={CR_pct(investedRet)} tone={tone(investedRet)} sub={"on invested capital · " + closedPos.length + " closed"} visual={<Ico name="calendar" size={17} color="var(--text-tertiary)" />} />
      <Kard label="invested" value={acctValue ? Math.round(((investedCost + unrealTotal) / acctValue) * 100) + "%" : "—"} sub={acctValue ? CR_usd(investedCost + unrealTotal, 0) + " of " + CR_usd(acctValue, 0) + " at work" : "needs a linked broker"} visual={ring(acctValue ? (investedCost + unrealTotal) / acctValue : 0)} />
      <style>{`
        .nt-kpi-row{ display:grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: var(--gap-grid); }
        @media (max-width: 720px){ .nt-kpi-row{ grid-template-columns: repeat(2, minmax(0,1fr)); grid-auto-rows: 1fr; } }
      `}</style>
    </div>
  );
  const markStamp = bs.synced_at ? "Revolut X · " + CR_ago(bs.synced_at) : null;

  // ---- market sentiment, read from the cache the bot fills (see src/market_data.py)
  const snap = (k) => (d.market && d.market[k] && d.market[k].payload) || null;
  const fng = snap("fng"), glob = snap("global");
  // The rail's marquee restarts whenever its React subtree re-renders (iOS recomposites
  // the animated layer), and this page reloads data every 4s — at phone speed that read
  // as "three tickers, then it starts again". The prices themselves only change when the
  // bot's CMC fetch lands (every 5 min), so hand the rail a coins array whose IDENTITY
  // only changes then: React.memo on CR_Rail skips every no-op re-render in between.
  const top10Stamp = (d.market && d.market.top10 && d.market.top10.fetched_at) || "";
  const coins = React.useMemo(() => (snap("top10") || {}).coins || [], [top10Stamp]);
  const series = React.useMemo(() => (snap("spark") || {}).series || {},
                               [(d.market && d.market.spark && d.market.spark.fetched_at) || ""]);
  const bigUsd = (v) => {
    const x = CR_n(v);
    if (x == null) return "—";
    if (x >= 1e12) return "$" + (x / 1e12).toFixed(2) + "T";
    if (x >= 1e9) return "$" + (x / 1e9).toFixed(1) + "B";
    return CR_usd(x, 0);
  };
  const fngDay = (n) => {
    if (n == null) return null;
    const b = CR_BANDS[Math.min(4, Math.floor(n / 20))];
    return <span style={{ color: b[0] }}>{n}</span>;
  };
  const fngWhen = (ts) => {
    if (!ts) return "";
    // CMC hands these back in seconds; Date wants milliseconds.
    const n = Number(ts), dt = new Date(n < 1e12 ? n * 1000 : n);
    return dt.toLocaleDateString("en-GB", { day: "numeric", month: "long" });
  };
  // The sentence under the gauge is written by the bot (src/market_data.py): Python computes
  // and phrases every clause, Claude picks which one leads today. Older cached rows have no
  // `line`, so the shape that was built here before is kept as the fallback.
  const runLine = (fng && fng.line) || (function () {
    if (!fng || fng.trough == null || !fng.run) return null;
    return "It sat at " + fng.trough + " for " + fng.trough_days + " day" +
           (fng.trough_days === 1 ? "" : "s") + " to " + fngWhen(fng.trough_at) +
           ", and has " + (fng.run > 0 ? "run +" : "fallen ") + Math.abs(fng.run) + " points since.";
  })();

  const fngChart = !fngOpen || !fng || !(fng.history || []).length ? null : (() => {
    const pts = fng.history, W = 100, H = 42;
    const xs = (i) => (i / (pts.length - 1)) * W;
    const ys = (v) => H - (v / 100) * H;
    const line = pts.map((p, i) => xs(i).toFixed(2) + " " + ys(p.v).toFixed(2)).join(" L ");
    const area = "M 0 " + H + " L " + line + " L " + W + " " + H + " Z";
    const first = pts[0], last = pts[pts.length - 1];
    // The SVG is stretched (preserveAspectRatio none), so the nearest point is found from the
    // pointer's position across the box rather than from SVG user units.
    // Works by finger as well as pointer. touch-action: pan-y below lets the browser keep
    // the vertical scroll for itself and hands us the sideways movement, so dragging across
    // the chart reads days out while a straight swipe down still scrolls the page. Nothing
    // here calls preventDefault, which is what would trap the gesture.
    const pick = (e) => {
      const b = e.currentTarget.getBoundingClientRect();
      if (!b.width) return;
      const t = e.touches && e.touches.length ? e.touches[0] : e;
      const x = t.clientX;
      if (x == null) return;
      const f = Math.min(1, Math.max(0, (x - b.left) / b.width));
      setFngHover(Math.round(f * (pts.length - 1)));
    };
    const hv = fngHover != null && pts[fngHover] ? pts[fngHover] : null;
    const band = hv ? CR_BANDS[Math.min(4, Math.floor(hv.v / 20))] : null;
    return (
      <div style={{ position: "relative", padding: "10px 20px 16px", borderBottom: "1px solid var(--border)" }}>
        {/* The readout only appears while a day is under the pointer — floated over the
            chart's top edge so the chart itself can sit higher in the card. */}
        {hv ? (
          <div style={{ position: "absolute", left: 0, right: 0, top: 6, zIndex: 1, textAlign: "center",
                        pointerEvents: "none",
                        font: "var(--w-regular) var(--t-xs)/1 var(--font-sans)", color: "var(--text-tertiary)" }}>
            <b style={{ font: "var(--w-medium) var(--t-sm)/1 var(--font-mono)", color: band[0] }}>{hv.v}</b>
            <span style={{ margin: "0 7px" }}>{hv.c || band[1]}</span>
            <span>{fngWhen(hv.t)}</span>
          </div>
        ) : null}
        <div style={{ position: "relative", marginTop: 8, touchAction: "pan-y" }}
             onMouseMove={pick} onMouseLeave={() => setFngHover(null)}
             onTouchStart={pick} onTouchMove={pick} onTouchEnd={() => setFngHover(null)}>
          <svg viewBox={"0 0 " + W + " " + H} preserveAspectRatio="none"
               style={{ width: "100%", height: 132, display: "block", overflow: "visible" }}>
            <defs>
              <linearGradient id="fngfill" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor="var(--profit)" stopOpacity=".26" />
                <stop offset="100%" stopColor="var(--profit)" stopOpacity="0" />
              </linearGradient>
            </defs>
            {/* the two lines that mean something: 25 is extreme fear, 75 extreme greed */}
            {[25, 75].map((g) => (
              <line key={g} x1="0" x2={W} y1={ys(g)} y2={ys(g)} stroke="var(--border)"
                    strokeWidth="1" vectorEffect="non-scaling-stroke" strokeDasharray="3 3" />
            ))}
            <path d={area} fill="url(#fngfill)" />
            <path d={"M " + line} fill="none" stroke="var(--profit)" strokeWidth="1.8" vectorEffect="non-scaling-stroke" />
            {hv ? (
              <React.Fragment>
                <line x1={xs(fngHover)} x2={xs(fngHover)} y1="0" y2={H} stroke="var(--text-tertiary)"
                      strokeWidth="1" vectorEffect="non-scaling-stroke" />
              </React.Fragment>
            ) : null}
          </svg>
          {/* the circle would be squashed by the stretched viewBox, so it is drawn in CSS */}
          {hv ? (
            <span style={{ position: "absolute", pointerEvents: "none",
                           left: "calc(" + (xs(fngHover)) + "% )", top: (ys(hv.v) / H) * 132,
                           width: 9, height: 9, marginLeft: -4.5, marginTop: -4.5,
                           borderRadius: "50%", background: band[0],
                           boxShadow: "0 0 0 2px var(--surface-card)" }} />
          ) : null}
        </div>
        <div style={{ display: "flex", justifyContent: "space-between", marginTop: 8,
                      font: "var(--w-regular) var(--t-2xs)/1 var(--font-sans)", color: "var(--text-tertiary)" }}>
          <span>{fngWhen(first.t)}</span>
          <span>{pts.length} daily closes · the dial above is live</span>
          <span>{fngWhen(last.t)}</span>
        </div>
      </div>
    );
  })();

  const sentimentCard = (
    <NT.Card title="Fear &amp; Greed" padding={20}
      bodyStyle={{ padding: 0, display: "flex", flexDirection: "column", minHeight: 0 }}
      action={d.market && d.market.fng ? <span style={{ font: "var(--w-regular) var(--t-xs)/1 var(--font-sans)", color: "var(--text-tertiary)" }}>
                {"CoinMarketCap · " + CR_ago(d.market.fng.fetched_at)}</span> : null}>
      {!fng ? (
        <div style={{ padding: "40px 20px", textAlign: "center", color: "var(--text-tertiary)",
                      font: "var(--w-regular) var(--t-sm)/1.5 var(--font-sans)" }}>
          Waiting for the first reading.
        </div>
      ) : (
        <React.Fragment>
          <CR_Gauge value={fng.value} />
          <div style={{ textAlign: "center", marginTop: -34, paddingBottom: 12,
                        font: "var(--w-medium) var(--t-sm)/1 var(--font-sans)",
                        letterSpacing: "var(--ls-wide)", textTransform: "lowercase",
                        color: "var(--text-secondary)" }}>{fng.classification}</div>
          <div style={{ textAlign: "center", paddingBottom: 14 }}>
            <button onClick={() => setFngOpen(!fngOpen)}
                    style={{ appearance: "none", cursor: "pointer", background: "transparent",
                             border: "1px solid var(--border)", borderRadius: 999, padding: "5px 13px",
                             font: "var(--w-regular) var(--t-2xs)/1 var(--font-sans)",
                             color: "var(--text-tertiary)" }}>
              {fngOpen ? "hide the 120-day chart" : "show the 120-day chart"}
            </button>
          </div>
          {fngChart}

          {/* yesterday · last week · last month, in the same colours as the dial */}
          <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)",
                        borderTop: "1px solid var(--border)", borderBottom: "1px solid var(--border)" }}>
            {[["yesterday", fng.yesterday], ["last week", fng.week], ["last month", fng.month]].map(([lbl, v]) => (
              <div key={lbl} style={{ padding: "13px 10px", textAlign: "center" }}>
                <div style={{ font: "var(--w-regular) var(--t-2xs)/1 var(--font-sans)",
                              letterSpacing: "var(--ls-wide)", textTransform: "uppercase",
                              color: "var(--text-tertiary)" }}>{lbl}</div>
                <div style={{ marginTop: 7, font: "var(--w-regular) 21px/1 var(--font-mono)",
                              fontVariantNumeric: "tabular-nums" }}>{fngDay(v) || "—"}</div>
              </div>
            ))}
          </div>

          {runLine ? (
            <div style={{ padding: "13px 20px", textAlign: "center",
                          font: "var(--w-regular) var(--t-sm)/1.55 var(--font-sans)",
                          color: "var(--text-secondary)", borderBottom: "1px solid var(--border)" }}>{runLine}</div>
          ) : null}

          {/* the two market-wide numbers that belong with sentiment */}
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", marginTop: "auto" }}>
            {[["total market cap", bigUsd(glob && glob.market_cap), glob && glob.market_cap_24h, "%"],
              ["btc dominance", glob && glob.btc_dominance != null ? glob.btc_dominance.toFixed(1) + "%" : "—",
               glob && glob.btc_dominance_24h, " pts"]
            ].map(([lbl, val, chg, unit]) => (
              <div key={lbl} style={{ padding: "14px 20px", textAlign: "center" }}>
                <div style={{ font: "var(--w-regular) var(--t-2xs)/1 var(--font-sans)",
                              letterSpacing: "var(--ls-wide)", textTransform: "uppercase",
                              color: "var(--text-tertiary)" }}>{lbl}</div>
                <div style={{ marginTop: 7, display: "flex", alignItems: "baseline",
                              justifyContent: "center", gap: 8 }}>
                  <span style={{ font: "var(--w-regular) 19px/1 var(--font-mono)", fontVariantNumeric: "tabular-nums" }}>{val}</span>
                  {chg == null ? null : (
                    <span style={{ font: "var(--w-medium) var(--t-2xs)/1 var(--font-mono)",
                                   color: chg >= 0 ? "var(--profit)" : "var(--loss)" }}>
                      {(chg >= 0 ? "+" : "−") + Math.abs(chg).toFixed(2) + unit}</span>
                  )}
                </div>
              </div>
            ))}
          </div>
        </React.Fragment>
      )}
    </NT.Card>
  );

  if (page === "crypto-trades") {
    const rows = d.positions.filter((p) => tradeFilter === "all" ? true : p.status === tradeFilter);
    // Newest first from the query, so rows[0] is the last trade — selected on arrival.
    const cur = rows.find((p) => p.id === chartOf) || rows[0] || null;
    const selId = cur ? cur.id : null;
    const fbtn = (v, label) => (
      <button key={v} type="button" onClick={() => setTradeFilter(v)}
        style={{ padding: "5px 12px", cursor: "pointer", borderRadius: "var(--radius-pill)", border: "1px solid " + (tradeFilter === v ? "var(--violet-line)" : "var(--border)"),
                 background: tradeFilter === v ? "var(--violet-soft)" : "transparent", color: tradeFilter === v ? "var(--text-primary)" : "var(--text-tertiary)", font: "var(--w-medium) var(--t-2xs)/1 var(--font-sans)" }}>{label}</button>
    );
    return (
      <div style={{ display: "flex", flexDirection: "column", gap: "var(--gap-grid)" }}>
        <PageHead title="Trades" />
        <CryptoBetaBanner />
        {kpiRow}
        {(function () {
          const table = posTable(rows, true, "No crypto trades yet.", { id: selId, onPick: setChartOf });
          const filters = <span style={{ display: "inline-flex", gap: 6 }}>{fbtn("all", "All")}{fbtn("open", "Open")}{fbtn("closed", "Closed")}</span>;
          let head = null, chart = null;
          if (cur) {
            const m = markOf(cur.symbol); const isOpen = cur.status === "open";
            const qty = CR_n(isOpen ? cur.qty : (cur.orig_qty || cur.qty)) || 0;
            const cap = qty * (CR_n(cur.avg_price) || 0);
            const px = isOpen ? (m && CR_n(m.px)) : CR_n(cur.exit_price);
            const val = (isOpen && m && m.mkt_value != null) ? CR_n(m.mkt_value) : (px != null ? px * qty : null);
            const pnl = isOpen ? ((m && m.unrealized_pnl != null) ? CR_n(m.unrealized_pnl) : (val != null && cap ? val - cap : null)) : CR_n(cur.realized_pnl);
            const pct = (pnl != null && cap) ? (pnl / cap) * 100 : null;
            head = (
              // Name left, P&L right where the status pill used to be — the pill only repeated
              // what the positions table on the left already says. The figures used to sit in
              // the same flex row as the name with hand-tuned paddingBottom, which is why they
              // rode high against it; they are their own labelled block now.
              <div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap", marginBottom: 16 }}>
                <span style={{ font: "var(--w-medium) var(--t-body)/1 var(--font-sans)", color: "var(--text-primary)" }}>{cur.name || coin(cur.symbol)}</span>
                <div style={{ marginLeft: "auto", display: "flex", alignItems: "flex-start", gap: 28 }}>
                  {isOpen && val != null && (
                    <div style={{ textAlign: "right" }}>
                      <div style={{ font: "var(--w-regular) var(--t-2xs)/1 var(--font-sans)", letterSpacing: "var(--ls-wide)", textTransform: "uppercase", color: "var(--text-tertiary)" }}>market value</div>
                      <div style={{ marginTop: 6 }}><span className="num" style={{ font: "var(--w-regular) var(--t-xl)/1 var(--font-mono)", color: "var(--text-primary)" }}>{CR_usd(val)}</span></div>
                    </div>
                  )}
                  <div style={{ textAlign: "right" }}>
                  <div style={{ font: "var(--w-regular) var(--t-2xs)/1 var(--font-sans)",
                                letterSpacing: "var(--ls-wide)", textTransform: "uppercase",
                                color: "var(--text-tertiary)" }}>{isOpen ? "unrealized p&l" : "realized p&l"}</div>
                  <div style={{ marginTop: 6, display: "flex", alignItems: "baseline",
                                justifyContent: "flex-end", gap: 8 }}>
                    <span className="num" style={{ font: "var(--w-regular) var(--t-xl)/1 var(--font-mono)", color: pnlColor(pnl) }}>
                      {pnl == null ? "—" : CR_usd(pnl)}
                    </span>
                    {pct != null && (
                      <span className="num" style={{ font: "var(--w-regular) var(--t-body)/1 var(--font-mono)", color: pnlColor(pnl) }}>{CR_pct(pct)}</span>
                    )}
                  </div>
                  </div>
                </div>
              </div>
            );
            chart = <NT_PriceChart symbol={cur.symbol} world="crypto" entry={CR_n(cur.avg_price)} entryTs={cur.opened_at} ccy="USD" height={isMobile ? 300 : 420} />;
          }

          // On a phone the two panels became two separate frames stacked on each other, which
          // reads as two unrelated things. One card, one divider: the table and the chart of
          // whichever row is selected.
          if (isMobile) {
            return (
              <NT.Card title={"Positions" + (rows.length ? " · " + rows.length : "")} padding={20}
                       bodyStyle={{ padding: 0 }} action={filters}>
                <div ref={tradesRef} style={{ minWidth: 0 }}>{table}</div>
                {cur && (
                  <div style={{ borderTop: "1px solid var(--border)", padding: "18px 16px 6px" }}>
                    {head}
                    {chart}
                  </div>
                )}
              </NT.Card>
            );
          }
          return (
            <div style={{ display: "grid", gap: "var(--gap-grid)", alignItems: "start",
                          // Half and half, on the SAME 16px gap as the KPI row above: the list
                          // lines up under cards 1-2 and the chart under cards 3-4.
                          gridTemplateColumns: "repeat(2, minmax(0,1fr))" }}>
              <NT.Card title={"Positions" + (rows.length ? " · " + rows.length : "")} padding={20}
                       bodyStyle={{ padding: 0 }} action={filters}>
                <div ref={tradesRef} style={{ minWidth: 0 }}>{table}</div>
              </NT.Card>
              {cur && <NT.Card padding={20}>{head}{chart}</NT.Card>}
            </div>
          );
        })()}
      </div>
    );
  }

  if (page === "crypto-sentiment") {
    // The Reezer-signal feed (Gianni's Claude Design, 27 Aug): every video gets our OWN
    // read — the dial score + verdict word, and a BTC + XRP signal from his call put
    // through the Fear & Greed lens. Click a row: the game plan panel slides in.
    const when = (iso) => (iso ? new Date(iso) : null);
    const dayTxt = (iso) => { const d = when(iso); return d ? d.toLocaleDateString("en-US", { day: "numeric", month: "short" }) : ""; };
    const timeTxt = (iso) => { const d = when(iso); return d ? d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }) : ""; };
    const briefs = d.briefs.filter((b) => b.status === "summarized");
    const pending = d.briefs.filter((b) => b.status !== "summarized");
    const sel = briefs.find((b) => b.id === openBrief) || null;

    // Seven signal levels; the STRONG pair renders filled — the one loud thing here.
    const SIG = {
      "STRONG BUY":  { fg: "#08120C", bg: "var(--profit)", line: "var(--profit)" },
      "BUY":         { fg: "var(--profit)", bg: "var(--profit-bg)", line: "rgba(33,199,122,0.35)" },
      "COND. BUY":   { fg: "var(--chip-entry)", bg: "var(--dryrun-bg)", line: "rgba(245,165,36,0.35)" },
      "HOLD":        { fg: "var(--text-secondary)", bg: "var(--surface-inset)", line: "var(--border-strong)" },
      "REDUCE":      { fg: "#F07A28", bg: "rgba(240,122,40,0.12)", line: "rgba(240,122,40,0.35)" },
      "SELL":        { fg: "var(--loss)", bg: "var(--loss-bg)", line: "rgba(240,69,75,0.35)" },
      "STRONG SELL": { fg: "#1A0505", bg: "var(--loss)", line: "var(--loss)" },
    };
    // Legacy rows (old-format briefs) fall back to their playbook labels.
    const sigOf = (b, k) => {
      const v = b[k === "BTC" ? "btc_signal" : "xrp_signal"];
      if (v && SIG[v]) return v;
      const lab = (((b.playbook || {})[k] || {}).label || "HOLD").replace("CONDITIONAL BUY", "COND. BUY").replace("NEUTRAL", "HOLD");
      return SIG[lab] ? lab : "HOLD";
    };
    const VCOL = { "Hard buy": "var(--profit)", "Buy": "#9CCB3B", "Hold position": "var(--breakeven)", "Reduce": "#F07A28", "Get out": "var(--loss)" };
    const scoreOf = (b) => (b.reezer_score != null ? b.reezer_score : b.sentiment_score);
    const verdictOf = (b) => b.reezer_verdict || (b.sentiment_score >= 62 ? "Buy" : b.sentiment_score >= 45 ? "Hold position" : "Reduce");
    const vcol = (b) => VCOL[verdictOf(b)] || "var(--text-secondary)";
    const fgMeta = (n) => n == null ? { label: "", color: "var(--text-tertiary)" }
      : n <= 24 ? { label: "extreme fear", color: "var(--loss)" }
      : n <= 44 ? { label: "fear", color: "var(--chip-entry)" }
      : n <= 55 ? { label: "neutral", color: "var(--text-secondary)" }
      : n <= 74 ? { label: "greed", color: "var(--profit)" }
      : { label: "extreme greed", color: "var(--profit)" };
    const Chip = ({ v }) => { const c = SIG[v]; return (
      <span style={{ font: "var(--w-medium) 11px/1 var(--font-mono)", letterSpacing: "0.06em", padding: "5px 10px", borderRadius: 9, background: c.bg, color: c.fg, boxShadow: "inset 0 0 0 1px " + c.line, whiteSpace: "nowrap" }}>{v}</span>
    ); };
    const Disc = ({ b, size = 18 }) => b.channel_avatar
      ? <img src={b.channel_avatar} alt="" style={{ width: size, height: size, borderRadius: 6, objectFit: "cover", border: "1px solid var(--border)", flex: "none" }} />
      : <span style={{ width: size, height: size, borderRadius: 6, background: "var(--surface-hover)", border: "1px solid var(--border)", display: "inline-flex", alignItems: "center", justifyContent: "center", font: "var(--w-semibold) 8px/1 var(--font-mono)", color: "var(--text-secondary)", flex: "none" }}>{String(b.channel_name || "?").split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase()}</span>;
    const newest = briefs[0];
    const syncTxt = newest && newest.summarized_at ? CR_ago(newest.summarized_at) : "—";
    const gridCols = isMobile ? "minmax(0,1fr)" : "72px 118px minmax(200px,1fr) 100px 100px 24px";
    const lbl = { font: "var(--w-medium) 10px/1 var(--font-sans)", letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-tertiary)" };

    return (
      <div style={{ display: "flex", flexDirection: "column", gap: "var(--gap-grid)" }}>
        <PageHead title="Sentiment"
          subtitle="Reezer AI reads each transcript at publish, weighs it against the Fear & Greed score of that moment, and emits a BTC and XRP signal. Click a row for the game plan."
          right={<span style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "9px 14px", background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: 11, font: "var(--w-regular) 11px/1 var(--font-mono)", color: "var(--text-secondary)", whiteSpace: "nowrap" }}>synced {syncTxt} <span style={{ color: "var(--text-tertiary)" }}>· read by Reezer AI</span></span>} />

        <NT.Card padding={20} bodyStyle={{ padding: 0 }}>
          {!isMobile && (
            <div style={{ display: "grid", gridTemplateColumns: gridCols, gap: 12, alignItems: "center", padding: "12px 20px", borderBottom: "1px solid var(--border)" }}>
              <span style={lbl}>published</span><span style={{ ...lbl, textAlign: "center" }}>reezer score</span><span style={lbl}>summary</span>
              <span style={{ ...lbl, textAlign: "center" }}>btc</span><span style={{ ...lbl, textAlign: "center" }}>xrp</span><span></span>
            </div>
          )}
          {briefs.length === 0 ? empty("No video briefs yet — the next upload appears here within minutes of its captions being ready.") : briefs.map((b, i) => (
            <div key={b.id} onClick={() => { setOpenBrief(openBrief === b.id ? null : b.id); setPlayVid(null); }} className="nt-trow"
                 style={{ display: "grid", gridTemplateColumns: gridCols, gap: isMobile ? 10 : 12, alignItems: "center", padding: isMobile ? "15px 16px" : "16px 20px", borderTop: i || !isMobile ? "1px solid var(--row-line)" : "none", cursor: "pointer", background: openBrief === b.id ? "var(--surface-inset)" : "transparent" }}>
              {isMobile ? (
                <div style={{ minWidth: 0 }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                    <span style={{ font: "var(--w-light) 26px/1 var(--font-mono)", fontVariantNumeric: "tabular-nums", color: vcol(b), flex: "none", width: 44, textAlign: "center" }}>{scoreOf(b)}</span>
                    <div style={{ minWidth: 0, flex: 1 }}>
                      <div style={{ font: "var(--w-medium) var(--t-sm)/1.25 var(--font-sans)", color: "var(--text-primary)" }}>{b.short_title || b.title}</div>
                      <div style={{ font: "var(--w-semibold) 9px/1 var(--font-mono)", letterSpacing: "0.07em", textTransform: "uppercase", color: vcol(b), marginTop: 4 }}>{verdictOf(b)}</div>
                    </div>
                  </div>
                  {/* The whole summary on the phone — it is capped at ~45 words at the
                      source, so a card grows a line instead of cutting mid-sentence. */}
                  <p style={{ margin: "8px 0 0", font: "var(--w-regular) var(--t-xs)/1.5 var(--font-sans)", color: "var(--text-secondary)" }}>{b.summary_short}</p>
                  {/* Phone shows the Reezer score only (Gianni, 28 Aug) — the per-asset
                      signals live in the opened panel. */}
                  <div style={{ display: "flex", alignItems: "center", marginTop: 8 }}>
                    <span style={{ marginLeft: "auto", font: "var(--w-regular) 10.5px/1 var(--font-mono)", color: "var(--text-tertiary)" }}>{dayTxt(b.published_at)} · {timeTxt(b.published_at)}</span>
                  </div>
                </div>
              ) : (
                <React.Fragment>
                  <div style={{ display: "flex", flexDirection: "column", gap: 3, font: "var(--w-regular) 13px/1 var(--font-mono)", fontVariantNumeric: "tabular-nums" }}>
                    <span style={{ color: "var(--text-primary)" }}>{dayTxt(b.published_at)}</span>
                    <span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>{timeTxt(b.published_at)}</span>
                  </div>
                  <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 5 }}>
                    <span style={{ font: "var(--w-light) 26px/1 var(--font-mono)", letterSpacing: "-0.02em", fontVariantNumeric: "tabular-nums", color: vcol(b) }}>{scoreOf(b)}</span>
                    <span style={{ font: "var(--w-medium) 9.5px/1.2 var(--font-mono)", letterSpacing: "0.07em", textTransform: "uppercase", color: vcol(b), whiteSpace: "pre-line", textAlign: "center" }}>{String(verdictOf(b) || "").replace(" ", "\n")}</span>
                  </div>
                  <div style={{ minWidth: 0 }}>
                    <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
                      <span style={{ font: "var(--w-medium) 14px/1.2 var(--font-sans)", letterSpacing: "-0.01em", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: "72ch" }}>{b.short_title || b.title}</span>
                      {b.duration_text ? <span style={{ flex: "none", font: "var(--w-regular) 10px/1 var(--font-mono)", color: "var(--text-tertiary)" }}>{b.duration_text}</span> : null}
                    </div>
                    <p style={{ margin: "5px 0 0", font: "var(--w-regular) 12.5px/1.5 var(--font-sans)", color: "var(--text-secondary)", maxWidth: "72ch", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{b.summary_short}</p>
                    <div style={{ display: "flex", alignItems: "center", gap: 7, marginTop: 8 }}>
                      <Disc b={b} />
                      <span style={{ font: "var(--w-regular) 11px/1 var(--font-sans)", color: "var(--text-tertiary)" }}>{b.channel_name}</span>
                    </div>
                  </div>
                  <div style={{ display: "flex", justifyContent: "center" }}><Chip v={sigOf(b, "BTC")} /></div>
                  <div style={{ display: "flex", justifyContent: "center" }}><Chip v={sigOf(b, "XRP")} /></div>
                  <div style={{ display: "flex", justifyContent: "flex-end", color: "var(--text-tertiary)" }}><Ico name="chevron-right" size={16} /></div>
                </React.Fragment>
              )}
            </div>
          ))}
        </NT.Card>
        <p style={{ margin: "0 4px", font: "var(--w-regular) 11px/1 var(--font-mono)", color: "var(--text-tertiary)" }}>{briefs.length} videos · transcripts read by Reezer AI, signals weighted against Fear & Greed at publish time</p>

        {pending.length ? (
          <NT.Card padding={20} bodyStyle={{ padding: 0 }} title={"Waiting for captions · " + pending.length}>
            {pending.map((b) => (
              <div key={b.id} style={{ display: "flex", justifyContent: "space-between", gap: 12, padding: "10px 20px", borderTop: "1px solid var(--row-line)", font: "var(--w-regular) var(--t-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
                <span>{b.title}</span><span style={{ color: b.status === "failed" ? "var(--loss)" : "var(--text-tertiary)" }}>{b.status === "failed" ? "captions not available yet — retrying" : "captions not ready, retrying"}</span>
              </div>
            ))}
          </NT.Card>
        ) : null}

        {/* ---- the game-plan panel, slid in from the right ---- */}
        <div onClick={() => setOpenBrief(null)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 40, opacity: sel ? 1 : 0, pointerEvents: sel ? "auto" : "none", transition: "opacity 200ms var(--ease-out)" }}></div>
        <aside style={{ position: "fixed", top: 0, right: 0, height: "100dvh", maxHeight: "100vh", width: "min(600px,94vw)", background: "var(--ink-1)", borderLeft: "1px solid var(--border-strong)", boxShadow: "0 8px 28px rgba(0,0,0,0.4)", zIndex: 50, transform: sel ? "translateX(0)" : "translateX(102%)", transition: "transform 260ms var(--ease-out)", display: "flex", flexDirection: "column" }}>
          {sel ? (() => {
            const fz = fgMeta(sel.fng_at_publish);
            const bS = sigOf(sel, "BTC"), xS = sigOf(sel, "XRP");
            const plan = Array.isArray(sel.game_plan) ? sel.game_plan : [];
            const voids = Array.isArray(sel.invalidators) ? sel.invalidators : ((sel.summary || {}).risks || []);
            const secLbl = { font: "var(--w-medium) 10px/1 var(--font-sans)", letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-tertiary)", marginBottom: 8 };
            return (
              <React.Fragment>
                <div style={{ flex: "none", display: "flex", alignItems: "center", gap: 12, padding: "calc(16px + env(safe-area-inset-top,0px)) 20px 16px", borderBottom: "1px solid var(--border)" }}>
                  <span style={{ font: "var(--w-regular) 11px/1 var(--font-mono)", color: "var(--text-tertiary)" }}>{dayTxt(sel.published_at)} · {timeTxt(sel.published_at)} · analysed at publish</span>
                  <button onClick={() => setOpenBrief(null)} style={{ marginLeft: "auto", border: "1px solid var(--border-strong)", background: "var(--surface-card)", color: "var(--text-secondary)", width: 30, height: 30, borderRadius: 9, cursor: "pointer", font: "14px/1 var(--font-sans)" }}>✕</button>
                </div>
                <div style={{ flex: 1, overflowY: "auto", WebkitOverflowScrolling: "touch", padding: "20px 20px calc(104px + env(safe-area-inset-bottom))" }}>
                  <div style={{ display: "grid", gridTemplateColumns: "auto 1fr", gap: 18, alignItems: "center", background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: "var(--radius-lg)", padding: "18px 20px", marginBottom: 18 }}>
                    <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4, paddingRight: 18, borderRight: "1px solid var(--border)" }}>
                      <span style={{ font: "var(--w-light) 38px/1 var(--font-mono)", letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums", color: vcol(sel) }}>{scoreOf(sel)}</span>
                      <span style={{ font: "var(--w-medium) 9.5px/1 var(--font-mono)", letterSpacing: "0.07em", textTransform: "uppercase", color: vcol(sel), whiteSpace: "nowrap" }}>{verdictOf(sel)}</span>
                    </div>
                    <div>
                      <div style={secLbl}>reezer score · why</div>
                      <p style={{ margin: 0, font: "var(--w-regular) 12.5px/1.6 var(--font-sans)", color: "var(--text-secondary)" }}>{sel.score_reasoning || sel.headline}</p>
                    </div>
                  </div>
                  <div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 10 }}>
                    <Disc b={sel} size={22} />
                    <span style={{ font: "var(--w-regular) 12px/1 var(--font-sans)", color: "var(--text-secondary)" }}>{sel.channel_name}</span>
                    {sel.duration_text ? <span style={{ font: "var(--w-regular) 11px/1 var(--font-mono)", color: "var(--text-tertiary)" }}>· {sel.duration_text}</span> : null}
                    {sel.views_text ? <span style={{ font: "var(--w-regular) 11px/1 var(--font-mono)", color: "var(--text-tertiary)" }}>· {sel.views_text}</span> : null}
                  </div>
                  <h2 style={{ margin: "0 0 16px", font: "var(--w-semibold) 19px/1.3 var(--font-sans)", letterSpacing: "-0.02em", color: "var(--text-primary)" }}>{sel.title}</h2>
                  <div style={{ aspectRatio: "16/9", borderRadius: "var(--radius-md)", border: "1px solid var(--border)", background: "var(--ink-0)", overflow: "hidden", marginBottom: 18, position: "relative" }}>
                    {playVid === sel.video_id ? (
                      <iframe title="video" src={"https://www.youtube-nocookie.com/embed/" + sel.video_id + "?autoplay=1"} allow="autoplay" allowFullScreen style={{ width: "100%", height: "100%", border: 0, display: "block" }} />
                    ) : (
                      <div onClick={() => setPlayVid(sel.video_id)} style={{ position: "absolute", inset: 0, cursor: "pointer" }}>
                        <img src={"https://i.ytimg.com/vi/" + sel.video_id + "/hqdefault.jpg"} alt="" style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
                        <span style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
                          <span style={{ width: 54, height: 54, borderRadius: 999, background: "rgba(8,8,10,0.75)", border: "1px solid var(--line-2)", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--fg-1, #E8E8ED)" }}>
                            <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
                          </span>
                        </span>
                      </div>
                    )}
                  </div>
                  <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, marginBottom: 20 }}>
                    {[["btc signal", bS, SIG[bS].fg === "#08120C" || SIG[bS].fg === "#1A0505" ? SIG[bS].bg : SIG[bS].fg],
                      ["xrp signal", xS, SIG[xS].fg === "#08120C" || SIG[xS].fg === "#1A0505" ? SIG[xS].bg : SIG[xS].fg],
                      ["f&g at publish", sel.fng_at_publish, fz.color, fz.label],
                      ["btc at publish", sel.btc_at_publish != null ? "$" + Number(sel.btc_at_publish).toLocaleString("nl-NL", { maximumFractionDigits: 0 }) : "—", "var(--text-primary)"],
                      ["xrp at publish", sel.xrp_at_publish != null ? "$" + Number(sel.xrp_at_publish).toLocaleString("nl-NL", { maximumFractionDigits: 2 }) : "—", "var(--text-primary)"]].map(([k, v, c, sub]) => (
                      <div key={k} style={{ background: "var(--surface-card)", border: "1px solid var(--border)", borderRadius: "var(--radius-md)", padding: 14 }}>
                        <div style={{ ...secLbl, fontSize: "9.5px" }}>{k}</div>
                        <div style={{ font: "var(--w-light) 19px/1.1 var(--font-mono)", letterSpacing: "-0.01em", color: c }}>{v} {sub ? <span style={{ font: "var(--w-regular) 10px/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-tertiary)" }}>{sub}</span> : null}</div>
                      </div>
                    ))}
                  </div>
                  <div style={{ marginBottom: 22 }}>
                    <div style={secLbl}>what the video says</div>
                    <p style={{ margin: 0, font: "var(--w-regular) 13.5px/1.7 var(--font-sans)", color: "var(--text-primary)" }}>{sel.long_read || ((sel.summary || {}).bullets || []).join(" ")}</p>
                  </div>
                  {plan.length ? (
                    <React.Fragment>
                      <div style={{ ...secLbl, marginBottom: 14 }}>game plan</div>
                      <div style={{ display: "flex", flexDirection: "column", marginBottom: 26 }}>
                        {plan.map((t, i) => (
                          <div key={i} style={{ display: "grid", gridTemplateColumns: "28px 1fr", gap: 14, alignItems: "stretch" }}>
                            <div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
                              <span style={{ width: 22, height: 22, borderRadius: 999, background: "var(--violet-soft)", boxShadow: "inset 0 0 0 1px var(--violet-line)", color: "var(--violet-400)", display: "flex", alignItems: "center", justifyContent: "center", font: "var(--w-regular) 10px/1 var(--font-mono)", flex: "none" }}>{String(i + 1).padStart(2, "0")}</span>
                              {i < plan.length - 1 ? <span style={{ flex: 1, width: 1, background: "var(--border)" }}></span> : null}
                            </div>
                            <span style={{ font: "var(--w-regular) 13.5px/1.6 var(--font-sans)", color: "var(--text-primary)", paddingBottom: 14 }}>{t}</span>
                          </div>
                        ))}
                      </div>
                    </React.Fragment>
                  ) : null}
                  {voids.length ? (
                    <React.Fragment>
                      <div style={secLbl}>plan is void if</div>
                      <div style={{ display: "flex", flexDirection: "column" }}>
                        {voids.map((t, i) => (
                          <div key={i} style={{ display: "grid", gridTemplateColumns: "28px 1fr", gap: 14, alignItems: "stretch" }}>
                            <div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
                              <span style={{ width: 22, height: 22, borderRadius: 999, background: "var(--loss-bg)", boxShadow: "inset 0 0 0 1px rgba(240,69,75,0.35)", color: "var(--loss)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, flex: "none" }}>✕</span>
                              {i < voids.length - 1 ? <span style={{ flex: 1, width: 1, background: "var(--border)" }}></span> : null}
                            </div>
                            <span style={{ font: "var(--w-regular) 13.5px/1.6 var(--font-sans)", color: "var(--text-secondary)", paddingBottom: 16 }}>{t}</span>
                          </div>
                        ))}
                      </div>
                    </React.Fragment>
                  ) : null}
                </div>
              </React.Fragment>
            );
          })() : null}
        </aside>
      </div>
    );
  }

  if (page === "crypto-strategies") {
    const db = window.NT_CLIENT;
    // No engine trades crypto yet, so NONE of the crypto strategies are real live-trading
    // strategies (they're just adoption holders positions hang off). For EVERY user the page
    // shows the "not live yet" placeholder — never a strategy — until crypto trading ships.
    const shown = [];
    const flip = (s, field) => {
      if (!db) return;
      const msg = field === "kill_switch" && !s.kill_switch ? "Stop trading crypto for this strategy? New orders are blocked; open positions stay." : null;
      if (msg && !window.confirm(msg)) return;
      db.from("crypto_strategies").update({ [field]: !s[field], updated_at: new Date().toISOString() }).eq("id", s.id).then(() => load());
    };
    return (
      <div style={{ display: "flex", flexDirection: "column", gap: "var(--gap-grid)" }}>
        <PageHead title="Strategies" />
        <CryptoBetaBanner />
        <NT.Card padding={20} bodyStyle={{ padding: 0 }} title={"Strategies" + (shown.length ? " · " + shown.length : "")}>
          {shown.length === 0 ? empty("No active crypto strategy — live trading isn't enabled yet.") : (
            <div style={{ overflowX: "auto" }}><table style={{ width: "100%", borderCollapse: "collapse", minWidth: 640 }}>
              <thead><tr><th style={th}>Name</th><th style={th}>Account</th><th style={th}>Sizing</th><th style={th}>Trading</th><th style={th}>Kill switch</th></tr></thead>
              <tbody>{shown.map((s) => (
                <tr key={s.id}>
                  <td style={td}><b>{s.name}</b><div style={{ font: "var(--w-regular) var(--t-2xs)/1.3 var(--font-sans)", color: "var(--text-tertiary)", maxWidth: 420 }}>{s.description}</div></td>
                  <td style={td}>{pill(String(s.account || "paper").toUpperCase(), s.account === "live")}</td>
                  <td style={td}>{s.sizing_mode || "tiers"} · USD</td>
                  <td style={td}><NT.Button variant="ghost" size="sm" onClick={() => flip(s, "paused")}>{s.paused ? "Paused — resume" : "Active — pause"}</NT.Button></td>
                  <td style={td}><NT.Button variant="ghost" size="sm" onClick={() => flip(s, "kill_switch")}>{s.kill_switch ? "Tripped — reset" : "Armed"}</NT.Button></td>
                </tr>
              ))}</tbody></table></div>
          )}
        </NT.Card>
      </div>
    );
  }

  // dashboard — same shape as the Swings dashboard: greeting, KPI row, Your portfolio.
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--gap-grid)" }}>
      <PageHead title={greeting(window.NT_USER_NAME || (window.NT_DATA && window.NT_DATA.session && window.NT_DATA.session.user))} />
      <CryptoBetaBanner />
      {kpiRow}
      {/* The rail rides above the two blocks on a desktop, and under the portfolio on a
          phone — where the split is a single column, so dropping it between the two cards
          puts it exactly there. */}
      {coins.length && !isMobile ? <CR_Rail coins={coins} series={series} /> : null}
      <div className="cr-split">
        <NT.Card title={"Your portfolio" + ((openPos.length + resting.length) ? " · " + (openPos.length + resting.length) : "")} padding={20} bodyStyle={{ padding: 0 }}
        action={markStamp ? <span style={{ font: "var(--w-regular) var(--t-xs)/1 var(--font-sans)", color: "var(--text-tertiary)" }}>{markStamp}</span> : null}>
          {posTable(openPos, false, "Nothing bought yet — the first position appears here when the engine buys, or when you buy on Revolut X.", null, true)}
        </NT.Card>
        {coins.length && isMobile ? <CR_Rail coins={coins} series={series} /> : null}
        {sentimentCard}
        <style>{`
          .cr-split{ display:grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: var(--gap-grid); align-items:stretch; }
          .cr-split > .nt-card{ display:flex; flex-direction:column; min-height:0; }
          @media (max-width: 980px){ .cr-split{ grid-template-columns: minmax(0,1fr); } }
        `}</style>
      </div>
    </div>
  );
}

Object.assign(window, { CryptoPage, CryptoBetaPill });
