← back to Ticket System
board :9794 — add shared nav-agent + day/night theme toggle + default sort by started (created_at)
e5913d6a78c035554622fc4722a045f6af02f343 · 2026-08-31 12:26:04 -0700 · Steve Abrams
Files touched
M board.htmlA nav-agent/nav-agent.cssA nav-agent/nav-agent.jsM server.js
Diff
commit e5913d6a78c035554622fc4722a045f6af02f343
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 31 12:26:04 2026 -0700
board :9794 — add shared nav-agent + day/night theme toggle + default sort by started (created_at)
---
board.html | 42 +++-
nav-agent/nav-agent.css | 133 +++++++++++
nav-agent/nav-agent.js | 620 ++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 9 +
4 files changed, 791 insertions(+), 13 deletions(-)
diff --git a/board.html b/board.html
index 63e74e4e..4496df92 100644
--- a/board.html
+++ b/board.html
@@ -1,8 +1,16 @@
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fleet Tickets — Table</title>
+<link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
+<script>try{document.documentElement.dataset.theme=JSON.parse(localStorage.getItem('tkTheme'))||'dark'}catch(e){document.documentElement.dataset.theme='dark'}</script>
<style>
:root{--bg:#0f1115;--card:#171b22;--line:#262a33;--ink:#e6e6e6;--mut:#8a93a5;--faint:#5b6270;--acc:#6db3f2;--gold:#C8A24B;
- --open:#4ea1ff;--doing:#6db3f2;--blocked:#e06c75;--done:#34d399;--stopped:#6b7280;--fs:12.5px;--rowpad:7px}
+ --open:#4ea1ff;--doing:#6db3f2;--blocked:#e06c75;--done:#34d399;--stopped:#6b7280;--fs:12.5px;--rowpad:7px;
+ --thbg:#1b2230;--rowhover:#141821;--selbg:#182233;--selhover:#1c2942;--detbg:#10131a;--grpbg:#12161f;--rowline:#1c2028}
+ html[data-theme="light"]{
+ --bg:#f5f6f8;--card:#ffffff;--line:#dfe3e9;--ink:#1b1f27;--mut:#5c6472;--faint:#98a1b1;--acc:#2563c9;--gold:#9a781f;
+ --open:#2563c9;--doing:#2f7fd6;--blocked:#c0392b;--done:#1a9e6a;--stopped:#8a92a0;
+ --thbg:#eef1f6;--rowhover:#f0f3f8;--selbg:#e3edfb;--selhover:#d6e6fb;--detbg:#f2f4f8;--grpbg:#eceff4;--rowline:#e7eaf0}
+ html[data-theme="light"] tr.grp td{color:#7b4fd0} html[data-theme="light"] .who{color:#7b4fd0}
*{box-sizing:border-box}
body{margin:0;font:14px -apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--ink)}
header{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:7}
@@ -21,7 +29,7 @@
#dtdchip b{color:var(--ink)}
.tblwrap{overflow:auto;max-height:calc(100vh - 52px)}
table.g{border-collapse:separate;border-spacing:0;width:100%;font-size:var(--fs);white-space:nowrap;table-layout:fixed}
- table.g thead th{position:sticky;top:0;background:#1b2230;color:var(--mut);text-transform:uppercase;letter-spacing:.3px;font-size:10.5px;
+ table.g thead th{position:sticky;top:0;background:var(--thbg);color:var(--mut);text-transform:uppercase;letter-spacing:.3px;font-size:10.5px;
padding:8px 10px;border-bottom:1px solid var(--line);cursor:pointer;user-select:none;z-index:2;text-align:left;position:relative;overflow:hidden;text-overflow:ellipsis}
table.g thead th:hover{color:var(--ink)}
table.g thead th.asc::after{content:' ▲';color:var(--gold)} table.g thead th.desc::after{content:' ▼';color:var(--gold)}
@@ -29,12 +37,12 @@
th .rsz{position:absolute;right:0;top:0;width:7px;height:100%;cursor:col-resize;z-index:3}
th.cb,td.cb{width:34px;text-align:center;padding-left:6px;padding-right:6px;cursor:default}
th.cb input,td.cb input{cursor:pointer;accent-color:var(--acc)}
- table.g td{padding:var(--rowpad) 10px;border-bottom:1px solid #1c2028;overflow:hidden;text-overflow:ellipsis;vertical-align:top}
- tr.row{cursor:pointer} tr.row:hover td{background:#141821}
- tr.row.sel td{background:#182233} tr.row.sel:hover td{background:#1c2942}
+ table.g td{padding:var(--rowpad) 10px;border-bottom:1px solid var(--rowline);overflow:hidden;text-overflow:ellipsis;vertical-align:top}
+ tr.row{cursor:pointer} tr.row:hover td{background:var(--rowhover)}
+ tr.row.sel td{background:var(--selbg)} tr.row.sel:hover td{background:var(--selhover)}
tr.row.stopped td{opacity:.5}
- tr.det td{background:#10131a;white-space:normal;cursor:default}
- tr.grp td{background:#12161f;font-weight:600;color:#c9a4f2;cursor:pointer;letter-spacing:.02em}
+ tr.det td{background:var(--detbg);white-space:normal;cursor:default}
+ tr.grp td{background:var(--grpbg);font-weight:600;color:#c9a4f2;cursor:pointer;letter-spacing:.02em}
tr.grp td .gcount{color:var(--faint);font-weight:400;margin-left:8px} tr.grp td .gcx{color:var(--faint);font-size:10px;margin-right:8px}
tr.grp input{accent-color:var(--acc);margin-right:8px;cursor:pointer;vertical-align:-1px}
.mono{font-family:ui-monospace,Menlo,monospace}
@@ -51,7 +59,7 @@
.num{color:var(--mut);text-align:right}
/* priority ranking + ratings (mirrors :9801 approvals viewer) */
.rank{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:20px;padding:0 5px;border-radius:6px;
- font-weight:700;font-size:11px;background:#1b2230;border:1px solid var(--line);color:var(--ink)}
+ font-weight:700;font-size:11px;background:var(--thbg);border:1px solid var(--line);color:var(--ink)}
.rank.r1{background:#3a2f12;border-color:#7a5f1f;color:#f0c65a}
.tier{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:20px;border:1px solid var(--line)}
.tier.high{color:#f0857d;border-color:#7a3b40}.tier.med{color:#f0c65a;border-color:#7a5f1f}.tier.low{color:var(--faint)}
@@ -64,8 +72,9 @@
.thread .c{font-size:12px;margin:4px 0;color:#c6ccd8} .thread .c i{color:var(--mut);font-style:normal;font-size:10px;margin:0 4px}
.c.k-note{color:#e8d48b}.c.k-action{color:#8fd49a}.c.k-win{color:#34d399}.c.k-challenge{color:#e06c75}.c.k-cody{color:#f59e0b}.c.none{color:var(--faint)}
.cts{color:var(--faint);font-size:10px;margin-left:6px}
+ html[data-theme="light"] .thread .c{color:#2b3038} html[data-theme="light"] tr.det td .c.k-action{color:#1a7a4a}
/* selection action bar */
- #actionbar{display:none;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid var(--line);background:#12161f;position:sticky;top:52px;z-index:6}
+ #actionbar{display:none;align-items:center;gap:8px;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid var(--line);background:var(--grpbg);position:sticky;top:52px;z-index:6}
#actionbar.show{display:flex}
#actionbar .n{color:var(--acc);font-weight:600}
.btn.run{border-color:#2b6f52;color:#8fd49a}.btn.run:hover{border-color:#34d399}
@@ -101,6 +110,7 @@
<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>
<a class="btn" href="/kanban">Kanban</a><a class="btn" href="/office">Office</a>
</header>
<div id="actionbar">
@@ -178,7 +188,8 @@ function lastTs(t){let ms=+new Date(t.updated_at||t.created_at||0);for(const a o
function lastText(t){const acts=(t.actions||[]).slice().sort((a,b)=>+new Date(b.ts)-+new Date(a.ts));if(acts[0])return acts[0].text;const cs=(t.comments||[]).slice().sort((a,b)=>+new Date(b.ts)-+new Date(a.ts));return cs[0]?cs[0].text:'';}
const CK=COLS.map(c=>c.k);
const LS=(k,d)=>{try{return JSON.parse(localStorage.getItem(k))??d}catch(e){return d}}, save=(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}};
-let DATA=[], q='', sortKey='priority', sortDir=-1;
+let DATA=[], q='';
+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]));
for(const c of COLS)if(!(c.k in VIS))VIS[c.k]=!!c.def; // new cols (runnow) default ON even with an old stored set
@@ -210,7 +221,7 @@ function renderHead(){
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;} render();};
+ 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();};
// 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;};
@@ -338,7 +349,7 @@ $('#colsBtn').onclick=()=>{closeMenus('#colmenu');$('#colmenu').classList.toggle
$('#pillsBtn').onclick=()=>{closeMenus('#pillmenu');$('#pillmenu').classList.toggle('show');};
$('#detBtn').onclick=()=>{closeMenus('#detmenu');$('#detmenu').classList.toggle('show');};
$('#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'].forEach(k=>localStorage.removeItem(k));location.reload();};
+$('#resetBtn').onclick=()=>{['tkCols','tkOrder','tkWid','tkDens','tkPills','tkDet','tkDM','tkGroup','tkView','tkSortKey','tkSortDir','tkTheme'].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;}
@@ -366,4 +377,9 @@ async function load(){
}
window.addEventListener('hashchange',()=>{const hq=decodeURIComponent((location.hash.match(/[#&]q=([^&]+)/)||[])[1]||'');setQ(hq);});
renderColMenu(); renderPillMenu(); renderDetMenu(); $('#dmBtn').classList.toggle('off',!DM_ON); load(); loadDTD().then(()=>{ if(DTD.running) pollDTD(); }); setInterval(load,30000);
-</script></body></html>
+// ── day / night theme toggle (persisted; head-script already applied it — this wires the button) ──
+(function(){const b=$('#themeBtn'); if(!b)return; const paint=()=>{b.textContent=document.documentElement.dataset.theme==='light'?'☀ Day':'🌙 Night';}; paint();
+ b.onclick=()=>{const light=document.documentElement.dataset.theme==='light'; document.documentElement.dataset.theme=light?'dark':'light'; save('tkTheme',light?'dark':'light'); paint();};})();
+</script>
+<script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
+</body></html>
diff --git a/nav-agent/nav-agent.css b/nav-agent/nav-agent.css
new file mode 100644
index 00000000..d43a0d11
--- /dev/null
+++ b/nav-agent/nav-agent.css
@@ -0,0 +1,133 @@
+/* ============================================================================
+ * Nav Agent — universal grid-controls drop-in (v1.0)
+ * Left field-toggle panel + sort-every-field + grouping + movable/removable/
+ * expandable data panels. Zero dependencies. Additive & defensive.
+ * Honors the house internal-line-viewer spec (left panel, density type-scale,
+ * tight cards, localStorage-persisted).
+ * ==========================================================================*/
+
+:root{
+ --na-acc:var(--acc,#1f9e8f);
+ --na-line:var(--line,#2a2f3a);
+ --na-bg:var(--panel,#12151c);
+ --na-bg2:var(--bg,#0c0e13);
+ --na-fg:var(--fg,#e7e9ee);
+ --na-mut:var(--mut,#8b93a3);
+}
+
+/* ---- launcher (bottom-left cog) --------------------------------------- */
+.na-launch{position:fixed;left:14px;bottom:14px;z-index:2147483000;
+ width:42px;height:42px;border-radius:50%;border:1px solid var(--na-line);
+ background:var(--na-bg);color:var(--na-fg);font-size:19px;cursor:pointer;
+ display:flex;align-items:center;justify-content:center;
+ box-shadow:0 4px 18px rgba(0,0,0,.45);transition:transform .15s}
+.na-launch:hover{transform:scale(1.08);border-color:var(--na-acc)}
+
+/* ---- left field/sort/group panel -------------------------------------- */
+.na-panel{position:fixed;left:0;top:0;bottom:0;width:290px;z-index:2147483001;
+ background:var(--na-bg);border-right:1px solid var(--na-line);color:var(--na-fg);
+ display:flex;flex-direction:column;font:13px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
+ transform:translateX(-100%);transition:transform .2s ease;box-shadow:2px 0 24px rgba(0,0,0,.4)}
+.na-panel.open{transform:translateX(0)}
+.na-panel header{display:flex;align-items:center;gap:8px;padding:12px 14px;
+ border-bottom:1px solid var(--na-line);font-weight:600;letter-spacing:.3px}
+.na-panel header .na-title{flex:1}
+.na-panel header button{background:none;border:0;color:var(--na-mut);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
+.na-panel header button:hover{color:var(--na-fg);background:rgba(255,255,255,.06)}
+.na-body{flex:1;overflow:auto;padding:8px 12px 40px}
+.na-sec{border-top:1px solid var(--na-line);padding:10px 0}
+.na-sec:first-child{border-top:0}
+.na-sec>.na-h{display:flex;align-items:center;gap:6px;cursor:pointer;
+ font-weight:600;color:var(--na-mut);text-transform:uppercase;font-size:11px;letter-spacing:.6px;user-select:none}
+.na-sec>.na-h::before{content:'▸';font-size:9px;transition:transform .15s}
+.na-sec.open>.na-h::before{transform:rotate(90deg)}
+.na-sec .na-c{display:none;padding:8px 2px 2px}
+.na-sec.open .na-c{display:block}
+.na-search{width:100%;box-sizing:border-box;background:var(--na-bg2);border:1px solid var(--na-line);
+ color:var(--na-fg);border-radius:6px;padding:8px 10px;margin:4px 0 10px;font-size:13px}
+.na-fieldrow{display:flex;align-items:center;gap:8px;padding:4px 2px;cursor:pointer;border-radius:4px}
+.na-fieldrow:hover{background:rgba(255,255,255,.04)}
+.na-fieldrow input{accent-color:var(--na-acc);cursor:pointer}
+.na-fieldrow .na-cnt{margin-left:auto;color:var(--na-mut);font-size:11px}
+.na-row{display:flex;gap:6px;align-items:center;margin:6px 0}
+.na-row label{color:var(--na-mut);min-width:52px}
+.na-row select,.na-row input[type=range]{flex:1;background:var(--na-bg2);border:1px solid var(--na-line);
+ color:var(--na-fg);border-radius:6px;padding:6px 8px;font-size:13px}
+.na-mini{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
+.na-mini button{background:var(--na-bg2);border:1px solid var(--na-line);color:var(--na-fg);
+ border-radius:6px;padding:5px 9px;font-size:12px;cursor:pointer}
+.na-mini button:hover,.na-mini button.on{border-color:var(--na-acc);color:var(--na-acc)}
+.na-reset{width:100%;margin-top:14px;background:var(--na-bg2);border:1px solid var(--na-line);
+ color:var(--na-fg);border-radius:6px;padding:9px;cursor:pointer;font-size:13px}
+.na-reset:hover{border-color:#c0563f;color:#e08a76}
+.na-note{color:var(--na-mut);font-size:11px;padding:2px;line-height:1.5}
+
+/* ---- grouping headers injected into the grid -------------------------- */
+.na-group-h{grid-column:1/-1;display:flex;align-items:center;gap:8px;
+ padding:8px 4px;margin:6px 0 2px;border-bottom:1px solid var(--na-line);
+ color:var(--na-acc);font-weight:600;font-size:13px;cursor:pointer;user-select:none}
+.na-group-h .na-gcount{color:var(--na-mut);font-weight:400}
+.na-group-h.collapsed + .na-group-body{display:none}
+table .na-group-h{display:table-row}
+table .na-group-h td{padding:8px 6px;color:var(--na-acc);font-weight:600;border-bottom:1px solid var(--na-line)}
+
+/* ---- movable / resizable / removable data-panel chrome ---------------- */
+.na-wrap{position:relative}
+.na-bar{display:flex;align-items:center;gap:6px;padding:4px 8px;cursor:move;
+ background:var(--na-bg);border:1px solid var(--na-line);border-bottom:0;
+ border-radius:6px 6px 0 0;color:var(--na-mut);font-size:12px;user-select:none}
+.na-bar .na-bt{flex:1;font-weight:600;color:var(--na-fg)}
+.na-bar button{background:none;border:0;color:var(--na-mut);cursor:pointer;font-size:14px;padding:1px 5px;border-radius:4px}
+.na-bar button:hover{color:var(--na-fg);background:rgba(255,255,255,.08)}
+.na-wrap.na-float{position:fixed;z-index:2000;box-shadow:0 10px 40px rgba(0,0,0,.55);
+ background:var(--na-bg2);border-radius:6px;resize:both;overflow:auto;min-width:280px;min-height:160px}
+.na-wrap.na-collapsed .na-inner{display:none}
+.na-wrap.na-removed{display:none}
+.na-wrap.na-max{position:fixed;inset:12px;z-index:2001;background:var(--na-bg2);
+ box-shadow:0 10px 60px rgba(0,0,0,.6);overflow:auto;resize:none}
+.na-restorebar{position:fixed;left:14px;bottom:66px;z-index:2147483000;display:flex;flex-direction:column;gap:6px}
+.na-restorebar button{background:var(--na-bg);border:1px solid var(--na-line);color:var(--na-fg);
+ border-radius:6px;padding:6px 10px;font-size:12px;cursor:pointer}
+.na-restorebar button:hover{border-color:var(--na-acc)}
+
+/* ---- merged mode: folded INTO an existing house panel ----------------- */
+.na-merge{border-top:1px dashed var(--na-line);margin-top:12px;padding-top:10px}
+.na-merge .na-mh{display:flex;align-items:center;gap:6px;font-weight:600;color:var(--na-acc);
+ text-transform:uppercase;font-size:11px;letter-spacing:.6px;margin-bottom:8px}
+.na-merge .na-row label{color:var(--na-mut);min-width:48px}
+.na-merge select{background:var(--na-bg2);border:1px solid var(--na-line);color:var(--na-fg);border-radius:6px;padding:6px 8px;font-size:13px;flex:1}
+
+/* ---- Grid / List view toggle ----------------------------------------- */
+.na-viewseg{display:inline-flex;margin:4px 0 10px;border:1px solid var(--na-line);border-radius:6px;overflow:hidden}
+.na-viewseg button{background:var(--na-bg2);color:var(--na-mut);border:0;border-right:1px solid var(--na-line);
+ padding:6px 12px;font-size:12px;cursor:pointer}
+.na-viewseg button:last-child{border-right:0}
+.na-viewseg button.on{background:var(--na-acc);color:#fff}
+
+/* ---- compact LIST view (the kv-list "third view") --------------------- */
+.na-list{width:100%}
+.na-lrows{display:flex;flex-direction:column}
+.na-lrow{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;padding:10px 14px;
+ border-bottom:1px solid var(--na-line)}
+.na-lrow:hover{background:rgba(255,255,255,.04)}
+.na-lmain{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;min-width:210px;flex:0 0 auto}
+.na-la{font-weight:600;color:var(--na-fg)}
+/* data spans the FULL row width in tight columns (left edge → right edge),
+ not left-packed chips — matches the CRCP "expanded to left and right" layout */
+.na-lmeta{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:6px 16px;flex:1 1 320px;min-width:0;align-content:start}
+.na-lm{font-size:12.5px;color:var(--na-fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.na-lmk{color:var(--na-mut);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-right:5px}
+.na-lempty{padding:40px;text-align:center;color:var(--na-mut)}
+
+/* host left-sidebar collapse (v1.3) — always collapsed on load, ☰ reveals */
+.na-side{transition:transform .18s ease}
+.na-side.na-side-collapsed{transform:translateX(-100%)!important;pointer-events:none}
+.na-side-main{transition:margin-left .18s ease}
+.na-side-main.na-side-shift{margin-left:0!important}
+.na-side-toggle{position:fixed;left:10px;top:10px;z-index:2147483002;width:38px;height:34px;
+ display:flex;align-items:center;justify-content:center;border-radius:8px;
+ border:1px solid var(--na-line);background:var(--na-bg2);color:var(--na-fg);
+ font-size:16px;line-height:1;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.28)}
+.na-side-toggle:hover,.na-side-toggle.on{border-color:var(--na-acc);color:var(--na-acc)}
+
+@media (max-width:640px){.na-panel{width:86vw}.na-lmain{min-width:100%}}
diff --git a/nav-agent/nav-agent.js b/nav-agent/nav-agent.js
new file mode 100644
index 00000000..d368cf48
--- /dev/null
+++ b/nav-agent/nav-agent.js
@@ -0,0 +1,620 @@
+/* ============================================================================
+ * Nav Agent — universal grid-controls drop-in (v1.3)
+ *
+ * Drop one <script src=".../nav-agent.js" defer></script> (+ the CSS) onto any
+ * page. It auto-detects the primary data grid (a <table> or a .grid/.card list),
+ * then injects:
+ * • a LEFT collapsible panel: text search, per-FIELD show/hide toggles,
+ * sort-by-any-field (asc/desc), group-by-any-field, density slider
+ * • a Grid / List view toggle — List is the compact "kv-list" third view:
+ * one scannable row per record, first visible field as the identity + the
+ * rest as inline LABEL·value chips (default OFF; the native grid is default)
+ * • movable / resizable / collapsible / removable / maximizable panel chrome
+ * on the grid itself
+ * All state persists to localStorage, namespaced per page path, so every
+ * surface remembers its own layout. Strictly additive & wrapped in try/catch —
+ * if it can't understand a page it degrades (panel chrome + search + density)
+ * and never throws into the host page.
+ *
+ * Manual control: window.NavAgent.mount({ target:'#grid', title:'Deals' })
+ * Opt-out a page: <body data-nav-agent="off">
+ * Force a target: <div id="grid" data-nav-grid> ... </div>
+ * ==========================================================================*/
+(function () {
+ 'use strict';
+ if (window.NavAgent) return;
+
+ var _uid = 0;
+ var navKey = function (target) {
+ if (!target.getAttribute('data-na-uid')) target.setAttribute('data-na-uid', target.id || ('g' + (++_uid)));
+ return 'navagent:' + location.pathname + ':' + target.getAttribute('data-na-uid');
+ };
+ var load = function (key) { try { return JSON.parse(localStorage.getItem(key)) || {}; } catch (e) { return {}; } };
+ var save = function (key, s) { try { localStorage.setItem(key, JSON.stringify(s)); } catch (e) {} };
+ var slug = function (t) { return String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'f'; };
+ var txt = function (el) { return (el && el.textContent || '').trim(); };
+ // magnitude-aware numeric parse — $2.1B > $780M > 42. Handles K/M/B/T + commas.
+ var num = function (v) {
+ var s = String(v).replace(/,/g, '').trim();
+ var m = s.match(/-?\d*\.?\d+/); if (!m) return null;
+ var n = parseFloat(m[0]); if (isNaN(n)) return null;
+ var suf = (s.slice(m.index + m[0].length).match(/[kmbt]/i) || [''])[0].toLowerCase();
+ var mult = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }[suf] || 1;
+ return n * mult;
+ };
+ var el = function (tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
+
+ /* -- target detection -------------------------------------------------- */
+ function findTarget(opt) {
+ if (opt && opt.target) return document.querySelector(opt.target);
+ var q = document.querySelector('[data-nav-grid]');
+ if (q) return q;
+ // biggest visible table wins, else #grid, else first .grid, else .cards
+ var tables = [].slice.call(document.querySelectorAll('table'))
+ .filter(function (t) { return t.querySelector('tbody tr') && t.offsetParent !== null; })
+ .sort(function (a, b) { return b.querySelectorAll('tr').length - a.querySelectorAll('tr').length; });
+ if (tables[0] && tables[0].querySelectorAll('tr').length > 2) return tables[0];
+ return document.querySelector('#grid') || document.querySelector('.grid') ||
+ document.querySelector('.cards') || tables[0] || null;
+ }
+
+ /* -- field model ------------------------------------------------------- */
+ // Returns { kind:'table'|'cards', fields:[{key,label,idx?,cls?}], items() }
+ function model(target) {
+ if (target.tagName === 'TABLE') {
+ var ths = [].slice.call(target.querySelectorAll('thead th, thead td'));
+ if (!ths.length) { // header-less table: synthesize from first row
+ var r0 = target.querySelector('tbody tr, tr');
+ ths = r0 ? [].slice.call(r0.children).map(function (_, i) { var s = el('th'); s.textContent = 'Col ' + (i + 1); return s; }) : [];
+ }
+ var fields = ths.map(function (th, i) { return { key: slug(txt(th)) + '-' + i, label: txt(th) || ('Col ' + (i + 1)), idx: i }; });
+ return {
+ kind: 'table', fields: fields,
+ rows: function () { return [].slice.call(target.querySelectorAll('tbody tr')).filter(function (r) { return !r.classList.contains('na-group-h'); }); },
+ cellText: function (row, f) { var c = row.children[f.idx]; return txt(c); }
+ };
+ }
+ // card grid — infer fields from repeated child element classes / [data-field]
+ var cards = [].slice.call(target.children).filter(function (c) { return c.nodeType === 1 && !c.classList.contains('na-group-h') && !c.classList.contains('na-group-body'); });
+ var sample = cards.slice(0, 24), seen = {}, order = [];
+ sample.forEach(function (card) {
+ var dfCells = [].slice.call(card.querySelectorAll('[data-field]'));
+ dfCells.forEach(function (n) {
+ var k = n.getAttribute('data-field'); if (!seen[k]) { seen[k] = { key: slug(k), label: k, cls: '[data-field="' + k + '"]', df: k, hits: 0 }; order.push(seen[k]); } seen[k].hits++;
+ });
+ // coarse child-class fields ONLY when this card tags no [data-field] cells —
+ // otherwise a cell with both a class and a data-field yields a duplicate field.
+ if (dfCells.length) return;
+ [].slice.call(card.children).forEach(function (n) {
+ var cls = (n.className && typeof n.className === 'string') ? n.className.split(/\s+/)[0] : '';
+ if (!cls || cls.indexOf('na-') === 0) return;
+ var k = 'c-' + cls;
+ if (!seen[k]) { seen[k] = { key: slug(cls), label: cls.replace(/[-_]/g, ' '), cls: '.' + cls, hits: 0 }; order.push(seen[k]); } seen[k].hits++;
+ });
+ });
+ // keep fields present on a majority of sampled cards (stable structure)
+ var min = Math.max(1, Math.floor(sample.length * 0.4));
+ var fields = order.filter(function (f) { return f.hits >= min; });
+ return {
+ kind: 'cards', cards: true, fields: fields,
+ rows: function () { return [].slice.call(target.children).filter(function (c) { return c.nodeType === 1 && !c.classList.contains('na-group-h') && !c.classList.contains('na-group-body') && c.offsetParent !== null || (c.nodeType === 1 && !c.classList.contains('na-group-h')); }); },
+ cellText: function (card, f) { var n = f.df ? card.querySelector('[data-field="' + f.df + '"]') : card.querySelector(f.cls); return txt(n); }
+ };
+ }
+
+ /* -- one instance ------------------------------------------------------ */
+ function Agent(target, opt) {
+ var key = navKey(target);
+ var st = load(key);
+ var M = model(target);
+ var self = { target: target, model: M, state: st, key: key };
+ var applyingSort = false;
+
+ /* field show/hide via CONTAINER classes (survives host re-renders) */
+ function fieldStyleTag() {
+ var id = 'na-style-' + key.replace(/[^a-z0-9]/gi, '');
+ var tag = document.getElementById(id) || (function () { var t = el('style'); t.id = id; document.head.appendChild(t); return t; })();
+ var css = '';
+ (st.hidden || []).forEach(function (key) {
+ var f = M.fields.filter(function (x) { return x.key === key; })[0]; if (!f) return;
+ if (M.kind === 'table') {
+ css += 'table[data-na] thead th:nth-child(' + (f.idx + 1) + '),table[data-na] tbody td:nth-child(' + (f.idx + 1) + '){display:none}';
+ } else {
+ css += target.getAttribute('data-na-sel') + ' ' + f.cls + '{display:none}';
+ }
+ });
+ tag.textContent = css;
+ }
+
+ function applySort() {
+ if (!st.sort) return;
+ var f = M.fields.filter(function (x) { return x.key === st.sort; })[0]; if (!f) return;
+ var dir = st.dir === 'desc' ? -1 : 1;
+ var rows = M.rows();
+ if (!rows.length) return;
+ applyingSort = true;
+ // detect numeric
+ var numeric = rows.slice(0, 12).every(function (r) { var v = M.cellText(r, f); return v === '' || num(v) !== null; });
+ rows.sort(function (a, b) {
+ var va = M.cellText(a, f), vb = M.cellText(b, f);
+ if (numeric) { var na = num(va), nb = num(vb); na = na == null ? -Infinity : na; nb = nb == null ? -Infinity : nb; return (na - nb) * dir; }
+ return va.localeCompare(vb, undefined, { numeric: true }) * dir;
+ });
+ var parent = M.kind === 'table' ? target.querySelector('tbody') : target;
+ rows.forEach(function (r) { parent.appendChild(r); });
+ applyGroup(); // grouping depends on sorted order
+ applyingSort = false;
+ }
+
+ function clearGroups() {
+ [].slice.call(target.querySelectorAll('.na-group-h')).forEach(function (n) { n.remove(); });
+ }
+
+ function applyGroup() {
+ clearGroups();
+ if (!st.group) return;
+ var f = M.fields.filter(function (x) { return x.key === st.group; })[0]; if (!f) return;
+ var rows = M.rows(), last = null, parent = M.kind === 'table' ? target.querySelector('tbody') : target;
+ var counts = {};
+ rows.forEach(function (r) { var v = M.cellText(r, f) || '—'; counts[v] = (counts[v] || 0) + 1; });
+ rows.forEach(function (r) {
+ var v = M.cellText(r, f) || '—';
+ if (v !== last) {
+ var h;
+ if (M.kind === 'table') { h = el('tr', 'na-group-h'); var td = el('td'); td.colSpan = M.fields.length; td.innerHTML = '▸ ' + esc(v) + ' <span class="na-gcount">(' + counts[v] + ')</span>'; h.appendChild(td); }
+ else { h = el('div', 'na-group-h', '▸ ' + esc(v) + ' <span class="na-gcount">(' + counts[v] + ')</span>'); }
+ parent.insertBefore(h, r); last = v;
+ }
+ });
+ }
+
+ function applySearch() {
+ var qraw = (st.q || '').toLowerCase();
+ M.rows().forEach(function (r) {
+ var show = !qraw || r.textContent.toLowerCase().indexOf(qraw) >= 0;
+ r.style.display = show ? '' : 'none';
+ });
+ }
+
+ function applyAll() { fieldStyleTag(); applySort(); applySearch(); if (self.renderList && st.view === 'list') self.renderList(); }
+
+ /* re-apply after host re-renders (debounced, ignores our own mutations).
+ If the FIELD SET changed (async data arrived, filter repopulated), tell
+ mount() to rebuild the panel UI. */
+ function fieldSig(m) { return m.kind + ':' + m.fields.map(function (f) { return f.key; }).join(','); }
+ var sig = fieldSig(M);
+ var t;
+ var mo = new MutationObserver(function () {
+ if (applyingSort) return;
+ clearTimeout(t); t = setTimeout(function () {
+ M = model(target); self.model = M;
+ var ns = fieldSig(M);
+ if (ns !== sig) { sig = ns; if (self.onFields) try { self.onFields(); } catch (e) {} }
+ applyAll();
+ }, 140);
+ });
+ try { mo.observe(M.kind === 'table' ? (target.querySelector('tbody') || target) : target, { childList: true }); } catch (e) {}
+
+ self.applyAll = applyAll;
+ self.applySort = applySort;
+ self.applyGroup = applyGroup;
+ self.applySearch = applySearch;
+ self.save = function () { save(key, st); };
+ return self;
+ }
+
+ function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); }
+
+ /* -- Grid / List view toggle (shared by standalone + merged panels) ---- */
+ function viewToggle(agent) {
+ var st = agent.state, wrap = el('div', 'na-viewseg');
+ wrap.innerHTML = '<button data-v="grid">▦ Grid</button><button data-v="list">☰ List</button>';
+ function mark() { [].slice.call(wrap.children).forEach(function (b) { b.classList.toggle('on', (st.view || 'grid') === b.getAttribute('data-v')); }); }
+ [].slice.call(wrap.children).forEach(function (b) { b.onclick = function () { agent.setView(b.getAttribute('data-v')); mark(); }; });
+ mark(); return wrap;
+ }
+
+ /* -- panel UI ---------------------------------------------------------- */
+ function buildPanel(agent, opt) {
+ var M = agent.model, st = agent.state;
+ var panel = el('aside', 'na-panel');
+ panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'View controls');
+ var title = (opt && opt.title) || document.title || 'View';
+ panel.appendChild(el('header', null,
+ '<span class="na-title">⚙ View · ' + esc(title) + '</span>' +
+ '<button class="na-x" title="Close">✕</button>'));
+ var body = el('div', 'na-body'); panel.appendChild(body);
+
+ // Grid / List view toggle (only meaningful when we have fields to lay out)
+ if (M.fields && M.fields.length) body.appendChild(viewToggle(agent));
+
+ // search
+ var search = el('input', 'na-search'); search.type = 'search'; search.placeholder = 'Search all fields…';
+ search.value = st.q || '';
+ search.addEventListener('input', function () { st.q = search.value; agent.save(); agent.applySearch(); });
+ body.appendChild(search);
+
+ function section(label, openDefault) {
+ var s = el('div', 'na-sec' + (openDefault ? ' open' : ''));
+ s.appendChild(el('div', 'na-h', label));
+ var c = el('div', 'na-c'); s.appendChild(c);
+ s.querySelector('.na-h').addEventListener('click', function () { s.classList.toggle('open'); });
+ body.appendChild(s); return c;
+ }
+
+ var haveFields = M.fields && M.fields.length;
+
+ // FIELDS toggle
+ if (haveFields) {
+ var fc = section('Fields (' + M.fields.length + ')', true);
+ st.hidden = st.hidden || [];
+ M.fields.forEach(function (f) {
+ var row = el('label', 'na-fieldrow');
+ var cb = el('input'); cb.type = 'checkbox'; cb.checked = st.hidden.indexOf(f.key) < 0;
+ cb.addEventListener('change', function () {
+ var i = st.hidden.indexOf(f.key);
+ if (cb.checked && i >= 0) st.hidden.splice(i, 1);
+ else if (!cb.checked && i < 0) st.hidden.push(f.key);
+ agent.save(); agent.applyAll();
+ });
+ row.appendChild(cb);
+ row.appendChild(el('span', null, esc(f.label)));
+ fc.appendChild(row);
+ });
+ var mini = el('div', 'na-mini',
+ '<button data-all>All</button><button data-none>None</button>');
+ mini.querySelector('[data-all]').onclick = function () { st.hidden = []; agent.save(); agent.applyAll(); refreshChecks(); };
+ mini.querySelector('[data-none]').onclick = function () { st.hidden = M.fields.map(function (f) { return f.key; }); agent.save(); agent.applyAll(); refreshChecks(); };
+ fc.appendChild(mini);
+ function refreshChecks() { [].slice.call(fc.querySelectorAll('.na-fieldrow input')).forEach(function (cb, i) { cb.checked = st.hidden.indexOf(M.fields[i].key) < 0; }); }
+ }
+
+ // SORT
+ if (haveFields) {
+ var sc = section('Sort', true);
+ var srow = el('div', 'na-row', '<label>Field</label>');
+ var sel = el('select'); sel.appendChild(new Option('— none —', ''));
+ M.fields.forEach(function (f) { sel.appendChild(new Option(f.label, f.key)); });
+ sel.value = st.sort || ''; srow.appendChild(sel); sc.appendChild(srow);
+ var dir = el('div', 'na-mini', '<button data-asc>▲ Asc</button><button data-desc>▼ Desc</button>');
+ function markDir() { dir.querySelector('[data-asc]').classList.toggle('on', st.dir !== 'desc'); dir.querySelector('[data-desc]').classList.toggle('on', st.dir === 'desc'); }
+ sel.addEventListener('change', function () { st.sort = sel.value; agent.save(); agent.applySort(); });
+ dir.querySelector('[data-asc]').onclick = function () { st.dir = 'asc'; markDir(); agent.save(); agent.applySort(); };
+ dir.querySelector('[data-desc]').onclick = function () { st.dir = 'desc'; markDir(); agent.save(); agent.applySort(); };
+ markDir(); sc.appendChild(dir);
+ }
+
+ // GROUP
+ if (haveFields) {
+ var gc = section('Group by', false);
+ var grow = el('div', 'na-row', '<label>Field</label>');
+ var gsel = el('select'); gsel.appendChild(new Option('— none —', ''));
+ M.fields.forEach(function (f) { gsel.appendChild(new Option(f.label, f.key)); });
+ gsel.value = st.group || ''; grow.appendChild(gsel); gc.appendChild(grow);
+ gsel.addEventListener('change', function () { st.group = gsel.value; agent.save(); agent.applyGroup(); });
+ }
+
+ // DENSITY (only if the target reacts to --cols or has a page density var)
+ var dc = section('Density', false);
+ var drow = el('div', 'na-row', '<label>Cols</label>');
+ var rng = el('input'); rng.type = 'range'; rng.min = 2; rng.max = 12; rng.value = st.cols || getComputedCols(agent.target) || 5;
+ rng.addEventListener('input', function () { st.cols = +rng.value; agent.save(); setCols(agent.target, +rng.value); });
+ drow.appendChild(rng); dc.appendChild(drow);
+ dc.appendChild(el('div', 'na-note', 'Adjusts grid columns where the layout supports it.'));
+ if (st.cols) setCols(agent.target, st.cols);
+
+ if (!haveFields) body.appendChild(el('div', 'na-note',
+ 'No structured fields detected on this grid — field toggles, sort & group need <code><table></code> headers or <code>[data-field]</code>/classed card elements. Search, density and the movable panel are active.'));
+
+ var reset = el('button', 'na-reset', '↺ Reset this page’s view');
+ reset.onclick = function () { try { localStorage.removeItem(agent.key); } catch (e) {} location.reload(); };
+ body.appendChild(reset);
+
+ panel.querySelector('.na-x').onclick = function () { panel.classList.remove('open'); };
+ document.body.appendChild(panel);
+ return panel;
+ }
+
+ function getComputedCols(t) { try { var v = getComputedStyle(t).getPropertyValue('--cols'); return v ? parseInt(v) : null; } catch (e) { return null; } }
+ function setCols(t, n) {
+ try {
+ t.style.setProperty('--cols', n);
+ // fallback for grids without a --cols hook
+ if (!/grid-template-columns/.test(t.style.cssText) && getComputedStyle(t).display === 'grid')
+ t.style.gridTemplateColumns = 'repeat(' + n + ',minmax(0,1fr))';
+ } catch (e) {}
+ }
+
+ /* -- movable / resizable / removable panel chrome ---------------------- */
+ function chrome(agent, opt) {
+ var t = agent.target;
+ if (t.closest('.na-wrap')) return;
+ var wrap = el('div', 'na-wrap');
+ var title = (opt && opt.title) || document.title || 'Data';
+ var bar = el('div', 'na-bar',
+ '<span class="na-bt">▤ ' + esc(title) + '</span>' +
+ '<button data-collapse title="Collapse">▁</button>' +
+ '<button data-max title="Maximize / float">⤢</button>' +
+ '<button data-remove title="Remove panel">✕</button>');
+ var inner = el('div', 'na-inner');
+ t.parentNode.insertBefore(wrap, t);
+ wrap.appendChild(bar); wrap.appendChild(inner); inner.appendChild(t);
+
+ var g = agent.state.geo || {};
+ bar.querySelector('[data-collapse]').onclick = function () { wrap.classList.toggle('na-collapsed'); persist(); };
+ bar.querySelector('[data-max]').onclick = function () { wrap.classList.toggle('na-max'); wrap.classList.remove('na-float'); persist(); };
+ bar.querySelector('[data-remove]').onclick = function () { wrap.classList.add('na-removed'); addRestore(); persist(); };
+
+ // drag to float/move
+ var down = null;
+ bar.addEventListener('mousedown', function (e) {
+ if (e.target.tagName === 'BUTTON') return;
+ var r = wrap.getBoundingClientRect();
+ if (!wrap.classList.contains('na-float')) { wrap.classList.add('na-float'); wrap.style.left = r.left + 'px'; wrap.style.top = r.top + 'px'; wrap.style.width = r.width + 'px'; }
+ down = { x: e.clientX, y: e.clientY, l: parseFloat(wrap.style.left) || r.left, t: parseFloat(wrap.style.top) || r.top };
+ e.preventDefault();
+ });
+ window.addEventListener('mousemove', function (e) {
+ if (!down) return;
+ wrap.style.left = (down.l + e.clientX - down.x) + 'px';
+ wrap.style.top = Math.max(0, down.t + e.clientY - down.y) + 'px';
+ });
+ window.addEventListener('mouseup', function () { if (down) { down = null; persist(); } });
+
+ function persist() {
+ agent.state.geo = {
+ float: wrap.classList.contains('na-float'), max: wrap.classList.contains('na-max'),
+ collapsed: wrap.classList.contains('na-collapsed'), removed: wrap.classList.contains('na-removed'),
+ left: wrap.style.left, top: wrap.style.top, width: wrap.style.width, height: wrap.style.height
+ };
+ agent.save();
+ }
+ function addRestore() {
+ var rb = document.querySelector('.na-restorebar') || (function () { var b = el('div', 'na-restorebar'); document.body.appendChild(b); return b; })();
+ var btn = el('button', null, '⟲ ' + esc(title));
+ btn.onclick = function () { wrap.classList.remove('na-removed'); btn.remove(); persist(); };
+ rb.appendChild(btn);
+ }
+ // restore saved geometry
+ if (g.float) { wrap.classList.add('na-float'); ['left', 'top', 'width', 'height'].forEach(function (k) { if (g[k]) wrap.style[k] = g[k]; }); }
+ if (g.max) wrap.classList.add('na-max');
+ if (g.collapsed) wrap.classList.add('na-collapsed');
+ // ROOT FIX (Steve-approved 2026-08-17): a "removed" grid must NEVER stay hidden across reloads —
+ // that trap has no recovery and blanked whole pages (CRCP mls.html). Do NOT restore `removed` on
+ // load: the grid always reappears; in-session Remove still works, it just no longer persist-hides.
+ // (Float / max / collapsed still persist — those are safe, recoverable layout choices.)
+ }
+
+ /* -- merged mode: fold into an existing house panel ("2 into 1") ------- */
+ function findHousePanel(target) {
+ var asides = [].slice.call(document.querySelectorAll('aside, .sidebar, .panel-left, [data-house-panel]'));
+ for (var i = 0; i < asides.length; i++) {
+ var a = asides[i];
+ if (a.classList.contains('na-panel')) continue;
+ if (a.contains(target)) continue; // a real control sidebar won't contain the grid
+ if (a.querySelector('input, select, details, .toggle, .chip')) return a; // has controls
+ }
+ return null;
+ }
+
+ function buildMergeSection(agent, house, opt) {
+ var M = agent.model, st = agent.state;
+ var old = house.querySelector('.na-merge'); if (old) old.remove();
+ var box = el('div', 'na-merge');
+ box.appendChild(el('div', 'na-mh', '⚙ Nav Agent'));
+ if (M.fields && M.fields.length) {
+ // sort ANY field (superset of the house's fixed sort options)
+ var srow = el('div', 'na-row', '<label>Sort</label>');
+ var ssel = el('select'); ssel.appendChild(new Option('— none —', ''));
+ M.fields.forEach(function (f) { ssel.appendChild(new Option(f.label, f.key)); });
+ ssel.value = st.sort || ''; srow.appendChild(ssel); box.appendChild(srow);
+ var dir = el('div', 'na-mini', '<button data-asc>▲ Asc</button><button data-desc>▼ Desc</button>');
+ function markDir() { dir.querySelector('[data-asc]').classList.toggle('on', st.dir !== 'desc'); dir.querySelector('[data-desc]').classList.toggle('on', st.dir === 'desc'); }
+ ssel.onchange = function () { st.sort = ssel.value; agent.save(); agent.applySort(); };
+ dir.querySelector('[data-asc]').onclick = function () { st.dir = 'asc'; agent.save(); agent.applySort(); markDir(); };
+ dir.querySelector('[data-desc]').onclick = function () { st.dir = 'desc'; agent.save(); agent.applySort(); markDir(); };
+ markDir(); box.appendChild(dir);
+ // group ANY field
+ var grow = el('div', 'na-row', '<label>Group</label>');
+ var gsel = el('select'); gsel.appendChild(new Option('— none —', ''));
+ M.fields.forEach(function (f) { gsel.appendChild(new Option(f.label, f.key)); });
+ gsel.value = st.group || ''; gsel.onchange = function () { st.group = gsel.value; agent.save(); agent.applyGroup(); };
+ grow.appendChild(gsel); box.appendChild(grow);
+ }
+ if (M.fields && M.fields.length) box.appendChild(viewToggle(agent));
+ box.appendChild(el('div', 'na-note', 'Drag the grid’s ▤ bar to float · ▁ collapse · ⤢ maximize · ✕ remove.'));
+ house.appendChild(box);
+ return box;
+ }
+
+ /* -- public mount ------------------------------------------------------ */
+ var API = {
+ instances: [],
+ mount: function (opt) {
+ opt = opt || {};
+ try {
+ var target = findTarget(opt);
+ if (!target) return null;
+ if (target.__naMounted) return target.__naAgent;
+ target.__naMounted = true;
+ if (target.tagName === 'TABLE') target.setAttribute('data-na', '1');
+ else target.setAttribute('data-na-sel', target.id ? '#' + target.id : '.' + (target.className.split(/\s+/)[0] || 'grid'));
+ var agent = Agent(target, opt);
+ target.__naAgent = agent;
+
+ // ---- compact LIST view (the kv-list "third view", v1.2) -----------
+ // Renders each live grid row as one scannable line: first visible field
+ // as the identity, the rest as inline LABEL·value chips. DOM-based (reads
+ // M.cellText), so sort/search/field-toggle all flow through for free.
+ // Default OFF — the page's native grid stays the default view.
+ function ensureListEl() {
+ if (agent._listEl) return agent._listEl;
+ var lel = el('div', 'na-list');
+ (target.parentNode || target).insertBefore(lel, target.nextSibling);
+ agent._listEl = lel; return lel;
+ }
+ agent.renderList = function () {
+ var lel = agent._listEl; if (!lel) return;
+ var M2 = agent.model, hidden = agent.state.hidden || [];
+ var vis = (M2.fields || []).filter(function (f) { return hidden.indexOf(f.key) < 0; });
+ if (!vis.length) vis = (M2.fields || []).slice();
+ if (!vis.length) { lel.innerHTML = '<div class="na-lempty">No structured fields detected on this grid.</div>'; return; }
+ var titleF = vis[0], chipF = vis.slice(1);
+ var rows = M2.rows().filter(function (r) { return r.style.display !== 'none'; });
+ if (!rows.length) { lel.innerHTML = '<div class="na-lempty">No matching rows.</div>'; return; }
+ lel.innerHTML = '<div class="na-lrows">' + rows.map(function (r) {
+ var tt = esc(M2.cellText(r, titleF) || '—');
+ var chips = chipF.map(function (f) {
+ var v = M2.cellText(r, f); if (v === '' || v == null) return '';
+ return '<span class="na-lm"><span class="na-lmk">' + esc(f.label) + '</span>' + esc(v) + '</span>';
+ }).join('');
+ return '<div class="na-lrow"><div class="na-lmain"><span class="na-la">' + tt + '</span></div>' +
+ '<div class="na-lmeta">' + chips + '</div></div>';
+ }).join('') + '</div>';
+ };
+ agent.setView = function (v) {
+ agent.state.view = (v === 'list') ? 'list' : 'grid'; agent.save();
+ if (agent.state.view === 'list') { ensureListEl(); target.style.display = 'none'; agent._listEl.style.display = ''; agent.renderList(); }
+ else { if (agent._listEl) agent._listEl.style.display = 'none'; target.style.display = ''; }
+ };
+ // "2 into 1": if the page already has a house control panel, fold into it
+ var house = opt.merge === false ? null : findHousePanel(target);
+ // (re)build the controls — called again by the observer when fields change
+ function makePanel() {
+ if (house) { agent._merge = buildMergeSection(agent, house, opt); return; }
+ var wasOpen = agent._panel && agent._panel.classList.contains('open');
+ if (agent._panel) agent._panel.remove();
+ agent._panel = buildPanel(agent, opt);
+ if (wasOpen) agent._panel.classList.add('open');
+ }
+ agent.onFields = makePanel;
+ agent.merged = !!house;
+ makePanel();
+ chrome(agent, opt);
+ agent.applyAll();
+ // restore a persisted List-view choice (default is Grid — nothing changes)
+ if (agent.state.view === 'list') agent.setView('list');
+
+ // standalone launcher only when NOT merged into a house panel
+ if (!house && !document.querySelector('.na-launch')) {
+ var b = el('button', 'na-launch', '⚙'); b.title = 'View controls';
+ b.onclick = function () {
+ var anyOpen = document.querySelector('.na-panel.open');
+ [].slice.call(document.querySelectorAll('.na-panel')).forEach(function (p) { p.classList.toggle('open', !anyOpen); });
+ };
+ document.body.appendChild(b);
+ }
+ API.instances.push(agent);
+ return agent;
+ } catch (e) { if (window.console) console.warn('[nav-agent] mount skipped:', e); return null; }
+ }
+ };
+ window.NavAgent = API;
+
+ /* -- host left-sidebar collapse (standard feature, v1.3, 2026-08-20) ----
+ * The Nav Agent collapses the host page's primary LEFT sidebar on load and
+ * adds a ☰ toggle to reveal/hide it. ALWAYS collapsed on load — the reveal is
+ * an in-session choice (deliberately NOT persisted), so every page load starts
+ * clean and collapsed. Runs even on pages with no data grid.
+ * Opt out: <body data-nav-sidebar="off">
+ * Force: <body data-nav-sidebar="#mySelector"> or <aside data-nav-sidebar>
+ * Content: the shifted main column auto-detects; force with [data-nav-main]. */
+ function naIsSidebar(e) {
+ if (!e || e === document.body) return false;
+ if (e.classList && (e.classList.contains('na-panel') || e.classList.contains('na-list') ||
+ e.classList.contains('na-launch') || e.classList.contains('na-side-toggle'))) return false;
+ var cs; try { cs = getComputedStyle(e); } catch (x) { return false; }
+ if (['fixed', 'sticky', 'absolute'].indexOf(cs.position) < 0) return false;
+ var r = e.getBoundingClientRect();
+ var atLeft = r.left <= 6;
+ var tall = r.height >= Math.min(window.innerHeight * 0.55, 380);
+ var narrow = r.width > 20 && r.width <= 360;
+ var hasNav = e.querySelectorAll('a,button,nav,.nav').length >= 1;
+ return atLeft && tall && narrow && hasNav;
+ }
+ function naFindSidebar() {
+ var body = document.body, a = body.getAttribute('data-nav-sidebar');
+ if (a === 'off') return null;
+ if (a && a !== 'on' && a !== '') { var s = document.querySelector(a); if (s) return s; }
+ var marked = document.querySelector('[data-nav-sidebar]');
+ if (marked && marked !== body) return marked;
+ var sel = ['#side', '#sidebar', '.sidebar', '#side-nav', '.side-nav', '#leftnav', '.leftnav', 'aside'];
+ for (var i = 0; i < sel.length; i++) { var e = document.querySelector(sel[i]); if (naIsSidebar(e)) return e; }
+ var kids = [].slice.call(body.children);
+ for (var j = 0; j < kids.length; j++) { if (naIsSidebar(kids[j])) return kids[j]; }
+ return null;
+ }
+ function naFindMain(side) {
+ var m = document.querySelector('[data-nav-main]'); if (m) return m;
+ var w = side.getBoundingClientRect().width;
+ var pool = [].slice.call(document.body.children).filter(function (c) {
+ return c.nodeType === 1 && c !== side &&
+ !(c.classList && (c.classList.contains('na-panel') || c.classList.contains('na-launch') ||
+ c.classList.contains('na-side-toggle') || c.classList.contains('na-list')));
+ });
+ for (var i = 0; i < pool.length; i++) {
+ var ml = parseFloat(getComputedStyle(pool[i]).marginLeft) || 0;
+ if (ml > 40 && Math.abs(ml - w) <= 28) return pool[i];
+ }
+ return side.nextElementSibling || null;
+ }
+ function naMountSidebar() {
+ try {
+ if (document.body.getAttribute('data-nav-agent') === 'off') return;
+ if (document.querySelector('.na-side-toggle')) return; // once per page
+ var side = naFindSidebar(); if (!side) return;
+ side.classList.add('na-side');
+ var main = naFindMain(side); if (main) main.classList.add('na-side-main');
+ var open = false; // ALWAYS collapsed on load
+ var tog = el('button', 'na-side-toggle', '☰'); tog.type = 'button';
+ function apply() {
+ side.classList.toggle('na-side-collapsed', !open);
+ if (main) main.classList.toggle('na-side-shift', !open);
+ tog.classList.toggle('on', open);
+ tog.title = open ? 'Collapse sidebar' : 'Expand sidebar';
+ tog.setAttribute('aria-expanded', open ? 'true' : 'false');
+ }
+ tog.onclick = function () { open = !open; apply(); };
+ document.body.appendChild(tog);
+ apply();
+ } catch (e) { if (window.console) console.warn('[nav-agent] sidebar collapse skipped:', e); }
+ }
+ API.collapseSidebar = naMountSidebar;
+
+ function boot() {
+ try {
+ var body = document.body;
+ if (!body || body.getAttribute('data-nav-agent') === 'off') return;
+ naMountSidebar(); // collapse the host left sidebar on load (grid-independent)
+ // wait a tick for API-driven grids to render their first batch
+ var tries = 0;
+ (function attempt() {
+ naMountSidebar(); // idempotent — catches a late-rendered sidebar too
+ var t = findTarget();
+ var populated = false;
+ if (t) {
+ if (t.tagName === 'TABLE') populated = !!t.querySelector('tbody tr');
+ else {
+ var kids = [].slice.call(t.children).filter(function (c) { return c.nodeType === 1; });
+ // ignore an empty-state placeholder (a lone div, often "No … match")
+ populated = !!t.querySelector('.card') || kids.length >= 2;
+ }
+ }
+ if (populated || tries++ > 32) {
+ var _agent = API.mount(); // mount anyway after ~8s; observer fills fields when data arrives
+ // diagnosability (DTD C, 2026-08-13): a null mount is otherwise SILENT — md5/marker/200
+ // all pass while no panel/launcher appears. Emit ONE passive console.info (never warn/error,
+ // never throw, never alter flow) so a future silent-null-mount is greppable, not invisible.
+ // Only nudge when there is GENUINELY no target. mount() also returns null when it
+ // THREW mid-mount on a real grid (its own console.warn already fired) — re-check
+ // findTarget() so we never emit a false "no grid" on a page that actually has one.
+ if (!_agent) { try { if (findTarget() === null && window.console && console.info) console.info('[nav-agent] no grid target found on this marker-bearing page — add data-nav-grid to the real grid container, or data-nav-agent="off" to opt out (see _shared/nav-agent/README.md).'); } catch (e) {} }
+ return;
+ }
+ setTimeout(attempt, 250);
+ })();
+ } catch (e) {}
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+ else boot();
+})();
diff --git a/server.js b/server.js
index eca6fea8..fe21a133 100644
--- a/server.js
+++ b/server.js
@@ -376,6 +376,15 @@ http.createServer((req, res) => {
return json(res, 200, { running, runningInfo, verdicts });
}
+ // shared nav-agent drop-in (grid-controls standard) — static assets
+ if (req.url === '/nav-agent/nav-agent.js' || req.url === '/nav-agent/nav-agent.css') {
+ const file = path.join(__dirname, req.url.replace(/^\//, ''));
+ const type = req.url.endsWith('.css') ? 'text/css' : 'application/javascript';
+ return fs.readFile(file, (e, buf) => {
+ if (e) { res.writeHead(404); return res.end('nav-agent asset missing'); }
+ res.writeHead(200, { 'Content-Type': type + '; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(buf);
+ });
+ }
if (req.url === '/office' || req.url === '/office.html') {
return fs.readFile(OFFICE_HTML, (e, buf) => {
if (e) { res.writeHead(500); return res.end('office view missing'); }
← 4559663c Record local model picker proof
·
back to Ticket System
·
auto-data-snapshot: 2026-09-01T12:55:30 (1 data files) — pac 481c7122 →