[object Object]

← 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 →