[object Object]

← back to Commercialrealestate

CRCP index: bake full nav-agent skillset into UI — list-on-load, KV+Table views, right-rail collapsed panels, group/sort-any-field/density (TK-10747)

be1fb30c667973e34b92abc0e78ef26bc81ac7e8 · 2026-08-20 11:28:20 -0700 · Steve

Files touched

Diff

commit be1fb30c667973e34b92abc0e78ef26bc81ac7e8
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 20 11:28:20 2026 -0700

    CRCP index: bake full nav-agent skillset into UI — list-on-load, KV+Table views, right-rail collapsed panels, group/sort-any-field/density (TK-10747)
---
 public/index.html | 415 ++++++++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 386 insertions(+), 29 deletions(-)

diff --git a/public/index.html b/public/index.html
index 06c7ee4..49720e3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -160,6 +160,46 @@
   .viewseg button { background:var(--card); color:var(--mut); border:0; border-right:1px solid var(--line); padding:7px 12px; font-size:12px; cursor:pointer; }
   .viewseg button:last-child { border-right:0; }
   .viewseg button.active { background:#1f6feb; color:#fff; }
+
+  /* ===== nav-agent inline views: compact List / KV / Table group headers ===== */
+  .compacthost, .kvhost { padding:20px 32px 44px; }
+  .compactlist { display:flex; flex-direction:column; gap:6px; }
+  .crow { display:flex; align-items:center; gap:12px; background:var(--card); border:1px solid var(--line); border-radius:9px; padding:8px 12px; }
+  .crow .starbtn { flex:0 0 auto; }
+  .crow .cid { flex:0 0 220px; min-width:180px; }
+  .crow .cla { display:block; font-weight:600; color:var(--ink); font-size:13.5px; }
+  .crow .clc { display:block; color:var(--mut); font-size:11px; }
+  .crow .cfields { flex:1; display:flex; flex-wrap:wrap; gap:6px 14px; align-items:center; min-width:0; }
+  .cchip { font-size:12px; color:var(--ink); white-space:nowrap; }
+  .cchip .cck { color:var(--mut); font-size:10px; text-transform:uppercase; letter-spacing:.3px; margin-right:3px; }
+  .kvgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:16px; }
+  .kvcard { background:var(--card); border:1px solid var(--line); border-radius:11px; padding:12px 14px; }
+  .kvhead { display:flex; align-items:center; gap:8px; margin-bottom:8px; padding-bottom:8px; border-bottom:1px solid var(--line); }
+  .kvaddr { font-weight:600; font-size:14px; color:var(--ink); }
+  .kvrow { display:flex; gap:10px; padding:3px 0; font-size:12.5px; border-bottom:1px solid rgba(127,127,127,.08); }
+  .kvrow:last-child { border-bottom:0; }
+  .kvk { flex:0 0 42%; color:var(--mut); text-transform:uppercase; font-size:10px; letter-spacing:.3px; padding-top:2px; }
+  .kvv { flex:1; color:var(--ink); min-width:0; word-break:break-word; }
+  .grouphead { padding:8px 12px; margin:10px 0 4px; background:rgba(88,166,255,.08); border-left:3px solid var(--blue); border-radius:4px; font-size:12px; color:var(--mut); text-transform:uppercase; letter-spacing:.4px; }
+  .grouphead b { color:var(--ink); }
+  .kvgrid .grouphead { grid-column:1/-1; }
+  tr.grouprow td.groupcell { background:rgba(88,166,255,.10); font-size:11px; text-transform:uppercase; letter-spacing:.4px; color:var(--mut); padding:7px 10px; }
+  tr.grouprow td.groupcell b { color:var(--ink); }
+
+  /* ===== right-hand control rail (in-flow, NOT floating) — nav-agent skill surface ===== */
+  #ctrlrail { flex:0 0 var(--railW,272px); width:var(--railW,272px); border-left:1px solid var(--line); background:var(--headbg); align-self:stretch; padding:12px 12px 40px; position:sticky; top:57px; max-height:calc(100vh - 57px); overflow-y:auto; }
+  #ctrlrail .railtitle { font-size:11px; text-transform:uppercase; letter-spacing:.6px; color:var(--mut); margin:2px 4px 12px; display:flex; align-items:center; gap:6px; }
+  .rpanel { border:1px solid var(--line); border-radius:9px; margin-bottom:8px; overflow:hidden; background:var(--card); }
+  .rpanel > .rphead { width:100%; display:flex; align-items:center; justify-content:space-between; gap:8px; background:transparent; color:var(--ink); border:0; padding:10px 12px; font-size:12.5px; font-weight:600; cursor:pointer; text-align:left; }
+  .rpanel > .rphead:hover { color:var(--blue); }
+  .rpanel > .rphead .caret { color:var(--mut); font-size:11px; transition:transform .15s; }
+  .rpanel.open > .rphead .caret { transform:rotate(90deg); color:var(--blue); }
+  .rpanel > .rpbody { display:none; padding:4px 12px 12px; }
+  .rpanel.open > .rpbody { display:block; }
+  .rpbody select, .rpbody input[type=text] { width:100%; background:var(--bg); color:var(--ink); border:1px solid var(--line); border-radius:7px; padding:7px 9px; font-size:12px; margin-top:6px; }
+  .rpbody .rlbl { font-size:11px; color:var(--mut); margin-top:8px; display:block; }
+  .rpbody .ftog { display:flex; width:100%; margin:0 0 4px; }
+  @media (max-width:1100px){ #ctrlrail { display:none; } }   /* on narrow screens the left drawer already carries these controls */
   /* ---- field show/hide toggles: one CARD per field, click to toggle on/off ---- */
   .fieldtoggles { display:flex; flex-wrap:wrap; gap:6px; max-height:360px; overflow-y:auto; padding:2px 4px 2px 0; }
   .ftog { display:inline-flex; align-items:center; gap:5px; font-size:12px; color:var(--mut); cursor:pointer; padding:5px 9px;
@@ -668,8 +708,10 @@
   <div class="bar">
     <span class="count" id="count">…</span>
     <span class="viewseg" id="viewseg">
-      <button data-view="grid" class="active">▦ Grid</button>
+      <button data-view="grid">▦ Grid</button>
       <button data-view="list">☰ List</button>
+      <button data-view="table">▤ Table</button>
+      <button data-view="kv">🗂 KV</button>
     </span>
     <div class="right">
       <input id="idxSearch" type="text" placeholder="🔎 search all fields — address, firm, agent, type… (space = AND)" style="background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 11px;font-size:12.5px;min-width:230px" title="Filters across EVERY field on each listing (nested-safe). Space = AND.">
@@ -688,6 +730,27 @@
   <div class="grid" id="grid"></div>
   <div class="foot" id="foot"></div>
 </main>
+<aside id="ctrlrail" aria-label="View controls">
+  <div class="railtitle">🧭 Controls</div>
+  <div class="rpanel" data-rp="fields"><button class="rphead" type="button">Fields <span class="caret">▸</span></button><div class="rpbody"><div id="railFields"></div></div></div>
+  <div class="rpanel" data-rp="sort"><button class="rphead" type="button">Sort <span class="caret">▸</span></button><div class="rpbody">
+    <select id="railSort"></select>
+    <label class="rlbl">Direction (any field)</label>
+    <select id="railColSort"><option value="">— sort by column instead —</option></select>
+    <select id="railColDir"><option value="1">Ascending ↑</option><option value="-1">Descending ↓</option></select>
+  </div></div>
+  <div class="rpanel" data-rp="group"><button class="rphead" type="button">Group <span class="caret">▸</span></button><div class="rpbody">
+    <label class="rlbl">Group by field</label>
+    <select id="railGroup"></select>
+  </div></div>
+  <div class="rpanel" data-rp="density"><button class="rphead" type="button">Density <span class="caret">▸</span></button><div class="rpbody">
+    <label class="rlbl">Cards per row (grid) <span class="rangeval" id="railDensVal">3</span></label>
+    <input type="range" id="railDensity" min="1" max="10" step="1" value="3" style="width:100%">
+  </div></div>
+  <div class="rpanel" data-rp="search"><button class="rphead" type="button">Search <span class="caret">▸</span></button><div class="rpbody">
+    <input id="railSearch" type="text" placeholder="🔎 search all fields…">
+  </div></div>
+</aside>
 </div><!-- /shell -->
 
 <div id="chatwrap">
@@ -843,7 +906,8 @@ const FIELDS = [
 const VIS = (function(){ let s={}; try{ s=JSON.parse(localStorage.getItem('cre_fields')||'{}'); }catch(e){} const o={}; FIELDS.forEach(f=>o[f.k]=(f.k in s)?!!s[f.k]:!!f.def); return o; })();
 function vis(k){ return !!VIS[k]; }
 function saveVis(){ try{ localStorage.setItem('cre_fields', JSON.stringify(VIS)); }catch(e){} }
-let view = localStorage.getItem('cre_view') || 'grid';
+let view = localStorage.getItem('cre_view') || 'list';
+let groupBy = localStorage.getItem('cre_group') || '';   // group-by-any-field; '' = off
 let colSort = null;  // {key, dir} — click-to-sort on a List column; overrides the sort dropdown when set
 const SHORT = new Set((function(){ try{ return JSON.parse(localStorage.getItem('cre_shortlist')||'[]'); }catch(e){ return []; } })());
 function saveShort(){ try{ localStorage.setItem('cre_shortlist', JSON.stringify([...SHORT])); }catch(e){} }
@@ -856,14 +920,52 @@ function applyHeat(rows){
   if(heat==='none') return;
   const vals=rows.map(heatVal).filter(v=>v!=null&&isFinite(v)); if(!vals.length) return;
   const min=Math.min(...vals), max=Math.max(...vals), span=(max-min)||1;
-  const listMode=$('#grid').classList.contains('listhost');
+  const gc=$('#grid').classList;
+  // Per-view heat target: Table stripes the sticky cell; List/KV stripe the row/card via a left inset;
+  // Grid tints the card left border. (Regression fix TK-10747: List + KV were previously un-heated.)
+  const mode = gc.contains('listhost') ? 'table' : gc.contains('compacthost') ? 'compact' : gc.contains('kvhost') ? 'kv' : 'grid';
+  const targetFor=id => mode==='table' ? document.querySelector('.listtbl tr[data-id="'+id+'"] td.stick')
+    : mode==='compact' ? document.querySelector('#grid .crow[data-id="'+id+'"]')
+    : mode==='kv' ? document.querySelector('#grid .kvcard[data-id="'+id+'"]')
+    : document.getElementById('card-'+id);
+  const usesBoxShadow = mode==='table'||mode==='compact'||mode==='kv';
   rows.forEach(p=>{ const v=heatVal(p);
-    const el=listMode?document.querySelector('.listtbl tr[data-id="'+p.id+'"] td.stick'):document.getElementById('card-'+p.id);
-    if(!el) return;
-    if(v==null||!isFinite(v)){ if(listMode) el.style.boxShadow=''; else el.style.borderLeft=''; return; }
+    const el=targetFor(p.id); if(!el) return;
+    if(v==null||!isFinite(v)){ if(usesBoxShadow) el.style.boxShadow=''; else el.style.borderLeft=''; return; }
     const col=heatColor((v-min)/span);
-    if(listMode) el.style.boxShadow='inset 5px 0 0 '+col; else el.style.borderLeft='6px solid '+col;
+    if(usesBoxShadow) el.style.boxShadow='inset 5px 0 0 '+col; else el.style.borderLeft='6px solid '+col;
+  });
+}
+// ===== right-hand control rail: disclosure panels (collapsed on load) + nav-agent controls =====
+function wireCtrlRail(densVal){
+  const rail=$('#ctrlrail'); if(!rail) return;
+  // Disclosure panels — collapsed by default; open only if a saved cre_rail_<name>==='1'.
+  rail.querySelectorAll('.rpanel').forEach(p=>{
+    const name=p.dataset.rp; const saved=localStorage.getItem('cre_rail_'+name);
+    if(saved==='1') p.classList.add('open');   // default (null) = collapsed
+    const head=p.querySelector('.rphead');
+    head.setAttribute('aria-expanded', p.classList.contains('open')?'true':'false');
+    head.onclick=()=>{ const open=p.classList.toggle('open'); head.setAttribute('aria-expanded',open?'true':'false'); localStorage.setItem('cre_rail_'+name, open?'1':'0'); };
   });
+  // Sort — mirror the existing named SORTS (#sort/#sortTop).
+  const rs=$('#railSort'); if(rs){ rs.innerHTML=$('#sort').innerHTML; rs.value=$('#sort').value;
+    rs.onchange=()=>{ colSort=null; $('#sort').value=rs.value; $('#sortTop').value=rs.value; if($('#railColSort'))$('#railColSort').value=''; render(); }; }
+  // Sort-by-ANY-field asc/desc via colSort (uses fieldVal).
+  const groupable=[{k:'addr',label:'Property'}].concat(FIELDS.filter(f=>f.col||['type','firm','status','city'].includes(f.k)));
+  const rcs=$('#railColSort'); if(rcs){ rcs.innerHTML='<option value="">— sort by column instead —</option>'+groupable.map(f=>`<option value="${f.k}">${esc(f.label)}</option>`).join('');
+    if(colSort) rcs.value=colSort.key;
+    const applyColSort=()=>{ const k=rcs.value; if(!k){ colSort=null; } else { colSort={key:k, dir:+($('#railColDir').value)||1}; } render(); };
+    rcs.onchange=applyColSort; $('#railColDir').onchange=()=>{ if(rcs.value) applyColSort(); };
+    if(colSort&&$('#railColDir')) $('#railColDir').value=String(colSort.dir); }
+  // Group-by-ANY-field.
+  const rg=$('#railGroup'); if(rg){ rg.innerHTML='<option value="">— no grouping —</option>'+groupable.map(f=>`<option value="${f.k}">${esc(f.label)}</option>`).join('');
+    rg.value=groupBy; rg.onchange=()=>{ groupBy=rg.value; localStorage.setItem('cre_group',groupBy); render(); }; }
+  // Density — mirror #density.
+  const rd=$('#railDensity'); if(rd){ rd.value=densVal; $('#railDensVal').textContent=densVal;
+    rd.oninput=()=>{ document.documentElement.style.setProperty('--cols',rd.value); $('#railDensVal').textContent=rd.value; if($('#density')){$('#density').value=rd.value; $('#densVal').textContent=rd.value;} localStorage.setItem('cre_density',rd.value); }; }
+  // Search — synced to the top #idxSearch.
+  const rse=$('#railSearch'); if(rse){ const top=$('#idxSearch'); if(top) rse.value=top.value;
+    rse.oninput=()=>{ idxQuery=rse.value.trim().toLowerCase(); if(top) top.value=rse.value; render(); }; }
 }
 function buildFieldToggles(){
   const host=$('#fFields'); if(!host) return;
@@ -875,6 +977,8 @@ function buildFieldToggles(){
     return colFs.concat(rest);
   })();
   host.innerHTML = ordered.map(f=>`<label class="ftog" draggable="true" data-fk="${f.k}"><span class="fdrag" title="Drag to reorder column">⠿</span><input type="checkbox" data-fk="${f.k}" ${vis(f.k)?'checked':''}><span class="flbl">${f.label}</span>${f.col?'<span class="colbadge" title="Also shows as a column in List view">▤</span>':''}</label>`).join('');
+  // Mirror into the right-rail Fields panel (checkbox-only, shares the same VIS state; no drag here).
+  const rail=$('#railFields'); if(rail){ rail.innerHTML=ordered.map(f=>`<label class="ftog" data-fk="${f.k}"><input type="checkbox" data-fk="${f.k}" ${vis(f.k)?'checked':''}><span class="flbl">${f.label}</span></label>`).join(''); }
 }
 // ---- extra computed data points ----
 function ppu(p){ return (p.price&&p.units)?Math.round(p.price/p.units):null; }
@@ -959,18 +1063,11 @@ function colCell(k,p){ const f=p.finance||{};
     default: return '—';
   }
 }
-function renderList(rows){
-  const cols=listCols();
-  // arrow as a CSS CLASS (asc/desc), NOT literal text — keeps the <th> text stable so col-resize's
-  // tableSig doesn't change on sort and saved column widths survive the re-render (TK-10089).
-  const arrow=k=>colSort&&colSort.key===k?(colSort.dir>0?' asc':' desc'):'';
-  // "Property" (addr) stays a fixed anchor; the rest are draggable to reorder + click to sort.
-  const head='<th scope="col" class="stick sortable'+arrow('addr')+'" data-sortk="addr" title="Sort by address">Property</th>'+cols.map(c=>`<th scope="col" class="sortable dragcol${arrow(c.k)}" draggable="true" data-sortk="${c.k}" data-k="${c.k}" title="Drag to reorder · click to sort by ${esc(c.label)}">${esc(c.label)}</th>`).join('');
-  const body=rows.map(p=>`<tr class="lrow" data-id="${p.id}">`
-    +`<td class="stick"><button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="${SHORT.has(p.id)?'Remove from':'Add to'} shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist">★</button> <span class="la">${esc(p.address)}</span><div class="lc">${esc(p.city)}, CA ${p.zip||''}</div>${sourceLink(p)?`<div class="lsrc">${sourceLink(p)}</div>`:''}</td>`
-    +cols.map(c=>`<td>${colCell(c.k,p)}</td>`).join('')+`</tr>`).join('');
-  return `<div class="listwrap"><table class="listtbl" data-no-sort><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
-}
+// arrow as a CSS CLASS (asc/desc), NOT literal text — keeps the <th> text stable so col-resize's
+// tableSig doesn't change on sort and saved column widths survive the re-render (TK-10089).
+// "Property" (addr) stays a fixed anchor; the rest are draggable to reorder + click to sort.
+// renderListShell (defined above) builds the identical thead for both renderList and renderTableGrouped.
+function renderList(rows){ return renderListShell(rows.map(renderListBodyRow).join('')); }
 // The inline panel that opens under a list row: deep property/agent searches + the agent CRM
 // (editable contact info, mark-contacted, saved-letter history, compose). Reuses the same
 // #lc-/#hist- containers the card view uses so fetchLiveComps/fetchHistory work unchanged.
@@ -1018,12 +1115,22 @@ function fieldVal(p,k){ const f=p.finance||{};
 }
 function sortRows(r){
   if(colSort){ const k=colSort.key, d=colSort.dir;
-    return r.sort((a,b)=>{ let x=fieldVal(a,k), y=fieldVal(b,k);
+    r.sort((a,b)=>{ let x=fieldVal(a,k), y=fieldVal(b,k);
       const xn=(x!=null&&x!==''&&!isNaN(+x)), yn=(y!=null&&y!==''&&!isNaN(+y));
       if(xn&&yn){ x=+x; y=+y; } else { x=String(x==null?'':x).toLowerCase(); y=String(y==null?'':y).toLowerCase(); }
       if(x<y) return -d; if(x>y) return d; return 0; });
+  } else {
+    r.sort(SORTS[$('#sort').value]||SORTS.composite);
+  }
+  // group-by: keep the within-group order, but bucket rows so each group is contiguous (stable partition).
+  if(groupBy){
+    const buckets=new Map();
+    r.forEach(p=>{ const gk=groupKeyLabel(p); if(!buckets.has(gk)) buckets.set(gk,[]); buckets.get(gk).push(p); });
+    const keys=[...buckets.keys()].sort((a,b)=>{ const an=!isNaN(+a),bn=!isNaN(+b); if(an&&bn) return +a-+b; return a.localeCompare(b); });
+    const out=[]; keys.forEach(k=>out.push(...buckets.get(k)));
+    r.length=0; r.push(...out);
   }
-  return r.sort(SORTS[$('#sort').value]||SORTS.composite);
+  return r;
 }
 
 // ---- Unified Deal Score (0-100) — DealScore.compute() over the signals we already have. ----
@@ -1541,8 +1648,12 @@ function stateToURL(){
   // NOTE: the app declares a local `history` (chat log) that shadows window.history — must qualify.
   try{ window.history.replaceState(null,'',location.pathname+(qs?('?'+qs):'')); }catch(e){}
 }
+// Snapshot the ORIGINAL query at first script-eval — an early render() can call stateToURL()
+// and rewrite location.search (it drops ?v= whenever the current view isn't grid) BEFORE
+// urlToState() runs, clobbering a shared ?v=/?t= link. Read shared state from this frozen copy.
+const INITIAL_SEARCH = location.search;
 function urlToState(){
-  const p=new URLSearchParams(location.search); let any=false;
+  const p=new URLSearchParams(INITIAL_SEARCH); let any=false;
   if(p.get('t')){ F.types=new Set(p.get('t').split(',').filter(Boolean)); any=true; }
   if(p.get('fm')){ F.firms=new Set(p.get('fm').split('~').filter(Boolean)); any=true; }
   if(p.get('city')){ F.cityQuery=p.get('city'); if($('#fCity'))$('#fCity').value=F.cityQuery; any=true; }
@@ -1616,7 +1727,7 @@ function exportCompareCSV(){
 function printCompare(){ document.body.classList.add('printing-compare'); window.print(); }
 // ---- paginated DOM (perf): render in pages, "Show more" appends ----
 let _rows=[], _shown=0;
-const PAGE=()=> view==='list'?200:60;
+const PAGE=()=> view==='grid'?60:200;
 function moreBtn(){ return _shown<_rows.length ? `<div class="showmore-wrap"><button class="showmore" id="showmore">Show ${Math.min(PAGE(),_rows.length-_shown)} more · showing ${_shown} of ${_rows.length}</button></div>` : ''; }
 // Infinite scroll: auto-trigger the "show more" grow when it scrolls near view (reuses the
 // existing #showmore click handler → _shown+=PAGE → paintPage). Viewport-rooted so it works
@@ -1631,16 +1742,85 @@ function wireAutoMore(){
 function paintPage(){
   const g=$('#grid'); const r=_rows; const slice=r.slice(0,_shown);
   if(!r.length){ g.className='grid'; g.innerHTML=`<div class="empty">No properties match these filters.<br><span class="small">Loosen a filter or hit “Reset all”.</span></div>`; return; }
-  if(view==='list'){ g.className='listhost'; g.innerHTML=renderList(slice)+moreBtn();
-    // When the visible column SET or ORDER changes, rebuild col-resize.js's colgroup + handles.
+  if(view==='table'){                                    // full columnar spreadsheet (sortable + drag-reorder + resizable cols)
+    g.className='listhost'; g.innerHTML=renderTableGrouped(slice)+moreBtn();
     const csig=listCols().map(c=>c.k).join(',');
     if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
   }
-  else { g.className='grid'; g.innerHTML=slice.map(card).join('')+moreBtn(); enrichAssessor(); }
+  else if(view==='kv'){                                  // key→value block per record (deep inspection)
+    g.className='kvhost'; g.innerHTML=renderKVGrouped(slice)+moreBtn();
+  }
+  else if(view==='list'){                                // compact one-row-per-record: identity + inline summary chips
+    g.className='compacthost'; g.innerHTML=renderCompactGrouped(slice)+moreBtn();
+  }
+  else { g.className='grid'; g.innerHTML=groupWrap(slice,card,'grid')+moreBtn(); enrichAssessor(); }
   applyHeat(slice);
   resolveContacts(slice);   // fetch REAL phones for the visible agents, then repaint (TK-10687)
   wireAutoMore();           // convert the show-more button into infinite scroll
 }
+// ===== nav-agent inline view renderers: List (compact) / KV / Table + group-by-any-field =====
+// The rail sort already ordered rows; sortRows() also front-sorts by the group key when groupBy is set,
+// so groups arrive contiguous here — we just detect a key change and emit a header band.
+function groupKeyLabel(p){ if(!groupBy) return ''; let v=fieldVal(p,groupBy); if(v==null||v==='') return '(none)'; return String(v); }
+// Generic grouping wrapper for non-table views: emits a heading band before each group.
+function groupWrap(rows, renderOne, kind){
+  if(!groupBy){ return rows.map(renderOne).join(''); }
+  let out='', last=null;
+  rows.forEach(p=>{ const gk=groupKeyLabel(p); if(gk!==last){ last=gk;
+    out+=`<div class="grouphead grouphead-${kind}">${esc(fieldLabel(groupBy))}: <b>${esc(gk)}</b></div>`; }
+    out+=renderOne(p); });
+  return out;
+}
+function fieldLabel(k){ if(k==='addr') return 'Property'; const f=FIELDS.find(x=>x.k===k); return f?f.label:k; }
+// ---- compact List: address identity + inline summary chips from the visible columns ----
+function compactChips(p){
+  const cols=listCols().slice(0,7);   // a handful of the visible fields as inline chips (keeps the row light)
+  return cols.map(c=>`<span class="cchip"><span class="cck">${esc(c.label)}</span> ${colCell(c.k,p)}</span>`).join('');
+}
+function renderCompactRow(p){
+  return `<div class="crow" data-id="${p.id}">`
+    +`<button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="${SHORT.has(p.id)?'Remove from':'Add to'} shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist">★</button>`
+    +`<div class="cid"><span class="cla">${esc(p.address)}</span><span class="clc">${esc(p.city)}, CA ${p.zip||''}</span></div>`
+    +`<div class="cfields">${compactChips(p)}</div></div>`;
+}
+function renderCompactGrouped(rows){ return `<div class="compactlist">${groupWrap(rows,renderCompactRow,'compact')}</div>`; }
+// ---- KV: each record a labeled key→value block, every visible field vertical ----
+function renderKVCard(p){
+  const cols=listCols();
+  const rowsHtml=[`<div class="kvrow"><span class="kvk">Property</span><span class="kvv">${esc(p.address)}, ${esc(p.city)} CA ${p.zip||''}</span></div>`]
+    .concat(cols.map(c=>`<div class="kvrow"><span class="kvk">${esc(c.label)}</span><span class="kvv">${colCell(c.k,p)}</span></div>`)).join('');
+  return `<div class="kvcard" data-id="${p.id}">`
+    +`<div class="kvhead"><button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="Shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist">★</button><span class="kvaddr">${esc(p.address)}</span></div>`
+    +rowsHtml+`</div>`;
+}
+function renderKVGrouped(rows){ return `<div class="kvgrid">${groupWrap(rows,renderKVCard,'kv')}</div>`; }
+// ---- Table: reuse the full renderList() spreadsheet, injecting group header rows when grouped ----
+function renderTableGrouped(rows){
+  if(!groupBy) return renderList(rows);
+  // Build the grouped body DIRECTLY (no regex-splice of renderList's output) so col-resize sees the
+  // same thead/table shell and we never rebuild a body just to discard it.
+  const ncols=listCols().length+1;
+  let bodyRows='', last=null;
+  rows.forEach(p=>{ const gk=groupKeyLabel(p);
+    if(gk!==last){ last=gk; bodyRows+=`<tr class="grouprow"><td class="groupcell" colspan="${ncols}">${esc(fieldLabel(groupBy))}: <b>${esc(gk)}</b></td></tr>`; }
+    bodyRows+=renderListBodyRow(p); });
+  return renderListShell(bodyRows);
+}
+// The table shell (listwrap + thead), body injected. Shared by renderList() and the grouped table so
+// the <thead>/sortable/drag headers are identical in both paths (col-resize signature stays stable).
+function renderListShell(bodyHtml){
+  const cols=listCols();
+  const arrow=k=>colSort&&colSort.key===k?(colSort.dir>0?' asc':' desc'):'';
+  const head='<th scope="col" class="stick sortable'+arrow('addr')+'" data-sortk="addr" title="Sort by address">Property</th>'+cols.map(c=>`<th scope="col" class="sortable dragcol${arrow(c.k)}" draggable="true" data-sortk="${c.k}" data-k="${c.k}" title="Drag to reorder · click to sort by ${esc(c.label)}">${esc(c.label)}</th>`).join('');
+  return `<div class="listwrap"><table class="listtbl" data-no-sort><thead><tr>${head}</tr></thead><tbody>${bodyHtml}</tbody></table></div>`;
+}
+// One <tr> body row, factored out of renderList so the grouped table can interleave group headers.
+function renderListBodyRow(p){
+  const cols=listCols();
+  return `<tr class="lrow" data-id="${p.id}">`
+    +`<td class="stick"><button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="${SHORT.has(p.id)?'Remove from':'Add to'} shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist">★</button> <span class="la">${esc(p.address)}</span><div class="lc">${esc(p.city)}, CA ${p.zip||''}</div>${sourceLink(p)?`<div class="lsrc">${sourceLink(p)}</div>`:''}</td>`
+    +cols.map(c=>`<td>${colCell(c.k,p)}</td>`).join('')+`</tr>`;
+}
 // ---- saved searches (name + restore the full shareable-URL state) ----
 const SAVED=(function(){ try{ return JSON.parse(localStorage.getItem('cre_saved')||'[]'); }catch(e){ return []; } })();
 function saveSaved(){ try{ localStorage.setItem('cre_saved', JSON.stringify(SAVED)); }catch(e){} }
@@ -2145,7 +2325,7 @@ function loadRanked(_retry){ return fetch('data/ranked.json').then(r=>{ if(!r.ok
   $('#linkbtn').onclick=copyShareLink;
   $('#csvbtn').onclick=exportCSV;
   // search-all-fields box (Steve list-build rule 7) — filters across EVERY field, space = AND
-  { const si=$('#idxSearch'); if(si) si.oninput=()=>{ idxQuery=si.value.trim().toLowerCase(); render(); }; }
+  { const si=$('#idxSearch'); if(si) si.oninput=()=>{ idxQuery=si.value.trim().toLowerCase(); if($('#railSearch'))$('#railSearch').value=si.value; render(); }; }
   // Reset the PRIMARY List table layout back to the tight default: column order, resize widths,
   // and visibility. Clears idxColOrder + col-resize's cr:<pathname> widths + resets VIS to defaults.
   { const rb=$('#idxReset'); if(rb) rb.onclick=()=>{
@@ -2183,11 +2363,15 @@ function loadRanked(_retry){ return fetch('data/ranked.json').then(r=>{ if(!r.ok
   $('#sortTop').value=$('#sort').value;
   // density
   const dv=String(Math.min(10,Math.max(1,+(localStorage.getItem('cre_density')||'3')||3))); $('#density').value=dv; $('#densVal').textContent=dv; document.documentElement.style.setProperty('--cols',dv); localStorage.setItem('cre_density',dv);
-  $('#density').oninput=()=>{ document.documentElement.style.setProperty('--cols',$('#density').value); $('#densVal').textContent=$('#density').value; localStorage.setItem('cre_density',$('#density').value); };
+  $('#density').oninput=()=>{ document.documentElement.style.setProperty('--cols',$('#density').value); $('#densVal').textContent=$('#density').value; localStorage.setItem('cre_density',$('#density').value); if($('#railDensity')){$('#railDensity').value=$('#density').value; $('#railDensVal').textContent=$('#density').value;} };
+
+  // ===== right-hand control rail (nav-agent skill surface) =====
+  wireCtrlRail(dv);
 
   // field show/hide toggles + list/grid view
   buildFieldToggles();
-  $('#fFields').onchange=e=>{ const cb=e.target.closest('input[data-fk]'); if(!cb) return; VIS[cb.dataset.fk]=cb.checked; saveVis(); render(); };
+  $('#fFields').onchange=e=>{ const cb=e.target.closest('input[data-fk]'); if(!cb) return; VIS[cb.dataset.fk]=cb.checked; saveVis(); buildFieldToggles(); render(); };
+  { const rf=$('#railFields'); if(rf) rf.onchange=e=>{ const cb=e.target.closest('input[data-fk]'); if(!cb) return; VIS[cb.dataset.fk]=cb.checked; saveVis(); buildFieldToggles(); render(); }; }
   $('#fAll').onclick=()=>{ FIELDS.forEach(f=>VIS[f.k]=true); saveVis(); buildFieldToggles(); render(); };
   $('#fNone').onclick=()=>{ FIELDS.forEach(f=>VIS[f.k]=false); saveVis(); buildFieldToggles(); render(); };
   $('#fDef').onclick=()=>{ FIELDS.forEach(f=>VIS[f.k]=!!f.def); saveVis(); buildFieldToggles(); render(); };
@@ -2302,5 +2486,178 @@ function loadRanked(_retry){ return fetch('data/ranked.json').then(r=>{ if(!r.ok
        movable/removable panel overlay (.na-restorebar, position:fixed) popped out over the grid.
        The native Fields show/hide/reorder panel (#fFields) is independent and stays. -->
   <!-- <script src="/nav-agent/nav-agent.js" defer></script> -->
+
+  <!-- ── Property detail + notes popup (Steve 2026-08-20) ──────────────────────────────
+       Click ANY property (row/card, any view) OR its 📝 icon -> a popup with ALL the
+       property's details + LA County assessor/sold/permit history + a private notes box.
+       Self-contained: reuses the shared #bkov modal, the per-user /api/agent-notes store,
+       and stamps the 📝 icon via a MutationObserver so NO page render function is edited. -->
+  <style>
+    .pdt-badge{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:7px;
+      border:1px solid var(--line);background:var(--bg2);color:var(--mut);font-size:13px;line-height:1;cursor:pointer;
+      flex:0 0 auto;vertical-align:middle;transition:color .15s,border-color .15s;}
+    .pdt-badge:hover{color:var(--blue);border-color:var(--blue);}
+    .pdt-badge.has{color:var(--gold);border-color:var(--gold);}
+    .card>.pdt-badge{position:absolute;top:12px;left:12px;z-index:2;}
+    td.stick .pdt-badge, .crow>.pdt-badge, .kvcard>.pdt-badge{margin-right:8px;}
+    .card,.lrow,.crow,.kvcard{cursor:pointer;}
+    #pdWrap .pdgrid{display:grid;grid-template-columns:auto 1fr;gap:5px 16px;font-size:13px;margin:12px 0;}
+    #pdWrap .pdk{color:var(--mut);white-space:nowrap;} #pdWrap .pdv{color:var(--ink);}
+    #pdWrap textarea{width:100%;min-height:96px;background:var(--bg2);border:1px solid var(--line);border-radius:10px;
+      color:var(--ink);padding:10px;font:13px/1.5 inherit;resize:vertical;}
+    #pdWrap .pdsave{margin-top:8px;display:flex;gap:10px;align-items:center;}
+    /* dedicated overlay — independent of the shared #bkov modal so nothing else can close it */
+    #pdOverlay{position:fixed;inset:0;background:rgba(0,0,0,.58);z-index:100000;display:none;
+      align-items:flex-start;justify-content:center;padding:40px 16px;overflow:auto;}
+    #pdOverlay.open{display:flex;}
+    #pdCard{background:var(--card);border:1px solid var(--line);border-radius:15px;max-width:640px;width:100%;
+      padding:22px 24px;position:relative;box-shadow:0 24px 70px rgba(0,0,0,.55);}
+    #pdCard>.x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;line-height:1;}
+    #pdCard>.x:hover{color:var(--ink);}
+    #pdWrap h3{margin:0 6px 2px 0;font-size:20px;padding-right:24px;} #pdWrap .mfirm{color:var(--acc);font-size:13px;}
+    #pdWrap .sec{margin-top:16px;} #pdWrap .sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);}
+    #pdWrap .litem{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid var(--line);font-size:13px;} #pdWrap .litem .m{color:var(--mut);}
+    #pdWrap .ci{display:flex;gap:14px;flex-wrap:wrap;font-size:13px;} #pdWrap .gate{color:var(--mut);font-size:13px;}
+    #pdWrap .primary{background:var(--blue);color:#fff;border:0;border-radius:8px;padding:8px 14px;cursor:pointer;font-size:13px;}
+  </style>
+  <script>
+  (function(){
+    var esc=function(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});};
+    var money=function(n){return (n==null||n==='')?'—':'$'+Number(n).toLocaleString();};
+    var keyOf=function(id){return 'prop:'+id;};
+    var findProp=function(id){try{return (window.DATA&&DATA.ranked||[]).find(function(x){return String(x.id)===String(id);});}catch(_){return null;}};
+    var notesSet=new Set();
+    function refreshNotesSet(){return fetch('/api/agent-notes',{headers:{Accept:'application/json'}}).then(function(r){return r.ok?r.json():{notes:{}};})
+      .then(function(j){notesSet=new Set(Object.keys(j.notes||{}).filter(function(k){return j.notes[k]&&j.notes[k].note&&j.notes[k].note.trim();}));return notesSet;}).catch(function(){return notesSet;});}
+
+    // ---- the popup ----
+    function detailRows(p){
+      var ppu=(p.price&&p.units)?Math.round(p.price/p.units):null;
+      var ppsf=(p.price&&p.sqft)?Math.round(p.price/p.sqft):null;
+      var f=p.finance||{};
+      var rows=[
+        ['Type',p.type],['Price',money(p.price)],['Units',p.units],
+        ['$/Unit',ppu?money(ppu):null],['Sqft',p.sqft?Number(p.sqft).toLocaleString():null],['$/SF',ppsf?money(ppsf):null],
+        ['Cap rate',(p.cap_rate!=null&&p.cap_rate!=='')?p.cap_rate+'%':null],
+        ['Cash-on-cash',(f.coc!=null)?f.coc+'%':null],['DSCR',(f.dscr!=null)?f.dscr:null],
+        ['Year built',p.year_built],['Status',p.status],
+        ['Deal score',(p.deal&&p.deal.score!=null)?p.deal.score:null],
+        ['Investment score',(p.composite!=null)?p.composite:(p.financeScore!=null?p.financeScore:null)],
+        ['Firm',p.firm||p.broker_firm],['Broker phone',p.broker_phone],
+        ['Source',p.source_host||p.source],['Zip',p.zip]
+      ];
+      var html=rows.filter(function(r){return r[1]!=null&&r[1]!=='';})
+        .map(function(r){return '<div class="pdk">'+esc(r[0])+'</div><div class="pdv">'+esc(r[1])+'</div>';}).join('');
+      // agent link(s)
+      var ags=p.broker_agents; if(Array.isArray(ags)&&ags.length){
+        var links=ags.slice(0,4).map(function(a){var n=(a&&a.name)||a; return '<a href="/agent.html?name='+encodeURIComponent(n)+'" style="color:var(--blue);text-decoration:none">'+esc(n)+'</a>';}).join(', ');
+        html+='<div class="pdk">Agent</div><div class="pdv">'+links+'</div>';
+      }
+      return html;
+    }
+    function histHtml(d){
+      var money2=function(n){return n?'$'+Number(n).toLocaleString():'—';};
+      var a=d.assessor||d.assessorArchive, h='';
+      if(a){h+='<div class="ci">'
+        +(a.year_built?'<span>🏗 Built '+esc(a.year_built)+'</span>':'')
+        +(a.sqft_main?'<span>📐 '+Number(a.sqft_main).toLocaleString()+' sqft</span>':'')
+        +(a.units?'<span>🏠 '+esc(a.units)+' units</span>':'')
+        +((a.roll_total_value||a.total_value)?'<span>💰 assessed '+money2(a.roll_total_value||a.total_value)+'</span>':'')+'</div>';}
+      var sales=d.sales||[];
+      h+='<div style="margin-top:6px"><b style="font-size:12px;color:var(--mut)">Sold history ('+sales.length+')</b>'
+        +(sales.length?sales.map(function(x){return '<div class="litem"><span>'+esc(x.address||'')+'</span><span class="m">'+money2(x.sold_price)+' · '+String(x.sold_date||'').slice(0,10)+'</span></div>';}).join(''):'<div class="gate">no sold records on file</div>')+'</div>';
+      if(d.permits&&d.permits.length)h+='<div style="margin-top:6px"><b style="font-size:12px;color:var(--mut)">Permits ('+d.permits.length+')</b>'
+        +d.permits.slice(0,20).map(function(pm){return '<div class="litem"><span>'+esc(pm.permit_type||pm.use_description||'permit')+'</span><span class="m">'+esc(String(pm.issue_date||'').slice(0,10))+(pm.valuation?' · '+money2(pm.valuation):'')+'</span></div>';}).join('')+'</div>';
+      if(!a&&!sales.length&&!(d.permits&&d.permits.length))h='<div class="gate">No LA County assessor / sold / permit record matched this address.</div>';
+      return h;
+    }
+    function ensureOverlay(){
+      var ov=document.getElementById('pdOverlay');
+      if(!ov){
+        ov=document.createElement('div'); ov.id='pdOverlay';
+        ov.innerHTML='<div id="pdCard"><button class="x" type="button" aria-label="Close">✕</button><div id="pdBody"></div></div>';
+        document.body.appendChild(ov);
+        var close=function(){ ov.classList.remove('open'); };
+        ov.addEventListener('click',function(e){ if(e.target===ov) close(); });
+        ov.querySelector('.x').addEventListener('click',close);
+        document.addEventListener('keydown',function(e){ if(e.key==='Escape') close(); });
+      }
+      return document.getElementById('pdBody');
+    }
+    window.openProperty=function(p){
+      if(!p) return;
+      var mb=ensureOverlay();
+      document.getElementById('pdOverlay').classList.add('open');
+      var key=keyOf(p.id);
+      mb.innerHTML='<div id="pdWrap"><h3>'+esc(p.address||'Property')+'</h3>'
+        +'<div class="mfirm">'+esc(p.city||'')+(p.zip?' '+esc(p.zip):'')+(p.type?' · '+esc(p.type):'')+'</div>'
+        +'<div class="pdgrid">'+detailRows(p)+'</div>'
+        +'<div class="sec"><h4>Notes (private to you)</h4><textarea id="pdNote" placeholder="Leave a note on this property…"></textarea>'
+        +'<div class="pdsave"><button id="pdSave" class="primary">💾 Save note</button><span id="pdStat" class="gate"></span></div></div>'
+        +'<div class="sec"><h4>LA County history</h4><div id="pdHist" class="gate">loading assessor · sold · permits…</div></div></div>';
+      fetch('/api/agent-notes',{headers:{Accept:'application/json'}}).then(function(r){return r.json();}).then(function(j){
+        var t=document.getElementById('pdNote'); if(t&&j.notes&&j.notes[key])t.value=j.notes[key].note||'';
+      }).catch(function(){});
+      var save=document.getElementById('pdSave');
+      if(save)save.onclick=function(){
+        var t=document.getElementById('pdNote'), st=document.getElementById('pdStat'); if(!t)return;
+        st.textContent='saving…';
+        fetch('/api/agent-notes/'+encodeURIComponent(key),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({note:t.value})})
+          .then(function(r){return r.json();}).then(function(){
+            st.textContent='saved ✓';
+            if(t.value.trim())notesSet.add(key); else notesSet.delete(key);
+            var b=document.querySelector('.pdt-badge[data-id="'+p.id+'"]'); if(b)b.classList.toggle('has',!!t.value.trim());
+            setTimeout(function(){st.textContent='';},1500);
+          }).catch(function(){st.textContent='error saving';});
+      };
+      fetch('/api/property-history?address='+encodeURIComponent(p.address||'')+'&city='+encodeURIComponent(p.city||''))
+        .then(function(r){return r.json();}).then(function(d){var el=document.getElementById('pdHist'); if(el)el.innerHTML=histHtml(d);})
+        .catch(function(){var el=document.getElementById('pdHist'); if(el)el.textContent='history unavailable';});
+    };
+
+    // ---- 📝 icon stamping (MutationObserver — no render-fn edits) ----
+    function badge(id){
+      var b=document.createElement('button');
+      b.className='pdt-badge'+(notesSet.has(keyOf(id))?' has':''); b.type='button';
+      b.setAttribute('data-id',id); b.title='Details & notes'; b.textContent='📝';
+      return b;
+    }
+    function stampAll(){
+      // cards
+      document.querySelectorAll('.card:not([data-pdt])').forEach(function(el){
+        el.setAttribute('data-pdt','1'); var id=(el.id||'').indexOf('card-')===0?el.id.slice(5):el.getAttribute('data-id');
+        if(id){ el.appendChild(badge(id)); }
+      });
+      // list rows -> into the sticky/first cell
+      document.querySelectorAll('.lrow:not([data-pdt])').forEach(function(el){
+        el.setAttribute('data-pdt','1'); var id=el.getAttribute('data-id'); if(!id)return;
+        var cell=el.querySelector('td.stick')||el.querySelector('td'); if(cell)cell.insertBefore(badge(id),cell.firstChild);
+      });
+      // compact rows + kv cards
+      document.querySelectorAll('.crow:not([data-pdt]),.kvcard:not([data-pdt])').forEach(function(el){
+        el.setAttribute('data-pdt','1'); var id=el.getAttribute('data-id'); if(id)el.insertBefore(badge(id),el.firstChild);
+      });
+    }
+    var raf=null;
+    function scheduleStamp(){ if(raf)return; raf=requestAnimationFrame(function(){raf=null;stampAll();}); }
+
+    function boot(){
+      var grid=document.getElementById('grid'); if(!grid)return;
+      // delegated open: the 📝 badge, or a plain row/card click (not on an existing control)
+      grid.addEventListener('click',function(e){
+        var b=e.target.closest('.pdt-badge');
+        if(b){ e.preventDefault(); e.stopPropagation(); openProperty(findProp(b.getAttribute('data-id'))); return; }
+        if(e.target.closest('a,button,input,select,textarea,label,[data-star],[data-copy],[data-quick],[data-sortk],.histbtn,.lcbtn,#showmore')) return;
+        var row=e.target.closest('.card,.lrow,.crow,.kvcard'); if(!row)return;
+        var id=row.getAttribute('data-id'); if(!id&&(row.id||'').indexOf('card-')===0)id=row.id.slice(5);
+        if(id)openProperty(findProp(id));
+      });
+      refreshNotesSet().then(scheduleStamp);
+      new MutationObserver(scheduleStamp).observe(grid,{childList:true,subtree:true});
+      scheduleStamp();
+    }
+    if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot); else boot();
+  })();
+  </script>
 </body>
 </html>

← 9b4f89b CRCP: top-right signed-in user badge (person icon + name + s  ·  back to Commercialrealestate  ·  CRCP index: fix property-detail popup — findProp must read t 39a36a4 →