← 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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,'&quot;')+'">'+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 &gt;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>