File size: 19,597 Bytes
86abfd0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
<!DOCTYPE html>
<!-- SPDX-License-Identifier: Apache-2.0
     © 2026 Lutar, Stephen P. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11
     Authored by Yachay (CTO). Co-Authored-By: Perplexity Computer Agent.
     Sentra v4 — EXECUTIVE-GRADE visual command surface. Sovereign: vanilla JS +
     inline SVG, no CDN, no external fonts. ADDITIVE — /sentra/inspect untouched. -->
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Sentra · Executive Command</title>
<style>
:root{
  --bg:#0A0E1A; --bg2:#0e1424; --card:#121a2e; --card2:#0d1322;
  --ink:#eef3fb; --mut:#8499b8; --dim:#5a6b87; --line:#1e2a44;
  --gold:#D4A444; --green:#34d399; --red:#fb5d6a; --amber:#f59e0b;
  --mono:ui-monospace,'JetBrains Mono','SF Mono',Menlo,Consolas,monospace;
  --sans:Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:radial-gradient(1200px 600px at 70% -10%,#101a32 0,var(--bg) 55%);
  color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;letter-spacing:-.01em}
.wrap{max-width:1320px;margin:0 auto;padding:0 24px 80px}
/* ---- masthead ---- */
.mast{display:flex;align-items:baseline;justify-content:space-between;padding:26px 0 8px;border-bottom:1px solid var(--line)}
.mast h1{font-size:21px;font-weight:700;margin:0;letter-spacing:-.02em}
.mast h1 b{color:var(--gold)}
.mast .meta{font-family:var(--mono);font-size:11px;color:var(--dim)}
.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--green);margin-right:6px;
  box-shadow:0 0 8px var(--green);animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
/* ---- ticker ---- */
.ticker{margin:0;height:38px;overflow:hidden;background:var(--card2);border-bottom:1px solid var(--line);
  display:flex;align-items:center;font-family:var(--mono);font-size:12.5px;white-space:nowrap;position:relative}
.ticker-track{display:inline-block;padding-left:100%;animation:scroll 38s linear infinite;will-change:transform}
.ticker:hover .ticker-track{animation-play-state:paused}
@keyframes scroll{0%{transform:translateX(0)}100%{transform:translateX(-100%)}}
.tk{margin-right:30px}.tk .ok{color:var(--green)}.tk .no{color:var(--red);font-weight:700}.tk .t{color:var(--dim)}.tk .id{color:var(--mut)}
/* ---- exec summary card ---- */
.execcard{margin:18px 0 4px;background:linear-gradient(100deg,var(--card),var(--card2));border:1px solid var(--line);
  border-left:3px solid var(--gold);border-radius:14px;padding:16px 20px;display:flex;align-items:center;gap:14px;
  font-size:15px;line-height:1.5;box-shadow:0 12px 40px -16px rgba(0,0,0,.7)}
.execcard .sig{font-family:var(--mono);font-size:10.5px;color:var(--green);margin-left:auto;white-space:nowrap}
.execcard .sig.unsigned{color:var(--amber)}
/* ---- hero numbers ---- */
.hero{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:18px 0}
.heronum{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:22px 24px;position:relative;overflow:hidden}
.heronum .lab{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut)}
.heronum .big{font-size:72px;font-weight:800;line-height:1;margin:8px 0 2px;letter-spacing:-.04em;font-variant-numeric:tabular-nums}
.heronum .sub{font-family:var(--mono);font-size:11.5px;color:var(--dim)}
.heronum.g .big{color:var(--ink)} .heronum.p .big{color:var(--green)} .heronum.r .big{color:var(--red)}
.heronum .spark{position:absolute;right:0;bottom:0;left:0;height:42px;opacity:.5}
/* ---- section title ---- */
.sec{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mut);
  margin:30px 0 12px;display:flex;align-items:center;gap:10px}
.sec::after{content:"";flex:1;height:1px;background:var(--line)}
/* ---- gate tiles ---- */
.gates{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}
@media(max-width:1000px){.gates{grid-template-columns:repeat(2,1fr)}.hero{grid-template-columns:1fr}}
.tile{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px 16px;cursor:pointer;
  transition:transform .15s,border-color .15s,box-shadow .15s;position:relative}
.tile:hover{transform:translateY(-3px);border-color:var(--gold);box-shadow:0 14px 36px -16px rgba(212,164,68,.4)}
.tile .gn{font-size:13.5px;font-weight:600;margin:0 0 2px}
.tile .gc{font-family:var(--mono);font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.06em}
.badge{position:absolute;top:13px;right:14px;font-family:var(--mono);font-size:10px;font-weight:700;
  padding:2px 9px;border-radius:999px}
.badge.armed{background:rgba(52,211,153,.14);color:var(--green)}
.badge.firing{background:rgba(251,93,106,.16);color:var(--red)}
.badge.idle{background:rgba(132,153,184,.12);color:var(--mut)}
.sparkbar{display:flex;gap:2px;align-items:flex-end;height:30px;margin:14px 0 8px}
.sparkbar i{flex:1;background:var(--green);border-radius:1px;min-height:2px;opacity:.85}
.sparkbar i.bad{background:var(--red)}
.tilefoot{display:flex;justify-content:space-between;font-family:var(--mono);font-size:11px}
.tilefoot .pr{color:var(--green)} .tilefoot .rj{color:var(--red)}
/* ---- heatmap ---- */
.heatcard,.geocard{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px 18px}
.heatrow{display:flex;align-items:center;gap:10px;margin:3px 0}
.heatrow .hl{width:150px;font-family:var(--mono);font-size:11px;color:var(--mut);text-align:right;flex:none}
.heatcells{display:flex;gap:2px;flex:1}
.heatcells i{flex:1;height:16px;border-radius:2px;background:#16203a}
.heataxis{display:flex;justify-content:space-between;font-family:var(--mono);font-size:10px;color:var(--dim);
  margin:8px 0 0 160px}
.legend{display:flex;gap:14px;align-items:center;font-family:var(--mono);font-size:10.5px;color:var(--mut);margin-top:10px}
.legend .sw{display:inline-block;width:11px;height:11px;border-radius:2px;margin-right:5px;vertical-align:-1px}
/* ---- geo ---- */
.geowrap{position:relative}
.geowrap svg{width:100%;height:auto;display:block}
.geowrap .land{fill:#16203a;stroke:#1e2a44;stroke-width:.5}
.gp{transition:r .3s}
/* ---- drill modal ---- */
.modal{position:fixed;inset:0;background:rgba(5,8,16,.78);backdrop-filter:blur(4px);display:none;
  align-items:center;justify-content:center;z-index:50;padding:24px}
.modal.on{display:flex}
.mbox{background:var(--card);border:1px solid var(--line);border-radius:16px;max-width:880px;width:100%;
  padding:22px 24px;box-shadow:0 30px 80px -20px #000}
.mbox h3{margin:0 0 2px;font-size:17px}.mbox .msub{font-family:var(--mono);font-size:11px;color:var(--mut);margin-bottom:16px}
.mbox .x{float:right;cursor:pointer;color:var(--mut);font-size:22px;line-height:1}
.chart{display:flex;align-items:flex-end;gap:3px;height:160px;border-bottom:1px solid var(--line);padding-bottom:0}
.chart i{flex:1;border-radius:2px 2px 0 0;min-height:2px;background:var(--green)}
.chart i.bad{background:var(--red)}
.mstats{display:flex;gap:26px;margin-top:14px;font-family:var(--mono);font-size:12px;color:var(--mut)}
.mstats b{color:var(--ink);font-size:18px;display:block}
.foot{margin-top:36px;border-top:1px solid var(--line);padding-top:14px;font-family:var(--mono);font-size:11px;color:var(--dim);line-height:1.7}
.foot a{color:var(--gold);text-decoration:none}
</style></head>
<body>
<div class="ticker" id="ticker"><div class="ticker-track" id="ttrack"><span class="tk t">awaiting live verdicts…</span></div></div>
<div class="wrap">
  <div class="mast">
    <h1>Sentra <b>·</b> Executive Command</h1>
    <div class="meta"><span class="dot"></span><span id="status">live</span> · 8 fail-CLOSED gates · Doctrine v11 749/14/163 · <span id="clock">--:--:--</span></div>
  </div>

  <!-- exec summary card -->
  <div class="execcard">
    <span id="exec-sentence">Loading live posture…</span>
    <span class="sig" id="exec-sig">receipt…</span>
  </div>

  <!-- hero numbers -->
  <div class="hero">
    <div class="heronum g"><div class="lab">Verdicts Today</div><div class="big" id="h-total"></div><div class="sub">inspections processed</div>
      <svg class="spark" id="sp-total" viewBox="0 0 100 42" preserveAspectRatio="none"></svg></div>
    <div class="heronum p"><div class="lab">Pass Rate</div><div class="big" id="h-pass"></div><div class="sub" id="h-pass-sub">clean admissions</div>
      <svg class="spark" id="sp-pass" viewBox="0 0 100 42" preserveAspectRatio="none"></svg></div>
    <div class="heronum r"><div class="lab">Reject Rate</div><div class="big" id="h-rej"></div><div class="sub" id="h-rej-sub">blocked at the door</div>
      <svg class="spark" id="sp-rej" viewBox="0 0 100 42" preserveAspectRatio="none"></svg></div>
  </div>

  <!-- gate tiles -->
  <div class="sec">8 Immune Gates · click any tile to drill into last 100 invocations</div>
  <div class="gates" id="gates"></div>

  <!-- heatmap -->
  <div class="sec">Reject Heat-Map · gate × time · last 30 min, 30-second cells</div>
  <div class="heatcard">
    <div id="heatrows"></div>
    <div class="heataxis"><span>−30 min</span><span>−20 min</span><span>−10 min</span><span>now</span></div>
    <div class="legend"><span>reject intensity:</span>
      <span><i class="sw" style="background:#16203a"></i>0</span>
      <span><i class="sw" style="background:#5a2530"></i>low</span>
      <span><i class="sw" style="background:#a32b3c"></i>med</span>
      <span><i class="sw" style="background:#fb5d6a"></i>high</span></div>
  </div>

  <!-- geo threat map -->
  <div class="sec">Geographic Threat Origin · reject events by rough geo</div>
  <div class="geocard geowrap" id="geocard"></div>

  <div class="foot">
    Sovereign surface — vanilla JS + inline SVG, no CDN. Fail-CLOSED AND-gate: verdict is PASS only if all 8 gates pass; any gate error ⇒ REJECT.
    Every endpoint emits a DSSE-signed Khipu receipt (real ECDSA-P256 cosign when the secret is present; honest UNSIGNED otherwise).
    Doctrine v11 LOCKED 749/14/163. The text-heavy operator console remains live at <a href="/sentra/inspect">/sentra/inspect</a>.
    Endpoints: <a href="/api/sentra/v4/exec-summary">/api/sentra/v4/exec-summary</a> · /api/sentra/v4/heatmap · /api/sentra/v4/geo · POST /api/sentra/v4/gates/{name}/weight.
  </div>
</div>

<!-- drill-down modal -->
<div class="modal" id="modal"><div class="mbox">
  <span class="x" onclick="closeModal()">×</span>
  <h3 id="m-title">gate</h3><div class="msub" id="m-sub"></div>
  <div class="chart" id="m-chart"></div>
  <div class="mstats" id="m-stats"></div>
</div></div>

<script>
"use strict";
const API="/api/sentra/v4";
let GATES=[], VERDICTS=[], HEAT=null;

function pct(x){return x==null?"—":(x*100).toFixed(1)+"%";}
function hhmmss(iso){return (iso||"").slice(11,19);}
function el(id){return document.getElementById(id);}

// ---- clock ----
setInterval(()=>{el("clock").textContent=new Date().toISOString().slice(11,19)+"Z";},1000);

// ---- ticker (poll verdicts every 2s) ----
async function pollVerdicts(){
  try{
    const r=await fetch(API+"/verdicts?limit=50"); const d=await r.json();
    VERDICTS=d.verdicts||[];
    const track=el("ttrack");
    if(!VERDICTS.length){track.innerHTML='<span class="tk t">awaiting live verdicts…</span>';return;}
    let html="";
    VERDICTS.forEach(v=>{
      const pass=v.verdict==="PASS";
      const id=(v.action_preview||"").replace(/[<>]/g,"").slice(0,22)||"action";
      html+=`<span class="tk"><span class="${pass?'ok':'no'}">${pass?'✓ pass':'✗ REJECT'}</span> `
        +`<span class="id">${id}</span> <span class="t">@ ${hhmmss(v.ts)}</span></span>`;
    });
    track.innerHTML=html+html; // duplicate for seamless scroll
  }catch(e){}
}

// ---- hero + exec summary (every 60s, hero also on verdict poll) ----
function sparkPath(svgId,vals,color){
  const svg=el(svgId);if(!svg)return;const n=vals.length||1;const mx=Math.max(1,...vals);
  let d="";vals.forEach((v,i)=>{const x=i/(n-1||1)*100;const y=42-(v/mx)*38;d+=(i?"L":"M")+x.toFixed(1)+" "+y.toFixed(1)+" ";});
  const area=d+`L100 42 L0 42 Z`;
  svg.innerHTML=`<path d="${area}" fill="${color}" opacity=".12"/><path d="${d}" fill="none" stroke="${color}" stroke-width="1.5"/>`;
}
async function pollExec(){
  try{
    const r=await fetch(API+"/exec-summary"); const d=await r.json();
    el("exec-sentence").textContent=d.sentence||"";
    const sig=el("exec-sig"); const signed=d.receipt&&d.receipt.signed;
    sig.textContent=signed?("◈ signed "+(d.receipt.receipt_sha||"").slice(0,22)):"◈ unsigned receipt";
    sig.className="sig"+(signed?"":" unsigned");
    el("h-total").textContent=(d.total_today||0).toLocaleString();
    el("h-pass").textContent=pct(d.pass_rate);
    el("h-rej").textContent=pct(d.reject_rate);
    el("h-pass-sub").textContent=(d.pass_today||0).toLocaleString()+" passed";
    el("h-rej-sub").textContent=(d.reject_today||0).toLocaleString()+" rejected";
  }catch(e){}
}

// build last-60s reject sparkline per hero from verdicts
function heroSparks(){
  const now=Date.now()/1000; const buckets=20; const span=60;
  const tot=new Array(buckets).fill(0),ps=new Array(buckets).fill(0),rj=new Array(buckets).fill(0);
  VERDICTS.forEach(v=>{const age=now-(v.ts_epoch||0);if(age<0||age>span)return;
    const b=buckets-1-Math.floor(age/span*buckets);if(b<0||b>=buckets)return;
    tot[b]++; if(v.verdict==="PASS")ps[b]++;else rj[b]++;});
  sparkPath("sp-total",tot,"#8499b8");sparkPath("sp-pass",ps,"#34d399");sparkPath("sp-rej",rj,"#fb5d6a");
}

// ---- gate tiles ----
function gateState(g){
  if(!g.evaluations)return"idle";
  if(g.reject_rate&&g.reject_rate>0.05)return"firing";
  return"armed";
}
// per-gate last-60s sparkbar from verdicts ring
function gateSpark(gid){
  const now=Date.now()/1000;const buckets=18;const span=60;const arr=new Array(buckets).fill(0);const bad=new Array(buckets).fill(0);
  VERDICTS.forEach(v=>{const age=now-(v.ts_epoch||0);if(age<0||age>span)return;
    const b=buckets-1-Math.floor(age/span*buckets);if(b<0||b>=buckets)return;arr[b]++;
    if((v.rejected_gates||[]).includes(gid))bad[b]++;});
  return arr.map((c,i)=>`<i class="${bad[i]?'bad':''}" style="height:${Math.min(30,4+c*6)}px"></i>`).join("");
}
async function pollGates(){
  try{
    const r=await fetch(API+"/gates"); const d=await r.json(); GATES=d.gates||[];
    const wrap=el("gates");wrap.innerHTML="";
    GATES.forEach(g=>{
      const st=gateState(g);const stxt=st==="firing"?"FIRING":st==="armed"?"ARMED":"IDLE";
      const div=document.createElement("div");div.className="tile";div.onclick=()=>drill(g);
      div.innerHTML=`<span class="badge ${st}">${stxt}</span>`
        +`<div class="gn">${g.label}</div><div class="gc">${g.id} · ${g.category}</div>`
        +`<div class="sparkbar">${gateSpark(g.id)}</div>`
        +`<div class="tilefoot"><span class="pr">pass ${pct(g.pass_rate)}</span>`
        +`<span class="rj">✗ ${g.reject||0}</span></div>`;
      wrap.appendChild(div);
    });
  }catch(e){}
}

// ---- drill-down: last 100 invocations of a gate ----
function drill(g){
  el("m-title").textContent=g.label;
  el("m-sub").textContent=g.id+" · "+g.category+" · fail-CLOSED · "+(g.evaluations||0)+" evaluations";
  // chart: per verdict in ring, did this gate reject? show last 100 as bars (height=1, red if rejected)
  const rows=VERDICTS.slice(0,100).reverse();
  const chart=el("m-chart");
  if(!rows.length){chart.innerHTML='<i style="height:100%;background:#16203a"></i>';}
  else chart.innerHTML=rows.map(v=>{
    const bad=(v.rejected_gates||[]).includes(g.id);
    return `<i class="${bad?'bad':''}" style="height:${bad?100:22}%" title="${v.ts} ${v.verdict}"></i>`;
  }).join("");
  const rejN=rows.filter(v=>(v.rejected_gates||[]).includes(g.id)).length;
  el("m-stats").innerHTML=
    `<div><b>${g.evaluations||0}</b>total evals</div>`
    +`<div><b>${pct(g.pass_rate)}</b>pass rate</div>`
    +`<div><b style="color:var(--red)">${g.reject||0}</b>rejects (all-time)</div>`
    +`<div><b>${rejN}</b>rejects (last 100)</div>`;
  el("modal").classList.add("on");
}
function closeModal(){el("modal").classList.remove("on");}
el("modal").addEventListener("click",e=>{if(e.target.id==="modal")closeModal();});

// ---- heatmap ----
function heatColor(v,mx){
  if(!v)return"#16203a";const t=mx?v/mx:0;
  if(t<0.34)return"#5a2530";if(t<0.67)return"#a32b3c";return"#fb5d6a";
}
async function pollHeat(){
  try{
    const r=await fetch(API+"/heatmap"); const d=await r.json(); HEAT=d;
    const wrap=el("heatrows");wrap.innerHTML="";const mx=d.max_cell||1;
    (d.gates||[]).forEach(g=>{
      const cells=g.cells.map(c=>`<i style="background:${heatColor(c,mx)}" title="${c} rejects"></i>`).join("");
      const row=document.createElement("div");row.className="heatrow";
      row.innerHTML=`<span class="hl">${g.label}</span><div class="heatcells">${cells}</div>`;
      wrap.appendChild(row);
    });
  }catch(e){}
}

// ---- geo threat map (inline equirectangular world, no CDN) ----
const WORLD_PATH="M158 92 L210 78 L262 70 L320 66 L388 70 L452 82 L510 98 L548 120 L560 150 L540 180 L500 196 L440 200 L380 196 L320 188 L262 182 L210 170 L172 144 Z M598 96 L660 84 L720 92 L740 120 L724 150 L680 162 L632 150 L606 124 Z M250 220 L300 214 L330 240 L322 290 L296 320 L268 300 L252 260 Z M150 110 L120 140 L130 170 L160 170 L168 140 Z M600 200 L650 196 L672 222 L658 256 L620 262 L596 232 Z";
function lonlat(lon,lat){return [ (lon+180)/360*800, (90-lat)/180*400 ];}
async function pollGeo(){
  try{
    const r=await fetch(API+"/geo"); const d=await r.json();
    const pts=(d.points||[]).filter(p=>p.region!=="unknown");
    // aggregate by region
    const agg={};pts.forEach(p=>{const k=p.region;(agg[k]=agg[k]||{...p,n:0}).n++;});
    let dots="";Object.values(agg).forEach(p=>{const[x,y]=lonlat(p.lon,p.lat);const r=4+Math.min(14,p.n*2);
      dots+=`<circle class="gp" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r}" fill="${p.color}" opacity=".75">`
        +`<title>${p.region} · ${p.n} reject(s) · ${p.category}</title></circle>`
        +`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${r}" fill="none" stroke="${p.color}" stroke-width="1" opacity=".4"><animate attributeName="r" from="${r}" to="${r+12}" dur="2s" repeatCount="indefinite"/><animate attributeName="opacity" from=".5" to="0" dur="2s" repeatCount="indefinite"/></circle>`;});
    const unk=(d.points||[]).length-pts.length;
    el("geocard").innerHTML=`<svg viewBox="0 0 800 400" preserveAspectRatio="xMidYMid meet">`
      +`<path class="land" d="${WORLD_PATH}"/>${dots}</svg>`
      +`<div class="legend"><span>threat class:</span>`
      +`<span><i class="sw" style="background:#ff5252"></i>SQLi/XSS/inject</span>`
      +`<span><i class="sw" style="background:#ff9800"></i>size-DoS</span>`
      +`<span><i class="sw" style="background:#e040fb"></i>STIX indicator</span>`
      +`<span><i class="sw" style="background:#ffd54f"></i>Λ-gate</span>`
      +(unk?`<span style="color:var(--dim)">· ${unk} ungeolocated</span>`:"")+`</div>`;
  }catch(e){}
}

// ---- orchestration ----
async function fast(){ await pollVerdicts(); heroSparks(); await pollGates(); }
async function slow(){ await pollExec(); await pollHeat(); await pollGeo(); }
fast(); slow();
setInterval(fast,2000);   // ticker + hero sparks + gate tiles (real-time)
setInterval(slow,60000);  // exec card + heatmap + geo (every 60s)
setInterval(pollHeat,15000); // heatmap a little faster for attack-pattern spotting
</script>
</body></html>