← back to Ungate Console
index.html
173 lines
<!doctype html>
<html lang="en">
<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>Ungate Console</title>
<style>
:root{ --bg:#0f1115; --card:#1a1d24; --card2:#20242d; --ink:#e8eaed; --dim:#9aa0ab;
--line:#2c313b; --steve:#ff5252; --ext:#ffb300; --agent:#7c8794; --chip:#2a3f5f; --accent:#4dd0a0; }
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
header{position:sticky;top:0;z-index:5;background:linear-gradient(180deg,#12151b,#0f1115);border-bottom:1px solid var(--line);padding:16px 22px}
h1{margin:0;font-size:20px;letter-spacing:.2px}
.sub{color:var(--dim);font-size:13px;margin-top:3px}
.counts{display:flex;gap:18px;margin-top:10px;flex-wrap:wrap}
.count{font-size:13px;color:var(--dim)}
.count b{font-size:22px;color:var(--ink);display:block}
main{max-width:1100px;margin:0 auto;padding:22px}
.sec{margin:28px 0 8px;display:flex;align-items:center;gap:10px}
.sec h2{margin:0;font-size:16px}
.sec .n{background:var(--card2);border:1px solid var(--line);border-radius:20px;padding:2px 11px;font-size:13px;color:var(--dim)}
.dot{width:11px;height:11px;border-radius:50%}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:12px}
.card{background:var(--card);border:1px solid var(--line);border-left:4px solid var(--agent);border-radius:12px;padding:14px 15px}
.card.steve{border-left-color:var(--steve)}
.card.external{border-left-color:var(--ext)}
.card.agent{border-left-color:var(--agent);opacity:.9}
.top{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px}
.tk{font-family:ui-monospace,Menlo,monospace;font-size:12px;color:var(--accent);font-weight:600}
.type{font-size:10.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--dim);border:1px solid var(--line);border-radius:6px;padding:2px 7px;white-space:nowrap}
.title{font-weight:600;font-size:14.5px;margin:2px 0 4px}
.proj{display:inline-block;background:var(--chip);color:#cfe0ff;font-size:11px;border-radius:5px;padding:1px 7px;margin-bottom:8px}
.action{font-size:13.5px;color:var(--ink)}
.action b{color:var(--accent)}
.paste{margin-top:9px;background:#0b0d11;border:1px solid var(--line);border-radius:7px;padding:8px 10px;font-family:ui-monospace,Menlo,monospace;font-size:11.5px;color:#cfe0ff;position:relative;overflow-x:auto;white-space:pre}
.copy{position:absolute;top:5px;right:5px;background:var(--card2);border:1px solid var(--line);color:var(--dim);border-radius:6px;font-size:10.5px;padding:2px 7px;cursor:pointer}
.copy:hover{color:var(--ink)}
.hint{color:var(--dim);font-size:12.5px;margin:2px 0 14px}
.btns{display:flex;gap:7px;margin-top:11px}
.btn{flex:1;border:1px solid var(--line);background:var(--card2);color:var(--dim);border-radius:7px;padding:6px 0;font-size:12px;font-weight:600;cursor:pointer;text-align:center}
.btn:hover{color:var(--ink)}
.btn.go.on{background:#0f5132;border-color:#1a7f4f;color:#7dffbb}
.btn.defer.on{background:#5a4a00;border-color:#8a7200;color:#ffe08a}
.btn.close.on{background:#5a1f1f;border-color:#8a3030;color:#ff9d9d}
.card.decided{outline:1px solid var(--accent)}
.bar{position:sticky;bottom:0;background:linear-gradient(0deg,#12151b,rgba(18,21,27,.94));border-top:1px solid var(--line);padding:12px 22px;display:flex;justify-content:space-between;align-items:center;z-index:6}
.bar .msg{font-size:13.5px}
.bar b{color:var(--accent)}
.bar button{background:var(--accent);color:#04120c;border:0;border-radius:8px;padding:9px 16px;font-weight:700;font-size:13px;cursor:pointer}
.bar button:disabled{opacity:.4;cursor:default}
@media (prefers-color-scheme:light){
:root{--bg:#f5f6f8;--card:#fff;--card2:#eef1f5;--ink:#1a1d24;--dim:#5b626e;--line:#dde1e8;--chip:#dce9ff}
header{background:linear-gradient(180deg,#fff,#f5f6f8)}
.paste{background:#f0f2f5;color:#1a3a6b}
}
</style>
</head>
<body>
<header>
<h1>🔓 Ungate Console</h1>
<div class="sub" id="sub">loading…</div>
<div class="counts" id="counts"></div>
</header>
<main>
<div class="sec"><span class="dot" style="background:var(--steve)"></span><h2>You must act</h2><span class="n" id="n-steve">0</span></div>
<div class="hint">Each of these is blocked ONLY on you — an approval, a credential, a value, a paste, an identity click, or a decision. These are the ones that shrink the wall.</div>
<div class="grid" id="g-steve"></div>
<div class="sec"><span class="dot" style="background:var(--ext)"></span><h2>Waiting on vendors / Apple</h2><span class="n" id="n-external">0</span></div>
<div class="hint">Nothing you can do — these move only when the outside party responds.</div>
<div class="grid" id="g-external"></div>
<div class="sec"><span class="dot" style="background:var(--agent)"></span><h2>Agents will finish (not yours)</h2><span class="n" id="n-agent">0</span></div>
<div class="hint">Open engineering / loop work parked here — the automation can complete these without you.</div>
<div class="grid" id="g-agent"></div>
</main>
<div class="bar">
<div class="msg" id="barmsg">No selections yet — click <b>GO</b>, <b>Defer</b>, or <b>Close</b> on any card.</div>
<button id="tell" disabled onclick="copyForClaude()">Copy decisions for Claude</button>
</div>
<script>
function esc(s){return (s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]))}
const DEC = {}; // tk -> decision
const ITEMS = {}; // tk -> item
function decide(tk, choice){
DEC[tk] = choice;
fetch('/api/decide',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({tk,decision:choice})}).catch(()=>{});
const el=document.getElementById('card-'+tk);
el.classList.add('decided');
['go','defer','close'].forEach(c=>el.querySelector('.btn.'+c).classList.toggle('on',c===choice));
refreshBar();
}
function refreshBar(){
const n=Object.keys(DEC).length;
const msg=document.getElementById('barmsg'), btn=document.getElementById('tell');
if(!n){ msg.innerHTML='No selections yet — click <b>GO</b>, <b>Defer</b>, or <b>Close</b> on any card.'; btn.disabled=true; return; }
const tally={go:0,defer:0,close:0}; Object.values(DEC).forEach(c=>tally[c]++);
msg.innerHTML=`<b>${n}</b> selected — ${tally.go} GO · ${tally.defer} defer · ${tally.close} close. Then tell Claude "process the console" (or copy →).`;
btn.disabled=false;
}
function copyForClaude(){
const lines=Object.entries(DEC).map(([tk,c])=>`${tk} ${c.toUpperCase()}`);
navigator.clipboard.writeText('process the console decisions:\n'+lines.join('\n'));
document.getElementById('tell').textContent='copied ✓';
setTimeout(()=>document.getElementById('tell').textContent='Copy decisions for Claude',1500);
}
function card(it){
ITEMS[it.tk]=it;
const paste = it.paste ? `<div class="paste"><button class="copy" onclick="navigator.clipboard.writeText(this.parentNode.dataset.p);this.textContent='copied'">copy</button>${esc(it.paste)}</div>` : '';
const el = document.createElement('div');
el.className = 'card '+it.bucket; el.id='card-'+it.tk;
el.innerHTML = `<div class="top"><span class="tk">${esc(it.tk)}</span><span class="type">${esc(it.type)}</span></div>
<div class="title">${esc(it.title)}</div>
<span class="proj">${esc(it.project)}</span>
<div class="action">→ ${esc(it.action)}</div>${paste}
<div class="btns">
<button class="btn go" onclick="decide('${it.tk}','go')">✓ GO</button>
<button class="btn defer" onclick="decide('${it.tk}','defer')">⏸ Defer</button>
<button class="btn close" onclick="decide('${it.tk}','close')">✕ Close</button>
</div>`;
if(it.paste){ el.querySelector('.paste').dataset.p = it.paste; }
return el;
}
Promise.all([fetch('/api/items').then(r=>r.json()), fetch('/api/decisions').then(r=>r.json()).catch(()=>({}))])
.then(([d,saved])=>{
const by={steve:[],external:[],agent:[]};
d.items.forEach(it=>by[it.bucket].push(it));
['steve','external','agent'].forEach(b=>{
const g=document.getElementById('g-'+b);
by[b].forEach(it=>g.appendChild(card(it)));
document.getElementById('n-'+b).textContent=by[b].length;
});
// restore previously-saved decisions
Object.values(saved||{}).forEach(r=>{ if(document.getElementById('card-'+r.tk)){ DEC[r.tk]=r.decision;
const el=document.getElementById('card-'+r.tk); el.classList.add('decided');
['go','defer','close'].forEach(c=>el.querySelector('.btn.'+c).classList.toggle('on',c===r.decision)); }});
refreshBar();
document.getElementById('sub').textContent =
`${d.items.length} blocked tickets · generated ${new Date(d.generated).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})}`;
document.getElementById('counts').innerHTML =
`<div class="count"><b style="color:var(--steve)">${by.steve.length}</b>need you</div>`+
`<div class="count"><b style="color:var(--ext)">${by.external.length}</b>vendors/Apple</div>`+
`<div class="count"><b style="color:var(--agent)">${by.agent.length}</b>agents</div>`;
});
</script>
</body>
</html>