← back to Approval Viewer
allow one-click full chip approvals
be5b41a2da0419300c7cea1858efaa27fb8ba106 · 2026-08-31 02:22:10 -0700 · Steve Abrams
Files touched
M README.mdM public/index.htmlM server.jsM verification/e2e-proof.jsonA verification/full-approval-e2e.mjsA verification/full-approval-e2e.png
Diff
commit be5b41a2da0419300c7cea1858efaa27fb8ba106
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 31 02:22:10 2026 -0700
allow one-click full chip approvals
---
README.md | 2 +-
public/index.html | 6 +--
server.js | 18 ++++++++-
verification/e2e-proof.json | 54 ++++++++++---------------
verification/full-approval-e2e.mjs | 79 +++++++++++++++++++++++++++++++++++++
verification/full-approval-e2e.png | Bin 0 -> 168908 bytes
6 files changed, 121 insertions(+), 38 deletions(-)
diff --git a/README.md b/README.md
index bd25127..7ca3a7d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Gated Approval Viewer
-Local, read-only approval surface for gated tickets. Decisions are appended to `decisions.jsonl` for audit; they never execute the approved action or contact an external system.
+Local approval surface for gated tickets. Any individual chip can receive full approval immediately, and **Approve all shown** records full approval for the current filtered view in one click. Decisions are appended to `decisions.jsonl` for audit; they never execute the approved action or contact an external system.
Run:
diff --git a/public/index.html b/public/index.html
index 3364f0d..efd790e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,4 +1,4 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Gated Approval Viewer</title><style>
-:root{color-scheme:dark;--bg:#101216;--panel:#191d24;--line:#303744;--ink:#f1f3f5;--muted:#aab3c0;--gold:#d9b166;--ok:#64d1ba;--red:#ed8e8e}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.45 system-ui,sans-serif}header{padding:24px max(18px,4vw);border-bottom:1px solid var(--line);background:#15181e}h1{margin:0 0 5px;font-size:25px}p{color:var(--muted);margin:5px 0}.notice{margin-top:14px;padding:10px 12px;border:1px solid #3e4b60;border-radius:9px;color:#c8d9f4}.tools{display:flex;gap:10px;flex-wrap:wrap;margin-top:17px}input,select,button,textarea{font:inherit;border:1px solid var(--line);border-radius:8px;padding:9px 11px;background:var(--panel);color:var(--ink)}input{min-width:240px}.grid{padding:22px max(18px,4vw);display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:15px}.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:10px}.card.done{border-color:#4b5b69}.title{font-weight:700;font-size:17px}.meta{display:flex;gap:7px;flex-wrap:wrap}.badge{font-size:12px;padding:3px 8px;border-radius:999px;border:1px solid var(--line);color:var(--muted)}.hard{color:var(--red);border-color:#704244}.soft{color:var(--ok);border-color:#32675d}.summary{color:#d2d7df;min-height:45px}.actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:auto}.actions button{cursor:pointer}.approve{border-color:#32675d;color:var(--ok)}.revise{border-color:#76602e;color:var(--gold)}.block{border-color:#704244;color:var(--red)}.status{font-size:13px;color:var(--muted)}.when{font-variant-numeric:tabular-nums}.empty{padding:30px;color:var(--muted)}textarea{width:100%;min-height:44px;resize:vertical}
-</style></head><body><header><h1>Gated approvals</h1><p>Read the request. Choose one clear next step.</p><div class="notice">Safe mode: buttons record a local decision only. They do not publish, email, spend, deploy, delete, or call outside systems.</div><div class="tools"><input id="search" type="search" placeholder="Search tickets…"><select id="filter"><option value="all">All gates</option><option value="hard">Hard gates</option><option value="soft">Safe local items</option></select><span id="count" class="status"></span></div></header><main id="grid" class="grid"></main><script>
-let data={items:[],decisions:[]};const $=s=>document.querySelector(s);const latest=()=>Object.fromEntries(data.decisions.map(d=>[d.id,d]));function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}function fmtDate(value){let date=new Date(value);return Number.isNaN(date.valueOf())?'Unknown':date.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}function render(){let q=$('#search').value.toLowerCase(),f=$('#filter').value,st=latest();let list=data.items.filter(i=>(f==='all'||i.risk===f)&&(!q||JSON.stringify(i).toLowerCase().includes(q)));$('#count').textContent=`${list.length} item${list.length===1?'':'s'} shown`;$('#grid').innerHTML=list.length?list.map(i=>{let d=st[i.id];return `<article class="card ${d?'done':''}"><div class="title">${esc(i.title)}</div><div class="meta"><span class="badge">${esc(i.id)}</span><span class="badge ${i.risk}">${esc(i.gate)}</span><span class="badge">${esc(i.owner)}</span><span class="badge when" title="${esc(i.created_at)}">🕓 ${esc(fmtDate(i.created_at))}</span></div><div class="summary">${esc(i.summary)}</div><textarea id="note-${esc(i.id)}" placeholder="Optional note" aria-label="Note for ${esc(i.title)}"></textarea>${d?`<div class="status">Recorded: <b>${esc(d.action)}</b> · ${esc(fmtDate(d.at))}</div>`:''}<div class="actions"><button class="approve" data-id="${esc(i.id)}" data-action="approve">Approve</button><button class="revise" data-id="${esc(i.id)}" data-action="revise">Revise</button><button class="block" data-id="${esc(i.id)}" data-action="block">Block</button></div></article>`}).join(''):'<div class="empty">No matching items.</div>';document.querySelectorAll('[data-action]').forEach(b=>b.onclick=()=>decide(b.dataset.id,b.dataset.action));}async function decide(id,action){let note=(document.getElementById(`note-${id}`)||{}).value||'';let r=await fetch('/api/decision',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,action,note})});let j=await r.json();if(!r.ok)return alert(j.error||'Could not record decision');data.decisions.push(j.decision);render();}async function load(){data=await (await fetch('/api/items')).json();render();}$('#search').oninput=render;$('#filter').onchange=render;load();</script></body></html>
+:root{color-scheme:dark;--bg:#101216;--panel:#191d24;--line:#303744;--ink:#f1f3f5;--muted:#aab3c0;--gold:#d9b166;--ok:#64d1ba;--red:#ed8e8e}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.45 system-ui,sans-serif}header{padding:24px max(18px,4vw);border-bottom:1px solid var(--line);background:#15181e}h1{margin:0 0 5px;font-size:25px}p{color:var(--muted);margin:5px 0}.notice{margin-top:14px;padding:10px 12px;border:1px solid #3e4b60;border-radius:9px;color:#c8d9f4}.tools{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:17px}input,select,button,textarea{font:inherit;border:1px solid var(--line);border-radius:8px;padding:9px 11px;background:var(--panel);color:var(--ink)}input{min-width:240px}.grid{padding:22px max(18px,4vw);display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:15px}.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:10px}.card.done{border-color:#4b5b69}.title{font-weight:700;font-size:17px}.meta{display:flex;gap:7px;flex-wrap:wrap}.badge{font-size:12px;padding:3px 8px;border-radius:999px;border:1px solid var(--line);color:var(--muted)}.hard{color:var(--red);border-color:#704244}.soft{color:var(--ok);border-color:#32675d}.summary{color:#d2d7df;min-height:45px}.actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:auto}.actions button,.approve-all{cursor:pointer}.approve,.approve-all{border-color:#32675d;color:var(--ok)}.approve-all{background:#183a33;font-weight:750}.approve-all:disabled{opacity:.55;cursor:wait}.revise{border-color:#76602e;color:var(--gold)}.block{border-color:#704244;color:var(--red)}.status{font-size:13px;color:var(--muted)}.when{font-variant-numeric:tabular-nums}.empty{padding:30px;color:var(--muted)}textarea{width:100%;min-height:44px;resize:vertical}
+</style></head><body><header><h1>Approval command center</h1><p>Every chip is immediately approvable. Approval is full-scope and recorded without a confirmation step.</p><div class="notice">Approvals are append-only audit decisions. This viewer never executes commands, publishes, emails, spends, deploys, or deletes by itself.</div><div class="tools"><input id="search" type="search" placeholder="Search tickets…"><select id="filter"><option value="all">All gates</option><option value="hard">Hard gates</option><option value="soft">Safe local items</option></select><button id="approve-all" class="approve-all" type="button">Approve all shown</button><span id="count" class="status"></span></div></header><main id="grid" class="grid"></main><script>
+let data={items:[],decisions:[]};const $=s=>document.querySelector(s);const latest=()=>Object.fromEntries(data.decisions.map(d=>[d.id,d]));function esc(s){return String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}function fmtDate(value){let date=new Date(value);return Number.isNaN(date.valueOf())?'Unknown':date.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}function visibleItems(){let q=$('#search').value.toLowerCase(),f=$('#filter').value;return data.items.filter(i=>(f==='all'||i.risk===f)&&(!q||JSON.stringify(i).toLowerCase().includes(q)));}function render(){let st=latest(),list=visibleItems();$('#count').textContent=`${list.length} item${list.length===1?'':'s'} shown`;$('#approve-all').textContent=`Approve all shown (${list.length})`;$('#approve-all').disabled=!list.length;$('#grid').innerHTML=list.length?list.map(i=>{let d=st[i.id];return `<article class="card ${d?'done':''}"><div class="title">${esc(i.title)}</div><div class="meta"><span class="badge">${esc(i.id)}</span><span class="badge ${i.risk}">${esc(i.gate)}</span><span class="badge">${esc(i.owner)}</span><span class="badge when" title="${esc(i.created_at)}">🕓 ${esc(fmtDate(i.created_at))}</span></div><div class="summary">${esc(i.summary)}</div><textarea id="note-${esc(i.id)}" placeholder="Optional note" aria-label="Note for ${esc(i.title)}"></textarea>${d?`<div class="status">Recorded: <b>${esc(d.action)}</b>${d.approvalScope==='full'?' · full approval':''} · ${esc(fmtDate(d.at))}</div>`:''}<div class="actions"><button class="approve" data-id="${esc(i.id)}" data-action="approve">Approve fully</button><button class="revise" data-id="${esc(i.id)}" data-action="revise">Revise</button><button class="block" data-id="${esc(i.id)}" data-action="block">Block</button></div></article>`}).join(''):'<div class="empty">No matching items.</div>';document.querySelectorAll('[data-action]').forEach(b=>b.onclick=()=>decide(b.dataset.id,b.dataset.action));}async function decide(id,action){let note=(document.getElementById(`note-${id}`)||{}).value||'';let r=await fetch('/api/decision',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id,action,note})});let j=await r.json();if(!r.ok)return alert(j.error||'Could not record decision');data.decisions.push(j.decision);render();}async function approveAll(){let button=$('#approve-all'),ids=visibleItems().map(i=>i.id);if(!ids.length)return;button.disabled=true;button.textContent='Approving…';let r=await fetch('/api/decisions/approve-all',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids})});let j=await r.json();if(!r.ok){button.disabled=false;render();return alert(j.error||'Could not approve shown items');}data.decisions.push(...j.decisions);render();}async function load(){data=await (await fetch('/api/items')).json();render();}$('#search').oninput=render;$('#filter').onchange=render;$('#approve-all').onclick=approveAll;load();</script></body></html>
diff --git a/server.js b/server.js
index 12523bc..19130ac 100644
--- a/server.js
+++ b/server.js
@@ -28,6 +28,9 @@ function items(){ const live=queueItems(); return live.length ? live : demoItems
function readDecisions(){ try{return fs.readFileSync(decisionsPath,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);}catch(_){return [];} }
function send(res,status,data,type='application/json'){res.writeHead(status,{'Content-Type':type});res.end(type==='application/json'?JSON.stringify(data):data);}
function body(req){return new Promise(resolve=>{let s='';req.on('data',c=>s+=c);req.on('end',()=>resolve(s));});}
+function approvalRecord(id,note=''){
+ return {id,action:'approve',approvalScope:'full',note:String(note).slice(0,500),at:new Date().toISOString(),writePerformed:false};
+}
const server=http.createServer(async (req,res)=>{
if(req.method==='GET' && req.url==='/favicon.ico') { res.writeHead(204); return res.end(); }
if(req.method==='GET' && (req.url==='/'||req.url==='/index.html')) return send(res,200,fs.readFileSync(path.join(__dirname,'public/index.html'),'utf8'),'text/html; charset=utf-8');
@@ -36,10 +39,23 @@ const server=http.createServer(async (req,res)=>{
let data; try{data=JSON.parse(await body(req));}catch(_){return send(res,400,{error:'invalid json'});}
if(!data.id||!['approve','revise','block'].includes(data.action)) return send(res,400,{error:'id and action approve|revise|block required'});
if(!items().some(i=>i.id===data.id)) return send(res,404,{error:'unknown item'});
- const rec={id:data.id,action:data.action,note:String(data.note||'').slice(0,500),at:new Date().toISOString(),writePerformed:false};
+ const rec=data.action==='approve'
+ ? approvalRecord(data.id,data.note||'')
+ : {id:data.id,action:data.action,note:String(data.note||'').slice(0,500),at:new Date().toISOString(),writePerformed:false};
fs.appendFileSync(decisionsPath,JSON.stringify(rec)+'\n');
return send(res,200,{ok:true,decision:rec,writePerformed:false});
}
+ if(req.method==='POST' && req.url==='/api/decisions/approve-all'){
+ let data; try{data=JSON.parse(await body(req));}catch(_){return send(res,400,{error:'invalid json'});}
+ if(!Array.isArray(data.ids)||!data.ids.length) return send(res,400,{error:'ids must be a non-empty array'});
+ const known=new Set(items().map(i=>i.id));
+ const ids=[...new Set(data.ids.map(String))];
+ const unknown=ids.filter(id=>!known.has(id));
+ if(unknown.length) return send(res,404,{error:'unknown item',ids:unknown});
+ const records=ids.map(id=>approvalRecord(id,data.note||'Approved in bulk from the local approval viewer.'));
+ fs.appendFileSync(decisionsPath,records.map(rec=>JSON.stringify(rec)).join('\n')+'\n');
+ return send(res,200,{ok:true,decisions:records,count:records.length,writePerformed:false});
+ }
send(res,404,{error:'not found'});
});
server.listen(PORT,()=>console.log(`Approval Viewer listening on http://127.0.0.1:${PORT}`));
diff --git a/verification/e2e-proof.json b/verification/e2e-proof.json
index 28ed6d8..fca2006 100644
--- a/verification/e2e-proof.json
+++ b/verification/e2e-proof.json
@@ -1,36 +1,24 @@
{
- "intent": "Record a REVISE decision for QUEUE-070926-gmc-orphan-reconcile-2026-08-31.md without executing the proposed GMC actions",
- "riskTier": "R2",
- "environment": "local approval-viewer",
- "timestamp": "2026-08-31T07:56:43Z",
- "baseline": "An earlier blank APPROVE record existed with writePerformed=false; the queue memo remained pending and unchanged",
- "action": "Appended a later REVISE record with writePerformed=false",
- "assertions": [
- {
- "boundary": "decision file",
- "verdict": "PASS",
- "evidence": "decisions.jsonl contains 12 parseable JSON records and the latest record for the queue ID is REVISE"
- },
- {
- "boundary": "viewer API",
- "verdict": "PASS",
- "evidence": "GET /api/items served the live pending-approval item and decision collection from the local viewer"
- },
- {
- "boundary": "operational side effects",
- "verdict": "PASS",
- "evidence": "Recorded decision has writePerformed=false; no GMC delete command was invoked"
- },
- {
- "boundary": "source artifacts",
- "verdict": "PASS",
- "evidence": "Queue memo and both ID lists retained their pre-review SHA-256 digests"
- }
- ],
- "negativeChecks": [
- "The earlier APPROVE remains in append-only history but is superseded by the later REVISE under the viewer's latest-record semantics",
- "No external, destructive, paid, email, deploy, DNS, or publish action was run"
- ],
- "cleanup": "No test records or external state created",
+ "ticket": "TK-11000",
+ "intent": "Allow immediate full approval of any individual chip and every chip in the filtered view",
+ "riskTier": "R2 local user-facing flow",
+ "environment": "http://127.0.0.1:19789",
+ "startedAt": "2026-08-31T09:21:51.863Z",
+ "finishedAt": "2026-08-31T09:21:53.476Z",
+ "assertions": {
+ "pageLoaded": "PASS",
+ "individualFullApproval": "PASS",
+ "filteredBulkFullApproval": "PASS",
+ "appendOnlyAudit": "PASS",
+ "unknownChipRejected": "PASS",
+ "externalActionPerformed": false,
+ "initialCount": 45,
+ "filteredCount": 2,
+ "recordedDecisions": 3
+ },
+ "artifacts": {
+ "screenshot": "/Users/macstudio3/Projects/approval-viewer/verification/full-approval-e2e.png",
+ "isolatedDecisionLog": "/var/folders/rq/j8g1f7nn6jv6_lr1cfmqym6w0000gn/T/approval-viewer-ui-1788168111862.jsonl"
+ },
"verdict": "PASS"
}
diff --git a/verification/full-approval-e2e.mjs b/verification/full-approval-e2e.mjs
new file mode 100644
index 0000000..970408e
--- /dev/null
+++ b/verification/full-approval-e2e.mjs
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+import { createRequire } from 'node:module';
+
+const require = createRequire('/Users/macstudio3/Projects/Designer-Wallcoverings/package.json');
+const { chromium } = require('playwright');
+const projectRoot = path.resolve(new URL('..', import.meta.url).pathname);
+const port = 19789;
+const base = `http://127.0.0.1:${port}`;
+const decisionsPath = path.join(os.tmpdir(), `approval-viewer-ui-${Date.now()}.jsonl`);
+const screenshotPath = new URL('./full-approval-e2e.png', import.meta.url);
+const reportPath = new URL('./e2e-proof.json', import.meta.url);
+const server = spawn(process.execPath, ['server.js'], {
+ cwd: projectRoot,
+ env: { ...process.env, PORT: String(port), DECISIONS_PATH: decisionsPath },
+ stdio: ['ignore', 'pipe', 'pipe'],
+});
+
+async function waitForServer() {
+ for (let attempt = 0; attempt < 40; attempt += 1) {
+ try {
+ const response = await fetch(`${base}/api/items`);
+ if (response.ok) return;
+ } catch {}
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ throw new Error('temporary approval viewer did not start');
+}
+
+const startedAt = new Date().toISOString();
+let browser;
+try {
+ await waitForServer();
+ browser = await chromium.launch({ headless: true, executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' });
+ const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
+ await page.goto(base, { waitUntil: 'networkidle' });
+ await page.getByRole('heading', { name: 'Approval command center' }).waitFor();
+ const initialCount = await page.locator('.card').count();
+ assert(initialCount > 1, 'expected multiple approval chips');
+
+ await page.locator('.card').first().getByRole('button', { name: 'Approve fully' }).click();
+ await page.locator('.card').first().getByText('full approval').waitFor();
+
+ await page.getByPlaceholder('Search tickets…').fill('dw-reels');
+ const filteredCount = await page.locator('.card').count();
+ assert(filteredCount > 0 && filteredCount < initialCount, 'search did not scope the visible chips');
+ await page.getByRole('button', { name: `Approve all shown (${filteredCount})` }).click();
+ await page.locator('.card').first().getByText('full approval').waitFor();
+ await page.screenshot({ path: screenshotPath.pathname, fullPage: true });
+
+ const records = fs.readFileSync(decisionsPath, 'utf8').trim().split('\n').map(JSON.parse);
+ assert.equal(records.length, 1 + filteredCount);
+ assert(records.every((record) => record.action === 'approve' && record.approvalScope === 'full'));
+ assert(records.every((record) => record.writePerformed === false));
+
+ const unknown = await fetch(`${base}/api/decisions/approve-all`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: ['unknown-chip'] }),
+ });
+ assert.equal(unknown.status, 404);
+
+ const report = {
+ ticket: 'TK-11000', intent: 'Allow immediate full approval of any individual chip and every chip in the filtered view',
+ riskTier: 'R2 local user-facing flow', environment: base, startedAt, finishedAt: new Date().toISOString(),
+ assertions: {
+ pageLoaded: 'PASS', individualFullApproval: 'PASS', filteredBulkFullApproval: 'PASS',
+ appendOnlyAudit: 'PASS', unknownChipRejected: 'PASS', externalActionPerformed: false,
+ initialCount, filteredCount, recordedDecisions: records.length,
+ },
+ artifacts: { screenshot: screenshotPath.pathname, isolatedDecisionLog: decisionsPath }, verdict: 'PASS',
+ };
+ fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
+ console.log(JSON.stringify(report, null, 2));
+} finally {
+ if (browser) await browser.close();
+ server.kill('SIGTERM');
+}
diff --git a/verification/full-approval-e2e.png b/verification/full-approval-e2e.png
new file mode 100644
index 0000000..6951341
Binary files /dev/null and b/verification/full-approval-e2e.png differ
← 52d1c00 auto-data-snapshot: 2026-08-31T02:00:54 (1 data files) — dec
·
back to Approval Viewer
·
auto-data-snapshot: 2026-08-31T09:29:05 (1 data files) — dec 8030db8 →