← back to Approval Command Center

public/index.html

306 lines

<!DOCTYPE html>
<html lang="en">
<head>
<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 charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Approval Command Center</title>
<style>
  :root{ --cols:3; --bg:#0f1115; --panel:#171a21; --line:#252a34; --ink:#e7ebf0; --dim:#98a2b3; --teal:#33c2b8; --gold:#d9b166; }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
  header{position:sticky;top:0;z-index:20;background:linear-gradient(180deg,#12151b,#12151bee);border-bottom:1px solid var(--line);padding:14px 20px;backdrop-filter:blur(6px)}
  h1{margin:0 0 2px;font-size:17px;font-weight:600;letter-spacing:.2px}
  .sub{color:var(--dim);font-size:12px}
  .bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:10px}
  input,select,button{font:inherit}
  input[type=search],select{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 10px}
  input[type=search]{min-width:230px}
  .range{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:12px}
  .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
  .chip{cursor:pointer;user-select:none;border:1px solid var(--line);background:var(--panel);color:var(--dim);border-radius:999px;padding:4px 11px;font-size:12px}
  .chip.on{color:#0f1115;font-weight:600}
  .chip.on[data-sev=hard]{background:#e06b6b;border-color:#e06b6b}
  .chip.on[data-sev=soft]{background:var(--teal);border-color:var(--teal)}
  .chip.on{background:var(--gold);border-color:var(--gold)}
  .legend{margin-top:9px;color:var(--dim);font-size:11.5px;display:flex;gap:16px;flex-wrap:wrap}
  .dotp{display:inline-block;width:9px;height:9px;border-radius:2px;vertical-align:middle;margin-right:5px}
  main{padding:18px 20px 80px}
  .grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:14px}
  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:13px 14px;display:flex;flex-direction:column;gap:8px;position:relative}
  .card.hard{border-left:3px solid #e06b6b}
  .card.soft{border-left:3px solid var(--teal)}
  .ctitle{font-weight:600;font-size:14px;line-height:1.35}
  .meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
  .badge{font-size:11px;padding:2px 8px;border-radius:6px;border:1px solid var(--line);color:var(--dim);white-space:nowrap}
  .badge.gate{background:#20242d}
  .badge.irrev{color:#e06b6b;border-color:#5a2b2b;background:#241618}
  .badge.rev{color:var(--teal);border-color:#1f4a47;background:#12211f}
  .badge.state{background:#1a1e26}
  .when{color:var(--dim);font-size:11.5px}
  .officer{color:var(--gold);font-size:11.5px}
  .body{display:none;white-space:pre-wrap;font:12px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;background:#0e1116;border:1px solid var(--line);border-radius:8px;padding:10px;max-height:340px;overflow:auto;color:#c7d0dc}
  .card.open .body{display:block}
  .row{display:flex;gap:6px;flex-wrap:wrap;margin-top:2px}
  .btn{cursor:pointer;border:1px solid var(--line);background:#20242d;color:var(--ink);border-radius:8px;padding:6px 11px;font-size:12.5px}
  .btn:hover{border-color:#3a4150}
  .btn.approve{background:#12332e;border-color:#1f5c52;color:#7ee0d3}
  .btn.reject{background:#2c1618;border-color:#5a2b2b;color:#f0a0a0}
  .btn.hold{background:#241f12;border-color:#5c4c1f;color:#e6c66b}
  .btn.sel{background:#161d2b;border-color:#2a3a5c;color:#9ec0ff}
  .btn.done{opacity:.5;pointer-events:none}
  .card.acted{opacity:.55}
  .selbox{position:absolute;top:11px;right:11px;transform:scale(1.25)}
  .bulk{position:fixed;bottom:0;left:0;right:0;background:#12151bfa;border-top:1px solid var(--line);padding:10px 20px;display:none;gap:10px;align-items:center;z-index:30}
  .bulk.show{display:flex}
  .bulk .n{color:var(--teal);font-weight:600}
  .toast{position:fixed;bottom:64px;right:18px;background:#12332e;border:1px solid #1f5c52;color:#7ee0d3;padding:9px 14px;border-radius:8px;z-index:40;display:none}
  a{color:var(--teal)}
  .count-pills{display:flex;flex-wrap:wrap;gap:8px;margin-top:9px}
  .cp{font-size:11.5px;color:var(--dim);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:4px 9px;cursor:pointer}
  .cp b{color:var(--ink)}
  /* fast-track tray — the reversible batch */
  .fasttrack{background:linear-gradient(180deg,#12211f,#0f1b1a);border:1px solid #1f5c52;border-radius:12px;padding:13px 16px;margin-bottom:16px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
  .fasttrack.empty{display:none}
  .fasttrack .ft-h{font-weight:600;color:#7ee0d3;font-size:14px;margin-bottom:2px}
  .fasttrack .ft-sub{color:var(--dim);font-size:12px;max-width:640px}
  .fasttrack .ft-actions{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
  /* per-card one-line risk reason */
  .risk{font-size:11.5px;line-height:1.4;display:flex;gap:6px;align-items:flex-start;color:#e6a5a5}
  .risk .rk{flex:none}
  .card.soft .risk{color:#8fcabb}
</style>
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
  <script src="/drill.js"></script>
</head>
<body>
<header>
  <h1>Approval Command Center <span class="sub">— the 109-item pending-approval digest · 2026-08-09</span></h1>
  <div class="sub">Your click is the human gate. <b>Approve</b> logs your go and queues the item for the live officer-yolo pass (DTD → officer → contrarian → verify). Nothing irreversible fires from this page unattended.</div>
  <div class="bar">
    <input type="hidden" id="pattern" value="">
    <input type="search" id="q" placeholder="Search title / body / slug…"/>
    <select id="sort">
      <option value="sev">Sort: Risk (hard first)</option>
      <option value="newest">Newest</option>
      <option value="oldest">Oldest</option>
      <option value="gate">Gate A→Z</option>
      <option value="title">Title A→Z</option>
      <option value="officer">Officer</option>
    </select>
    <label class="range">Density
      <input type="range" id="dens" min="1" max="5" value="3"/>
    </label>
    <button class="btn sel" id="selall">Select all visible</button>
  </div>
  <div class="chips" id="chips"></div>
  <div class="count-pills" id="pills"></div>
  <div class="legend">
    <span><span class="dotp" style="background:#e06b6b"></span>HARD gate — irreversible / spend / DNS / bulk-send / canonical → live officer-yolo only</span>
    <span><span class="dotp" style="background:#33c2b8"></span>SOFT gate — reversible → safe to fast-track</span>
  </div>
</header>
<main>
  <div class="fasttrack empty" id="fasttrack"></div>
  <div class="grid" id="grid"></div>
</main>
<div class="bulk" id="bulk">
  <span class="n" id="bulkn">0</span> selected
  <button class="btn approve" onclick="bulk('approve')">Approve + queue selected</button>
  <button class="btn reject" onclick="bulk('reject')">Reject selected</button>
  <button class="btn hold" onclick="bulk('hold')">Hold selected</button>
  <button class="btn" onclick="clearSel()">Clear</button>
</div>
<div class="toast" id="toast"></div>
<script>
// href-drill primitives (TK-10903)
var FIELDS=[['gate','Gate'],['sev','Severity'],['state','State'],['officer','Officer']];
var FIL={gate:'',sev:'',state:'',officer:''};
function applyFilters(push){if(push)writeURL(true);render();}

let ITEMS=[], SEL=new Set();
const activeGates=new Set(), activeSev=new Set(), activeState=new Set();
const $=s=>document.querySelector(s);
const gates=["destructive","dw_unified canonical","Shopify write","deploy","DNS / domain","send (comms-law — NEVER auto)","other"];

// one-line, plain-English "why is this gated / what's at stake" per gate.
const RISK={
  "destructive":"Deletes or overwrites data/state — no undo once it runs.",
  "dw_unified canonical":"Writes the canonical catalog DB — a wrong value propagates fleet-wide.",
  "Shopify write":"Edits the LIVE storefront — customer-facing the instant it lands.",
  "deploy":"Ships code to prod — changes the live site until reverted.",
  "DNS / domain":"Reroutes DNS/domain — slow to propagate, can black-hole a site.",
  "send (comms-law — NEVER auto)":"Sends outbound comms — CAN-SPAM/TCPA exposure, can't be unsent.",
  "other":"Gated action — open the memo for exactly what it touches."
};
function riskLine(it){
  if(it.severity==='soft') return "Reversible & no hard gate — undoable/local; safe to fast-track.";
  const base=RISK[it.gate]||RISK.other;
  // a reversible item that still rides a hard gate: say why it's still officer-only
  return (it.reversible && it.severity==='hard') ? base+" (reversible, but hard-gated → still officer-only)" : base;
}

function fmtWhen(ms){ if(!ms) return "—"; return new Date(ms).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }

function load(){
  fetch('/api/items').then(r=>r.json()).then(d=>{ ITEMS=d.items; render(); });
}
function passesFilter(it){
  if(FIL.gate && it.gate!==FIL.gate) return false;
  if(FIL.sev && it.severity!==FIL.sev) return false;
  if(FIL.state && it.where!==FIL.state) return false;
  if(FIL.officer && (it.officer||'')!==FIL.officer) return false;
  if(activeGates.size && !activeGates.has(it.gate)) return false;
  if(activeSev.size && !activeSev.has(it.severity)) return false;
  if(activeState.size && !activeState.has(it.where)) return false;
  const q=$('#q').value.trim().toLowerCase();
  if(q && !((it.title+it.slug+it.body).toLowerCase().includes(q))) return false;
  return true;
}
function sortItems(a){
  const s=$('#sort').value;
  const sevRank={hard:0,soft:1};
  return a.sort((x,y)=>{
    if(s==='sev') return (sevRank[x.severity]-sevRank[y.severity])|| (y.created-x.created);
    if(s==='newest') return (y.created||0)-(x.created||0);
    if(s==='oldest') return (x.created||0)-(y.created||0);
    if(s==='gate') return x.gate.localeCompare(y.gate);
    if(s==='title') return x.title.localeCompare(y.title);
    if(s==='officer') return (x.officer||'').localeCompare(y.officer||'');
    return 0;
  });
}
function render(){
  // chips
  const cw=$('#chips'); cw.innerHTML='';
  const mkchip=(label,set,val,sev)=>{ const c=document.createElement('span'); c.className='chip'+(set.has(val)?' on':''); if(sev)c.dataset.sev=sev; c.textContent=label;
    c.onclick=()=>{ set.has(val)?set.delete(val):set.add(val); render(); }; cw.appendChild(c); };
  mkchip('● HARD only',activeSev,'hard','hard'); mkchip('● SOFT only',activeSev,'soft','soft');
  gates.forEach(g=>mkchip(g,activeGates,g));
  ['pending','approved','rejected','held'].forEach(s=>mkchip('state:'+s,activeState,s));

  // pills (counts by gate) — clickable → filter
  const pills=$('#pills'); pills.innerHTML='';
  const byGate={}; ITEMS.forEach(i=>byGate[i.gate]=(byGate[i.gate]||0)+1);
  const total=ITEMS.length, irrev=ITEMS.filter(i=>!i.reversible).length;
  const addpill=(html,fn)=>{ const p=document.createElement('span'); p.className='cp'; p.innerHTML=html; if(fn)p.onclick=fn; pills.appendChild(p); };
  addpill(`<b>${total}</b> total`);
  addpill(`<b>${irrev}</b> irreversible`, ()=>{activeSev.clear();activeSev.add('hard');render();});
  addpill(`<b>${ITEMS.filter(i=>i.reversible).length}</b> reversible`, ()=>{activeSev.clear();activeSev.add('soft');render();});
  gates.forEach(g=>addpill(`<b>${byGate[g]||0}</b> ${g}`, ()=>{activeGates.clear();activeGates.add(g);render();}));

  // fast-track tray — reversible & no-hard-gate items still pending
  const softPending=ITEMS.filter(i=>i.severity==='soft' && i.where==='pending');
  const ft=$('#fasttrack');
  if(softPending.length){
    ft.classList.remove('empty');
    ft.innerHTML=`
      <div>
        <div class="ft-h">⚡ Ready to fast-track — ${softPending.length} reversible</div>
        <div class="ft-sub">Reversible &amp; local, no hard gate. Approving logs your go and queues them on the <b>reversible-fast-track</b> path — they're still executed by the live drain, never fired from this page.</div>
      </div>
      <div class="ft-actions">
        <button class="btn approve" id="ftapprove">Approve all ${softPending.length} reversible</button>
        <button class="btn sel" id="ftselect">Select them instead</button>
      </div>`;
    $('#ftapprove').onclick=()=>{
      if(!confirm(`Queue all ${softPending.length} reversible items for the fast-track drain?\n\nThis logs your go + enqueues them. Nothing executes from this page — the live pass drains the queue.`)) return;
      post('/api/bulk',{slugs:softPending.map(i=>i.slug),action:'approve'}).then(()=>{ toast(`APPROVE · ${softPending.length} reversible queued`); load(); });
    };
    $('#ftselect').onclick=()=>{ softPending.forEach(i=>SEL.add(i.slug)); render(); };
  } else { ft.classList.add('empty'); ft.innerHTML=''; }

  const grid=$('#grid'); grid.innerHTML='';
  const list=sortItems(ITEMS.filter(passesFilter));
  list.forEach(it=>grid.appendChild(cardEl(it)));
  updateBulk();
}
function cardEl(it){
  const c=document.createElement('div'); c.className='card '+it.severity+(SEL.has(it.slug)?'':'');
  const acted = it.where!=='pending';
  if(acted) c.classList.add('acted');
  c.innerHTML=`
    ${!acted?`<input type="checkbox" class="selbox" ${SEL.has(it.slug)?'checked':''}>`:''}
    <div class="ctitle">${esc(it.title)}</div>
    <div class="when">🕓 ${fmtWhen(it.created)}</div>
    <div class="meta">
      ${drill('gate',it.gate,`<span class="badge gate">${esc(it.gate)}</span>`,'')}
      <span class="badge ${it.reversible?'rev':'irrev'}">${it.reversible?'reversible':'IRREVERSIBLE'}</span>
      ${drill('state',it.where,`<span class="badge state">${it.where}</span>`,'')}
      ${it.officer&&it.officer!=='unassigned'?drill('officer',it.officer,`<span class="officer">${esc(it.officer)}</span>`,''):''}
    </div>
    <div class="risk"><span class="rk">${it.severity==='soft'?'✓':'⚠'}</span><span>${esc(riskLine(it))}</span></div>
    <div class="row">
      <button class="btn toggle">▸ memo</button>
      ${acted?`<span class="badge">${it.where.toUpperCase()}</span>`
        :`<button class="btn approve">Approve + queue</button>
          <button class="btn reject">Reject</button>
          <button class="btn hold">Hold</button>`}
    </div>
    <div class="body" title="${it.mdname||''}">${esc(it.body||'(no memo body found on disk)')}</div>`;
  c.querySelector('.toggle').onclick=()=>c.classList.toggle('open');
  const cb=c.querySelector('.selbox');
  if(cb) cb.onchange=()=>{ cb.checked?SEL.add(it.slug):SEL.delete(it.slug); updateBulk(); };
  if(!acted){
    c.querySelector('.approve').onclick=()=>act(it,'approve');
    c.querySelector('.reject').onclick=()=>act(it,'reject');
    c.querySelector('.hold').onclick=()=>act(it,'hold');
  }
  return c;
}
function act(it,action){
  if(action==='approve' && it.severity==='hard'){
    if(!confirm(`HARD gate — ${it.gate}${it.reversible?'':' · IRREVERSIBLE'}\n\n"${it.title}"\n\nApprove logs your go and QUEUES it for the live officer-yolo pass (verified, per-item). It does NOT fire from this page.\n\nProceed?`)) return;
  }
  post('/api/action',{slug:it.slug,action}).then(()=>{ toast(`${action.toUpperCase()} · ${it.slug}`); load(); });
}
function bulk(action){
  const slugs=[...SEL]; if(!slugs.length) return;
  const hard=slugs.filter(s=>{const i=ITEMS.find(x=>x.slug===s);return i&&i.severity==='hard';}).length;
  if(action==='approve' && hard){
    if(!confirm(`${slugs.length} selected, ${hard} are HARD gates.\n\nApprove logs your go + queues them for the live officer-yolo verified pass. Nothing fires from this page. Proceed?`)) return;
  }
  post('/api/bulk',{slugs,action}).then(()=>{ toast(`${action.toUpperCase()} · ${slugs.length} items`); SEL.clear(); load(); });
}
function updateBulk(){ $('#bulkn').textContent=SEL.size; $('#bulk').classList.toggle('show',SEL.size>0); }
function clearSel(){ SEL.clear(); render(); }
$('#selall').onclick=()=>{ sortItems(ITEMS.filter(passesFilter)).forEach(i=>{ if(i.where==='pending') SEL.add(i.slug); }); render(); };
function post(url,b){ return fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(r=>r.json()); }
function toast(m){ const t=$('#toast'); t.textContent=m; t.style.display='block'; setTimeout(()=>t.style.display='none',2200); }
function esc(s){ return (s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }

// density + sort persistence — readURL restores filter state from URL (href-drill, TK-10903)
readURL();
const dens=$('#dens'); dens.value=localStorage.acc_dens||3; document.documentElement.style.setProperty('--cols',6-dens.value);
dens.oninput=()=>{ localStorage.acc_dens=dens.value; document.documentElement.style.setProperty('--cols',6-dens.value); };
$('#sort').value=localStorage.acc_sort||'sev'; $('#sort').onchange=()=>{ localStorage.acc_sort=$('#sort').value; render(); };
$('#q').oninput=render;
load();
</script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>