← back to AbramsEgo
index: liquid/fluid bg + motion graphics + vitals graphs + freshness chips
054fe687fd6acd237c5592544e33c4546ecb2eb5 · 2026-07-08 10:54:54 -0700 · Steve
- LIQUID: 4 slow-morphing mesh-gradient blob layers behind everything (GPU transform/opacity, pause on tab-hidden, respects reduced-motion)
- MOTION: count-up tweens on scoreboard tiles + dial + gauge (easeOutCubic, per-el delta memory); pulsing STATUS pill (green/amber/red heartbeat); staggered panel entrance
- GRAPHS (zero-dep SVG): liquid-fill self-funding dial with animated wave surface; radial fleet up/total gauge; canary W-L stacked bar; wins-over-time sparkline from the real 7-day series
- FRESHNESS: per-source 'as of Nh ago' chips in a header strip + on the self-funding + fleet tiles; revenue.json honestly shown STALE (7d) instead of silently trusting the frozen number
- verified headless: 0 console/page errors, all graphs populate from real /api/data
Files touched
Diff
commit 054fe687fd6acd237c5592544e33c4546ecb2eb5
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 8 10:54:54 2026 -0700
index: liquid/fluid bg + motion graphics + vitals graphs + freshness chips
- LIQUID: 4 slow-morphing mesh-gradient blob layers behind everything (GPU transform/opacity, pause on tab-hidden, respects reduced-motion)
- MOTION: count-up tweens on scoreboard tiles + dial + gauge (easeOutCubic, per-el delta memory); pulsing STATUS pill (green/amber/red heartbeat); staggered panel entrance
- GRAPHS (zero-dep SVG): liquid-fill self-funding dial with animated wave surface; radial fleet up/total gauge; canary W-L stacked bar; wins-over-time sparkline from the real 7-day series
- FRESHNESS: per-source 'as of Nh ago' chips in a header strip + on the self-funding + fleet tiles; revenue.json honestly shown STALE (7d) instead of silently trusting the frozen number
- verified headless: 0 console/page errors, all graphs populate from real /api/data
---
public/index.html | 304 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 296 insertions(+), 8 deletions(-)
diff --git a/public/index.html b/public/index.html
index ec4acc7..0206911 100644
--- a/public/index.html
+++ b/public/index.html
@@ -12,8 +12,31 @@
--gold:#ffd166; --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
- body{margin:0;background:radial-gradient(1200px 600px at 70% -10%,#12203300,#0a0e14),var(--bg);
+ 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}
@@ -65,6 +88,56 @@
.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}
@@ -126,11 +199,21 @@
</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>
@@ -144,6 +227,39 @@
<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&L <span class="muted" id="pnlwin">· today</span></h2>
@@ -262,6 +378,28 @@ 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){
@@ -415,6 +553,11 @@ async function load(){
// ---- 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+'%');
@@ -624,21 +767,150 @@ function renderScoreboard(d){
const canCls=canFail>0?'bad':canWarn>0?'warn':'good';
const fleetCls=fleetDown>0?'bad':'good';
const tiles=[
- {cls:'hero '+fundCls, k:'Self-funding', v:selfPct, u:'%',
+ {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', v:(w.today||0), u:'',
+ {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', v:fleetUp, u:'/'+fleetTotal,
+ {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', v:(us.activeSessions||0), u:'',
+ {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=>
+ $('sb-tiles').innerHTML=tiles.map((t,i)=>
`<div class="sb-tile ${t.cls}"><div class="sk">${esc(t.k)}</div>`+
- `<div class="sv">${t.v}${t.u?`<span class="su">${esc(t.u)}</span>`:''}</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
@@ -719,7 +991,23 @@ $('q').addEventListener('keydown',async e=>{
}catch(e){ $('ans').textContent='chat error'; }
});
-load(); setInterval(load, 15000);
+// 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;
+async function tick(){ if(document.hidden) return; await load(); if(!_booted){ _booted=true; playEntrance(); } }
+tick(); setInterval(tick, 15000);
</script>
</body>
</html>
← fb901d1 server: add per-source data-freshness block + real 7-day win
·
back to AbramsEgo
·
auto-save: 2026-07-08T11:05:21 (2 files) — data/local-fleet. b323c75 →