← back to AbramsEgo

public/index.html

1062 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AbramsEgo · Command Center</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230a0e14'/%3E%3Ctext x='16' y='23' font-size='20' text-anchor='middle' fill='%234d9fff' font-family='sans-serif' font-weight='bold'%3EA%3C/text%3E%3C/svg%3E"/>
<style>
  :root{
    --bg:#0a0e14; --panel:#111823; --panel2:#0d141d; --line:#1e2a3a; --ink:#dbe6f3;
    --dim:#7d8fa6; --accent:#4d9fff; --good:#3fd18b; --warn:#ffb454; --bad:#ff5d6c;
    --gold:#ffd166; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);
    color:var(--ink);font:14px/1.45 var(--mono);-webkit-font-smoothing:antialiased}

  /* ============ LIQUID / FLUID BACKGROUND (site-wide, GPU, pausable) ========
     Slow-morphing mesh-gradient "blobs" drift + breathe behind everything. Pure
     CSS keyframes (compositor-only transform/opacity) so it costs ~0 CPU; paused
     via body.paused when the tab is hidden. Colors are theme accents at very low
     alpha so the dark command-center palette stays dominant. */
  #liquid{position:fixed;inset:0;z-index:-2;overflow:hidden;pointer-events:none;
    background:radial-gradient(1200px 600px at 70% -10%,#12203322,#0a0e14 60%),var(--bg)}
  #liquid .blob{position:absolute;border-radius:50%;filter:blur(60px);opacity:.5;
    mix-blend-mode:screen;will-change:transform}
  .blob.b1{width:46vw;height:46vw;left:-8vw;top:-10vw;background:radial-gradient(circle at 30% 30%,#4d9fff,#4d9fff00 70%);animation:drift1 26s ease-in-out infinite}
  .blob.b2{width:40vw;height:40vw;right:-6vw;top:8vh;background:radial-gradient(circle at 60% 40%,#3fd18b,#3fd18b00 70%);animation:drift2 32s ease-in-out infinite}
  .blob.b3{width:38vw;height:38vw;left:22vw;bottom:-14vw;background:radial-gradient(circle at 50% 50%,#a78bfa,#a78bfa00 70%);animation:drift3 38s ease-in-out infinite}
  .blob.b4{width:30vw;height:30vw;right:14vw;bottom:2vh;background:radial-gradient(circle at 50% 50%,#ffd166,#ffd16600 70%);animation:drift1 30s ease-in-out infinite reverse}
  @keyframes drift1{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(9vw,7vh) scale(1.15)}66%{transform:translate(-5vw,4vh) scale(.92)}}
  @keyframes drift2{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(-8vw,6vh) scale(1.12)}66%{transform:translate(4vw,-5vh) scale(.9)}}
  @keyframes drift3{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(6vw,-6vh) scale(.9)}66%{transform:translate(-7vw,3vh) scale(1.18)}}
  body.paused .blob{animation-play-state:paused}
  @media(prefers-reduced-motion:reduce){.blob{animation:none}}

  /* entrance animation: panels rise + fade in on first load (staggered in JS) */
  @keyframes riseIn{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:translateY(0)}}
  main.enter section.card{opacity:0;animation:riseIn .5s cubic-bezier(.2,.7,.3,1) forwards}
  header{display:flex;align-items:center;gap:14px;padding:12px 18px;border-bottom:1px solid var(--line);
    position:sticky;top:0;background:rgba(10,14,20,.9);backdrop-filter:blur(8px);z-index:5}
  .logo{font-weight:700;letter-spacing:.5px;font-size:16px}
  .logo b{color:var(--accent)}
  .metrics{display:flex;gap:16px;margin-left:auto;flex-wrap:wrap;align-items:center}
  .metric{display:flex;flex-direction:column;align-items:flex-end;line-height:1.1}
  .metric .v{font-size:15px;font-weight:600}
  .metric .k{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.6px}
  .dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px}
  .refresh{font-size:11px;color:var(--dim)}
  main{padding:16px;display:grid;grid-template-columns:repeat(12,1fr);gap:14px;max-width:1500px;margin:0 auto}
  .card{background:linear-gradient(180deg,var(--panel),var(--panel2));border:1px solid var(--line);
    border-radius:12px;padding:14px 16px;min-height:60px}
  .card h2{margin:0 0 10px;font-size:11px;letter-spacing:1px;text-transform:uppercase;color:var(--dim);
    display:flex;align-items:center;gap:8px}
  /* androoagi "[ OUTPUT TERMINAL ]" boxed-label panel headers */
  .card h2::before{content:'[';color:#3b4f68;font-weight:400}
  .card h2::after{content:']';color:#3b4f68;font-weight:400;margin-left:auto}
  /* androoagi segmented 10-cell mini-gauges (top metrics bar) */
  .seg{display:inline-flex;gap:1.5px;margin-top:2px}
  .seg i{width:5px;height:7px;border-radius:1px;background:#ffffff14}
  .seg i.on{background:currentColor}
  /* stable per-agent hue chip (golden-angle) — same entity, same color, every panel */
  .hue{display:inline-block;width:7px;height:7px;border-radius:2px;margin-right:5px;flex-shrink:0;vertical-align:baseline}
  /* androoagi focus cue: clicked panel glows, siblings dim */
  main.focused section.card{opacity:.45;transition:opacity .15s ease}
  main.focused section.card.focus{opacity:1;border-color:#4d9fff88;
    box-shadow:0 0 0 1px #4d9fff33,0 0 26px -8px #4d9fff99}
  .span12{grid-column:span 12}.span8{grid-column:span 8}.span7{grid-column:span 7}.span6{grid-column:span 6}
  .span5{grid-column:span 5}.span4{grid-column:span 4}.span3{grid-column:span 3}
  @media(max-width:1000px){.span8,.span7,.span6,.span5,.span4,.span3{grid-column:span 12}}
  /* ---- SCOREBOARD (the at-a-glance game-state hero) ---- */
  .scoreboard{background:linear-gradient(135deg,#0e1826,#0a1018 60%,#0d141d);
    border:1px solid #263a52;box-shadow:0 0 0 1px #4d9fff10, inset 0 0 60px #4d9fff08}
  .sb-head{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px}
  .sb-status{font-size:13px;font-weight:700;letter-spacing:2px;text-transform:uppercase;
    padding:5px 14px;border-radius:8px;border:1px solid var(--line)}
  .sb-status.win{color:var(--good);border-color:#3fd18b55;background:#3fd18b12;text-shadow:0 0 14px #3fd18b55}
  .sb-status.hold{color:var(--warn);border-color:#ffb45455;background:#ffb45412;text-shadow:0 0 14px #ffb45455}
  .sb-status.down{color:var(--bad);border-color:#ff5d6c55;background:#ff5d6c12;text-shadow:0 0 14px #ff5d6c55}
  .sb-clock{margin-left:auto;font-size:11px;color:var(--dim)}
  .sb-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
  .sb-tile{background:#0b1421;border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;overflow:hidden}
  .sb-tile::after{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--dim);opacity:.6}
  .sb-tile.good::after{background:var(--good)} .sb-tile.warn::after{background:var(--warn)}
  .sb-tile.bad::after{background:var(--bad)} .sb-tile.gold::after{background:var(--gold)} .sb-tile.accent::after{background:var(--accent)}
  .sb-tile .sk{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:1px}
  .sb-tile .sv{font-size:38px;font-weight:800;line-height:1.05;margin-top:4px;font-variant-numeric:tabular-nums}
  .sb-tile .su{font-size:15px;font-weight:600;color:var(--dim);margin-left:4px}
  .sb-tile .ss{font-size:10.5px;color:var(--dim);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .sb-tile.hero .sv{font-size:44px}
  /* motion: entrance shimmer on hero tile + glow sweep across scoreboard */
  .sb-tile{animation:tileIn .5s ease both}
  @keyframes tileIn{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}
  /* pulsing STATUS pill — the "heartbeat" of the operation */
  .sb-status{position:relative}
  .sb-status.win{animation:pulseGood 2.4s ease-in-out infinite}
  .sb-status.hold{animation:pulseWarn 1.8s ease-in-out infinite}
  .sb-status.down{animation:pulseBad 1.1s ease-in-out infinite}
  @keyframes pulseGood{0%,100%{box-shadow:0 0 0 0 #3fd18b00}50%{box-shadow:0 0 16px 1px #3fd18b55}}
  @keyframes pulseWarn{0%,100%{box-shadow:0 0 0 0 #ffb45400}50%{box-shadow:0 0 16px 1px #ffb45466}}
  @keyframes pulseBad{0%,100%{box-shadow:0 0 0 0 #ff5d6c00}50%{box-shadow:0 0 20px 2px #ff5d6c88}}
  body.paused .sb-status,body.paused .sb-tile{animation-play-state:paused}

  /* ---- LIQUID-FILL self-funding dial ---- */
  .dial-wrap{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
  .dial{position:relative;width:118px;height:118px;flex-shrink:0}
  .dial svg{width:118px;height:118px;transform:rotate(-90deg)}
  .dial .lqlabel{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}
  .dial .lqlabel b{font-size:26px;font-weight:800;font-variant-numeric:tabular-nums}
  .dial .lqlabel span{font-size:9px;color:var(--dim);text-transform:uppercase;letter-spacing:1px;margin-top:2px}
  /* animated liquid wave that fills the dial from the bottom */
  .wave{transform-box:fill-box;transform-origin:center;animation:waveMove 5s linear infinite}
  @keyframes waveMove{from{transform:translateX(0)}to{transform:translateX(-50%)}}
  body.paused .wave{animation-play-state:paused}

  /* ---- radial fleet gauge + generic mini bits ---- */
  .gauge{position:relative;width:110px;height:110px;flex-shrink:0}
  .gauge svg{width:110px;height:110px;transform:rotate(-90deg)}
  .gauge .glabel{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}
  .gauge .glabel b{font-size:22px;font-weight:800}
  .gauge .glabel span{font-size:9px;color:var(--dim)}
  /* canary W-L stacked bar */
  .wl{display:flex;height:16px;border-radius:8px;overflow:hidden;border:1px solid var(--line);margin:8px 0}
  .wl i{display:block;height:100%}
  .wl .w{background:var(--good)}.wl .warn{background:var(--warn)}.wl .l{background:var(--bad)}
  .wl-legend{display:flex;gap:14px;font-size:11px;flex-wrap:wrap}
  .wl-legend b{font-variant-numeric:tabular-nums}
  /* freshness "as of" chip — visible staleness so a tile isn't silently wrong */
  .fresh{display:inline-flex;align-items:center;gap:4px;font-size:9.5px;padding:1px 7px;border-radius:20px;
    border:1px solid var(--line);color:var(--dim);white-space:nowrap;vertical-align:middle}
  .fresh::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--good)}
  .fresh.aging{color:var(--warn);border-color:#ffb45444;background:#ffb4540c}
  .fresh.aging::before{background:var(--warn)}
  .fresh.stale{color:var(--bad);border-color:#ff5d6c55;background:#ff5d6c12;animation:pulseBad 1.6s ease-in-out infinite}
  .fresh.stale::before{background:var(--bad)}
  body.paused .fresh.stale{animation-play-state:paused}
  /* sparkline */
  .spark{display:block;width:100%;height:auto}
  /* count-up number sizing helper (shares tabular nums) */
  .cu{font-variant-numeric:tabular-nums}
  .big{font-size:30px;font-weight:700}
  .good{color:var(--good)}.warn{color:var(--warn)}.bad{color:var(--bad)}.gold{color:var(--gold)}.dim{color:var(--dim)}
  .row{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px dashed #ffffff08}
  .row:last-child{border-bottom:0}
  /* list row with a visible created/started date+time line (Steve's admin-card rule) */
  .lrow{padding:5px 0;border-bottom:1px dashed #ffffff08}
  .lrow:last-child{border-bottom:0}
  .lrow-top{display:flex;justify-content:space-between;gap:10px;align-items:baseline}
  .lrow-when{margin-top:2px}
  .when{font-size:9.5px;color:var(--dim);white-space:nowrap}
  /* historical transcript snippet (session opener) — must NEVER read as live status */
  .sess-open{margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .sess-open-label{font-size:8.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);
    border:1px solid var(--line);border-radius:20px;padding:1px 6px;margin-right:6px;vertical-align:1px}
  .sess-open-quote{font-size:10px;font-style:italic;color:var(--dim)}
  /* sort + density control bar shared by every grid/list */
  .gridctl{display:flex;gap:12px;align-items:center;margin:0 0 10px;flex-wrap:wrap}
  .gridctl select,.gridctl input[type=range]{background:#0c1420;border:1px solid var(--line);color:var(--ink);
    border-radius:6px;font-family:var(--mono);font-size:11px;padding:3px 6px}
  .gridctl label{display:flex;align-items:center;gap:5px;color:var(--dim);font-size:11px}
  .gsentinel{grid-column:1/-1;text-align:center;color:var(--dim);font-size:10px;padding:6px}
  .pill{font-size:10px;padding:2px 7px;border-radius:20px;border:1px solid var(--line);color:var(--dim);white-space:nowrap}
  .pill.gated{color:var(--warn);border-color:#ffb45444;background:#ffb4540f}
  .pill.live{color:var(--good);border-color:#3fd18b44;background:#3fd18b0f}
  .bar{height:6px;border-radius:6px;background:#ffffff10;overflow:hidden;margin-top:6px}
  .bar>span{display:block;height:100%;background:var(--accent)}
  .prov-wrap{display:flex;gap:14px;align-items:center;margin-top:12px;flex-wrap:wrap}
  .donut{position:relative;width:96px;height:96px;border-radius:50%;flex-shrink:0}
  .donut::after{content:'';position:absolute;inset:22%;border-radius:50%;
    background:linear-gradient(180deg,var(--panel),var(--panel2))}
  .donut-c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;
    justify-content:center;text-align:center;z-index:1}
  .donut-c b{font-size:12px;color:var(--ink)}
  .donut-c span{font-size:8.5px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px}
  .prov-legend{flex:1;min-width:150px;font-size:11px}
  .prov-legend .row{align-items:center}
  .prov-legend .dot{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:6px;flex-shrink:0}
  .prov-legend .lbl{display:flex;align-items:center;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .prov-legend .amt{color:var(--dim);white-space:nowrap}
  .pnlgrid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
  @media(max-width:800px){.pnlgrid{grid-template-columns:repeat(2,1fr)}}
  .stat{background:#0c1420;border:1px solid var(--line);border-radius:10px;padding:12px}
  .stat .k{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.6px}
  .stat .v{font-size:22px;font-weight:700;margin-top:3px}
  .eng{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 0;border-bottom:1px dashed #ffffff08}
  .list{max-height:240px;overflow:auto}
  .list::-webkit-scrollbar{width:6px}.list::-webkit-scrollbar-thumb{background:#ffffff18;border-radius:6px}
  .mono{font-family:var(--mono)}
  .chat input{width:100%;background:#0c1420;border:1px solid var(--line);color:var(--ink);
    border-radius:8px;padding:10px;font-family:var(--mono)}
  .chat .ans{margin-top:10px;color:var(--ink);white-space:pre-wrap;background:#0c1420;border:1px solid var(--line);
    border-radius:8px;padding:10px;min-height:20px}
  .muted{color:var(--dim);font-size:11px}
  .subcard{background:#0c1420;border:1px solid var(--line);border-radius:9px;padding:9px 11px;margin-bottom:8px}
  .subcard-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
  .subcard-proj{font-weight:600;color:var(--accent);font-size:12px}
  .subcard-task{color:var(--ink);font-size:11px;margin:5px 0;opacity:.85;
    display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
  .subcard-when{color:var(--dim);font-size:10px}
  a{color:var(--accent);text-decoration:none}
  .alert{grid-column:span 12;background:#ff5d6c14;border:1px solid #ff5d6c44;color:#ffd7db;
    border-radius:10px;padding:8px 14px;font-size:12px;display:none}
  .alert.show{display:block}
</style>
</head>
<body>
<!-- LIQUID / FLUID mesh-gradient blob background (GPU, pauses on tab-hide) -->
<div id="liquid" aria-hidden="true">
  <div class="blob b1"></div><div class="blob b2"></div>
  <div class="blob b3"></div><div class="blob b4"></div>
</div>
<header>
  <div class="logo"><b>Abrams</b>Ego <span class="dim" style="font-size:11px">· command center</span></div>
  <div class="metrics" id="metrics"></div>
  <div class="refresh" id="refresh">…</div>
</header>
<!-- DATA FRESHNESS strip — per-source "as of" ages so stale tiles are visibly stale -->
<div id="freshbar" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:7px 18px;border-bottom:1px solid var(--line);
  background:rgba(10,14,20,.7);position:sticky;top:49px;z-index:4;backdrop-filter:blur(6px)">
  <span class="muted" style="font-size:10px;letter-spacing:.6px">DATA FRESHNESS</span>
</div>
<main id="main">
  <div class="alert" id="alert"></div>

  <!-- SCOREBOARD — at-a-glance game-state of the whole operation (all fields from /api/data) -->
  <section class="card span12 scoreboard">
    <div class="sb-head">
      <h2 style="margin:0">🏟 Scoreboard</h2>
      <span class="sb-status" id="sb-status">—</span>
      <span class="sb-clock" id="sb-clock"></span>
    </div>
    <div class="sb-tiles" id="sb-tiles"></div>
  </section>

  <!-- VITALS & GRAPHS — liquid dial, radial gauge, canary W-L, wins sparkline -->
  <section class="card span12">
    <h2>📊 Vitals <span class="muted">· liquid self-funding dial · fleet gauge · canary W–L · wins trend</span></h2>
    <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:18px;align-items:center">
      <!-- liquid-fill self-funding dial -->
      <div>
        <div class="muted" style="margin-bottom:8px">Self-funding <span class="fresh" id="fresh-fund" title=""></span></div>
        <div class="dial-wrap">
          <div class="dial" id="fund-dial"></div>
          <div style="font-size:11px" id="fund-note" class="muted"></div>
        </div>
      </div>
      <!-- radial fleet up/total gauge -->
      <div>
        <div class="muted" style="margin-bottom:8px">Fleet online <span class="fresh" id="fresh-fleet" title=""></span></div>
        <div class="dial-wrap"><div class="gauge" id="fleet-gauge"></div>
          <div style="font-size:11px" id="fleet-note" class="muted"></div></div>
      </div>
      <!-- canary win-loss record -->
      <div>
        <div class="muted" style="margin-bottom:8px">Canary W–L</div>
        <div class="wl" id="wl-bar"></div>
        <div class="wl-legend" id="wl-legend"></div>
      </div>
      <!-- wins-over-time sparkline (real 7-day series) -->
      <div>
        <div class="muted" style="margin-bottom:8px">Wins · 7-day trend</div>
        <div id="wins-spark"></div>
        <div class="muted" id="wins-spark-note" style="font-size:11px;margin-top:4px"></div>
      </div>
    </div>
  </section>

  <!-- SELF-FUNDING P&L (the money panel) -->
  <section class="card span8">
    <h2>💰 Self-Funding P&amp;L <span class="muted" id="pnlwin">· today</span></h2>
    <div class="pnlgrid">
      <div class="stat"><div class="k">Cost today</div><div class="v" id="pnl-cost">—</div><div class="muted" id="pnl-cost-sub"></div></div>
      <div class="stat"><div class="k">Revenue today</div><div class="v gold" id="pnl-rev">—</div><div class="muted">5 engines · gated</div></div>
      <div class="stat"><div class="k">Net</div><div class="v" id="pnl-net">—</div><div class="muted">revenue − cost</div></div>
      <div class="stat"><div class="k">Self-funding</div><div class="v" id="pnl-pct">—</div><div class="muted">of run cost covered</div></div>
    </div>
    <div style="margin-top:14px">
      <div class="muted" id="pnl-breakeven">Break-even: —</div>
      <div class="bar" title="progress toward self-funding (100%)"><span id="pnl-progress" style="width:0%"></span></div>
      <div class="muted" id="pnl-runway" style="margin-top:6px"></div>
    </div>
    <div style="margin-top:12px" id="engines"></div>
  </section>

  <!-- WPB MARKETPLACE SALES — proxied from wpb-sales-dashboard (:9812) via /api/wpb -->
  <section class="card span12" id="wpb-card">
    <h2>🛍️ WPB Marketplace Sales <span class="muted" id="wpb-sub">· Wallpaper's Back design shops · <a href="http://127.0.0.1:9812/" target="_blank" rel="noopener" style="color:inherit">open :9812 ↗</a></span></h2>
    <div class="stats" id="wpb-stats"><div class="muted">loading…</div></div>
    <div style="margin-top:12px" id="wpb-markets"></div>
  </section>

  <!-- COST BREAKDOWN -->
  <section class="card span4">
    <h2>🔌 Cost to run · 30d</h2>
    <div class="big" id="cost30">—</div>
    <div class="muted" id="cost30sub"></div>
    <div class="prov-wrap" id="costproviders"></div>
    <div style="margin-top:10px" id="costapps"></div>
  </section>

  <!-- COST TREND · 7d + PER-MODEL -->
  <section class="card span12">
    <h2>📈 Cost trend · 7d <span class="muted">· daily total (tokens + energy est) · per-model 30d</span></h2>
    <div style="display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start">
      <div style="flex:2;min-width:280px" id="trend"><span class="muted">loading…</span></div>
      <div style="flex:1;min-width:200px" id="bymodel"></div>
    </div>
  </section>

  <!-- FLEET -->
  <section class="card span4">
    <h2>🛰 Fleet · Kamatera</h2>
    <div class="big" id="kam-up">—</div>
    <div id="kam-ctl"></div>
    <div class="list" id="kam-list" style="margin-top:8px"></div>
  </section>
  <section class="card span4">
    <h2>🖥 Fleet · Local</h2>
    <div class="big" id="loc-up">—</div>
    <div class="list" id="loc-list" style="margin-top:8px"></div>
  </section>

  <!-- CANARIES -->
  <section class="card span4">
    <h2>🐤 Canary board</h2>
    <div id="canaries-ctl"></div>
    <div class="list" id="canaries"></div>
  </section>

  <!-- SCHEDULE + OFFICERS -->
  <section class="card span3">
    <h2>⏱ launchd jobs</h2>
    <div class="big" id="cron-loaded">—</div>
    <div class="muted">loaded / total com.steve.*</div>
  </section>
  <section class="card span3">
    <h2>🏛 Officers</h2>
    <div class="big" id="off-count">—</div>
    <div class="lrow-when" id="off-when"></div>
    <div class="list" id="officers" style="margin-top:8px"></div>
  </section>

  <!-- WINS -->
  <section class="card span6">
    <h2>🏆 Wins <span class="muted" id="wins-today"></span></h2>
    <div class="list" id="wins"></div>
  </section>

  <!-- SESSIONS -->
  <section class="card span6">
    <h2>💬 Live Claude sessions</h2>
    <div class="list" id="sessions"></div>
  </section>

  <!-- MODEL USAGE + SUB-AGENT RUNS (panel 5) -->
  <section class="card span5">
    <h2>🧠 Model usage <span class="muted" id="usage-sub"></span></h2>
    <div id="modelbars"></div>
  </section>
  <section class="card span7">
    <h2>🧵 Recent sub-agent runs <span class="muted" id="subruns-sub"></span></h2>
    <div id="subruns-ctl"></div>
    <div class="list" id="subruns" style="max-height:340px"></div>
  </section>

  <!-- GIT ACTIVITY FEED (panel 6) -->
  <section class="card span12">
    <h2>📜 Activity <span class="muted" id="activity-sub">· recent commits across ~/Projects</span></h2>
    <div id="activity-ctl"></div>
    <div id="activity" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:8px;max-height:440px;overflow:auto"></div>
  </section>

  <!-- CRON / LAUNCHD (panel 4) — every scheduled com.steve.* job as an admin card -->
  <section class="card span12">
    <h2>⏱ Cron / launchd <span class="muted" id="crons-sub"></span></h2>
    <div id="crongrid-ctl"></div>
    <div id="crongrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;max-height:560px;overflow:auto"></div>
  </section>

  <!-- AI CHAT -->
  <section class="card span12 chat">
    <h2>🤖 Ask AbramsEgo <span class="muted">· grounded in the live snapshot · cost shown per query</span></h2>
    <input id="q" placeholder="e.g. what did the fleet do overnight? is it paying for itself?" />
    <div class="ans" id="ans">Ask a question about your fleet, cost, or P&amp;L.</div>
  </section>
</main>

<script>
const $ = (id)=>document.getElementById(id);
const usd = (n)=> n==null? '—' : '$'+Number(n).toFixed(n<1?4:2);
const cls = (pct,warn,bad)=> pct>=bad?'bad':pct>=warn?'warn':'good';

// ---- MOTION: animated count-up. Tweens an element's number from its last shown
// value to the target over ~700ms (ease-out). Remembers per-element last value so
// re-renders every 15s only animate the delta, not a jarring 0→N each time.
// Respects prefers-reduced-motion (snaps to final). fmt() renders the number.
const _cuLast=new WeakMap();
const _reduceMotion=window.matchMedia&&window.matchMedia('(prefers-reduced-motion:reduce)').matches;
function countUp(el, target, fmt){
  if(!el) return;
  fmt = fmt || ((v)=>String(Math.round(v)));
  target = Number(target)||0;
  const from = _cuLast.has(el)? _cuLast.get(el) : 0;
  _cuLast.set(el, target);
  if(_reduceMotion || from===target){ el.textContent=fmt(target); return; }
  const t0=performance.now(), dur=700;
  function step(now){
    const p=Math.min(1,(now-t0)/dur), e=1-Math.pow(1-p,3); // easeOutCubic
    el.textContent=fmt(from+(target-from)*e);
    if(p<1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

// ---- created/started date+time chip (Steve's hard admin-card rule) ----------
// Accepts an ISO string OR an epoch-ms number/string; returns {iso,label} or null.
function fmtWhen(v){
  if(v==null||v==='') return null;
  let iso = (typeof v==='number' || /^\d{10,}$/.test(String(v))) ? new Date(Number(v)).toISOString() : String(v);
  const dt=new Date(iso); if(isNaN(dt)) return null;
  return {iso, label: dt.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})};
}
// visible 🕓 date+time line with full ISO in title=; picks the first present field
function whenChip(...vals){
  for(const v of vals){ const w=fmtWhen(v); if(w) return `<span class="when" title="${esc(w.iso)}">🕓 ${esc(w.label)}</span>`; }
  return '';
}
function tsOf(...vals){ for(const v of vals){ const w=fmtWhen(v); if(w) return Date.parse(w.iso); } return 0; }
// two-line list row: main + right-meta, then a visible created/started date+time
function lrow(main, meta, whenHtml){
  return `<div class="lrow"><div class="lrow-top"><span>${main}</span><span class="dim">${meta||''}</span></div>`+
    (whenHtml?`<div class="lrow-when">${whenHtml}</div>`:'')+`</div>`;
}

// ---- reusable grid: sort <select> + density slider + infinite scroll --------
// Persists sort+density to localStorage; renders in batches with an
// IntersectionObserver sentinel so a list can exceed ~40 rows without lag.
const GRIDS={};
function initGrid(name, cfg){
  if(GRIDS[name]) return GRIDS[name];
  const g=GRIDS[name]={items:[], cfg, shown:cfg.batch};
  const kSort='ae.'+name+'.sort', kDens='ae.'+name+'.dens';
  const savedSort=localStorage.getItem(kSort)||cfg.sortModes[0].k;
  const savedDens=parseInt(localStorage.getItem(kDens)||cfg.defaultCols,10);
  const bar=document.createElement('div'); bar.className='gridctl';
  bar.innerHTML=`<label>sort <select class="gsort">${cfg.sortModes.map(m=>`<option value="${m.k}"${m.k===savedSort?' selected':''}>${m.label}</option>`).join('')}</select></label>`+
    `<label>▦ <input type="range" class="gdens" min="${cfg.minCols}" max="${cfg.maxCols}" value="${savedDens}"></label>`;
  cfg.controlsHost.appendChild(bar);
  g.sortEl=bar.querySelector('.gsort'); g.densEl=bar.querySelector('.gdens');
  g.sortEl.addEventListener('change',()=>{localStorage.setItem(kSort,g.sortEl.value);g.shown=cfg.batch;draw(g);});
  g.densEl.addEventListener('input',()=>{localStorage.setItem(kDens,g.densEl.value);applyDensity(g);});
  applyDensity(g);
  g.io=new IntersectionObserver((ents)=>{
    if(ents.some(e=>e.isIntersecting) && g.shown<g.items.length){ g.shown+=cfg.batch; draw(g); }
  },{root:cfg.host, rootMargin:'140px'});
  return g;
}
function applyDensity(g){
  const cols=parseInt(g.densEl.value,10);
  g.cfg.host.style.display='grid';
  g.cfg.host.style.gap='8px';
  g.cfg.host.style.gridTemplateColumns=`repeat(${cols},minmax(0,1fr))`;
}
function draw(g){
  const mode=g.cfg.sortModes.find(m=>m.k===g.sortEl.value)||g.cfg.sortModes[0];
  const items=g.items.slice().sort(mode.cmp);
  const slice=items.slice(0,g.shown);
  g.cfg.host.innerHTML = slice.map(g.cfg.render).join('') || `<span class="muted">${g.cfg.empty||'nothing yet'}</span>`;
  if(g.shown<items.length){
    const s=document.createElement('div'); s.className='gsentinel';
    s.textContent=`… ${items.length-g.shown} more · scroll to load`;
    g.cfg.host.appendChild(s); g.io.observe(s);
  }
}
function feedGrid(name, items){ const g=GRIDS[name]; if(!g) return; g.items=Array.isArray(items)?items:[]; if(g.shown>g.items.length) g.shown=g.cfg.batch; draw(g); }

async function load(){
  let d; try{ d = await (await fetch('/api/data')).json(); }catch(e){ return; }
  if(!d || !d.builtAt) return;
  // metrics bar — segmented 10-cell gauges + bracketed labels (androoagi);
  // cell fill keeps the threshold colors (green must still mean healthy)
  const s=d.system||{};
  const gauge=(pct)=>{ if(pct==null) return '';
    const on=Math.max(0,Math.min(10,Math.round(pct/10)));
    return `<span class="seg">${Array.from({length:10},(_,i)=>`<i${i<on?' class="on"':''}></i>`).join('')}</span>`; };
  const swapMB=(s.swap && s.swap.usedMB!=null)?s.swap.usedMB:null;
  $('metrics').innerHTML = [
    ['CPU', (s.loadPct??'—')+'%', cls(s.loadPct,60,85), s.loadPct],
    ['RAM', (s.memUsedPct??'—')+'%', cls(s.memUsedPct,70,88), s.memUsedPct],
    ['SWAP', swapMB!=null ? (swapMB>=1024 ? (swapMB/1024).toFixed(1)+'G' : Math.round(swapMB)+'M') : '—', swapMB!=null ? cls(swapMB, 2048, 8192) : '', swapMB!=null?Math.min(100,swapMB/8192*100):null],
    ['DISK', (s.diskUsedPct??'—')+'%', cls(s.diskUsedPct,80,90), s.diskUsedPct],
    ['NODE', s.node||'—','',null],
    ['UP', fmtDur(s.dashboardUptimeSec)||'—','',null],
  ].map(([k,v,c,g])=>`<div class="metric ${c}"><div class="v">${v}</div>${gauge(g)}<div class="k">[${k}]</div></div>`).join('');
  $('refresh').textContent = 'snapshot '+new Date(d.builtAt).toLocaleTimeString()+' · auto 15s · $0 (local)'+(d.version?' · v'+d.version:'');

  // ---- P&L ----
  const p=(d.pnl&&d.pnl.today)||{}, c=(d.cost&&d.cost.today)||{};
  $('pnl-cost').textContent=usd(p.cost);
  $('pnl-cost-sub').textContent=`tokens ${usd(c.tokens)} + energy ${usd(c.energy)} est`;
  $('pnl-rev').textContent=usd(p.revenue);
  $('pnl-net').textContent=usd(p.net);
  $('pnl-net').className='v '+((p.net||0)>=0?'good':'bad');
  $('pnl-pct').textContent=(p.selfFundingPct==null?'0':p.selfFundingPct)+'%';
  $('pnl-pct').className='v '+((p.selfFundingPct||0)>=100?'good':(p.selfFundingPct||0)>0?'warn':'bad');
  // break-even target + progress toward self-funding (100%)
  const pn=d.pnl||{}, pct=p.selfFundingPct||0;
  const col=pct>=100?'var(--good)':pct<50?'var(--bad)':'var(--warn)';
  $('pnl-breakeven').innerHTML=`Break-even: <b style="color:var(--gold)">${usd(pn.breakEvenUsdPerDay)}/day</b> · <b style="color:var(--gold)">${usd(pn.breakEvenUsdPerMonth)}/mo</b>`;
  const bar=$('pnl-progress'); bar.style.width=Math.min(100,pct)+'%'; bar.style.background=col;
  $('pnl-runway').textContent=pn.runwayNote||'';
  const engs=(d.revenue&&d.revenue.engines)||[];
  $('engines').innerHTML = engs.map(e=>`<div class="eng"><span>${e.label} <span class="muted">${e.note}</span></span>
     <span class="pill ${e.status==='live'?'live':'gated'}">${e.status==='live'?usd(e.amount):'GATED'}</span></div>`).join('');

  // ---- cost 30d ----
  const c30=(d.cost&&d.cost.month)||{};
  $('cost30').textContent=usd(c30.total);
  $('cost30sub').textContent=`tokens ${usd(c30.tokens)} (${c30.tokensCount||0} calls) + energy ${usd(c30.energy)} est`;
  renderProviderDonut(d);
  const apps=Object.entries(c30.byApp||{}).sort((a,b)=>b[1]-a[1]).slice(0,6);
  const max=Math.max(...apps.map(a=>a[1]),0.0001);
  $('costapps').innerHTML=apps.map(([a,v])=>`<div class="row"><span class="dim">${a}</span><span>${usd(v)}</span></div><div class="bar"><span style="width:${Math.round(v/max*100)}%"></span></div>`).join('')||'<span class="muted">no cost data</span>';

  // ---- cost trend · 7d (pure-SVG area chart) + per-model 30d ----
  renderCostTrend(d);

  // ---- model usage + sub-agent runs (panel 5) ----
  renderUsage(d);

  // ---- git activity feed (panel 6) ----
  renderActivity(d);

  // ---- cron / launchd (panel 4) ----
  renderCrons(d);

  // ---- fleet ----
  const k=d.kamatera||{}, l=d.localFleet||{};
  $('kam-up').innerHTML=`<span class="${cls(100-(k.up/(k.total||1)*100),20,50)}">${k.up??'—'}</span><span class="dim" style="font-size:16px">/${k.total??'—'} up</span> `+(whenChip(k.fetchedAt)||'')+(k.note?` <span class="warn" style="font-size:11px" title="${esc(k.error||'')}">⚠ ${esc(k.note)}</span>`:'');
  renderKamFleet(k);
  $('loc-up').innerHTML=`<span class="good">${l.up??'—'}</span><span class="dim" style="font-size:16px">/${l.total??'—'} up</span>`+(l.note?` <span class="warn" style="font-size:11px" title="${esc(l.error||'')}">⚠ ${esc(l.note)}</span>`:'');
  $('loc-list').innerHTML=(l.procs||[]).map(pr=>lrow(`${statusDot(pr.status)}${hueChip(pr.name)}${esc(pr.name)}`,`${pr.memMB!=null?pr.memMB+'M':''}`,whenChip(pr.startedAt))).join('')||`<span class="muted">${esc(l.note||'no local pm2 procs')}</span>`;

  // ---- canaries ---- (sort + density + infinite scroll like every DW grid)
  const cans=(d.canaries&&d.canaries.canaries)||[];
  renderCanaries(cans);

  // ---- crons / officers ----
  const cr=d.crons||{};
  $('cron-loaded').innerHTML=`<span class="${cls(100-(cr.loadedCount/(cr.count||1)*100),40,70)}">${cr.loadedCount??'—'}</span><span class="dim" style="font-size:16px">/${cr.count??'—'}</span>`;
  const off=d.officers||{};
  $('off-count').textContent=off.count??'—';
  $('off-when').innerHTML=whenChip(off.fetchedAt)||'';
  $('officers').innerHTML=(off.officers||[]).map(o=>`<div class="row"><span>${o.vp||'—'}</span><span class="dim">${o.domain||''}</span></div>`).join('');

  // ---- wins ----
  const w=d.wins||{};
  $('wins-today').textContent=`· ${w.today||0} today · ${w.count||0} total`;
  $('wins').innerHTML=(w.recent||[]).map(x=>lrow(esc(x.title),`<span class="dim">${esc(x.project||'')}</span>`,whenChip(x.createdAt,x.date))).join('')||'<span class="muted">no wins logged</span>';

  // ---- sessions ----
  // firstUser is a HISTORICAL transcript snippet (the session's opening paste) —
  // label it "session opener" + quote/italic/mute + truncate so it can never be
  // mistaken for a live status card. Full text stays on hover via title=.
  const sessOpener=(t)=>{ if(!t) return '';
    const one=String(t).replace(/\s+/g,' ').trim();
    const cut=one.length>140?one.slice(0,140).replace(/\s+\S*$/,'')+'…':one;
    return `<div class="sess-open" title="${esc(one)}"><span class="sess-open-label">session opener</span><span class="sess-open-quote">&ldquo;${esc(cut)}&rdquo;</span></div>`; };
  const ss=(d.sessions&&d.sessions.recent)||[];
  $('sessions').innerHTML=ss.map(x=>lrow(esc(x.cwd||'session'),`${x.msgCount!=null?x.msgCount+' msg':''}`,whenChip(x.lastTs,x.mtime,x.firstTs)+sessOpener(x.firstUser))).join('')||'<span class="muted">no sessions</span>';

  // ---- scoreboard (the game-state hero; reuses everything already in d) ----
  renderScoreboard(d);

  // ---- data freshness strip + tile chips (honest staleness) ----
  renderFreshness(d);
  // ---- vitals graphs: liquid dial, fleet gauge, canary W-L, wins sparkline ----
  renderVitals(d);

  // ---- alert banner ----
  const alerts=[];
  if((s.diskUsedPct||0)>=90) alerts.push('disk '+s.diskUsedPct+'%');
  if((s.memUsedPct||0)>=88) alerts.push('RAM '+s.memUsedPct+'%');
  const failCan=cans.filter(x=>/FAIL|DOWN|CRIT/.test((x.verdict||'').toUpperCase()));
  if(failCan.length) alerts.push(failCan.length+' canary FAIL');
  if((p.selfFundingPct||0)<100) alerts.push('not yet self-funding ('+ (p.selfFundingPct||0) +'% of cost covered)');
  const a=$('alert'); if(alerts.length){a.classList.add('show');a.textContent='⚠ '+alerts.join('  ·  ');}else{a.classList.remove('show');}
}
// Per-provider share of 30d spend as a pure CSS conic-gradient donut + legend.
// Palette cycles the theme accent colors; each provider always shows its $ figure
// (Steve's "always show costs" rule) and free/local buckets read "$0 (local)".
const PROV_COLORS=['#4d9fff','#3fd18b','#ffb454','#ff5d6c','#ffd166','#a78bfa','#22d3ee','#f472b6','#94a3b8'];
function renderProviderDonut(d){
  const el=$('costproviders'); if(!el) return;
  const bp=((d.cost&&d.cost.byProvider)||[]).slice();
  if(!bp.length){ el.innerHTML='<span class="muted">no provider data</span>'; return; }
  const total=bp.reduce((s,p)=>s+(p.total||0),0);
  // With no paid spend at all the conic-gradient would have nothing to draw —
  // fall back to an even split so the local/$0 providers still render.
  const denom=total>0?total:bp.reduce((s,p)=>s+1,0);
  let acc=0; const stops=[];
  bp.forEach((p,i)=>{
    const share=total>0?(p.total||0):1;
    const from=acc/denom*360, to=(acc+share)/denom*360; acc+=share;
    const col=PROV_COLORS[i%PROV_COLORS.length];
    stops.push(`${col} ${from.toFixed(2)}deg ${to.toFixed(2)}deg`);
  });
  const legend=bp.map((p,i)=>{
    const col=PROV_COLORS[i%PROV_COLORS.length];
    const pctv=total>0?Math.round((p.total||0)/total*100):Math.round(100/bp.length);
    const amt=p.local?'$0 (local)':usd(p.total);
    return `<div class="row"><span class="lbl"><span class="dot" style="background:${col}"></span>${p.provider}</span>`+
      `<span class="amt">${amt} · ${pctv}%</span></div>`;
  }).join('');
  el.innerHTML=`<div class="donut" style="background:conic-gradient(${stops.join(',')})">`+
    `<div class="donut-c"><b>${usd(total)}</b><span>${bp.length} prov</span></div></div>`+
    `<div class="prov-legend">${legend}</div>`;
}
function renderCostTrend(d){
  const series=(d.cost&&d.cost.series7d)||[];
  const el=$('trend');
  if(el){
    if(!series.length){ el.innerHTML='<span class="muted">no cost data</span>'; }
    else{
      const W=560,H=150,padL=10,padR=10,padT=14,padB=24;
      const iw=W-padL-padR, ih=H-padT-padB, n=series.length;
      const vals=series.map(s=>s.total), max=Math.max(...vals,0.0001);
      const x=i=> padL + (n<=1?iw/2:(i/(n-1))*iw);
      const y=v=> padT + ih - (v/max)*ih;
      const pts=series.map((s,i)=>[x(i),y(s.total)]);
      const line=pts.map((p,i)=>(i?'L':'M')+p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' ');
      const base=(padT+ih).toFixed(1);
      const area='M '+pts[0][0].toFixed(1)+' '+base+' '+pts.map(p=>'L'+p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' ')+' L '+pts[n-1][0].toFixed(1)+' '+base+' Z';
      const dots=pts.map((p,i)=>`<circle cx="${p[0].toFixed(1)}" cy="${p[1].toFixed(1)}" r="2.6" fill="var(--accent)"><title>${series[i].day}: ${usd(series[i].total)}</title></circle>`).join('');
      const labels=series.map((s,i)=>`<text x="${x(i).toFixed(1)}" y="${H-7}" fill="var(--dim)" font-size="9" text-anchor="middle">${s.day.slice(5)}</text>`).join('');
      const peak=`<text x="${padL}" y="${padT-2}" fill="var(--dim)" font-size="9">max ${usd(max)}</text>`;
      el.innerHTML=`<svg viewBox="0 0 ${W} ${H}" width="100%" style="display:block;height:auto">
        <defs><linearGradient id="cg" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stop-color="var(--accent)" stop-opacity="0.35"/>
          <stop offset="1" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>
        <path d="${area}" fill="url(#cg)"/>
        <path d="${line}" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
        ${dots}${labels}${peak}
      </svg>`;
    }
  }
  const bm=(d.cost&&d.cost.byModel)||[];
  const bel=$('bymodel');
  if(bel){
    const mmax=Math.max(...bm.map(m=>m.usd),0.0001);
    bel.innerHTML='<div class="muted" style="margin-bottom:6px">per-model · 30d</div>'+
      (bm.slice(0,8).map(m=>`<div class="row"><span class="dim">${esc(m.api)}</span><span>${usd(m.usd)}</span></div><div class="bar"><span style="width:${Math.round(m.usd/mmax*100)}%"></span></div>`).join('')||'<span class="muted">no model data</span>');
  }
}
function renderUsage(d){
  const u=d.usage||{};
  const sess=u.sessions||{}, models=(u.models&&u.models.allTime)||[], m24=(u.models&&u.models.last24h)||[];
  const runs=(u.subagentRuns&&u.subagentRuns.recent)||[];
  const fmtTok=(n)=> n==null?'—':(n>=1e6?(n/1e6).toFixed(1)+'M':n>=1e3?(n/1e3).toFixed(0)+'k':String(n));
  // header sub-lines
  $('usage-sub').textContent=`· ${sess.active||0} active · ${sess.recent||0} recent sessions (24h)`;
  $('subruns-sub').textContent=`· ${u.subagentRuns&&u.subagentRuns.count||0} total · showing ${runs.length}`;
  // model mini-bar-chart — per-model share of all-time spend
  const mel=$('modelbars');
  if(mel){
    const mmax=Math.max(...models.map(m=>m.cost),0.0001);
    const c24=new Map(m24.map(m=>[m.model,m]));
    mel.innerHTML=(models.slice(0,10).map(m=>{
      const d24=c24.get(m.model);
      const t=d24?` <span class="dim">· ${d24.calls} calls/24h</span>`:'';
      return `<div class="row"><span class="dim">${esc(m.model)}${t}</span>`+
        `<span>${usd(m.cost)} <span class="dim">· ${m.calls} calls · ${fmtTok(m.tokens)} tok</span></span></div>`+
        `<div class="bar"><span style="width:${Math.round(m.cost/mmax*100)}%"></span></div>`;
    }).join('')) || '<span class="muted">no model usage</span>';
  }
  // recent sub-agent runs — each is an ADMIN CARD → shows created date+time;
  // grid gets sort + density + infinite scroll.
  initGrid('subruns',{
    controlsHost:$('subruns-ctl'), host:$('subruns'), batch:10, minCols:1, maxCols:3, defaultCols:1,
    empty:'no sub-agent runs',
    sortModes:[
      {k:'new',  label:'Newest',      cmp:(a,b)=>tsOf(b.startedAt,b.updatedAt)-tsOf(a.startedAt,a.updatedAt)},
      {k:'old',  label:'Oldest',      cmp:(a,b)=>tsOf(a.startedAt,a.updatedAt)-tsOf(b.startedAt,b.updatedAt)},
      {k:'proj', label:'Project A→Z', cmp:(a,b)=>(a.project||'').localeCompare(b.project||'')},
      {k:'model',label:'Model A→Z',   cmp:(a,b)=>(a.model||'').localeCompare(b.model||'')},
    ],
    render:(r)=>`<div class="subcard" title="${esc(r.startedAt||r.updatedAt||'')}">
      <div class="subcard-top"><span class="subcard-proj">${hueChip(r.project)}${esc(r.project||'?')}</span>`+
      `<span class="pill">${esc(r.model||'unknown')}</span></div>`+
      `<div class="subcard-task">${esc(r.task||'')}</div>`+
      `<div class="subcard-when">${whenChip(r.startedAt,r.updatedAt)||'🕓 —'}</div></div>`,
  });
  feedGrid('subruns', runs);
}
// git activity — each commit is an ADMIN CARD → shows created date+time;
// grid gets sort + density + infinite scroll (can exceed ~40 rows).
function renderActivity(d){
  const a=d.activity||{};
  const commits=a.commits||[];
  const sub=$('activity-sub');
  if(sub) sub.textContent=`· ${commits.length} commits · ${a.repoCount||0} repos`;
  initGrid('activity',{
    controlsHost:$('activity-ctl'), host:$('activity'), batch:12, minCols:1, maxCols:5, defaultCols:3,
    empty:'no recent commits',
    sortModes:[
      {k:'new', label:'Newest', cmp:(x,y)=>tsOf(y.date)-tsOf(x.date)},
      {k:'old', label:'Oldest', cmp:(x,y)=>tsOf(x.date)-tsOf(y.date)},
      {k:'repo',label:'Repo A→Z',cmp:(x,y)=>(x.repo||'').localeCompare(y.repo||'')||tsOf(y.date)-tsOf(x.date)},
    ],
    render:(cm)=>`<div class="subcard" title="${esc(cm.date||'')}">
      <div class="subcard-top"><span class="subcard-proj">${hueChip(cm.repo)}${esc(cm.repo||'?')}</span>`+
      `<span class="pill">${esc(cm.hash||'')}</span></div>`+
      `<div class="subcard-task">${esc(cm.subject||'')}</div>`+
      `<div class="subcard-when">${whenChip(cm.date)||'🕓 —'}${cm.author?' · '+esc(cm.author):''}</div></div>`,
  });
  feedGrid('activity', commits);
}
// panel 4 — every com.steve.* launchd job as an ADMIN CARD → MUST show created
// date+time (🕓 toLocaleString, full ISO in title=). Status pill: running=green
// PID · idle=grey · nonzero-exit=red.
function renderCrons(d){
  const cr=d.crons||{};
  const jobs=cr.jobs||[];
  $('crons-sub').textContent=`· ${cr.loadedCount??0} loaded / ${cr.count??0} total · com.steve.*`;
  const cronPill=(j)=>{
    if(j.state==='running') return `<span class="pill live">▶ running · ${esc(j.pid)}</span>`;
    if(j.state==='error') return `<span class="pill" style="color:var(--bad);border-color:#ff5d6c44;background:#ff5d6c0f">✕ exit ${esc(j.lastExit)}</span>`;
    return `<span class="pill">idle</span>`;
  };
  const cronRank=(j)=> j.state==='error'?0 : j.state==='running'?1 : 2;
  initGrid('crons',{
    controlsHost:$('crongrid-ctl'), host:$('crongrid'), batch:18, minCols:1, maxCols:6, defaultCols:4,
    empty:'no com.steve.* jobs',
    sortModes:[
      {k:'new',  label:'Newest',    cmp:(a,b)=>tsOf(b.created,b.modified)-tsOf(a.created,a.modified)},
      {k:'old',  label:'Oldest',    cmp:(a,b)=>tsOf(a.created,a.modified)-tsOf(b.created,b.modified)},
      {k:'name', label:'Name A→Z',  cmp:(a,b)=>String(a.label||'').localeCompare(String(b.label||''))},
      {k:'stat', label:'Status',    cmp:(a,b)=>cronRank(a)-cronRank(b)||String(a.label||'').localeCompare(String(b.label||''))},
    ],
    render:(j)=>{
      const iso=j.created||j.modified||'';
      const name=String(j.label||'').replace(/^com\.steve\./,'');
      const sched=j.schedule?`⏲ ${esc(j.schedule)}`:'<span class="dim">no schedule</span>';
      const prog=j.program?` · <span class="dim">${esc(j.program)}</span>`:'';
      // SPEC panel 4: last/next run per job. Interval jobs have no knowable
      // phase, so nextRun falls back to the schedule text.
      const runs=`last ${whenChip(j.lastRun)||'—'} · next ${whenChip(j.nextRun)||esc(j.schedule||'—')}`;
      const model=j.model?` <span class="pill">${esc(j.model)}</span>`:'';
      return `<div class="subcard" title="created ${esc(iso)}">
        <div class="subcard-top"><span class="subcard-proj">${esc(name)}</span>${cronPill(j)}</div>
        <div class="subcard-when" style="margin-top:6px">${sched}${prog}</div>
        <div class="subcard-when">${runs}${model}</div>
        <div class="subcard-when">${whenChip(j.created,j.modified)||'🕓 —'}</div>
      </div>`;
    },
  });
  feedGrid('crons', jobs);
}
// ---- SCOREBOARD ------------------------------------------------------------
// The at-a-glance "game state" hero. Every number is pulled straight from the
// live /api/data snapshot (P&L, wins, canaries, fleet, sessions) — nothing is
// invented. Headline STATUS is a rolled-up verdict; tiles are the big scores.
function renderScoreboard(d){
  const p=(d.pnl&&d.pnl.today)||{}, w=d.wins||{};
  const cans=(d.canaries&&d.canaries.canaries)||[];
  const k=d.kamatera||{}, l=d.localFleet||{}, us=d.usageSummary||{};
  // canary W-L record: wins = PASS/UP/HEALTHY/OK, losses = FAIL/DOWN/CRIT, warns split out
  const rank=(v)=>{v=(v||'').toUpperCase(); return /FAIL|DOWN|CRIT/.test(v)?'L':/WARN|DEGRAD/.test(v)?'W?':/PASS|UP|HEALTH|OK/.test(v)?'W':'?';};
  const rec={W:0,'W?':0,L:0,'?':0}; cans.forEach(c=>rec[rank(c.verdict||c.status)]++);
  const canFail=rec.L, canWarn=rec['W?'], canTotal=cans.length;
  const fleetUp=(k.up||0)+(l.up||0), fleetTotal=(k.total||0)+(l.total||0);
  const fleetDown=fleetTotal-fleetUp;
  const selfPct=p.selfFundingPct||0;
  const net=p.net||0;

  // headline STATUS — the "who's winning" call. DOWN if a canary is failing or a
  // proc is down; HOLD if warns or not self-funding; WIN only when all-green.
  let status='WIN', scls='win';
  if(canFail>0 || fleetDown>0){ status='DEFENSE'; scls='down'; }
  else if(canWarn>0 || selfPct<100){ status='HOLDING'; scls='hold'; }
  $('sb-status').textContent=status; $('sb-status').className='sb-status '+scls;
  $('sb-clock').innerHTML=whenChip(d.builtAt)+' · live';

  // tiles — big legible scores. self-funding is the headline "score".
  const fundCls=selfPct>=100?'good':selfPct>0?'warn':'bad';
  const canCls=canFail>0?'bad':canWarn>0?'warn':'good';
  const fleetCls=fleetDown>0?'bad':'good';
  const tiles=[
    {cls:'hero '+fundCls, k:'Self-funding', num:selfPct, fmt:v=>Math.round(v)+'', u:'%',
      s:`${usd(p.revenue)} rev − ${usd(p.cost)} cost = <b class="${net>=0?'good':'bad'}">${usd(net)}</b>`},
    {cls:'gold', k:'Wins today', num:(w.today||0), fmt:v=>Math.round(v)+'', u:'',
      s:`${(w.count||0).toLocaleString()} all-time`},
    {cls:canCls, k:'Canary record', v:`${rec.W}–${canFail}`, u:'',
      s:`${canTotal} checks${canWarn?` · ${canWarn} warn`:''}`},
    {cls:fleetCls, k:'Fleet online', num:fleetUp, fmt:v=>Math.round(v)+'', u:'/'+fleetTotal,
      s:`Mac2 ${l.up||0}/${l.total||0} · Kam ${k.up||0}/${k.total||0}${k.stale?' (stale)':''}`},
    {cls:'accent', k:'Live sessions', num:(us.activeSessions||0), fmt:v=>Math.round(v)+'', u:'',
      s:`${(us.subagentRuns||0).toLocaleString()} sub-agent runs`},
  ];
  $('sb-tiles').innerHTML=tiles.map((t,i)=>
    `<div class="sb-tile ${t.cls}"><div class="sk">${esc(t.k)}</div>`+
    `<div class="sv"><span class="cu" data-tile="${i}">${t.num!=null?'0':t.v}</span>${t.u?`<span class="su">${esc(t.u)}</span>`:''}</div>`+
    `<div class="ss">${t.s}</div></div>`).join('');
  // MOTION: count-up the numeric tiles from their last value to the new one
  tiles.forEach((t,i)=>{ if(t.num!=null){ countUp($('sb-tiles').querySelector(`[data-tile="${i}"]`), t.num, t.fmt); } });
}

// ---- DATA FRESHNESS: honest per-source "as of" ages (fixes the silent 6-day
// stale revenue.json powering the self-funding tile). Renders the header strip
// + returns a lookup so individual tiles can show their own fresh/aging/stale chip.
let FRESH={};
function freshChip(f){
  if(!f) return '';
  const cl = f.state==='stale'?'stale':f.state==='aging'?'aging':'';
  return `<span class="fresh ${cl}" title="${esc(f.label)} — updated ${esc(f.updatedAt||'unknown')}">as of ${esc(f.ageLabel||'—')}</span>`;
}
function renderFreshness(d){
  const fr=(d.freshness&&d.freshness.sources)||{};
  FRESH=fr;
  const bar=$('freshbar'); if(!bar) return;
  const chips=Object.values(fr).map(f=>{
    const cl=f.state==='stale'?'stale':f.state==='aging'?'aging':'';
    return `<span class="fresh ${cl}" title="updated ${esc(f.updatedAt||'unknown')}">${esc(f.label)} · ${esc(f.ageLabel||'—')}</span>`;
  }).join('');
  const worst=d.freshness&&d.freshness.worst;
  const warn=worst==='stale'?`<span class="fresh stale" title="one or more sources are stale">⚠ ${d.freshness.staleCount} stale</span>`:'';
  bar.innerHTML=`<span class="muted" style="font-size:10px;letter-spacing:.6px">DATA FRESHNESS</span>${warn}${chips}`;
}

// ---- VITALS GRAPHS (zero-dependency SVG) ----------------------------------
function renderVitals(d){
  const p=(d.pnl&&d.pnl.today)||{};
  const k=d.kamatera||{}, l=d.localFleet||{};
  const cans=(d.canaries&&d.canaries.canaries)||[];
  renderFundDial(p);
  renderFleetGauge(k,l);
  renderCanaryWL(cans);
  renderWinsSpark(d.wins||{});
  // per-tile freshness chips (self-funding tile's true honesty fix)
  if($('fresh-fund')){ const f=FRESH.revenue; $('fresh-fund').outerHTML=freshChip(f).replace('class="fresh','id="fresh-fund" class="fresh'); }
  if($('fresh-fleet')){ const f=FRESH.kamatera; $('fresh-fleet').outerHTML=freshChip(f).replace('class="fresh','id="fresh-fleet" class="fresh'); }
}

// Liquid-fill self-funding dial: a circle whose fill LEVEL = % of run-cost
// covered by revenue, with an animated liquid wave surface. 0% honest = empty.
function renderFundDial(p){
  const el=$('fund-dial'); if(!el) return;
  const pct=Math.max(0,Math.min(200,p.selfFundingPct||0));
  const fillPct=Math.min(100,pct);           // clamp fill height to the cup
  const col=pct>=100?'#3fd18b':pct>0?'#ffb454':'#ff5d6c';
  const R=54,CX=59,CY=59;
  // liquid surface y within the circle (100% = top, 0% = bottom)
  const top=CY-R, h=2*R, level=CY+R-(fillPct/100)*h;
  // a repeating wave path 2× the width so translateX(-50%) loops seamlessly
  const wave=(y)=>{let dd=`M ${CX-R} ${y}`;const amp=fillPct>0&&fillPct<100?4:0;const w=R/2;
    for(let x=CX-R;x<=CX+2*R;x+=w){dd+=` q ${w/2} ${-amp} ${w} 0 q ${w/2} ${amp} ${w} 0`;}
    dd+=` L ${CX+2*R} ${CY+R} L ${CX-R} ${CY+R} Z`;return dd;};
  el.innerHTML=`<svg viewBox="0 0 118 118" style="transform:none">
    <defs><clipPath id="cup"><circle cx="${CX}" cy="${CY}" r="${R}"/></clipPath>
      <linearGradient id="lqg" x1="0" y1="0" x2="0" y2="1">
        <stop offset="0" stop-color="${col}" stop-opacity=".95"/><stop offset="1" stop-color="${col}" stop-opacity=".45"/></linearGradient></defs>
    <circle cx="${CX}" cy="${CY}" r="${R}" fill="#0b1421" stroke="var(--line)" stroke-width="2"/>
    <g clip-path="url(#cup)">
      ${fillPct>0?`<path class="wave" d="${wave(level)}" fill="url(#lqg)"/>
        <path class="wave" style="animation-duration:7s;opacity:.5" d="${wave(level+3)}" fill="${col}"/>`:''}
    </g>
    <circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="${col}" stroke-width="2" opacity=".55"/>
  </svg>
  <div class="lqlabel"><b class="cu" data-fund style="color:${col}">0</b><span>of cost covered</span></div>`;
  countUp(el.querySelector('[data-fund]'), pct, v=>Math.round(v)+'%');
  const note=$('fund-note');
  if(note) note.innerHTML=`${usd(p.revenue)} earned vs ${usd(p.cost)} spent today · net <b class="${(p.net||0)>=0?'good':'bad'}">${usd(p.net)}</b>`;
}

// Radial fleet gauge: an arc that fills to up/total, colored by whether any proc is down.
function renderFleetGauge(k,l){
  const el=$('fleet-gauge'); if(!el) return;
  const up=(k.up||0)+(l.up||0), total=(k.total||0)+(l.total||0);
  const frac=total>0?up/total:0;
  const col=up<total?'#ff5d6c':'#3fd18b';
  const R=48,CX=55,CY=55,C=2*Math.PI*R;
  const dash=(frac*C).toFixed(1);
  el.innerHTML=`<svg viewBox="0 0 110 110">
    <circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="#ffffff12" stroke-width="9"/>
    <circle cx="${CX}" cy="${CY}" r="${R}" fill="none" stroke="${col}" stroke-width="9" stroke-linecap="round"
      stroke-dasharray="${dash} ${(C-dash).toFixed(1)}" style="transition:stroke-dasharray .7s cubic-bezier(.2,.7,.3,1)"/>
  </svg>
  <div class="glabel"><b class="cu" data-fleet style="color:${col}">0</b><span>${total} total</span></div>`;
  countUp(el.querySelector('[data-fleet]'), up, v=>Math.round(v)+'');
  const note=$('fleet-note');
  if(note) note.innerHTML=`Mac2 <b>${l.up||0}/${l.total||0}</b> · Kamatera <b>${k.up||0}/${k.total||0}</b>${k.stale?' <span class="warn">(cache stale)</span>':''}`;
}

// Canary W–L: PASS/UP/HEALTHY = W, WARN/DEGRADED = warn, FAIL/DOWN/CRIT = L.
function renderCanaryWL(cans){
  const bar=$('wl-bar'), leg=$('wl-legend'); if(!bar) return;
  const rank=(v)=>{v=(v||'').toUpperCase();return /FAIL|DOWN|CRIT/.test(v)?'l':/WARN|DEGRAD/.test(v)?'warn':/PASS|UP|HEALTH|OK/.test(v)?'w':'x';};
  const rec={w:0,warn:0,l:0,x:0}; cans.forEach(c=>rec[rank(c.verdict||c.status)]++);
  const tot=Math.max(1,cans.length);
  bar.innerHTML=`<i class="w" style="width:${rec.w/tot*100}%"></i><i class="warn" style="width:${rec.warn/tot*100}%"></i>`+
    `<i class="l" style="width:${rec.l/tot*100}%"></i><i style="width:${rec.x/tot*100}%;background:#2a3340"></i>`;
  leg.innerHTML=`<span class="good">● <b>${rec.w}</b> pass</span><span class="warn">● <b>${rec.warn}</b> warn</span>`+
    `<span class="bad">● <b>${rec.l}</b> fail</span><span class="dim">${cans.length} checks</span>`;
}

// Wins-over-time sparkline from the REAL server-computed 7-day series.
function renderWinsSpark(w){
  const el=$('wins-spark'); if(!el) return;
  const series=w.series7d||[];
  if(!series.length){ el.innerHTML='<span class="muted">no wins series</span>'; return; }
  const W=280,H=70,padL=6,padR=6,padT=8,padB=16;
  const iw=W-padL-padR, ih=H-padT-padB, n=series.length;
  const vals=series.map(s=>s.count), max=Math.max(...vals,1);
  const x=i=> padL+(n<=1?iw/2:(i/(n-1))*iw);
  const y=v=> padT+ih-(v/max)*ih;
  const pts=series.map((s,i)=>[x(i),y(s.count)]);
  const line=pts.map((p,i)=>(i?'L':'M')+p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' ');
  const base=(padT+ih).toFixed(1);
  const area='M '+pts[0][0].toFixed(1)+' '+base+' '+pts.map(p=>'L'+p[0].toFixed(1)+' '+p[1].toFixed(1)).join(' ')+' L '+pts[n-1][0].toFixed(1)+' '+base+' Z';
  const bars=series.map((s,i)=>{const bw=iw/n*.5,bx=x(i)-bw/2,bh=(s.count/max)*ih;
    return `<rect x="${bx.toFixed(1)}" y="${(padT+ih-bh).toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.max(1,bh).toFixed(1)}" fill="var(--gold)" opacity=".22" rx="1"/>`;}).join('');
  const dots=pts.map((p,i)=>`<circle cx="${p[0].toFixed(1)}" cy="${p[1].toFixed(1)}" r="2.4" fill="var(--gold)"><title>${series[i].day}: ${series[i].count} wins</title></circle>`).join('');
  const labels=series.map((s,i)=>`<text x="${x(i).toFixed(1)}" y="${H-4}" fill="var(--dim)" font-size="8" text-anchor="middle">${s.day.slice(5)}</text>`).join('');
  el.innerHTML=`<svg class="spark" viewBox="0 0 ${W} ${H}">
    <defs><linearGradient id="wg" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="var(--gold)" stop-opacity=".3"/><stop offset="1" stop-color="var(--gold)" stop-opacity="0"/></linearGradient></defs>
    ${bars}<path d="${area}" fill="url(#wg)"/>
    <path d="${line}" fill="none" stroke="var(--gold)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
    ${dots}${labels}</svg>`;
  const total7=vals.reduce((a,b)=>a+b,0);
  const note=$('wins-spark-note');
  if(note) note.innerHTML=`<b class="gold">${total7}</b> wins in 7 days · <b class="gold">${w.today||0}</b> today · ${(w.count||0).toLocaleString()} all-time`;
}

// canary board — each canary is a card with a created/scanned date+time; grid
// gets sort + density + infinite scroll (verdict/staleness/name modes).
function renderCanaries(cans){
  const vrank=(cn)=>{ const v=(cn.verdict||'').toUpperCase();
    return /FAIL|DOWN|CRIT/.test(v)?0:/WARN|DEGRAD/.test(v)?1:/PASS|UP|HEALTH|OK/.test(v)?2:3; };
  initGrid('canaries',{
    controlsHost:$('canaries-ctl'), host:$('canaries'), batch:20, minCols:1, maxCols:3, defaultCols:1,
    empty:'no canaries',
    sortModes:[
      {k:'new',    label:'Newest',    cmp:(a,b)=>tsOf(b.mtime,b.scanned)-tsOf(a.mtime,a.scanned)},
      {k:'verdict',label:'Verdict',   cmp:(a,b)=>vrank(a)-vrank(b)||(a.name||'').localeCompare(b.name||'')},
      {k:'stale',  label:'Staleness', cmp:(a,b)=>(b.ageMin||0)-(a.ageMin||0)},
      {k:'name',   label:'Name A→Z',  cmp:(a,b)=>(a.name||'').localeCompare(b.name||'')},
    ],
    render:(cn)=>{
      const v=(cn.verdict||'').toUpperCase();
      const col=/FAIL|DOWN|CRIT/.test(v)?'bad':/WARN|DEGRAD/.test(v)?'warn':/PASS|UP|HEALTH|OK/.test(v)?'good':'dim';
      return lrow(esc(cn.name),`<span class="${col}">${esc(cn.verdict||'—')}</span> <span class="dim">${cn.ageMin}m${cn.stale?' ⚠':''}</span>`,whenChip(cn.mtime,cn.scanned));
    },
  });
  feedGrid('canaries', cans);
}
// Kamatera fleet — 226 live procs; grid (sort + density + infinite scroll)
// replaces the old silent slice(0,40) cap. Same lrow render, 🕓 chip kept.
function renderKamFleet(k){
  const srank=(pr)=> pr.status==='online'?1:0; // offline first
  initGrid('kamfleet',{
    controlsHost:$('kam-ctl'), host:$('kam-list'), batch:25, minCols:1, maxCols:3, defaultCols:1,
    empty:(k.note||'kamatera unreachable'),
    sortModes:[
      {k:'new',     label:'Newest',     cmp:(a,b)=>tsOf(b.startedAt)-tsOf(a.startedAt)},
      {k:'old',     label:'Oldest',     cmp:(a,b)=>tsOf(a.startedAt)-tsOf(b.startedAt)},
      {k:'name',    label:'Name A→Z',   cmp:(a,b)=>(a.name||'').localeCompare(b.name||'')},
      {k:'restarts',label:'Restarts ↓', cmp:(a,b)=>(b.restarts||0)-(a.restarts||0)},
      {k:'status',  label:'Status',     cmp:(a,b)=>srank(a)-srank(b)||(a.name||'').localeCompare(b.name||'')},
    ],
    render:(pr)=>lrow(`${statusDot(pr.status)}${hueChip(pr.name)}${esc(pr.name)}`,`${pr.restarts??''}↺`,whenChip(pr.startedAt)),
  });
  // empty text can change per snapshot (unreachable note vs default)
  GRIDS['kamfleet'].cfg.empty=(k.note||'kamatera unreachable');
  feedGrid('kamfleet', k.procs||[]);
}
function statusDot(st){const c=st==='online'?'var(--good)':st?'var(--bad)':'var(--dim)';return `<span class="dot" style="background:${c}"></span>`;}
// stable per-agent identity color: name hash spun around the golden angle
// (137.508°, same trick as /color) so every process/repo keeps one hue everywhere
function agentHue(name){let h=0;const s=String(name||'');for(let i=0;i<s.length;i++)h=(h*31+s.charCodeAt(i))>>>0;return Math.round((h%997)*137.508)%360;}
function hueChip(name){return `<span class="hue" style="background:hsl(${agentHue(name)},62%,56%)"></span>`;}
// click a panel → it glows, siblings dim (click again or Esc to clear);
// clicks on links/controls inside a panel are left alone
document.getElementById('main').addEventListener('click',(e)=>{
  const card=e.target.closest('section.card'); if(!card) return;
  if(e.target.closest('a,select,input,button,textarea,label')) return;
  const main=document.getElementById('main');
  const was=card.classList.contains('focus');
  main.querySelectorAll('section.card.focus').forEach(c=>c.classList.remove('focus'));
  main.classList.toggle('focused',!was);
  if(!was) card.classList.add('focus');
});
document.addEventListener('keydown',(e)=>{
  if(e.key!=='Escape') return;
  const main=document.getElementById('main');
  main.classList.remove('focused');
  main.querySelectorAll('section.card.focus').forEach(c=>c.classList.remove('focus'));
});
function fmtDur(s){if(s==null)return null;if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m';return Math.floor(s/3600)+'h';}
function esc(x){return (x==null?'':String(x)).replace(/[<>&]/g,m=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[m]));}

// chat
$('q').addEventListener('keydown',async e=>{
  if(e.key!=='Enter')return;
  const q=$('q').value.trim(); if(!q)return;
  $('ans').textContent='…thinking';
  try{
    const r=await (await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({q})})).json();
    $('ans').textContent=(r.answer||r.error||'no answer')+`\n\n— ${r.engine||'?'} · cost ${r.cost||'$0 (local ollama)'}`;
  }catch(e){ $('ans').textContent='chat error'; }
});

// MOTION: pause the liquid blobs + pulses + wave animations while the tab is
// hidden (perf + battery); resume on return. Also skip the 15s poll when hidden.
function applyPaused(){ document.body.classList.toggle('paused', document.hidden); }
document.addEventListener('visibilitychange', applyPaused); applyPaused();

// MOTION: staggered entrance — panels rise+fade in sequence on first paint only.
function playEntrance(){
  const main=$('main'); if(!main) return;
  main.classList.add('enter');
  [...main.querySelectorAll('section.card')].forEach((c,i)=>{ c.style.animationDelay=(i*60)+'ms'; });
  // strip the class after the last panel finishes so re-renders don't re-trigger it
  setTimeout(()=>main.classList.remove('enter'), 60*24+700);
}

let _booted=false;
// WPB marketplace sales — fetched from this server's /api/wpb proxy (which pulls :9812).
async function loadWpb(){
  let w; try{ w = await (await fetch('/api/wpb')).json(); }catch(e){ w = null; }
  const stats = document.getElementById('wpb-stats'), mk = document.getElementById('wpb-markets');
  if(!stats) return;
  if(!w || !w.ok){ stats.innerHTML = '<div class="muted">WPB dashboard (:9812) unreachable</div>'; if(mk) mk.innerHTML=''; return; }
  const k = w.kpis || {};
  const money = v => '$' + (v||0).toLocaleString(undefined,{maximumFractionDigits:2});
  stats.innerHTML = [
    ['Gross sales', money(k.salesGross), (k.ordersCount||0)+' orders · '+(k.unitsSold||0)+' units','gold'],
    ['Net', money(k.salesNet), 'after fees',''],
    ['Listings', (k.listingsTotal||0), (k.listingsLive||0)+' live · '+(k.listingsPrivate||0)+' private · '+(k.listingsPlanned||0)+' planned',''],
    ['Designs', (k.designsTotal||0), 'inventory',''],
    ['Marketplaces', (k.marketplacesLive||0)+'/'+(k.marketplacesTotal||0), 'live',''],
  ].map(([kk,v,sub,cls])=>`<div class="stat"><div class="k">${kk}</div><div class="v ${cls}">${v}</div><div class="muted">${sub}</div></div>`).join('');
  if(mk){
    mk.innerHTML = (w.markets||[]).map(m=>{
      const pill = m.status==='live' ? '#2ec4b6' : '#8a8f9c';
      return `<span style="display:inline-flex;align-items:center;gap:6px;margin:3px 10px 3px 0;font-size:12px">
        <span style="width:9px;height:9px;border-radius:2px;background:${m.color||'#555'}"></span>
        <b>${m.name}</b>
        <span style="color:${pill}">${m.status}</span>
        <span class="muted">${m.listings} listings${m.orders?' · '+money(m.salesGross):''}</span></span>`;
    }).join('');
  }
  if(!w.launched){ const sub=document.getElementById('wpb-sub'); if(sub && !sub.dataset.pre){ sub.dataset.pre='1'; sub.innerHTML += ' · <span style="color:var(--muted,#9aa0ad)">🌱 pre-launch ($0)</span>'; } }
}
async function tick(){ if(document.hidden) return; await load(); loadWpb(); if(!_booted){ _booted=true; playEntrance(); } }
tick(); setInterval(tick, 15000);
</script>
<script>window.UNBLOCK_CONFIG={surface:'abramsego',endpoint:'/api/dispatch',statusEndpoint:'/api/yolo/status'};</script>
<script src="/unblock.js" defer></script>
</body>
</html>