[object Object]

← back to Ticket System

board.html: UX polish pass — a11y + catalog density + inline errors + anchored menus (TK-11442)

1f98a37af208e800590dd0e26def40af7c7dabc1 · 2026-09-10 14:31:14 -0700 · Steve Abrams

#1 a11y: sortable <th> now keyboard-operable (tabindex/role=button/aria-sort/
   Enter-Space), aria-labels on icon controls + row/group/select-all checkboxes,
   :focus-visible ring (focus was invisible).
#4: catalog (agents/skills) grid gets its own density slider (2-6 cols),
   persisted tkCatalogCols, added to Reset.
#5: DTD/stop/reopen/yes errors route through inline #runmsg — zero native alert().
#6: column/pill/detail dropdowns anchor under their trigger button (position:fixed
   + getBoundingClientRect) instead of pinning to viewport right:14px (drifted on wrap).
Verified in a real browser: JS parses, board 200, 33 rows, keyboard sort fires,
dropdown anchors, catalog slider 4->6 updates cols, no interaction errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 1f98a37af208e800590dd0e26def40af7c7dabc1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 14:31:14 2026 -0700

    board.html: UX polish pass — a11y + catalog density + inline errors + anchored menus (TK-11442)
    
    #1 a11y: sortable <th> now keyboard-operable (tabindex/role=button/aria-sort/
       Enter-Space), aria-labels on icon controls + row/group/select-all checkboxes,
       :focus-visible ring (focus was invisible).
    #4: catalog (agents/skills) grid gets its own density slider (2-6 cols),
       persisted tkCatalogCols, added to Reset.
    #5: DTD/stop/reopen/yes errors route through inline #runmsg — zero native alert().
    #6: column/pill/detail dropdowns anchor under their trigger button (position:fixed
       + getBoundingClientRect) instead of pinning to viewport right:14px (drifted on wrap).
    Verified in a real browser: JS parses, board 200, 33 rows, keyboard sort fires,
    dropdown anchors, catalog slider 4->6 updates cols, no interaction errors.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 board.html | 59 +++++++++++++++++++++++++++++++++++------------------------
 1 file changed, 35 insertions(+), 24 deletions(-)

diff --git a/board.html b/board.html
index 7a4fdc82..3278453d 100644
--- a/board.html
+++ b/board.html
@@ -86,7 +86,9 @@
   .btn.stop{border-color:#7a3b40;color:#e79aa2}.btn.stop:hover{border-color:#e06c75}
   .btn.dtd{border-color:#5a4a7a;color:#d8bdf6}.btn.dtd:hover{border-color:#c9a4f2}
   .btn.reopen{border-color:#3a5a7a;color:#9ec7f2}
-  #colmenu,#pillmenu,#detmenu{position:absolute;top:44px;right:14px;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px;display:none;z-index:10;max-height:70vh;overflow:auto;min-width:180px}
+  #colmenu,#pillmenu,#detmenu{position:fixed;top:44px;right:14px;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px;display:none;z-index:10;max-height:70vh;overflow:auto;min-width:180px}
+  /* a11y: visible keyboard focus (was invisible — buttons relied on hover only) */
+  a:focus-visible,button:focus-visible,select:focus-visible,input:focus-visible,th:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--acc);outline-offset:2px;border-radius:4px}
   #colmenu.show,#pillmenu.show,#detmenu.show{display:block} #colmenu label,#pillmenu label,#detmenu label{display:flex;gap:7px;align-items:center;font-size:12px;padding:3px 6px;cursor:pointer;border-radius:6px} #colmenu label:hover,#pillmenu label:hover,#detmenu label:hover{background:#1c2130}
   #colmenu .gh,#pillmenu .gh,#detmenu .gh{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--faint);margin:6px 6px 2px}
   .btn.off{opacity:.5}
@@ -118,13 +120,13 @@
   <span id="dtdchip" title="last DTD run-now sweep">DTD: —</span>
   <span class="grow"></span>
   <span class="layoutnav" title="Grid or compact KV list"><button data-layout="grid">▦ Grid</button><button data-layout="kv">☷ KV</button></span>
-  <input type="range" id="dens" min="4" max="14" step="1" title="row density">
-  <button class="btn" id="colsBtn">▦ Columns</button>
-  <button class="btn" id="pillsBtn" title="Toggle stat pills">◍ Pills</button>
-  <button class="btn" id="detBtn" title="Toggle expanded-row detail fields">⤢ Detail</button>
-  <button class="btn" id="dmBtn" title="Toggle the DM panel">✉ DMs</button>
-  <button class="btn" id="resetBtn" title="Reset all view settings">↺ Reset</button>
-  <button class="btn" id="themeBtn" title="Toggle day / night theme">🌙 Night</button>
+  <input type="range" id="dens" min="4" max="14" step="1" title="row density" aria-label="Row density">
+  <button class="btn" id="colsBtn" aria-label="Toggle column menu">▦ Columns</button>
+  <button class="btn" id="pillsBtn" title="Toggle stat pills" aria-label="Toggle stat pills">◍ Pills</button>
+  <button class="btn" id="detBtn" title="Toggle expanded-row detail fields" aria-label="Toggle expanded-row detail fields">⤢ Detail</button>
+  <button class="btn" id="dmBtn" title="Toggle the DM panel" aria-label="Toggle the DM panel">✉ DMs</button>
+  <button class="btn" id="resetBtn" title="Reset all view settings" aria-label="Reset all view settings">↺ Reset</button>
+  <button class="btn" id="themeBtn" title="Toggle day / night theme" aria-label="Toggle day or night theme">🌙 Night</button>
   <a class="btn" href="/kanban">Kanban</a><a class="btn" href="/office">Office</a>
 </header>
 <div id="actionbar">
@@ -211,7 +213,7 @@ let DATA=[], AGENTS=[], SKILLS=[], q='';
 const URLSTATE=new URLSearchParams(location.search);
 let SECTION=URLSTATE.get('section')||LS('tkSection','blocked'), LAYOUT=URLSTATE.get('layout')||LS('tkLayout','grid');
 if(!['all','open','blocked','doing','agents','skills'].includes(SECTION))SECTION='blocked';if(!['grid','kv'].includes(LAYOUT))LAYOUT='grid';
-let CATALOG_SORT=LS('tkCatalogSort','name'), CATALOG_DIR=LS('tkCatalogDir',1);
+let CATALOG_SORT=LS('tkCatalogSort','name'), CATALOG_DIR=LS('tkCatalogDir',1), CATALOG_COLS=LS('tkCatalogCols',4);
 let sortKey=LS('tkSortKey','created_at'), sortDir=LS('tkSortDir',-1); if(!CK.includes(sortKey))sortKey='created_at';
 let VIEW=LS('tkView','task');
 let VIS=LS('tkCols',null)||Object.fromEntries(COLS.map(c=>[c.k,!!c.def]));
@@ -239,14 +241,17 @@ function renderHead(){
   const th=$('#thead'); th.innerHTML='';
   // fixed leading select-all checkbox (outside the draggable/resizable COLS)
   const cbth=document.createElement('th'); cbth.className='cb';
-  cbth.innerHTML='<input type="checkbox" id="selall" title="select all shown">';
+  cbth.innerHTML='<input type="checkbox" id="selall" title="select all shown" aria-label="Select all shown tickets">';
   th.appendChild(cbth);
   for(const c of ordered()){
     const el=document.createElement('th'); el.dataset.k=c.k; el.draggable=true;
+    el.tabIndex=0; el.setAttribute('role','button'); el.setAttribute('aria-label','Sort by '+c.l);
     el.innerHTML=esc(c.l)+'<span class="rsz"></span>';
     el.style.width=(WID[c.k]||c.w)+'px';
-    if(c.k===sortKey)el.classList.add(sortDir>0?'asc':'desc');
-    el.onclick=e=>{if(e.target.classList.contains('rsz'))return; if(sortKey===c.k)sortDir*=-1;else{sortKey=c.k;sortDir=1;} save('tkSortKey',sortKey);save('tkSortDir',sortDir); render();};
+    if(c.k===sortKey){el.classList.add(sortDir>0?'asc':'desc');el.setAttribute('aria-sort',sortDir>0?'ascending':'descending');}
+    const doSort=()=>{if(sortKey===c.k)sortDir*=-1;else{sortKey=c.k;sortDir=1;} save('tkSortKey',sortKey);save('tkSortDir',sortDir); render();};
+    el.onclick=e=>{if(e.target.classList.contains('rsz'))return; doSort();};
+    el.onkeydown=e=>{if((e.key==='Enter'||e.key===' ')&&!e.target.classList.contains('rsz')){e.preventDefault();doSort();}};
     // resize
     el.querySelector('.rsz').addEventListener('mousedown',e=>{e.stopPropagation();e.preventDefault();const sx=e.pageX,sw=el.offsetWidth;
       const mv=ev=>{const w=Math.max(48,sw+ev.pageX-sx);el.style.width=w+'px';WID[c.k]=w;};
@@ -287,7 +292,7 @@ function render(){
       d.innerHTML=`<td colspan="${span}"><div class="thread">${thread}</div></td>`;return d;};
   const rowTr=t=>{
     const tr=document.createElement('tr');tr.className='row'+(SEL.has(t.id)?' sel':'')+(t.status==='stopped'?' stopped':'');tr.dataset.id=t.id;
-    tr.innerHTML=`<td class="cb"><input type="checkbox" class="rowcb" ${SEL.has(t.id)?'checked':''}></td>`+cols.map(c=>`<td>${c.cell(t)}</td>`).join('');
+    tr.innerHTML=`<td class="cb"><input type="checkbox" class="rowcb" aria-label="Select ${esc(t.id)}" ${SEL.has(t.id)?'checked':''}></td>`+cols.map(c=>`<td>${c.cell(t)}</td>`).join('');
     tr.querySelector('.rowcb').onclick=e=>{e.stopPropagation();if(e.target.checked)SEL.add(t.id);else SEL.delete(t.id);tr.classList.toggle('sel',e.target.checked);setRunMsg('');syncBar();};
     tr.onclick=e=>{if(e.target.closest('a,.who,.proj,input'))return;const nx=tr.nextElementSibling;if(nx&&nx.classList.contains('det')){nx.remove();EXPANDED.delete(t.id);return;}
       tr.after(buildDet(t));EXPANDED.add(t.id);};
@@ -305,7 +310,7 @@ function render(){
       const gkey=GROUP+':'+gv; const collapsed=COLLAPSED.has(gkey);
       const allSel=list.every(t=>SEL.has(t.id))&&list.length>0;
       const gtr=document.createElement('tr');gtr.className='grp';
-      gtr.innerHTML=`<td colspan="${span}"><span class="gcx">${collapsed?'▸':'▾'}</span><input type="checkbox" ${allSel?'checked':''} title="select all in group"> ${esc(groupLabel(GROUP,gv))}<span class="gcount">${list.length}</span></td>`;
+      gtr.innerHTML=`<td colspan="${span}"><span class="gcx">${collapsed?'▸':'▾'}</span><input type="checkbox" ${allSel?'checked':''} title="select all in group" aria-label="Select all in ${esc(groupLabel(GROUP,gv))}"> ${esc(groupLabel(GROUP,gv))}<span class="gcount">${list.length}</span></td>`;
       gtr.querySelector('td').onclick=e=>{if(e.target.tagName==='INPUT')return;if(collapsed)COLLAPSED.delete(gkey);else COLLAPSED.add(gkey);render();};
       gtr.querySelector('input').onclick=e=>{e.stopPropagation();if(e.target.checked)for(const t of list)SEL.add(t.id);else for(const t of list)SEL.delete(t.id);render();syncBar();};
       tb.appendChild(gtr);
@@ -326,8 +331,10 @@ function renderCatalog(){
   const meta=r=>fields.slice(1).map(([k,l])=>r[k]==null||r[k]===''?'':`<span class="kv"><b>${esc(l)}</b>${k.endsWith('_at')?dt(r[k]):esc(r[k])}</span>`).join('');
   const stamp=r=>`<span class="stamp" title="${esc(r.created_at||'')}">🕓 ${r.created_at?new Date(r.created_at).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'unknown'}</span>`;
   const body=LAYOUT==='kv'?rows.map(r=>`<article class="kvrow" title="${esc(r.description||r.about)}"><h3>${esc(r.name)}</h3><div class="kvmeta">${meta(r)}${stamp(r)}</div></article>`).join(''): `<div class="catalog-grid">${rows.map(r=>`<article class="catalog-card" title="${esc(r.description||r.about)}"><h3>${esc(r.name)}</h3><p>${esc(r.about)}</p><div class="kvmeta">${meta(r)}</div>${stamp(r)}</article>`).join('')}</div>`;
-  $('#catalog').innerHTML=`<div class="catalog-head"><h2>${SECTION==='agents'?'Agent activity':'Installed skills'}</h2><small>${rows.length} shown</small><span class="grow"></span><select class="gsel" id="catalogSort">${opts}</select><button class="btn" id="catalogDir">${CATALOG_DIR>0?'▲ Asc':'▼ Desc'}</button></div>${body||'<div class="c none">No matching records.</div>'}`;
+  $('#catalog').innerHTML=`<div class="catalog-head"><h2>${SECTION==='agents'?'Agent activity':'Installed skills'}</h2><small>${rows.length} shown</small><span class="grow"></span><input type="range" id="catalogCols" min="2" max="6" step="1" value="${CATALOG_COLS}" title="grid density" aria-label="Catalog grid columns" style="width:96px;accent-color:var(--acc)"><select class="gsel" id="catalogSort" aria-label="Sort catalog by">${opts}</select><button class="btn" id="catalogDir" aria-label="Toggle sort direction">${CATALOG_DIR>0?'▲ Asc':'▼ Desc'}</button></div>${body||'<div class="c none">No matching records.</div>'}`;
+  $('#catalog').style.setProperty('--catalog-cols',CATALOG_COLS);
   $('#catalogSort').onchange=e=>{CATALOG_SORT=e.target.value;save('tkCatalogSort',CATALOG_SORT);renderCatalog();};$('#catalogDir').onclick=()=>{CATALOG_DIR*=-1;save('tkCatalogDir',CATALOG_DIR);renderCatalog();};
+  $('#catalogCols').oninput=e=>{CATALOG_COLS=+e.target.value;save('tkCatalogCols',CATALOG_COLS);$('#catalog').style.setProperty('--catalog-cols',CATALOG_COLS);};
 }
 function syncUrl(){const u=new URL(location.href);u.searchParams.set('section',SECTION);u.searchParams.set('layout',LAYOUT);history.replaceState(null,'',u);}
 function setSection(section){SECTION=section;save('tkSection',SECTION);syncUrl();q='';$('#q').value='';document.querySelectorAll('[data-section]').forEach(b=>b.classList.toggle('on',b.dataset.section===SECTION));render();}
@@ -357,12 +364,12 @@ $('#runBtn').onclick=async()=>{const ids=selIds();if(!ids.length){setRunMsg('sel
   $('#runBtn').disabled=true;setRunMsg(`launching ${ids.length} ${label} session(s)…`);
   try{const r=await post('/api/run',{ids,profile:RUN_PROFILE});if(r.error)throw new Error(r.error);const sk=(r.skipped&&r.skipped.length)?(' · skipped '+r.skipped.length):'';setRunMsg(`launched ${(r.launched||[]).length}${sk} — windows opening (check the feed if none appear)`);}catch(e){setRunMsg('run failed: '+e.message);}finally{$('#runBtn').disabled=false;SEL.clear();load();}};
 $('#stopBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;if(!confirm(`Stop ${ids.length} ticket(s) FOREVER (TicketStopped)? Reversible via Reopen.`))return;
-  try{await post('/api/stop',{ids});}catch(e){alert('stop failed: '+e.message);}SEL.clear();load();};
-$('#reopenBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;try{await post('/api/reopen',{ids});}catch(e){alert('reopen failed: '+e.message);}SEL.clear();load();};
+  try{await post('/api/stop',{ids});}catch(e){setRunMsg('stop failed: '+e.message);}SEL.clear();load();};
+$('#reopenBtn').onclick=async()=>{const ids=selIds();if(!ids.length)return;try{await post('/api/reopen',{ids});}catch(e){setRunMsg('reopen failed: '+e.message);}SEL.clear();load();};
 $('#dtdBtn').onclick=async()=>{const ids=selIds();$('#dtdBtn').disabled=true;
-  try{const r=await post('/api/dtd',ids.length?{ids}:{});if(r.already)dtdChip('analyzing…');else dtdChip('analyzing '+(ids.length?ids.length+' selected':'recent')+'…');pollDTD();}catch(e){alert('dtd failed: '+e.message);}finally{setTimeout(()=>{$('#dtdBtn').disabled=false;},1500);}};
-$('#yesBtn').onclick=()=>{if(!DTD.verdicts||!DTD.verdicts.tickets)return alert('Run "Analyze w/ DTD" first.');
-  let n=0;for(const t of DATA){const v=verdictOf(t);if(v&&v.verdict==='yes'){SEL.add(t.id);n++;}}render();syncBar();if(!n)alert('No YES verdicts yet.');};
+  try{const r=await post('/api/dtd',ids.length?{ids}:{});if(r.already)dtdChip('analyzing…');else dtdChip('analyzing '+(ids.length?ids.length+' selected':'recent')+'…');pollDTD();}catch(e){setRunMsg('DTD failed: '+e.message);}finally{setTimeout(()=>{$('#dtdBtn').disabled=false;},1500);}};
+$('#yesBtn').onclick=()=>{if(!DTD.verdicts||!DTD.verdicts.tickets){setRunMsg('Run "Analyze w/ DTD" first.');return;}
+  let n=0;for(const t of DATA){const v=verdictOf(t);if(v&&v.verdict==='yes'){SEL.add(t.id);n++;}}render();syncBar();if(!n)setRunMsg('No YES verdicts yet.');};
 // ── DTD status feed ──
 function dtdChip(txt){$('#dtdchip').innerHTML='DTD: '+txt;}
 function renderDtdChip(){if(DTD.running){dtdChip('<b>analyzing…</b>');return;}
@@ -407,11 +414,15 @@ $('#grp').onchange=e=>{GROUP=e.target.value;save('tkGroup',GROUP);render();};
 $('#view').onchange=e=>{VIEW=e.target.value;save('tkView',VIEW);SEL.clear();render();syncBar();};
 $('#dens').oninput=e=>{DENS=+e.target.value;document.documentElement.style.setProperty('--rowpad',DENS+'px');save('tkDens',DENS);};
 const closeMenus=except=>['#colmenu','#pillmenu','#detmenu'].forEach(id=>{if(id!==except)$(id).classList.remove('show');});
-$('#colsBtn').onclick=()=>{closeMenus('#colmenu');$('#colmenu').classList.toggle('show');};
-$('#pillsBtn').onclick=()=>{closeMenus('#pillmenu');$('#pillmenu').classList.toggle('show');};
-$('#detBtn').onclick=()=>{closeMenus('#detmenu');$('#detmenu').classList.toggle('show');};
+// anchor a dropdown under its trigger button (position:fixed → viewport coords) so it
+// no longer drifts to the right edge when the header flex-wraps on a narrow viewport
+const anchorMenu=(menu,btn)=>{const r=btn.getBoundingClientRect();menu.style.top=(r.bottom+4)+'px';menu.style.right=Math.max(6,window.innerWidth-r.right)+'px';};
+const toggleMenu=(menuSel,btn)=>{closeMenus(menuSel);const m=$(menuSel);const show=!m.classList.contains('show');m.classList.toggle('show',show);if(show)anchorMenu(m,btn);};
+$('#colsBtn').onclick=e=>toggleMenu('#colmenu',e.currentTarget);
+$('#pillsBtn').onclick=e=>toggleMenu('#pillmenu',e.currentTarget);
+$('#detBtn').onclick=e=>toggleMenu('#detmenu',e.currentTarget);
 $('#dmBtn').onclick=()=>{DM_ON=DM_ON?0:1;save('tkDM',DM_ON);$('#dmBtn').classList.toggle('off',!DM_ON);dmPanel(LASTMSG);};
-$('#resetBtn').onclick=()=>{['tkCols','tkOrder','tkWid','tkDens','tkPills','tkDet','tkDM','tkGroup','tkView','tkSortKey','tkSortDir','tkTheme','tkSection','tkLayout','tkSectionOrder','tkCatalogSort','tkCatalogDir','tkRunProfile','tkRunProfileDefaultV2'].forEach(k=>localStorage.removeItem(k));location.reload();};
+$('#resetBtn').onclick=()=>{['tkCols','tkOrder','tkWid','tkDens','tkPills','tkDet','tkDM','tkGroup','tkView','tkSortKey','tkSortDir','tkTheme','tkSection','tkLayout','tkSectionOrder','tkCatalogSort','tkCatalogDir','tkCatalogCols','tkRunProfile','tkRunProfileDefaultV2'].forEach(k=>localStorage.removeItem(k));location.reload();};
 document.addEventListener('click',e=>{if(!e.target.closest('#colmenu,#colsBtn'))$('#colmenu').classList.remove('show');if(!e.target.closest('#pillmenu,#pillsBtn'))$('#pillmenu').classList.remove('show');if(!e.target.closest('#detmenu,#detBtn'))$('#detmenu').classList.remove('show');});
 function dmPanel(mm){
   if(!DM_ON){$('#dmwrap').innerHTML='';return;}

← efc30bc9 Record ordered ticket cycle and rejected terminal scan exper  ·  back to Ticket System  ·  auto-data-snapshot: 2026-09-10T14:47:21 (1 data files) — dat 8f5bcafe →