← back to Ungate Console
Ungate Console: per-card GO/Defer/Close selection + decisions log + stable port
e063e7f70ea65f6fec736809831febbe3df5f6a6 · 2026-09-03 10:33:12 -0700 · Steve Abrams
Files touched
A data/decisions.jsonlA data/decisions.tmpM index.htmlM server.js
Diff
commit e063e7f70ea65f6fec736809831febbe3df5f6a6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 3 10:33:12 2026 -0700
Ungate Console: per-card GO/Defer/Close selection + decisions log + stable port
---
data/decisions.jsonl | 1 +
data/decisions.tmp | 0
index.html | 59 +++++++++++++++++++++++++++++++++++++++++++++++++---
server.js | 35 ++++++++++++++++++++++++++++++-
4 files changed, 91 insertions(+), 4 deletions(-)
diff --git a/data/decisions.jsonl b/data/decisions.jsonl
new file mode 100644
index 0000000..ebb4347
--- /dev/null
+++ b/data/decisions.jsonl
@@ -0,0 +1 @@
+{"tk":"TK-TEST","decision":"go","note":"","ts":"2026-09-03T17:33:11.973Z"}
diff --git a/data/decisions.tmp b/data/decisions.tmp
new file mode 100644
index 0000000..e69de29
diff --git a/index.html b/index.html
index 01a7f0b..a822636 100644
--- a/index.html
+++ b/index.html
@@ -36,6 +36,18 @@
.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)}
@@ -62,20 +74,56 @@
<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.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="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;
}
-fetch('/api/items').then(r=>r.json()).then(d=>{
+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=>{
@@ -83,6 +131,11 @@ fetch('/api/items').then(r=>r.json()).then(d=>{
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 =
diff --git a/server.js b/server.js
index 696cbc1..e1c3a5f 100644
--- a/server.js
+++ b/server.js
@@ -19,6 +19,19 @@ function auth(req, res) {
return false;
}
+const DECISIONS = path.join(DIR, 'data', 'decisions.jsonl');
+
+// current decision per tk (last-write-wins) from the append-only log
+function decisionMap() {
+ const m = {};
+ try {
+ fs.readFileSync(DECISIONS, 'utf8').split('\n').filter(Boolean).forEach(l => {
+ try { const d = JSON.parse(l); if (d.tk) m[d.tk] = d; } catch {}
+ });
+ } catch {}
+ return m;
+}
+
const srv = http.createServer((req, res) => {
if (!auth(req, res)) return;
const url = req.url.split('?')[0];
@@ -26,6 +39,23 @@ const srv = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(fs.readFileSync(path.join(DIR, 'data', 'items.json')));
}
+ if (url === '/api/decisions') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify(decisionMap()));
+ }
+ if (url === '/api/decide' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
+ req.on('end', () => {
+ let d; try { d = JSON.parse(body); } catch { res.writeHead(400); return res.end('bad json'); }
+ if (!d.tk || !d.decision) { res.writeHead(400); return res.end('need tk+decision'); }
+ const rec = { tk: d.tk, decision: d.decision, note: (d.note || '').slice(0, 500), ts: new Date().toISOString() };
+ fs.appendFileSync(DECISIONS, JSON.stringify(rec) + '\n');
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, rec }));
+ });
+ return;
+ }
if (url === '/' || url === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(fs.readFileSync(path.join(DIR, 'index.html')));
@@ -33,7 +63,10 @@ const srv = http.createServer((req, res) => {
res.writeHead(404); res.end('not found');
});
-srv.listen(0, '127.0.0.1', () => {
+let want = Number(process.env.UC_PORT) || 0;
+if (!want) { try { want = Number(fs.readFileSync(path.join(DIR, '.port'), 'utf8').trim()) || 0; } catch {} }
+srv.on('error', e => { if (e.code === 'EADDRINUSE' && want) { want = 0; srv.listen(0, '127.0.0.1'); } });
+srv.listen(want, '127.0.0.1', () => {
const port = srv.address().port;
fs.writeFileSync(path.join(DIR, '.port'), String(port));
console.log(`Ungate Console → http://127.0.0.1:${port} (admin/DW2024!)`);
← 24be850 Ungate Console — one-page board of 35 blocked tickets groupe
·
back to Ungate Console
·
auto-data-snapshot: 2026-09-03T11:10:51 (2 data files) — dat ef771ea →