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