← back to Agent Cabinet
pyramid: fix P0 UI/UX (a11y + drawer focus + double-write guard)
281dc4afd46f702465d4e94d3bfd78a66a5db0d0 · 2026-06-18 18:51:22 -0700 · Steve Abrams
Codex UI/UX review P0s:
1. Drawer focus management — focus the primary action on open, restore
focus to the trigger on close, trap Tab inside the drawer (central
MutationObserver on #drawer, so every opener inherits it).
2. Keyboard-accessible nodes — every clickable div ([data-kind]/[data-gap])
gets tabindex+role=button+aria-label via makeAccessible(), and Enter/Space
activates them (mirrors the click handlers). Visible :focus-visible rings
on all interactive surfaces.
3. Double-write guard — decideGap()/decide() disable both drawer buttons and
show Approving…/Dismissing… while the cabinet.yaml write is in flight;
re-render (or close) re-enables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
Diff
commit 281dc4afd46f702465d4e94d3bfd78a66a5db0d0
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jun 18 18:51:22 2026 -0700
pyramid: fix P0 UI/UX (a11y + drawer focus + double-write guard)
Codex UI/UX review P0s:
1. Drawer focus management — focus the primary action on open, restore
focus to the trigger on close, trap Tab inside the drawer (central
MutationObserver on #drawer, so every opener inherits it).
2. Keyboard-accessible nodes — every clickable div ([data-kind]/[data-gap])
gets tabindex+role=button+aria-label via makeAccessible(), and Enter/Space
activates them (mirrors the click handlers). Visible :focus-visible rings
on all interactive surfaces.
3. Double-write guard — decideGap()/decide() disable both drawer buttons and
show Approving…/Dismissing… while the cabinet.yaml write is in flight;
re-render (or close) re-enables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
pyramid.html | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 82 insertions(+), 8 deletions(-)
diff --git a/pyramid.html b/pyramid.html
index bbb0676..cccd5c3 100644
--- a/pyramid.html
+++ b/pyramid.html
@@ -87,9 +87,15 @@
.foot code{background:#101725;border:1px solid var(--line);border-radius:4px;padding:1px 5px}
/* clickable nodes */
- [data-kind]{cursor:pointer}
+ [data-kind],[data-gap]{cursor:pointer}
.agent:hover,.skill:hover,.wf:hover{outline:1px solid #4b5b7a;filter:brightness(1.18)}
.officer-node[data-kind]:hover{filter:brightness(1.12)}
+ /* keyboard focus — every interactive surface gets a visible ring */
+ [data-kind]:focus-visible,[data-gap]:focus-visible,.gap-h:focus-visible,.sug-h:focus-visible,
+ .dr-btn:focus-visible,.dr-x:focus-visible,.mini:focus-visible,.tb-btn:focus-visible,
+ .modal-x:focus-visible,.m-btn:focus-visible,.route-chip:focus-visible,.idea-card:focus-visible,
+ .toolbar input:focus-visible{outline:2px solid #8ab4ff;outline-offset:2px;border-radius:8px}
+ .dr-btn:disabled{opacity:.55;cursor:wait;filter:saturate(.6)}
/* drawer */
#drawer{position:fixed;top:0;right:0;height:100vh;width:min(560px,94vw);background:#0d1119;border-left:1px solid #243049;
@@ -514,7 +520,22 @@ function timeline(){
// ── Runnable / openable: click a node → drawer with source + Run + Open ──
let _toastT;
function toast(msg){ const t=document.getElementById('toast'); t.textContent=msg; t.classList.add('show'); clearTimeout(_toastT); _toastT=setTimeout(()=>t.classList.remove('show'),2600); }
-function closePanel(){ document.getElementById('drawer').classList.remove('open'); }
+// ── Drawer focus management (P0): remember what opened the drawer, focus the
+// primary action on open, restore focus to the trigger on close, trap Tab. ──
+let _drawerReturn=null, _drawerWasOpen=false;
+function closePanel(){
+ const dr=document.getElementById('drawer'); dr.classList.remove('open');
+ // focus restore is handled centrally by the MutationObserver below
+}
+// Disable both drawer actions while an approve/dismiss write is in flight so a
+// double-click can't fire two cabinet.yaml writes (P0). Re-render re-enables.
+function setDrawerBusy(busy,label){
+ const run=document.getElementById('dr-run'), open=document.getElementById('dr-open');
+ [run,open].forEach(b=>{ if(b) b.disabled=busy; });
+ if(!run) return;
+ if(busy){ if(run._prevTxt==null) run._prevTxt=run.textContent; run.textContent=label||'Working…'; }
+ else if(run._prevTxt!=null){ run.textContent=run._prevTxt; run._prevTxt=null; }
+}
async function runNode(kind,id){
toast('Launching '+(kind==='skill'?'/':'@')+id+' …');
try{ const r=await(await fetch('/api/run?kind='+kind+'&name='+encodeURIComponent(id))).json();
@@ -531,6 +552,7 @@ async function openPanel(kind,id,label){
document.getElementById('dr-title').textContent=label||id;
const k=document.getElementById('dr-kind'); k.textContent=kind==='skill'?'SKILL':'AGENT'; k.className='dr-kind '+(kind==='skill'?'k-skill':'k-agent');
const run=document.getElementById('dr-run'), open=document.getElementById('dr-open');
+ run.disabled=false; open.disabled=false; run._prevTxt=null;
run.style.display=''; open.style.display='';
run.textContent='▶ Run in new session'; open.textContent='📂 Open in editor';
run.onclick=()=>runNode(kind,id); open.onclick=()=>openNode(kind,id);
@@ -687,6 +709,7 @@ function openSuggestion(key){
const k=document.getElementById('dr-kind'); k.textContent=dec?dec.toUpperCase():'SUGGESTED';
k.className='dr-kind'; k.style.cssText=dec==='approved'?'background:#0f2a1c;color:#86efac;border:1px solid #16a34a':'background:#33280a;color:#fbbf24;border:1px solid #fbbf24';
const run=document.getElementById('dr-run'), open=document.getElementById('dr-open');
+ run.disabled=false; open.disabled=false; run._prevTxt=null;
run.style.display=''; open.style.display='';
run.className='dr-btn run'; run.textContent=dec==='approved'?'✓ Approved':'✓ Approve';
open.className='dr-btn'; open.style.cssText='flex:1;display:flex;align-items:center;justify-content:center;gap:7px;padding:10px;border-radius:9px;font-size:13px;font-weight:700;cursor:pointer;background:#2a1414;color:#fca5a5;border:1px solid #b91c1c';
@@ -699,16 +722,17 @@ function openSuggestion(key){
`<div class="dr-note" style="margin-top:14px;font-size:11px">Approve appends <code>- ${g.kind}: ${g.id}</code> under <b>${g.to}</b>'s directors in cabinet.yaml (idempotent, git-reversible). Remove just dismisses it.</div>`;
}
async function decide(key,action){
+ setDrawerBusy(true, action==='approve'?'Approving…':action==='remove'?'Removing…':'Resetting…');
try{
const r=await(await fetch('/api/suggest?action='+action+'&key='+encodeURIComponent(key))).json();
- if(!r.ok){ toast('failed: '+(r.error||'?')); return; }
+ if(!r.ok){ toast('failed: '+(r.error||'?')); setDrawerBusy(false); return; }
SUG_STATE=r.decisions||SUG_STATE; drawCross();
const g=SUGGEST.find(x=>sKey(x)===key)||{};
toast(action==='approve'?(r.cabinet&&r.cabinet.already?'already in cabinet.yaml':('✓ approved — committed '+(r.commit||'(no hash)')+' · added under '+offName(g.to))):action==='remove'?'✕ suggestion removed':'↺ decision reset');
if(document.getElementById('dr-title').textContent==='Cross-officer analysis') showAnalysis();
else if(action==='remove') closePanel();
else openSuggestion(key);
- }catch(e){ toast('server unreachable — open via http://localhost:9766/pyramid'); }
+ }catch(e){ toast('server unreachable — open via http://localhost:9766/pyramid'); setDrawerBusy(false); }
}
// ── Click a red dotted gap line / + handle → drawer with Approve / Dismiss ──
@@ -721,6 +745,7 @@ function openGap(key){
const k=document.getElementById('dr-kind'); k.textContent=dec?dec.toUpperCase():'GAP'; k.className='dr-kind';
k.style.cssText=dec==='approved'?'background:#0f2a1c;color:#86efac;border:1px solid #16a34a':'background:#2a1414;color:#fca5a5;border:1px solid #b91c1c';
const run=document.getElementById('dr-run'), open=document.getElementById('dr-open');
+ run.disabled=false; open.disabled=false; run._prevTxt=null;
run.style.display=''; open.style.display='';
run.className='dr-btn run'; run.textContent=dec==='approved'?'✓ Approved':(g.type==='new-vp'?'✓ Stand up officer':'✓ Adopt orphan');
open.className='dr-btn'; open.style.cssText='flex:1;display:flex;align-items:center;justify-content:center;gap:7px;padding:10px;border-radius:9px;font-size:13px;font-weight:700;cursor:pointer;background:#2a1414;color:#fca5a5;border:1px solid #b91c1c';
@@ -741,13 +766,14 @@ function openGap(key){
document.getElementById('dr-body').innerHTML=body;
}
async function decideGap(key,action){
+ setDrawerBusy(true, action==='approve'?'Approving…':action==='remove'?'Dismissing…':'Resetting…');
try{
const r=await(await fetch('/api/gap?action='+action+'&key='+encodeURIComponent(key))).json();
- if(!r.ok){ toast('failed: '+(r.error||'?')); return; }
+ if(!r.ok){ toast('failed: '+(r.error||'?')); setDrawerBusy(false); return; }
GAP_STATE=r.decisions||GAP_STATE; drawGaps();
toast(action==='approve'?(r.cabinet&&r.cabinet.already?'already in cabinet.yaml':('✓ approved — committed '+(r.commit||'(no hash)'))):action==='remove'?'✕ gap dismissed':'↺ decision reset');
- if(action==='remove') closePanel(); else openGap(key);
- }catch(e){ toast('server unreachable — open via http://localhost:9766/pyramid'); }
+ if(action==='remove') closePanel(); else openGap(key); // re-render re-enables the buttons
+ }catch(e){ toast('server unreachable — open via http://localhost:9766/pyramid'); setDrawerBusy(false); }
}
// ───────────────────── IDEAS MODALS ─────────────────────
@@ -857,8 +883,56 @@ function clearOverlays(silent){
if(!silent) toast('overlay cleared');
}
-legend(); build(); timeline(); buildToolbar();
+// Make every clickable div behave like a button for keyboard + AT users (P0):
+// focusable, announced as a button, activatable with Enter/Space. Real <button>
+// handles (.gap-h/.sug-h) and the toolbar are already accessible — skip them.
+function makeAccessible(){
+ document.querySelectorAll('[data-kind],[data-gap]').forEach(el=>{
+ if(el.tagName==='BUTTON') return;
+ el.tabIndex=0; el.setAttribute('role','button');
+ if(!el.getAttribute('aria-label')){
+ const lbl=(el.dataset.label||el.dataset.id||el.textContent||'').trim();
+ if(lbl) el.setAttribute('aria-label', el.dataset.gap?('Org gap: '+lbl):lbl);
+ }
+ });
+}
+legend(); build(); timeline(); buildToolbar(); makeAccessible();
document.getElementById('pyramid').addEventListener('click', onNodeClick);
+// Enter / Space activates a focused node (mirrors the click handlers).
+document.addEventListener('keydown', e=>{
+ if(e.key!=='Enter' && e.key!==' ' && e.key!=='Spacebar') return;
+ const el=e.target.closest('[data-kind],[data-gap]'); if(!el || el.tagName==='BUTTON') return;
+ e.preventDefault();
+ if(el.dataset.gap){ openGap(el.dataset.gap); return; }
+ const kind=el.dataset.kind;
+ if(kind==='scrapers'){ openScrapers(JSON.parse(el.dataset.scrapers||'[]')); return; }
+ openPanel(kind, el.dataset.id, el.dataset.label);
+});
+// Central drawer focus management: focus the primary action when the drawer
+// opens, restore focus to whatever opened it when it closes, trap Tab inside.
+(function(){
+ const dr=document.getElementById('drawer');
+ new MutationObserver(()=>{
+ const open=dr.classList.contains('open');
+ if(open && !_drawerWasOpen){
+ _drawerReturn=document.activeElement;
+ const btn=[...dr.querySelectorAll('.dr-actions .dr-btn, .dr-x')].find(b=>b.offsetParent!==null);
+ if(btn) requestAnimationFrame(()=>btn.focus());
+ } else if(!open && _drawerWasOpen){
+ if(_drawerReturn && typeof _drawerReturn.focus==='function') _drawerReturn.focus();
+ _drawerReturn=null;
+ }
+ _drawerWasOpen=open;
+ }).observe(dr,{attributes:true,attributeFilter:['class']});
+ dr.addEventListener('keydown', e=>{
+ if(e.key!=='Tab') return;
+ const f=[...dr.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])')].filter(el=>!el.disabled && el.offsetParent!==null);
+ if(!f.length) return;
+ const first=f[0], last=f[f.length-1];
+ if(e.shiftKey && document.activeElement===first){ e.preventDefault(); last.focus(); }
+ else if(!e.shiftKey && document.activeElement===last){ e.preventDefault(); first.focus(); }
+ });
+})();
document.getElementById('xlines').addEventListener('click', e=>{ const t=e.target.closest('.sug-hit'); if(t) openSuggestion(t.dataset.key); });
document.getElementById('sug-handles').addEventListener('click', e=>{ const t=e.target.closest('.sug-h'); if(t) openSuggestion(t.dataset.key); });
document.getElementById('glines').addEventListener('click', e=>{ const t=e.target.closest('.gap-hit'); if(t) openGap(t.dataset.key); });
← 372ea93 pyramid: add Gaps dotted-line overlay (stand up officers / a
·
back to Agent Cabinet
·
redirect /pyramid.html and /pyramid/ to canonical /pyramid ( 6f9f1ff →