← back to Stack Map Viewer
index.html
544 lines
<!doctype html><html><head><meta charset="utf-8">
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
var _fetch = window.fetch.bind(window);
var cleanBase = function () { return location.origin + location.pathname; };
window.fetch = function (input, init) {
try {
if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
input = new URL(input, cleanBase()).href;
} else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
input = new Request(new URL(input.url, cleanBase()).href, input);
}
} catch (_) { /* fall through to native */ }
return _fetch(input, init);
};
})();
</script>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Stack Interconnection Map</title>
<style>
:root{--bg:#0f1117;--panel:#12151f;--ink:#eef1f7;--dim:#7d8aa5}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,'Helvetica Neue',Arial,sans-serif}
header{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;padding:14px 20px;border-bottom:1px solid #222a3a}
header h1{font-size:20px;margin:0}
.sub{color:var(--dim);font-size:13px}
.badge{margin-left:auto;font-weight:700;padding:4px 12px;border-radius:20px;font-size:13px}
.PASS{background:#14261d;color:#8fe3b4;border:1px solid #2f6a48}
.WARN{background:#2e2712;color:#ffd27a;border:1px solid #8a6a2a}
.FAIL{background:#2e1c1c;color:#f3b6b6;border:1px solid #8a4a4a}
.UNKNOWN{background:#20233a;color:#b9c0ff;border:1px solid #4a5388}
/* view switcher */
#toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 20px;border-bottom:1px solid #222a3a}
.vbtn{background:#181c28;border:1px solid #39507a;color:#c7d2e6;border-radius:8px;padding:6px 14px;cursor:pointer;font-family:inherit;font-size:13px}
.vbtn.on{background:#20304d;color:#eef1f7;border-color:#5f7bb0;font-weight:700}
.tbtn{background:#231a1a;border:1px solid #6b4141;color:#e6c7c7;border-radius:8px;padding:6px 12px;cursor:pointer;font-family:inherit;font-size:12px}
.tbtn:hover{background:#3a2323}
.tspace{margin-left:auto;color:var(--dim);font-size:12px}
#wrap{overflow:auto;padding:8px}
svg{display:block}
g.dnode{cursor:grab}
g.dnode:active{cursor:grabbing}
g.dnode rect.node{transition:filter .12s}
g.dnode:hover rect.node{filter:brightness(1.2)}
g.sub{cursor:pointer}
g.sub:hover rect{filter:brightness(1.4)}
/* tree */
#treebox{padding:10px 22px 40px;display:none}
.tnode{margin:1px 0}
.tlabel{display:inline-flex;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;cursor:pointer;font-size:13px;color:#c7d2e6}
.tlabel:hover{background:#181c28}
.tlabel .tw{color:#6f7d97;width:12px;display:inline-block;text-align:center}
.tlabel .tt{color:#5f7bb0;font-size:11px;background:#181c28;border:1px solid #2a3247;border-radius:8px;padding:0 6px}
.tlabel .tm{color:#6f7d97;font-size:11px}
.kids{margin-left:18px;border-left:1px solid #222a3a;padding-left:6px}
.kids.hide{display:none}
.tleaf{color:#6f7d97;font-size:12px;padding:2px 8px}
/* treemap */
#treemapbox{padding:8px;display:none}
#treemapbox svg text{pointer-events:none;font-family:inherit}
#treemapbox rect{cursor:pointer;stroke:#0f1117;stroke-width:1.5}
#treemapbox rect:hover{filter:brightness(1.3)}
/* scrapers staleness table */
#scrapersbox{padding:10px 20px 40px;display:none}
#scrapersbox .sumbar{color:var(--dim);font-size:12px;margin:2px 0 12px}
#scrapersbox .sumbar b{color:#eef1f7}
table.stale{border-collapse:collapse;width:100%;font-size:13px}
table.stale th{text-align:left;color:#9fc4ff;font-weight:700;font-size:11px;letter-spacing:.4px;padding:6px 10px;border-bottom:1px solid #2a3247;cursor:pointer;user-select:none;white-space:nowrap}
table.stale th:hover{color:#eef1f7}
table.stale th .ar{color:#5f7bb0;font-size:10px}
table.stale td{padding:6px 10px;border-bottom:1px solid #181c28}
table.stale tr{cursor:pointer}
table.stale tbody tr:hover td{background:#181c28}
table.stale td.num{color:#6f7d97;text-align:right;font-variant-numeric:tabular-nums}
table.stale td.age{font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap}
table.stale td.tbl{color:#6f7d97;font-size:11px}
.agepill{display:inline-block;min-width:8px}
/* drawer */
#drawer{position:fixed;top:0;right:-460px;width:440px;height:100%;background:var(--panel);border-left:1px solid #2a3247;
transition:right .18s;overflow:auto;padding:18px;box-shadow:-8px 0 30px rgba(0,0,0,.5);z-index:50}
#drawer.open{right:0}
/* when the drill drawer is open, push the category chips out from under it so none are buried/unclickable */
@media(min-width:900px){body.drawer-open #folders{padding-right:456px;transition:padding-right .18s}}
#crumbs{font-size:12px;margin:0 0 8px;color:#9fc4ff;line-height:1.7}
#crumbs a{cursor:pointer;color:#9fc4ff;text-decoration:none}
#crumbs a:hover{text-decoration:underline}
#crumbs .sep{color:#3a4761;margin:0 6px}
#drawer h2{font-size:16px;margin:0 0 4px;word-break:break-all}
#drawer .cnt{color:var(--dim);font-size:12px;margin-bottom:12px}
/* 5W info card */
#dinfo{margin:0 0 14px}
.icard{background:#0e1420;border:1px solid #2a3247;border-radius:10px;padding:12px 12px 8px}
.idesc{font-size:12.5px;line-height:1.55;color:#c7d2e6;margin-bottom:10px;border-bottom:1px solid #222a3a;padding-bottom:10px}
.iw{display:flex;gap:10px;padding:5px 0;align-items:baseline}
.iw .ik{flex:0 0 52px;font-size:10px;font-weight:700;letter-spacing:.6px;text-align:right;padding-top:1px}
.iw .iv{flex:1;font-size:12px;line-height:1.5;color:#dde3f0;word-break:break-word}
#drawer ul{list-style:none;margin:0;padding:0;font-size:13px}
#drawer li{padding:6px 8px;border-radius:6px;cursor:pointer;display:flex;gap:8px;align-items:baseline}
#drawer li:nth-child(odd){background:#181c28}
#drawer li:hover{background:#20304d}
#drawer li .li-l{flex:1;word-break:break-all}
#drawer li .li-m{color:#6f7d97;font-size:11px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#drawer li .li-t{color:#5f7bb0;font-size:10px;background:#12151f;border:1px solid #2a3247;border-radius:8px;padding:0 6px}
#close{float:right;cursor:pointer;color:var(--dim);font-size:20px;line-height:1}
.foot{color:var(--dim);font-size:12px;padding:8px 20px}
#folders{padding:4px 20px 30px}
.fbar{color:var(--dim);font-size:12px;margin:2px 0 10px;border-top:1px solid #222a3a;padding-top:12px}
.fgroup{margin-bottom:14px}
.ghead{font-size:13px;font-weight:700;color:#9fc4ff;letter-spacing:.4px;margin:6px 0 8px}
.ghead .gn{color:#6f7d97;font-weight:400;font-size:11px;margin-left:6px}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip{display:inline-flex;align-items:center;gap:8px;background:#181c28;border:1px solid #39507a;border-radius:8px;
color:#c7d2e6;font-size:12px;padding:6px 10px;cursor:pointer;font-family:inherit;transition:background .12s}
.chip:hover{background:#20304d}
.chip .cn{background:#20304d;color:#8fe3b4;border-radius:10px;padding:1px 8px;font-weight:700;font-size:11px}
</style></head><body>
<header>
<h1>Steve's Stack — Interconnection Map</h1>
<span class="sub" id="sub">scanning…</span>
<span class="badge UNKNOWN" id="verdict">…</span>
</header>
<div id="toolbar">
<span style="color:var(--dim);font-size:12px">View:</span>
<button class="vbtn" data-view="map">Map</button>
<button class="vbtn" data-view="tree">Tree</button>
<button class="vbtn" data-view="treemap">Treemap</button>
<button class="vbtn" data-view="scrapers">Scrapers ⏱</button>
<button class="tbtn" id="resetLayout" title="Clear saved node positions">Reset layout</button>
<span class="tspace" id="viewhint"></span>
<span id="navdate" style="margin-left:14px;color:#9fc4ff;font-size:12px;font-weight:600;white-space:nowrap" title="Live data as-of this scan">📅 …</span>
</div>
<div id="wrap"><div id="svgbox"></div><div id="treebox"></div><div id="treemapbox"></div><div id="scrapersbox"></div></div>
<div id="folders"></div>
<div class="foot" id="foot"></div>
<div id="drawer"><span id="close">✕</span>
<div id="crumbs"></div>
<h2 id="dtitle"></h2><div class="cnt" id="dcnt"></div><div id="dinfo"></div><ul id="dlist"></ul></div>
<script>
const N=(v)=>v==null?'…':v.toLocaleString();
let DATA=null;
// ── localStorage helpers (try/catch — may throw / return empty) ──────────────
function lsGet(k,d){try{const v=localStorage.getItem(k);return v==null?d:v;}catch{return d;}}
function lsSet(k,v){try{localStorage.setItem(k,v);}catch{}}
function loadPos(){try{return JSON.parse(localStorage.getItem('smv_pos')||'{}')||{};}catch{return {};}}
function savePos(p){try{localStorage.setItem('smv_pos',JSON.stringify(p));}catch{}}
let POS=loadPos();
let VIEW=lsGet('smv_view','map');
// ── Node/edge model for the draggable Map ────────────────────────────────────
const VBW=1680, VBH=920;
function nodes(d){
const T=(x,y,t,c,s,extra)=>({x,y,t,c,s,extra:extra||''});
return [
{id:'steve',x:60,y:20,w:220,h:66,fill:'#243b53',stroke:'#4a6c92',lines:[
T(110,30,'STEVE','#eef1f7',20,'text-anchor="middle" font-weight="700"'),
T(110,52,'/dtd · /officer-yolo · /yolo','#a9b6cc',12,'text-anchor="middle"')]},
{id:'officers',x:40,y:140,w:480,h:116,fill:'#1c2740',stroke:'#3f5a86',drill:{type:'cat',id:'officers'},lines:[
T(16,28,N(d.officers)+' CABINET OFFICERS (vp-*) ▸','#9fc4ff',16,'font-weight="700"'),
T(16,54,'commerce · marketing · engineering · operations · security · cncp','#c7d2e6',12),
T(16,74,'research · directories · compliance · special · consulting · abramsego','#c7d2e6',12),
T(16,100,'route + final sign-off on gated work — click to drill','#6f7d97',11,'font-style="italic"')]},
{id:'workers',x:40,y:306,w:480,h:120,fill:'#1c2740',stroke:'#3f5a86',drill:{type:'cat',id:'ag_workers'},lines:[
T(16,26,'~'+N(d.agents-d.officers)+' WORKER AGENTS ('+N(d.agents)+' total) ▸','#9fc4ff',16,'font-weight="700"'),
T(16,50,'vendor stewards · site/build · marketing · directory builders','#c7d2e6',12),
T(16,70,'contrarian · approval-agent · fleet-lifecycle · masterdot · merchant','#c7d2e6',12),
T(16,94,'own & supervise; recruit subagents (plan→split→argue→merge)','#6f7d97',11,'font-style="italic"')]},
{id:'skills',x:40,y:476,w:480,h:214,fill:'#182135',stroke:'#39507a',drill:{type:'cat',id:'sk_all'},lines:[
T(16,26,N(d.skills)+' SKILLS (invoked via Skill tool) ▸','#9fc4ff',16,'font-weight="700"'),
T(16,148,'+ deploy · pricing · settlement · storefront · research','#c7d2e6',12),
T(16,184,N(d.health)+' emit data/latest.json health heartbeat','#6f7d97',11,'font-style="italic"')],
subs:[
{x:16,y:40,w:200,h:34,fill:'#20304d',label:N(d.canaries)+' canaries / monitors',type:'cat',id:'canaries'},
{x:226,y:40,w:238,h:34,fill:'#20304d',label:N(d.scrapers)+' scraper-managers',type:'cat',id:'scrapers'},
{x:16,y:82,w:200,h:34,fill:'#20304d',label:N(d.dots)+' terminal dot/color',type:'cat',id:'dots'},
{x:226,y:82,w:238,h:34,fill:'#20304d',label:N(d.video)+' hyperframes / video',type:'cat',id:'video'}]},
{id:'cron',x:600,y:20,w:300,h:120,fill:'#2a2136',stroke:'#6b4f86',drill:{type:'cat',id:'cronPlists'},lines:[
T(16,26,N(d.cronLoaded)+' CRON JOBS (launchd) ▸','#d6b8ff',16,'font-weight="700"'),
T(16,50,'com.steve.* — '+N(d.cronPlists)+' plists on disk','#cfc3e0',12),
T(16,70,'canaries · drips · refreshes · rollups','#cfc3e0',12),
T(16,94,N(d.cronToSkill)+' point directly at a skill dir','#8f7fb0',11,'font-style="italic"')]},
{id:'canaries',x:600,y:200,w:300,h:96,fill:'#14261d',stroke:'#2f6a48',drill:{type:'cat',id:'canaries'},lines:[
T(16,26,N(d.canaries)+' canaries + '+N(d.health)+' health skills ▸','#8fe3b4',15,'font-weight="700"'),
T(16,50,'each writes data/latest.json','#bfe6cf',12),
T(16,72,'PASS / WARN / FAIL vocabulary','#5f9c78',11,'font-style="italic"')]},
{id:'rollup',x:600,y:336,w:300,h:80,fill:'#14261d',stroke:'#3f8a5f',drill:{type:'skill',id:'fleet-health-rollup'},lines:[
T(16,30,'fleet-health-rollup ▸','#8fe3b4',15,'font-weight="700"'),
T(16,52,'→ single 7am "is anything on fire?" panel','#bfe6cf',12)]},
{id:'morning',x:600,y:456,w:300,h:52,fill:'#0f2016',stroke:'#2f6a48',lines:[
T(150,31,'MORNING PANEL → Steve ['+d.verdict+']','#8fe3b4',14,'text-anchor="middle" font-weight="700"')]},
{id:'parked',x:600,y:540,w:300,h:150,fill:'#2e1c1c',stroke:'#8a4a4a',drill:{type:'cat',id:'parked'},lines:[
T(16,28,N(d.parked)+' PARKED (pending-approval) ▸','#f3b6b6',15,'font-weight="700"'),
T(16,52,'gated: spend · send-list · publish · DNS','#e6c7c7',12),
T(16,72,'remote push · canonical dw_unified/Shopify','#e6c7c7',12),
T(16,98,'⚠ '+N(d.p24)+' > 24h old · '+N(d.p7d)+' > 7 days','#ff9b6a',12,'font-weight="700"'),
T(16,120,'cleared by /ungate · approval-agent — click to drill','#a87a7a',11,'font-style="italic"')]},
{id:'ledger',x:950,y:540,w:290,h:150,fill:'#14261d',stroke:'#2f6a48',lines:[
T(16,28,N(d.ledger)+' REVERSIBLE ACTIONS','#8fe3b4',15,'font-weight="700"'),
T(16,52,'auto-executed (3-question tier):','#bfe6cf',12),
T(16,72,'reversible + no externality + bounded','#bfe6cf',12),
T(16,98,'→ executed-reversible/ledger.jsonl','#bfe6cf',12),
T(16,120,'post-hoc /rollback-runbook','#5f9c78',11,'font-style="italic"')]},
{id:'terminal',x:950,y:20,w:290,h:150,fill:'#20233a',stroke:'#4a5388',drill:{type:'cat',id:'dots'},lines:[
T(16,28,'TERMINAL FLEET (iTerm2) ▸','#b9c0ff',15,'font-weight="700"'),
T(16,52,'🟢 working · 🟡 needs direction','#cfd3ee',12),
T(16,72,'🟣 gated · 🟠 paste · 🔵 needs-Steve','#cfd3ee',12),
T(16,92,'🩷 parked','#cfd3ee',12),
T(16,118,'painted by '+N(d.dots)+' dot skills + masterdot','#7f88ad',11,'font-style="italic"')]},
{id:'projects',x:950,y:200,w:290,h:150,fill:'#2a2136',stroke:'#6b4f86',drill:{type:'cat',id:'pj_all'},lines:[
T(16,28,N(d.projects)+' PROJECTS (~/Projects) ▸','#d6b8ff',15,'font-weight="700"'),
T(16,98,'DW · scrapers · websites · directories','#cfc3e0',12),
T(16,118,'viewers · media · infra — click to drill','#8f7fb0',11,'font-style="italic"')],
subs:[{x:16,y:44,w:130,h:30,fill:'#3a2f52',label:N(d.projectRepos)+' git repos',type:'cat',id:'pj_repos'}]},
{id:'legend',x:1270,y:20,w:370,h:560,fill:'#12151f',stroke:'#2a3247',lines:[
T(18,30,'Reading the map','#eef1f7',17,'font-weight="700"'),
T(18,62,'Drag any node to rearrange — layout saves.','#c7d2e6',13),
T(18,84,'Click a bordered node to drill infinitely.','#c7d2e6',13),
T(18,120,'1 · Cron → Skills','#d98b3a',13,'font-weight="700"'),
T(30,140,N(d.cronLoaded)+' jobs fire skills on a clock.','#c7d2e6',13),
T(18,170,'2 · Canaries → fleet-health-rollup','#4fae7a',13,'font-weight="700"'),
T(30,190,N(d.health)+' heartbeats fold into one panel.','#c7d2e6',13),
T(18,220,'3 · Officers → Agents → Skills','#9fc4ff',13,'font-weight="700"'),
T(30,240,'the cabinet hierarchy does the work.','#c7d2e6',13),
T(18,270,'4 · Gated → pending-approval','#f3b6b6',13,'font-weight="700"'),
T(30,290,'risky pool for Steve; reversible auto-run.','#c7d2e6',13),
T(18,332,'Streamline signals','#ffd27a',14,'font-weight="700"'),
T(18,358,'• '+N(d.parked)+' parked, '+N(d.p24)+' >24h — backlog','#e6c7c7',13),
T(18,380,'• '+N(d.skills)+' skills — heavy sprawl','#e6c7c7',13),
T(18,402,'• '+N(d.canaries)+' canaries + '+N(d.cronLoaded)+' cron — dense','#e6c7c7',13),
T(18,424,'• '+N(d.agents)+' agents / '+N(d.officers)+' officers — deep','#e6c7c7',13),
T(18,470,'Views: Map · Tree · Treemap (top).','#6f7d97',11,'font-style="italic"')]},
];
}
const EDGES=[
{from:'steve',to:'officers',color:'#7d8aa5',m:'arw',fa:'b',ta:'t'},
{from:'officers',to:'workers',color:'#7d8aa5',m:'arw',fa:'b',ta:'t'},
{from:'workers',to:'skills',color:'#7d8aa5',m:'arw',fa:'b',ta:'t'},
{from:'cron',to:'skills',color:'#d98b3a',w:2.4,m:'arwR',fa:'b',ta:'t',label:'drives'},
{from:'skills',to:'canaries',color:'#4fae7a',w:2.2,m:'arwG',fa:'r',ta:'l'},
{from:'canaries',to:'rollup',color:'#4fae7a',w:2.2,m:'arwG',fa:'b',ta:'t'},
{from:'rollup',to:'morning',color:'#4fae7a',w:2.2,m:'arwG',fa:'b',ta:'t'},
{from:'skills',to:'parked',color:'#d98b3a',w:2.2,m:'arwR',fa:'r',ta:'l'},
{from:'parked',to:'ledger',color:'#3a4761',w:1.6,m:'arw',dash:1,fa:'r',ta:'l'},
{from:'workers',to:'projects',color:'#3a4761',w:1.4,m:'arw',dash:1,fa:'r',ta:'l'},
{from:'skills',to:'terminal',color:'#3a4761',w:1.4,m:'arw',dash:1,fa:'r',ta:'t'},
];
let NODEMAP={};
function defXY(id){const n=NODEMAP[id];return n?{x:n.x,y:n.y}:{x:0,y:0};}
function curXY(id){const n=NODEMAP[id];if(!n)return{x:0,y:0,w:0,h:0};const p=POS[id]||{x:n.x,y:n.y};return{x:p.x,y:p.y,w:n.w,h:n.h};}
function anchor(id,side){const b=curXY(id);switch(side){case 'b':return[b.x+b.w/2,b.y+b.h];case 't':return[b.x+b.w/2,b.y];case 'l':return[b.x,b.y+b.h/2];case 'r':return[b.x+b.w,b.y+b.h/2];default:return[b.x+b.w/2,b.y+b.h/2];}}
function drawEdges(){
const layer=document.getElementById('edges');if(!layer)return;
layer.innerHTML=EDGES.map(e=>{const a=anchor(e.from,e.fa||'b'),b=anchor(e.to,e.ta||'t');
return '<line x1="'+a[0]+'" y1="'+a[1]+'" x2="'+b[0]+'" y2="'+b[1]+'" stroke="'+(e.color||'#7d8aa5')+'" stroke-width="'+(e.w||2)+'"'+(e.dash?' stroke-dasharray="5 4"':'')+' marker-end="url(#'+(e.m||'arw')+')"/>';
}).join('');
}
function renderMap(d){
const ns=nodes(d);NODEMAP={};ns.forEach(n=>NODEMAP[n.id]=n);
const grp=n=>{const xy=POS[n.id]||{x:n.x,y:n.y};
const txt=(n.lines||[]).map(l=>'<text x="'+l.x+'" y="'+l.y+'" fill="'+l.c+'" font-size="'+l.s+'" '+l.extra+'>'+l.t+'</text>').join('');
const subs=(n.subs||[]).map(sb=>'<g class="sub" data-dtype="'+sb.type+'" data-did="'+encodeURIComponent(sb.id)+'"><rect x="'+sb.x+'" y="'+sb.y+'" width="'+sb.w+'" height="'+sb.h+'" rx="6" fill="'+sb.fill+'"/><text x="'+(sb.x+10)+'" y="'+(sb.y+21)+'" fill="#c7d2e6" font-size="12">'+sb.label+'</text></g>').join('');
const dd=n.drill?(' data-dtype="'+n.drill.type+'" data-did="'+encodeURIComponent(n.drill.id)+'"'):'';
return '<g class="dnode" data-id="'+n.id+'"'+dd+' transform="translate('+xy.x+','+xy.y+')"><rect class="node" x="0" y="0" width="'+n.w+'" height="'+n.h+'" rx="10" fill="'+n.fill+'" stroke="'+n.stroke+'"/>'+txt+subs+'</g>';
};
const svg='<svg id="mapsvg" width="'+VBW+'" height="'+VBH+'" viewBox="0 0 '+VBW+' '+VBH+'" xmlns="http://www.w3.org/2000/svg">'
+'<defs>'
+'<marker id="arw" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#7d8aa5"/></marker>'
+'<marker id="arwG" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#4fae7a"/></marker>'
+'<marker id="arwR" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#d98b3a"/></marker>'
+'</defs><rect width="'+VBW+'" height="'+VBH+'" fill="#0f1117"/><g id="edges"></g>'
+ns.map(grp).join('')+'</svg>';
document.getElementById('svgbox').innerHTML=svg;
drawEdges();
wireMap();
}
function wireMap(){
const svg=document.getElementById('mapsvg');if(!svg)return;
let cur=null,sx=0,sy=0,ox=0,oy=0,moved=false,scale=1;
svg.querySelectorAll('g.dnode').forEach(g=>{
g.addEventListener('pointerdown',e=>{
if(e.target.closest('g.sub'))return; // sub handles its own click
cur=g;moved=false;
const xy=POS[g.dataset.id]||defXY(g.dataset.id);ox=xy.x;oy=xy.y;sx=e.clientX;sy=e.clientY;
const r=svg.getBoundingClientRect();scale=VBW/(r.width||VBW);
try{g.setPointerCapture(e.pointerId);}catch{}
e.preventDefault();
});
g.addEventListener('pointermove',e=>{
if(cur!==g)return;
const dx=(e.clientX-sx)*scale,dy=(e.clientY-sy)*scale;
if(Math.abs(e.clientX-sx)+Math.abs(e.clientY-sy)>3)moved=true;
const nx=Math.round(ox+dx),ny=Math.round(oy+dy);
POS[g.dataset.id]={x:nx,y:ny};
g.setAttribute('transform','translate('+nx+','+ny+')');
drawEdges();
});
g.addEventListener('pointerup',e=>{
if(cur!==g)return;cur=null;
if(moved){savePos(POS);}
else if(g.dataset.dtype){startDrill(g.dataset.dtype,decodeURIComponent(g.dataset.did),decodeURIComponent(g.dataset.did));}
});
});
svg.querySelectorAll('g.sub').forEach(s=>{
s.addEventListener('pointerdown',e=>e.stopPropagation());
s.addEventListener('click',e=>{e.stopPropagation();startDrill(s.dataset.dtype,decodeURIComponent(s.dataset.did),decodeURIComponent(s.dataset.did));});
});
}
// ── Recursive drill drawer ───────────────────────────────────────────────────
let CRUMBS=[];
const drillCache=new Map();
async function fetchDrill(type,id){
const k=type+'|'+id;if(drillCache.has(k))return drillCache.get(k);
try{const r=await fetch('/api/drill?type='+encodeURIComponent(type)+'&id='+encodeURIComponent(id));const j=await r.json();drillCache.set(k,j);return j;}
catch(e){return {title:type+' · '+id,children:[],error:String(e)};}
}
function startDrill(type,id,label){CRUMBS=[];openDrill(type,id,label);}
function openDrill(type,id,label){CRUMBS.push({type,id,label:label||id});renderDrill();}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m]));}
// ── 5W info card (description + what/where/who/why/when) ──────────────────────
function renderInfo(info){
const el=document.getElementById('dinfo');
if(!info){el.innerHTML='';return;}
const row=(k,v,c)=>v?'<div class="iw"><span class="ik" style="color:'+c+'">'+k+'</span><span class="iv">'+esc(v)+'</span></div>':'';
el.innerHTML='<div class="icard">'
+(info.description?'<div class="idesc">'+esc(info.description)+'</div>':'')
+row('WHAT',info.what,'#8fe3b4')
+row('WHY',info.why,'#ffd27a')
+row('WHERE',info.where,'#9fc4ff')
+row('WHO',info.who,'#d6b8ff')
+row('WHEN',info.when,'#f3b6b6')
+row('LAST RUN',info.lastRun,'#5fd0a8')
+'</div>';
}
async function renderDrill(){
const c=CRUMBS[CRUMBS.length-1];
const dw=document.getElementById('drawer');dw.classList.add('open');document.body.classList.add('drawer-open');
document.getElementById('dtitle').textContent='…';document.getElementById('dlist').innerHTML='';document.getElementById('dcnt').textContent='';document.getElementById('dinfo').innerHTML='';
renderCrumbs();
const j=await fetchDrill(c.type,c.id);
if(CRUMBS[CRUMBS.length-1]!==c)return; // superseded by a faster click
document.getElementById('dtitle').textContent=j.title||c.label;
renderInfo(j.info);
document.getElementById('dcnt').textContent=((j.children||[]).length)+' items'+(j.error?(' · error: '+j.error):'');
const html=(j.children||[]).map((ch,i)=>'<li data-type="'+ch.type+'" data-id="'+encodeURIComponent(ch.id)+'"><span class="li-l">'+(ch.label||ch.id)+'</span>'+(ch.meta?'<span class="li-m" title="'+String(ch.meta).replace(/"/g,'"')+'">'+ch.meta+'</span>':'')+'<span class="li-t">'+ch.type+'</span></li>').join('');
document.getElementById('dlist').innerHTML=html||'<li>(no deeper detail)</li>';
document.querySelectorAll('#dlist li[data-type]').forEach(li=>li.addEventListener('click',()=>openDrill(li.dataset.type,decodeURIComponent(li.dataset.id),li.querySelector('.li-l').textContent)));
}
function renderCrumbs(){
document.getElementById('crumbs').innerHTML=CRUMBS.map((c,i)=>'<a data-i="'+i+'">'+c.label+'</a>').join('<span class="sep">›</span>');
document.querySelectorAll('#crumbs a').forEach(a=>a.addEventListener('click',()=>{CRUMBS=CRUMBS.slice(0,+a.dataset.i+1);renderDrill();}));
}
function closeDrawer(){document.getElementById('drawer').classList.remove('open');document.body.classList.remove('drawer-open');CRUMBS=[];}
document.getElementById('close').addEventListener('click',closeDrawer);
// Persistent panel, NOT a modal: dismiss only via ✕ or Esc. An earlier "any click outside closes"
// handler killed the cross-reference workflow (open a 5W card, then flip Map→Tree→Scrapers, or hit
// Reset layout) by wiping the drawer on unrelated clicks. Reachability of the chips underneath is
// handled by push-layout (body.drawer-open shifts #folders), so no outside-click dismiss is needed.
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&document.getElementById('drawer').classList.contains('open'))closeDrawer();});
// ── Tree view (recursive collapsible, same /api/drill) ───────────────────────
function treeRoots(d){return [
{type:'cat',id:'sk_all',label:'SKILLS ('+N(d.skills)+')'},
{type:'cat',id:'ag_all',label:'AGENTS ('+N(d.agents)+')'},
{type:'cat',id:'officers',label:'OFFICERS ('+N(d.officers)+')'},
{type:'cat',id:'pj_all',label:'PROJECTS ('+N(d.projects)+')'},
{type:'cat',id:'cronPlists',label:'CRON PLISTS ('+N(d.cronPlists)+')'},
{type:'cat',id:'canaries',label:'CANARIES ('+N(d.canaries)+')'},
{type:'cat',id:'parked',label:'PARKED ('+N(d.parked)+')'},
];}
function treeRowHTML(c){
return '<div class="tnode" data-type="'+c.type+'" data-id="'+encodeURIComponent(c.id)+'">'
+'<span class="tlabel"><span class="tw">▸</span><span class="tn">'+(c.label||c.id)+'</span><span class="tt">'+c.type+'</span>'+(c.meta?'<span class="tm">'+c.meta+'</span>':'')+'</span>'
+'<div class="kids hide"></div></div>';
}
function renderTree(d){
const box=document.getElementById('treebox');
box.innerHTML='<div class="fbar">Click any node to expand its children inline — recurses arbitrarily deep via /api/drill.</div>'+treeRoots(d).map(treeRowHTML).join('');
box.querySelectorAll(':scope > .tnode').forEach(wireTreeNode);
}
function wireTreeNode(node){
const label=node.querySelector(':scope > .tlabel');
label.addEventListener('click',async()=>{
const kids=node.querySelector(':scope > .kids');const arrow=label.querySelector('.tw');
if(node.dataset.loaded){const h=kids.classList.toggle('hide');arrow.textContent=h?'▸':'▾';return;}
arrow.textContent='⋯';
const j=await fetchDrill(node.dataset.type,decodeURIComponent(node.dataset.id));
kids.innerHTML=(j.children||[]).map(treeRowHTML).join('')||'<div class="tleaf">(no deeper detail)</div>';
node.dataset.loaded='1';kids.classList.remove('hide');arrow.textContent='▾';
kids.querySelectorAll(':scope > .tnode').forEach(wireTreeNode);
});
}
// ── Treemap view (d3, area = count; leaf drillable) ──────────────────────────
let d3ready=false;
function loadD3(){return new Promise((res,rej)=>{if(window.d3){d3ready=true;return res();}const s=document.createElement('script');s.src='https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js';s.onload=()=>{d3ready=true;res();};s.onerror=rej;document.head.appendChild(s);});}
async function renderTreemap(d){
const box=document.getElementById('treemapbox');
box.innerHTML='<div class="fbar">Loading d3 (CDN)…</div>';
try{await loadD3();}catch{box.innerHTML='<div class="fbar">Could not load d3 from CDN — use Map or Tree.</div>';return;}
const folders=(d.folders||[]);
const groups={};folders.forEach(f=>{(groups[f.group]=groups[f.group]||[]).push(f);});
const gcolor={SKILLS:'#39507a',AGENTS:'#3f5a86',PROJECTS:'#6b4f86'};
const data={name:'stack',children:Object.keys(groups).map(g=>({name:g,color:gcolor[g]||'#39507a',children:groups[g].map(f=>({name:f.label,value:Math.max(1,f.n),kind:f.kind,count:f.n}))}))};
const W=Math.max(900,(box.clientWidth||1200)-16),H=760;
const root=d3.hierarchy(data).sum(x=>x.value).sort((a,b)=>b.value-a.value);
d3.treemap().size([W,H]).paddingInner(2).paddingTop(20).paddingOuter(3)(root);
box.innerHTML='';
const svg=d3.select(box).append('svg').attr('width',W).attr('height',H);
const gnode=svg.selectAll('g.gg').data(root.descendants().filter(x=>x.depth===1)).enter().append('g');
gnode.append('text').attr('x',x=>x.x0+4).attr('y',x=>x.y0+14).attr('fill','#9fc4ff').attr('font-size',13).attr('font-weight',700).text(x=>x.data.name);
const leaves=svg.selectAll('g.leaf').data(root.leaves()).enter().append('g').attr('class','leaf');
leaves.append('rect').attr('x',x=>x.x0).attr('y',x=>x.y0).attr('width',x=>Math.max(0,x.x1-x.x0)).attr('height',x=>Math.max(0,x.y1-x.y0))
.attr('fill',x=>x.parent.data.color).attr('opacity',.82)
.on('click',(ev,x)=>startDrill('cat',x.data.kind,x.data.name));
leaves.append('text').attr('x',x=>x.x0+5).attr('y',x=>x.y0+16).attr('fill','#eef1f7').attr('font-size',11)
.text(x=>((x.x1-x.x0)>70&&(x.y1-x.y0)>20)?x.data.name:'');
leaves.append('text').attr('x',x=>x.x0+5).attr('y',x=>x.y0+30).attr('fill','#c7d2e6').attr('font-size',10)
.text(x=>((x.x1-x.x0)>40&&(x.y1-x.y0)>34)?x.data.count:'');
}
// ── Scrapers staleness view (sortable, stalest-first, click row → 5W card) ───
let SCR=null, SCRSORT={key:'days',dir:-1};
function ageCell(d){
if(d==null)return '<span class="agepill" style="color:#6f7d97">— no data</span>';
const c=d>30?'#f3b6b6':d>14?'#ffd27a':'#5fd0a8';
return '<span class="agepill" style="color:'+c+'">'+d+'d</span>';
}
function cadenceCell(r){
if(r.cadence==='cadence')return '<span style="color:#5fd0a8" title="'+r.scrapeDates+' distinct scrape dates since '+r.firstScrape+' — had a cadence; now lapsed">✓ '+r.scrapeDates+' scrapes</span>';
if(r.cadence==='once')return '<span style="color:#ffd27a" title="only one scrape date — scraped at onboarding, never refreshed">⟳ once (onboard)</span>';
if(r.cadence==='never')return '<span style="color:#f3b6b6" title="no true scrape timestamp ever recorded">✗ never scraped</span>';
return '<span style="color:#6f7d97">—</span>';
}
function healthCell(r){
if(r.health==='broken')return '<span style="color:#f3b6b6" title="'+esc(r.healthNote||'healthcheck: broken')+'">✗ broken</span>';
if(r.health==='verified')return '<span style="color:#5fd0a8" title="healthcheck: scraper verified working">✓ verified</span>';
if(r.health==='discontinued')return '<span style="color:#8a6a8a" title="'+esc(r.healthNote||'vendor site dead/moved — discontinued')+'">⊘ discontinued</span>';
if(r.health==='paused')return '<span style="color:#c79a4a" title="'+esc(r.healthNote||'paused — do not scrape for now')+'">⏸ paused</span>';
return '<span style="color:#6f7d97" title="not in the scraper healthcheck set — health unknown">○ unmonitored</span>';
}
const HRANK={broken:0,verified:1,paused:2,discontinued:3}; // broken first when ascending
function sortScr(rows){
const {key,dir}=SCRSORT;
return rows.slice().sort((a,b)=>{
let av=a[key],bv=b[key];
if(key==='days'){ // nulls always last regardless of dir
if(av==null&&bv==null)return (a.name||'').localeCompare(b.name||'');
if(av==null)return 1; if(bv==null)return -1; return (av-bv)*dir;
}
if(key==='cadence'){av=a.scrapeDates||0;bv=b.scrapeDates||0;return (av-bv)*dir;}
if(key==='health'){av=HRANK[a.health]??2;bv=HRANK[b.health]??2;return (av-bv)*dir;}
if(key==='rows'){av=av==null?-1:av;bv=bv==null?-1:bv;return (av-bv)*dir;}
return String(av||'').localeCompare(String(bv||''))*dir;
});
}
function renderScrapersTable(){
if(!SCR)return;
const box=document.getElementById('scrapersbox');
const cols=[['name','Vendor'],['last','Last scrape'],['days','Age'],['health','Health'],['cadence','History'],['rows','Rows'],['tbl','Staging table']];
const arrow=k=>SCRSORT.key===k?(' <span class="ar">'+(SCRSORT.dir<0?'▼':'▲')+'</span>'):'';
const head='<tr><th style="cursor:default;color:#6f7d97">#</th>'+cols.map(c=>'<th data-k="'+c[0]+'">'+c[1]+arrow(c[0])+'</th>').join('')+'</tr>';
const rows=sortScr(SCR.rows).map((r,i)=>'<tr'+(r.skill?' data-skill="'+r.skill+'"':' style="cursor:default"')+'>'
+'<td class="num">'+(i+1)+'</td>'
+'<td>'+esc(r.name)+(r.skill?' <span style="color:#5f7bb0;font-size:10px" title="has a scraper-manager skill — click for its 5W card">◆</span>':' <span style="color:#6f7d97;font-size:10px" title="registry vendor — no dedicated skill">·reg</span>')+'</td>'
+'<td class="age">'+(r.last?esc(r.last)+(r.source==='db-touch'?' <span style="color:#c79a4a;font-weight:400;font-size:10px" title="no true scrape timestamp; date is from updated_at (a DB touch)">⚠ db-touch</span>':''):'<span style="color:#6f7d97">—</span>')+'</td>'
+'<td class="age">'+ageCell(r.days)+'</td>'
+'<td>'+healthCell(r)+'</td>'
+'<td>'+cadenceCell(r)+'</td>'
+'<td class="num">'+(r.rows==null?'—':r.rows.toLocaleString())+'</td>'
+'<td class="tbl">'+(r.tbl||'<span style="color:#8a4a4a">no table</span>')+'</td></tr>').join('');
box.innerHTML='<div class="sumbar"><b>'+SCR.count+'</b> vendors · <b style="color:#f3b6b6">'+SCR.broken+'</b> ✗ broken · <b style="color:#f3b6b6">'+SCR.stale30+'</b> stale >30d · <b style="color:#ffd27a">'+SCR.onceOnly+'</b> onboarded-once · <b>'+SCR.withSkill+'</b> ◆ have a skill · <b>Health</b> = live scraper healthcheck (hover ✗ for the failure reason) · click a ◆ row for its 5W card · headers re-sort</div>'
+'<table class="stale"><thead>'+head+'</thead><tbody>'+rows+'</tbody></table>';
box.querySelectorAll('th[data-k]').forEach(th=>th.addEventListener('click',()=>{const k=th.dataset.k;if(SCRSORT.key===k)SCRSORT.dir*=-1;else SCRSORT={key:k,dir:k==='name'||k==='tbl'?1:-1};renderScrapersTable();}));
box.querySelectorAll('tr[data-skill]').forEach(tr=>tr.addEventListener('click',()=>startDrill('skill',tr.dataset.skill,tr.dataset.skill)));
}
async function renderScrapers(){
const box=document.getElementById('scrapersbox');
box.innerHTML='<div class="sumbar">Loading scraper run dates…</div>';
try{const r=await fetch('/api/scrapers');SCR=await r.json();}catch(e){box.innerHTML='<div class="sumbar">Could not load /api/scrapers.</div>';return;}
renderScrapersTable();
}
// ── View switching ───────────────────────────────────────────────────────────
function setView(v){
VIEW=v;lsSet('smv_view',v);
document.querySelectorAll('.vbtn').forEach(b=>b.classList.toggle('on',b.dataset.view===v));
document.getElementById('svgbox').style.display=v==='map'?'block':'none';
document.getElementById('treebox').style.display=v==='tree'?'block':'none';
document.getElementById('treemapbox').style.display=v==='treemap'?'block':'none';
document.getElementById('scrapersbox').style.display=v==='scrapers'?'block':'none';
document.getElementById('resetLayout').style.display=v==='map'?'inline-block':'none';
const hint={map:'drag nodes to rearrange · click to drill',tree:'click any row to expand deeper',treemap:'rectangle area = member count · click a leaf to drill',scrapers:'sorted stalest-first · click a header to re-sort · click a row for the 5W card'};
document.getElementById('viewhint').textContent=hint[v]||'';
if(v==='scrapers'){renderScrapers();return;}
if(!DATA)return;
if(v==='map')renderMap(DATA);
else if(v==='tree')renderTree(DATA);
else if(v==='treemap')renderTreemap(DATA);
}
document.querySelectorAll('.vbtn').forEach(b=>b.addEventListener('click',()=>setView(b.dataset.view)));
document.getElementById('resetLayout').addEventListener('click',()=>{POS={};savePos(POS);if(DATA)renderMap(DATA);});
// ── Folders (chips) — shared across views ────────────────────────────────────
function renderFolders(folders){
const el=document.getElementById('folders');
if(!folders||!folders.length){el.innerHTML='';return;}
const groups={};folders.forEach(f=>{(groups[f.group]=groups[f.group]||[]).push(f);});
const order=['SKILLS','AGENTS','PROJECTS'];
const gcolor={SKILLS:'#39507a',AGENTS:'#3f5a86',PROJECTS:'#6b4f86'};
let html='<div class="fbar">Folders — click any to drill into its members (live-scanned, recurses infinitely)</div>';
order.forEach(g=>{const arr=groups[g];if(!arr)return;
const tot=arr.reduce((a,f)=>a+f.n,0);
html+='<div class="fgroup"><div class="ghead">'+g+'<span class="gn">'+arr.length+' folders · '+N(tot)+' members</span></div><div class="chips">'+
arr.map(f=>'<button class="chip" style="border-color:'+(gcolor[g]||'#39507a')+'" onclick="startDrill(\'cat\',\''+f.kind+'\',\''+f.label.replace(/'/g,"\\'")+'\')" title="'+f.label+'"><span class="cl">'+f.label+'</span><span class="cn">'+N(f.n)+'</span></button>').join('')+
'</div></div>';
});
el.innerHTML=html;
}
// ── Data / refresh ───────────────────────────────────────────────────────────
let bootDrilled=false;
function applyData(d){
DATA=d;
// on load, auto-open one skill's 5W card so the description/who-what-where-why-when panel is visible to test
if(!bootDrilled){bootDrilled=true;setTimeout(()=>startDrill('skill','greenland-scraper-manager','greenland-scraper-manager'),450);}
document.getElementById('sub').textContent=N(d.skills)+' skills · '+N(d.agents)+' agents · '+N(d.projects)+' projects · '+N(d.cronLoaded)+' cron · '+N(d.parked)+' parked';
const vb=document.getElementById('verdict');vb.textContent='fleet: '+d.verdict;vb.className='badge '+d.verdict;
document.getElementById('foot').textContent='Live from ~/.claude · ~/Library/LaunchAgents · yolo-queue — auto-refresh 30s — last scan '+new Date(d.ts).toLocaleTimeString();
try{const nd=document.getElementById('navdate');if(nd)nd.textContent='📅 '+new Date(d.ts).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch{}
renderFolders(d.folders||[]);
setView(VIEW);
}
async function refresh(){try{const r=await fetch('/api/stats');applyData(await r.json());}catch(e){document.getElementById('sub').textContent='scan error';}}
refresh();setInterval(refresh,30000);
</script></body></html>