[object Object]

← back to Commercialrealestate

CRCP list sweep (round 2): deferred pages + Explorer flagship

add9ada94a20990dcb482e8e3a420075dd069f42 · 2026-07-31 09:01:36 -0700 · Steve Abrams

- index.html (Explorer flagship): primary ranked-properties List table gets the
  full 7 controls (tight default, drag-reorder idxColOrder, toggles, expand,
  data-no-sort click-sort, search-ALL-fields, Reset); the two secondary tables
  (broker-detail mls, compare cmptbl) deliberately left untouched
- linkedin.html + licensed.html (cards): cards subset — sort, field-toggle,
  start-tight, card-expand modal, search-all
- crcp.html: judged a HUB (not a browsable list); minimal change = made the
  Call Segments result table click-sortable, no column machinery forced
Verified: all 4 parse clean + HTTP 200 + search-all on a clean serve.js boot.
Note: index List+Grid share one field-visibility set (cre_fields) by design.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files touched

Diff

commit add9ada94a20990dcb482e8e3a420075dd069f42
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 31 09:01:36 2026 -0700

    CRCP list sweep (round 2): deferred pages + Explorer flagship
    
    - index.html (Explorer flagship): primary ranked-properties List table gets the
      full 7 controls (tight default, drag-reorder idxColOrder, toggles, expand,
      data-no-sort click-sort, search-ALL-fields, Reset); the two secondary tables
      (broker-detail mls, compare cmptbl) deliberately left untouched
    - linkedin.html + licensed.html (cards): cards subset — sort, field-toggle,
      start-tight, card-expand modal, search-all
    - crcp.html: judged a HUB (not a browsable list); minimal change = made the
      Call Segments result table click-sortable, no column machinery forced
    Verified: all 4 parse clean + HTTP 200 + search-all on a clean serve.js boot.
    Note: index List+Grid share one field-visibility set (cre_fields) by design.
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 public/crcp.html     |  28 ++++++++--
 public/index.html    | 107 ++++++++++++++++++++++++++++++++---
 public/licensed.html | 113 +++++++++++++++++++++++++++++++++----
 public/linkedin.html | 155 +++++++++++++++++++++++++++++++++++++++++++++++----
 4 files changed, 370 insertions(+), 33 deletions(-)

diff --git a/public/crcp.html b/public/crcp.html
index db70931..afe4b7a 100644
--- a/public/crcp.html
+++ b/public/crcp.html
@@ -95,7 +95,7 @@
 <div class="wrap">
   <div class="tiles" id="tiles"></div>
   <div class="panel"><h3>📞 Call Segments — prospects by property category <span class="c">click a segment for the call list</span></h3>
-    <table id="segTbl"><thead><tr><th>Segment</th><th>Category / note</th><th class="n">Properties</th><th class="n">Price range (med)</th><th class="n">Agents to call</th></tr></thead><tbody></tbody></table>
+    <table id="segTbl"><thead><tr><th class="srt" data-col="label">Segment</th><th class="srt" data-col="product">Category / note</th><th class="srt n" data-col="props">Properties</th><th class="srt n" data-col="priceMed">Price range (med)</th><th class="srt n" data-col="agents">Agents to call</th></tr></thead><tbody></tbody></table>
   </div>
   <div class="cols">
     <div>
@@ -177,17 +177,37 @@ const LEAD_SOURCES=[
   ['⚖ Compliance','ECOA/fair-lending (no protected-class targeting), TCPA (no cold calls/texts w/o consent), CAN-SPAM, NMLS disclosure. Inbound/referral only — never scrape/buy a consumer list.']
 ];
 function leadSourcesHtml(){ return `<div class="sec"><h4>Lead sources — finding self-employed buyers (compliant)</h4>${LEAD_SOURCES.map(([t,d])=>`<div style="margin-bottom:8px"><b style="font-size:13px">${t}</b><div class="meta" style="margin-top:2px">${d}</div></div>`).join('')}</div>`; }
-async function loadSegments(){
-  let d; try{ d=await (await fetch('/api/segments')).json(); }catch(e){ return; }
+// Segments are a fixed summary/lookup set (a handful of rows), NOT a browsable catalog —
+// so no column machinery, just page-owned click-to-sort on the multi-row result table
+// (Steve list-build rule: any multi-row result table must be click-sortable). Segment
+// rows stay click-to-drill; empty/null values always sink to the bottom.
+let SEGMENTS=[], segSort={col:null,dir:-1};
+function renderSegments(){
   const kM=n=>n==null?null:(n>=1e6?'$'+(n/1e6).toFixed(n>=1e7?0:1)+'M':'$'+Math.round(n/1e3)+'k');
   const priceCell=g=>{ if(g.priceMed==null) return '<span style="color:var(--muted)">—</span>';
     const range=(g.priceMin!=null&&g.priceMax!=null&&g.priceMax>g.priceMin)?`<div class="meta" style="margin:0">${kM(g.priceMin)}–${kM(g.priceMax)}</div>`:'';
     return `<b style="color:var(--gold)">${kM(g.priceMed)}</b>${range}`; };
-  $('#segTbl tbody').innerHTML=(d.segments||[]).map(g=>`<tr class="srow" data-key="${g.key}" style="cursor:${g.props?'pointer':'default'}${g.props?'':';opacity:.5'}">
+  let rows=SEGMENTS.slice();
+  if(segSort.col){ const c=segSort.col, dir=segSort.dir;
+    rows.sort((a,b)=>{ let x=a[c], y=b[c];
+      const xE=(x==null||x===''), yE=(y==null||y===''); if(xE&&yE) return 0; if(xE) return 1; if(yE) return -1;
+      const xn=!isNaN(+x)&&x!=='', yn=!isNaN(+y)&&y!=='';
+      if(xn&&yn) return dir*((+x)-(+y));
+      return dir*String(x).toLowerCase().localeCompare(String(y).toLowerCase()); }); }
+  $('#segTbl tbody').innerHTML=rows.map(g=>`<tr class="srow" data-key="${g.key}" style="cursor:${g.props?'pointer':'default'}${g.props?'':';opacity:.5'}">
     <td><b>${g.label}</b></td><td style="color:var(--muted)">${g.product}</td>
     <td class="n">${(g.props||0).toLocaleString()}</td><td class="n">${priceCell(g)}</td><td class="n" style="color:var(--draft)">${g.agents!=null?g.agents.toLocaleString():'—'}</td></tr>`).join('');
   document.querySelectorAll('.srow').forEach(r=>r.onclick=()=>openSegment(r.dataset.key));
+  markHdr('segTbl',segSort);
+}
+async function loadSegments(){
+  let d; try{ d=await (await fetch('/api/segments')).json(); }catch(e){ return; }
+  SEGMENTS=d.segments||[]; renderSegments();
 }
+// wire segment header sort once (page-owned, same pattern as brTbl/fmTbl)
+document.querySelectorAll('#segTbl th.srt').forEach(th=>th.onclick=()=>{ const c=th.dataset.col;
+  if(segSort.col===c) segSort.dir*=-1; else { segSort.col=c; segSort.dir=(th.classList.contains('n'))?-1:1; }
+  renderSegments(); });
 async function openSegment(key){
   const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); mb.innerHTML='<div class="meta">loading…</div>';
   let d; try{ d=await (await fetch('/api/segment?key='+encodeURIComponent(key))).json(); }catch(e){ mb.innerHTML='error'; return; }
diff --git a/public/index.html b/public/index.html
index 44c3ac4..50a3214 100644
--- a/public/index.html
+++ b/public/index.html
@@ -174,6 +174,11 @@
   .listtbl td.stick, .listtbl th.stick { text-align:left; position:sticky; left:0; background:var(--card); }
   .listtbl th.stick { background:#0b0e13; z-index:2; }
   .listtbl th.sortable { cursor:pointer; } .listtbl th.sortable:hover { color:var(--ink); }
+  /* draggable column reorder on the PRIMARY list table (Steve list-build rule) */
+  .listtbl th.dragcol { cursor:grab; }
+  .listtbl th.dragging { opacity:.45; }
+  .listtbl th.dropL { box-shadow:inset 3px 0 0 var(--blue); }
+  .listtbl th.dropR { box-shadow:inset -3px 0 0 var(--blue); }
   .listtbl tr.lrow:hover td { background:rgba(88,166,255,.06); }
   .listtbl tr.lrow:hover td.stick { background:#1b222c; }
   .listtbl .la { font-weight:600; color:var(--ink); text-decoration:none; }
@@ -438,8 +443,8 @@
   </div>
   <div class="fsec"><h4>Fields — show / hide</h4>
     <div class="fieldtoggles" id="fFields"></div>
-    <div style="margin-top:8px"><button class="minibtn" id="fAll">All</button><button class="minibtn" id="fNone">None</button><button class="minibtn" id="fDef">Default</button></div>
-    <div class="small" style="margin-top:6px">▤ = also a column in List view</div>
+    <div style="margin-top:8px"><button class="minibtn" id="fAll">All</button><button class="minibtn" id="fNone">None</button><button class="minibtn" id="fDef">Default</button><button class="minibtn" id="idxReset" title="Reset List columns to the tight default — order, widths &amp; visibility">↺ Reset layout</button></div>
+    <div class="small" style="margin-top:6px">▤ = also a column in List view · drag a List header to reorder · drag its right edge to resize</div>
   </div>
   <button class="resetbtn" id="reset">Reset all filters</button>
 </aside>
@@ -604,6 +609,7 @@
       <button data-view="list">☰ List</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.">
       <button id="shortbtn" class="ex" title="Show only your shortlisted (starred) listings">⭐ Shortlist (0)</button>
       <button id="cmpbtn" class="ex" title="Compare your shortlisted listings side by side">⚖ Compare</button>
       <button id="csvbtn" class="ex" title="Download the current filtered + sorted view (visible columns) as CSV">⬇ CSV</button>
@@ -799,7 +805,30 @@ function grm(p){ const r=rentEstimate(p); if(!r||!r.std) return null; const annu
 function daysOnMarket(p){ const ld=listingDate(p); if(!ld||!ld.exact) return null; const d=Math.round((Date.now()-ld.d.getTime())/86400000); return d>=0?d:null; }
 function shortStatus(p){ if(/Under Contract|Pending|Escrow/i.test(p.status)) return p.status; if(/Sold|Closed/i.test(p.status)) return 'Sold'; if(/OUT OF BUDGET/i.test(p.status)) return 'Over budget'; return 'Active'; }
 // ---- list-view renderer ----
-function listCols(){ return FIELDS.filter(f=>f.col && vis(f.k)); }
+// ── PRIMARY-TABLE column ORDER (Steve list-build rule: draggable + persisted per-browser) ──
+// COLORDER is the display order over the col-eligible FIELDS keys; new/removed keys are
+// reconciled on every read. Persisted as idxColOrder. The always-first "Property" (addr)
+// stick column is NOT part of COLORDER — it's a fixed anchor, reordering happens among the rest.
+let COLORDER=(function(){ let a=[]; try{ a=JSON.parse(localStorage.getItem('idxColOrder')||'[]'); }catch(e){} return Array.isArray(a)?a:[]; })();
+function colKeys(){ return FIELDS.filter(f=>f.col).map(f=>f.k); }               // every col-eligible field key
+function syncColOrder(){ const keys=colKeys();
+  COLORDER=COLORDER.filter(k=>keys.includes(k));                                // prune removed
+  keys.forEach(k=>{ if(!COLORDER.includes(k)) COLORDER.push(k); });            // append new
+}
+function saveColOrder(){ try{ localStorage.setItem('idxColOrder',JSON.stringify(COLORDER)); }catch(e){} }
+// Ordered, col-eligible FIELDS in the user's chosen order.
+function orderedCols(){ syncColOrder(); return COLORDER.map(k=>FIELDS.find(f=>f.k===k)).filter(Boolean); }
+// The visible List columns, in order — drives the header + body + resize signature.
+function listCols(){ return orderedCols().filter(f=>vis(f.k)); }
+// Column-set/order signature guard: when it changes, hand the table back to col-resize.js so
+// it rebuilds its <colgroup> + drag handles for the current columns (Steve list-build rule 1).
+let lastColSig='';
+function reinitResize(){
+  const tbl=document.querySelector('#grid .listtbl'); if(!tbl) return;
+  const cg=tbl.querySelector('colgroup[data-cr]'); if(cg) cg.remove();
+  delete tbl.dataset.crDone; tbl.style.tableLayout=''; tbl.style.width='';
+  if(window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
+}
 function colCell(k,p){ const f=p.finance||{};
   switch(k){
     case 'score': return p.composite??'—';
@@ -840,11 +869,12 @@ function colCell(k,p){ const f=p.finance||{};
 function renderList(rows){
   const cols=listCols();
   const arrow=k=>colSort&&colSort.key===k?(colSort.dir>0?' ▲':' ▼'):'';
-  const head='<th scope="col" class="stick sortable" data-sortk="addr" title="Sort by address">Property'+arrow('addr')+'</th>'+cols.map(c=>`<th scope="col" class="sortable" data-sortk="${c.k}" title="Sort by ${esc(c.label)}">${esc(c.label)}${arrow(c.k)}</th>`).join('');
+  // "Property" (addr) stays a fixed anchor; the rest are draggable to reorder + click to sort.
+  const head='<th scope="col" class="stick sortable" data-sortk="addr" title="Sort by address">Property'+arrow('addr')+'</th>'+cols.map(c=>`<th scope="col" class="sortable dragcol" draggable="true" data-sortk="${c.k}" data-k="${c.k}" title="Drag to reorder · click to sort by ${esc(c.label)}">${esc(c.label)}${arrow(c.k)}</th>`).join('');
   const body=rows.map(p=>`<tr class="lrow" data-id="${p.id}" title="Click to open the agent window (contact · letters · find · comps · history)">`
     +`<td class="stick"><span class="exptog" aria-hidden="true" title="Open agent window">⧉</span><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></td>`
     +cols.map(c=>`<td>${colCell(c.k,p)}</td>`).join('')+`</tr>`).join('');
-  return `<div class="listwrap"><table class="listtbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
+  return `<div class="listwrap"><table class="listtbl" data-no-sort><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
 }
 // 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
@@ -938,9 +968,32 @@ function loopnetLink(p){
 const F = { types:new Set(), firms:new Set(), cityQuery:'', priceMin:null, priceMax:null,
             capMin:0, unitsMin:null, yearMin:null, status:'all', afford:false, unwarrantable:false, nonqm:false, shortlist:false };
 
+// ── SEARCH ALL DATA (Steve list-build rule 7) ──────────────────────────────────
+// Free-text search across EVERY field on a listing — not just the visible/defined columns,
+// nested objects included (finance/qwen/deal…) — via JSON.stringify. Space = AND.
+// A per-row haystack is memoized on `p.__idxHay` and invalidated when the row identity changes.
+let idxQuery='';
+function idxHay(p){
+  if(p.__idxHay!=null) return p.__idxHay;
+  let s;
+  try{ s=JSON.stringify(p, (k,v)=> k==='__idxHay'?undefined:v); }
+  catch(e){ s=Object.keys(p).map(k=>{ const v=p[k]; return v==null?'':(typeof v==='object'?'':String(v)); }).join(' '); }
+  // fold in the CRM-saved contact (agent name/phone/email) which lives outside the row object
+  const c=window.CONTACTS&&window.CONTACTS[p.id]; if(c) s+=' '+JSON.stringify(c);
+  s=s.toLowerCase();
+  try{ Object.defineProperty(p,'__idxHay',{value:s,enumerable:false,configurable:true,writable:true}); }catch(_){ p.__idxHay=s; }
+  return s;
+}
+function idxSearchPass(p){
+  if(!idxQuery) return true;
+  const hay=idxHay(p);
+  return idxQuery.split(/\s+/).filter(Boolean).every(t=>hay.includes(t));
+}
+
 function recompute(){
   const A = SCEN[scenario];
   DATA.ranked.forEach(p=>{
+    try{ delete p.__idxHay; }catch(_){}   // finance/deal recomputed → refresh the search haystack
     p.finance = CREFin.finance(p, A);
     const fsc = CREFin.financeScore(p, p.finance);
     p.financeScore = fsc.score; p.financeConf = fsc.conf;
@@ -1166,6 +1219,7 @@ function enrichAssessor(){
 // ---- the rich filter pass ----
 function applyFilters(list){
   return list.filter(p=>{
+    if(!idxSearchPass(p)) return false;
     if(F.types.size && !F.types.has(p.type)) return false;
     if(F.firms.size && !F.firms.has(firmLabel(p))) return false;
     if(F.cityQuery){ const hay=((p.city||'')+' '+(p.address||'')).toLowerCase(); if(!hay.includes(F.cityQuery)) return false; }
@@ -1184,6 +1238,7 @@ function applyFilters(list){
 }
 // Match everything EXCEPT one facet dimension — powers live facet counts (Amazon-style narrowing).
 function passExcept(p, skip){
+  if(!idxSearchPass(p)) return false;
   if(skip!=='type' && F.types.size && !F.types.has(p.type)) return false;
   if(skip!=='firm' && F.firms.size && !F.firms.has(firmLabel(p))) return false;
   if(skip!=='city' && F.cityQuery){ const hay=((p.city||'')+' '+(p.address||'')).toLowerCase(); if(!hay.includes(F.cityQuery)) return false; }
@@ -1363,7 +1418,11 @@ function moreBtn(){ return _shown<_rows.length ? `<div class="showmore-wrap"><bu
 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(); }
+  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.
+    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(); }
   applyHeat(slice);
 }
@@ -1423,7 +1482,8 @@ function render(){
   { const sb=$('#shortbtn'); if(sb){ sb.textContent='⭐ Shortlist ('+SHORT.size+')'; sb.classList.toggle('shorton',F.shortlist); } }
   renderActiveBar(); stateToURL(); updateFacetCounts();
   // Wait-for-selection: on a clean load (no filter chosen) don't dump 2,134 cards — prompt for a pick.
-  if(fc===0 && !F.cityQuery){
+  // A free-text search counts as a selection, so typing in the search box shows results immediately.
+  if(fc===0 && !F.cityQuery && !idxQuery){
     g.className='grid';
     g.innerHTML=`<div class="empty">👈 Expand a filter tab on the left — or start with one of these:
       <div class="quickstart">
@@ -1606,6 +1666,8 @@ function toast(msg){ let t=document.getElementById('crmToast'); if(!t){ t=docume
 // expanded rows). Works for every asset type in either view.
 function refreshContactUI(id){
   const p=DATA.ranked.find(x=>x.id===id); if(!p) return;
+  try{ delete p.__idxHay; }catch(_){}   // CRM changed → let the search haystack rebuild
+
   const row=[...document.querySelectorAll('.listtbl tr.lrow')].find(r=>r.dataset.id===id);
   if(row){ const cols=listCols(); const ai=cols.findIndex(c=>c.k==='agent');
     if(ai>=0 && row.children[ai+1]) row.children[ai+1].innerHTML=colCell('agent',p);
@@ -1808,6 +1870,16 @@ fetch('data/ranked.json').then(r=>r.json()).then(async d=>{
   $('#savebtn').onclick=()=>{ const name=(prompt('Name this saved search:')||'').trim(); if(!name) return; SAVED.push({name, qs:location.search||''}); saveSaved(); buildSavedList(); };
   $('#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(); }; }
+  // 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=()=>{
+      COLORDER=[]; saveColOrder();
+      try{ Object.keys(localStorage).forEach(k=>{ if(k.indexOf('cr:'+location.pathname+':')===0) localStorage.removeItem(k); }); }catch(e){}
+      FIELDS.forEach(f=>VIS[f.k]=!!f.def); saveVis();
+      lastColSig=''; buildFieldToggles(); render();
+    }; }
   // facet chip handlers (multi-select toggles)
   $('#fType').onclick=e=>{ const c=e.target.closest('.chip'); if(!c) return;
     if(c.dataset.warr==='nonqm'){ F.nonqm=!F.nonqm; syncChips(); render(); return; }
@@ -1857,6 +1929,27 @@ fetch('data/ranked.json').then(r=>r.json()).then(async d=>{
     const si=e.target.closest('[data-saveinfo]'); if(si){ saveAgentInfo(si.dataset.saveinfo); return; }
     const ld=e.target.closest('[data-letdel]'); if(ld){ deleteLetter(ld.dataset.letdel, ld.dataset.idx); return; }
     const row=e.target.closest('.listtbl tr.lrow'); if(row && !e.target.closest('a,button,input,textarea,select,[data-firmfilter],[data-copyval]')){ openContact(row.dataset.id); return; } };
+  // ── drag-to-reorder PRIMARY List columns (Steve list-build rule 2) ──
+  // Delegated on #grid since the list table is re-rendered each render(). Property (addr) is a
+  // fixed anchor (no data-k) so it never participates as a drag source or drop target.
+  let idxDragKey=null;
+  $('#grid').addEventListener('dragstart',e=>{ const th=e.target.closest('.listtbl th.dragcol'); if(!th||!th.dataset.k) return;
+    if(e.target.closest && e.target.closest('.cr-handle')){ e.preventDefault(); return; }  // that grab is a resize, not a move
+    idxDragKey=th.dataset.k; e.dataTransfer.effectAllowed='move'; try{ e.dataTransfer.setData('text/plain',idxDragKey); }catch(_){}
+    th.classList.add('dragging'); });
+  $('#grid').addEventListener('dragover',e=>{ if(!idxDragKey) return; const th=e.target.closest('.listtbl th.dragcol'); if(!th||!th.dataset.k||th.dataset.k===idxDragKey) return;
+    e.preventDefault(); e.dataTransfer.dropEffect='move';
+    const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
+    th.classList.toggle('dropR',after); th.classList.toggle('dropL',!after); });
+  $('#grid').addEventListener('dragleave',e=>{ const th=e.target.closest('.listtbl th.dragcol'); if(th) th.classList.remove('dropL','dropR'); });
+  $('#grid').addEventListener('drop',e=>{ if(!idxDragKey) return; const th=e.target.closest('.listtbl th.dragcol'); if(!th||!th.dataset.k){ idxDragKey=null; return; }
+    e.preventDefault(); const tgt=th.dataset.k;
+    if(tgt!==idxDragKey){ const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
+      syncColOrder(); const from=COLORDER.indexOf(idxDragKey);
+      if(from>=0){ COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,idxDragKey);
+        saveColOrder(); lastColSig=''; render(); } }
+    idxDragKey=null; });
+  $('#grid').addEventListener('dragend',()=>{ document.querySelectorAll('.listtbl th.dragging,.listtbl th.dropL,.listtbl th.dropR').forEach(x=>x.classList.remove('dragging','dropL','dropR')); idxDragKey=null; });
   $('#shortbtn').onclick=()=>{ F.shortlist=!F.shortlist; render(); };
   $('#cmpbtn').onclick=openCompare;
   $('#heat').value=heat; $('#heat').onchange=()=>{ heat=$('#heat').value; localStorage.setItem('cre_heat',heat); render(); };
diff --git a/public/licensed.html b/public/licensed.html
index d6725f2..fe2435b 100644
--- a/public/licensed.html
+++ b/public/licensed.html
@@ -33,6 +33,26 @@ input#q{min-width:230px}
 .figc{font-size:11.5px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
 .figm{font-size:10px;color:var(--mut);font-variant-numeric:tabular-nums}
 .empty{color:var(--mut);padding:40px;text-align:center}
+/* data-point show/hide toggles (Steve list-build rule 2026-07-31) */
+.dpwrap{display:flex;align-items:center;gap:6px;position:relative}
+.dpbtn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 10px;font-size:12.5px;cursor:pointer}
+.dpbtn:hover{border-color:var(--blue)}
+.dppop{display:none;position:absolute;top:calc(100% + 6px);right:0;z-index:20;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:9px 11px;min-width:190px;box-shadow:0 8px 26px rgba(0,0,0,.5)}
+.dppop.on{display:block}
+.dppop .dphd{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);margin:0 0 6px}
+.dppop label{display:flex;align-items:center;gap:7px;font-size:12.5px;padding:3px 2px;cursor:pointer;color:var(--ink)}
+.dppop label:hover{color:var(--blue)}.dppop input{accent-color:var(--blue)}
+.dppop .dpmini{margin-top:8px;display:flex;gap:6px}
+.dppop .dpmini button{flex:1;background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:7px;padding:4px 0;font-size:11px;cursor:pointer}
+.dppop .dpmini button:hover{border-color:var(--blue);color:var(--ink)}
+.card{cursor:pointer}
+/* detail modal (card-click expand → all fields) */
+#ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.62);z-index:60;align-items:flex-start;justify-content:center;padding:44px 14px;overflow-y:auto}
+#ov.on{display:flex}
+#modal{width:min(560px,96vw);background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;position:relative}
+#modal h3{margin:0 0 3px;font-size:19px}#modal .x{position:absolute;top:11px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;line-height:1}
+#modal .mrow{display:flex;justify-content:space-between;gap:12px;padding:5px 0;border-bottom:1px solid var(--line);font-size:13px}
+#modal .mrow .mk{color:var(--mut);text-transform:capitalize}#modal .mrow .mv{text-align:right;word-break:break-word}
 </style></head><body>
 <header>
   <div><h1>📋 Licensed Directory <span class="sub" id="scope"></span></h1></div>
@@ -45,15 +65,42 @@ input#q{min-width:230px}
       <option value="recent">License newest</option>
     </select>
     <span class="rng">Density <input type="range" id="dens" min="2" max="7" value="4"></span>
+    <div class="dpwrap"><button class="dpbtn" id="dpBtn" title="Show / hide data points on the cards">⚙ Fields</button>
+      <div class="dppop" id="dpPop"><div class="dphd">Data points on cards</div><div id="dpList"></div>
+        <div class="dpmini"><button id="dpAll">All</button><button id="dpNone">None</button><button id="dpReset">Reset</button></div></div></div>
     <a href="/" class="sub">← CRCP</a>
   </div>
 </header>
 <div class="wrap"><div class="count" id="count">Loading…</div><div class="grid" id="grid"></div></div>
+<div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
 <script>
 const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
 const $=s=>document.querySelector(s);
 let DATA={agents:[],firms:[],title:[],escrow:[]}, FIRM_IG={}, FIRM_IDX={}, CONTACTS={};
 let tab='firms', sort=localStorage.getItem('ld_sort')||'name';
+// ── Data-point show/hide (Steve list-build rule 2026-07-31) ──
+// Cards render as key·value chips per enabled field. START TIGHT: only the essentials
+// show on first load; the rest are toggled on from the ⚙ Fields popout. Per-tab pref
+// persists under ldFields. Internal keys (id/kind/name) are never card data points.
+const SKIP_FIELDS=new Set(['id','kind','name','__row']);
+// Tight defaults per tab — a few fields only.
+const TIGHT_DEFAULTS={
+  firms:new Set(['agent_count','metro']),
+  agents:new Set(['firm','license_type','license_number','city']),
+  title:new Set(['underwriter']),
+  escrow:new Set(['underwriter']),
+};
+let VISFIELDS=(function(){let s={};try{s=JSON.parse(localStorage.getItem('ldFields')||'{}');}catch(e){}return s;})();
+function tabFieldKeys(){ // union of non-internal keys present in the current tab's rows
+  const seen=[],has=new Set();
+  (DATA[tab]||[]).slice(0,400).forEach(r=>Object.keys(r||{}).forEach(k=>{
+    if(SKIP_FIELDS.has(k)||has.has(k))return; const v=r[k]; if(v==null||v==='')return; has.add(k); seen.push(k);
+  }));
+  return seen;
+}
+function fieldVis(k){ const t=VISFIELDS[tab]||{}; if(k in t) return !!t[k]; return (TIGHT_DEFAULTS[tab]||new Set()).has(k); }
+function setFieldVis(k,v){ (VISFIELDS[tab]=VISFIELDS[tab]||{})[k]=v; try{localStorage.setItem('ldFields',JSON.stringify(VISFIELDS));}catch(e){} }
+function fieldLabel(k){ return k.replace(/_/g,' '); }
 const normFirm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]/g,'');
 function firmHandle(firm){const n=normFirm(firm);if(!n)return null;if(FIRM_IDX[n])return FIRM_IDX[n];
   for(const[a,h]of Object.entries(FIRM_IDX)){if(n===a||(a.length>=5&&n.includes(a))||(a.length>=3&&n.startsWith(a)))return h;}return null;}
@@ -77,16 +124,31 @@ async function boot(){
   $('#scope').textContent=`· ${(ld.scope&&ld.scope.state)||'CA'} · ${(ld.scope&&ld.scope.metros||[]).join(', ')}`;
   const T=[['firms','Firms',DATA.firms.length],['title','Title',DATA.title.length],['escrow','Escrow',DATA.escrow.length],['agents','Agents',DATA.agents.length]];
   $('#tabs').innerHTML=T.map(([k,l,n])=>`<span class="tab${k===tab?' on':''}" data-tab="${k}">${l} <b>${n}</b></span>`).join('');
-  $('#tabs').onclick=e=>{const t=e.target.closest('[data-tab]');if(!t)return;tab=t.dataset.tab;document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x.dataset.tab===tab));render();};
+  $('#tabs').onclick=e=>{const t=e.target.closest('[data-tab]');if(!t)return;tab=t.dataset.tab;document.querySelectorAll('.tab').forEach(x=>x.classList.toggle('on',x.dataset.tab===tab));buildFieldToggles();render();};
   $('#q').oninput=render; $('#sort').value=sort; $('#sort').onchange=e=>{sort=e.target.value;localStorage.setItem('ld_sort',sort);render();};
   const dens=$('#dens'); dens.value=localStorage.getItem('ld_dens')||4; document.documentElement.style.setProperty('--cols',dens.value);
   dens.oninput=e=>{document.documentElement.style.setProperty('--cols',e.target.value);localStorage.setItem('ld_dens',e.target.value);};
+  buildFieldToggles();
   render();
 }
+// ── ⚙ Fields popout wiring ──
+$('#dpBtn').onclick=e=>{e.stopPropagation();$('#dpPop').classList.toggle('on');};
+$('#dpPop').onclick=e=>e.stopPropagation();
+document.addEventListener('click',()=>$('#dpPop').classList.remove('on'));
+$('#dpList').addEventListener('change',e=>{const cb=e.target.closest('input[data-fk]');if(!cb)return;setFieldVis(cb.dataset.fk,cb.checked);render();});
+$('#dpAll').onclick=()=>{tabFieldKeys().forEach(k=>setFieldVis(k,true));buildFieldToggles();render();};
+$('#dpNone').onclick=()=>{tabFieldKeys().forEach(k=>setFieldVis(k,false));buildFieldToggles();render();};
+$('#dpReset').onclick=()=>{if(VISFIELDS[tab])delete VISFIELDS[tab];try{localStorage.setItem('ldFields',JSON.stringify(VISFIELDS));}catch(e){}buildFieldToggles();render();};
+$('#ov').addEventListener('click',e=>{if(e.target.id==='ov')$('#ov').classList.remove('on');});
 function render(){
   const q=$('#q').value.trim().toLowerCase();
   let rows=DATA[tab]||[];
-  if(q) rows=rows.filter(r=>((r.name||'')+' '+(r.firm||'')+' '+(r.license_number||'')).toLowerCase().includes(q));
+  if(q){
+    // Search ALL fields (Steve list-build rule): every field on a row via JSON.stringify,
+    // incl. nested objects. Space = AND (every term must match somewhere on the row).
+    const terms=q.split(/\s+/).filter(Boolean);
+    rows=rows.filter(r=>{const hay=JSON.stringify(r).toLowerCase();return terms.every(t=>hay.includes(t));});
+  }
   rows=[...rows].sort((a,b)=> sort==='count'?((b.agent_count||0)-(a.agent_count||0))
     : sort==='recent'?String(b.expiration||'').localeCompare(String(a.expiration||''))
     : String(a.name||'').localeCompare(String(b.name||'')));
@@ -94,26 +156,55 @@ function render(){
   $('#count').textContent=`${rows.length.toLocaleString()} ${tab}${rows.length>CAP?` · showing first ${CAP} (search to narrow)`:''}`;
   $('#grid').innerHTML = shown.length? shown.map(card).join('') : '<div class="empty">No matches.</div>';
 }
+// Card meta = only the ENABLED data points (⚙ Fields), rendered "label: value".
+function fieldDisplay(r,k){
+  const v=r[k]; if(v==null||v==='')return '';
+  if(k==='agent_count') return `${v} licensed agent${v===1?'':'s'}`;
+  if(k==='license_number') return '#'+v;
+  return fieldLabel(k)+': '+v;
+}
 function card(r){
   const id=r.id, c=CONTACTS[id]||{};
   const firmForIg = r.kind==='agent' ? r.firm : r.name;
-  const metaBits=[];
-  if(r.kind==='firm') metaBits.push(`${r.agent_count} licensed agent${r.agent_count===1?'':'s'}`, r.metro);
-  if(r.kind==='agent') metaBits.push(r.firm||'—', r.license_type, r.license_number?('#'+r.license_number):'', r.city);
-  if(r.kind==='title'||r.kind==='escrow') metaBits.push(r.underwriter?('underwriter: '+r.underwriter):'deal-side');
+  const metaBits=tabFieldKeys().filter(fieldVis).map(k=>fieldDisplay(r,k)).filter(Boolean);
   const contacted = c.contacted_at?`<span class="chip">✓ ${esc(String(c.contacted_at).slice(0,10))}</span>`:'';
   return `<div class="card" data-id="${esc(id)}">
     <div class="ctop"><div class="nm">${esc(r.name)}</div><span class="kind ${r.kind}">${r.kind}</span></div>
-    <div class="meta">${metaBits.filter(Boolean).map(esc).join(' · ')}</div>
+    <div class="meta">${metaBits.map(esc).join(' · ')}</div>
     ${igStrip(firmForIg)}
     <div class="actions"><button class="btn mark" data-id="${esc(id)}">${c.contacted_at?'↻ Update':'✓ Mark contacted'}</button>${contacted}</div>
   </div>`;
 }
+// ── ⚙ Fields popout: build per-tab toggle list ──
+function buildFieldToggles(){
+  const keys=tabFieldKeys();
+  $('#dpList').innerHTML=keys.map(k=>`<label><input type="checkbox" data-fk="${esc(k)}" ${fieldVis(k)?'checked':''}><span>${esc(fieldLabel(k))}</span></label>`).join('')||'<div class="dphd">no fields</div>';
+}
+// ── card-click → detail modal (ALL fields for the row) ──
+function openDetail(id){
+  const r=(DATA[tab]||[]).find(x=>String(x.id)===String(id)); if(!r) return;
+  const ov=$('#ov'), mb=$('#mbody');
+  const rowsHtml=Object.keys(r).filter(k=>!SKIP_FIELDS.has(k)).map(k=>{
+    const v=r[k]; if(v==null||v==='')return '';
+    const vs=(typeof v==='object')?JSON.stringify(v):String(v);
+    return `<div class="mrow"><span class="mk">${esc(fieldLabel(k))}</span><span class="mv">${esc(vs)}</span></div>`;
+  }).filter(Boolean).join('');
+  const c=CONTACTS[id]||{};
+  const contacted=c.contacted_at?`<div class="mrow"><span class="mk">contacted</span><span class="mv">${esc(String(c.contacted_at).slice(0,10))}</span></div>`:'';
+  mb.innerHTML=`<h3>${esc(r.name)} <span class="kind ${esc(r.kind)}">${esc(r.kind)}</span></h3>${rowsHtml}${contacted}`;
+  ov.classList.add('on');
+}
 document.addEventListener('click',async e=>{
-  const b=e.target.closest('.mark'); if(!b) return;
-  b.disabled=true;
-  try{ const j=await fetch('/api/contacts/'+encodeURIComponent(b.dataset.id)+'/contacted',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json());
-    if(j.contact){ CONTACTS[b.dataset.id]=j.contact; render(); } }catch{} finally{ b.disabled=false; }
+  const b=e.target.closest('.mark');
+  if(b){
+    b.disabled=true;
+    try{ const j=await fetch('/api/contacts/'+encodeURIComponent(b.dataset.id)+'/contacted',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json());
+      if(j.contact){ CONTACTS[b.dataset.id]=j.contact; render(); } }catch{} finally{ b.disabled=false; }
+    return;
+  }
+  // card click (not on a button, anchor, or IG thumb) → expand-to-detail modal (all fields)
+  const cardEl=e.target.closest('.card');
+  if(cardEl && !e.target.closest('button,a,.figt')) openDetail(cardEl.dataset.id);
 });
 boot().catch(e=>{$('#count').textContent='Failed to load: '+e.message;});
 </script></body></html>
diff --git a/public/linkedin.html b/public/linkedin.html
index 2f65c7d..5a1af0d 100644
--- a/public/linkedin.html
+++ b/public/linkedin.html
@@ -37,6 +37,25 @@
   .fbtn.on{background:rgba(112,173,71,.16);border-color:var(--active);color:var(--active);}
   .gate{margin-top:16px;font-size:12px;color:var(--muted);border-top:1px solid var(--line);padding-top:12px;}
   .empty{color:var(--muted);padding:30px;text-align:center;}
+  /* sort + data-points controls (Steve list-build rule 2026-07-31) */
+  #sortsel{background:var(--panel2);color:var(--fg);border:1px solid var(--line);border-radius:9px;padding:8px 10px;font-size:12px;}
+  .dpbtn{background:var(--panel2);color:var(--muted);border:1px solid var(--line);border-radius:9px;padding:8px 11px;font-size:12px;cursor:pointer;} .dpbtn:hover{color:var(--fg);border-color:var(--gold);}
+  .dppop{position:absolute;z-index:30;margin-top:6px;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:10px 12px;box-shadow:0 8px 30px rgba(0,0,0,.5);min-width:180px;display:none;}
+  .dppop.on{display:block;}
+  .dptog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--fg);cursor:pointer;padding:3px 4px;border-radius:6px;} .dptog:hover{background:rgba(200,162,75,.08);} .dptog input{accent-color:var(--gold);}
+  .dppop .minibtn{background:var(--panel2);color:var(--muted);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;margin-right:5px;margin-top:8px;} .dppop .minibtn:hover{border-color:var(--gold);color:var(--fg);}
+  /* card-field rows (toggled by data-points) */
+  .row .dp{font-size:11px;color:var(--muted);white-space:nowrap;}
+  .row.clk{cursor:pointer;} .row.clk:hover{border-color:rgba(200,162,75,.5);}
+  /* detail modal — all fields */
+  #ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.62);z-index:60;align-items:flex-start;justify-content:center;padding:44px 14px;overflow-y:auto;}
+  #ov.on{display:flex;}
+  #modal{width:min(560px,96vw);background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
+  #modal h3{margin:0 0 2px;font-size:20px;} #modal .mfirm{color:var(--gold);font-size:13px;}
+  #modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--muted);font-size:22px;cursor:pointer;}
+  #modal .litem{display:flex;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid #20262f;font-size:13px;}
+  #modal .litem .m{color:var(--muted);} #modal .litem a{color:var(--draft);text-decoration:none;} #modal .litem a:hover{text-decoration:underline;}
+  #modal .mact{display:flex;gap:10px;margin-top:14px;flex-wrap:wrap;}
   @media(max-width:680px){ header{flex-wrap:wrap;} header .nav{flex-wrap:wrap;gap:10px;width:100%;} .row{flex-wrap:wrap;} .row .lc{order:5;width:100%;} }
 </style>
 </head>
@@ -54,63 +73,177 @@
     Open each profile and follow manually — this list tracks who you've followed (saved in this browser).
     <b>No automated following</b> — LinkedIn's TOS prohibits 3rd-party automation, so this is a links-only playbook.</div>
   <div class="bar">
-    <input type="text" id="q" placeholder="search name or firm… e.g. Marcus &amp; Millichap, Luther">
+    <input type="text" id="q" placeholder="search ALL fields — name, firm, type, listings… (space = AND)">
     <div class="seg" id="filt">
       <button data-f="all" class="on">All</button>
       <button data-f="profile">Has profile</button>
       <button data-f="followed">Followed</button>
     </div>
+    <select id="sortsel" title="Sort the list"></select>
+    <div style="position:relative">
+      <button class="dpbtn" id="dpbtn" title="Show / hide data points on each card">⚙ Data points</button>
+      <div class="dppop" id="dppop"></div>
+    </div>
     <div class="count" id="count">loading…</div>
   </div>
   <div class="list" id="list"></div>
   <div class="gate">⚑ Manual &amp; TOS-compliant: profile/search links only; the followed state is stored locally in this browser.
     Actual connecting/messaging happens in LinkedIn by hand (or via Sales Navigator) — never automated here.</div>
 </div>
+<div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
 <script>
 const $=s=>document.querySelector(s);
 const FOLLOW_KEY='crcp_li_followed';
 // one-time migration off the legacy persona key
 try{const _o=localStorage.getItem('frank_li_followed');if(_o!==null&&localStorage.getItem(FOLLOW_KEY)===null){localStorage.setItem(FOLLOW_KEY,_o);localStorage.removeItem('frank_li_followed');}}catch(_){}
 let followed=new Set(JSON.parse(localStorage.getItem(FOLLOW_KEY)||'[]'));
-let people=[], filt='all', q='';
+let people=[], filt='all', q='', sortKey='listings', sortDir=-1;
 const atype=t=>t==='residential'?'<span class="atype res">Residential</span>':'<span class="atype com">Commercial</span>';
-const esc=s=>(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
+const esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
 function saveFollowed(){ localStorage.setItem(FOLLOW_KEY, JSON.stringify([...followed])); }
 function toggle(id){ id=String(id); followed.has(id)?followed.delete(id):followed.add(id); saveFollowed(); render(); }
+
+// ── Data points (card fields) — START TIGHT: only a couple show on first load, the
+// user toggles the rest on. A saved pref in liFields always wins. (Steve list-build rule)
+const FIELDS=[
+  {k:'agent_type',l:'Type',t:'atype'},
+  {k:'firm',l:'Company / Firm',t:'s'},
+  {k:'listings',l:'Listings',t:'n'},
+  {k:'linkedin',l:'Profile',t:'prof'},
+];
+const DEFAULT_ON=new Set(['firm','listings']);
+let VISF=(function(){let s={};try{s=JSON.parse(localStorage.getItem('liFields')||'{}');}catch(e){}return s;})();
+function fVis(k){ if(k in VISF) return !!VISF[k]; return DEFAULT_ON.has(k); }
+function saveFields(){ try{localStorage.setItem('liFields',JSON.stringify(VISF));}catch(e){} }
+const dpFmt=(p,f)=>{
+  const v=p[f.k];
+  if(f.t==='atype') return v==='residential'?'Residential':'Commercial';
+  if(f.t==='n') return (v||0)+' listing'+((v||0)===1?'':'s');
+  if(f.t==='prof') return p.linkedin?'has profile':'search only';
+  return v==null||v===''?'—':String(v);
+};
+
 function visible(){
-  return people.filter(p=>{
+  let rows=people.filter(p=>{
     if(filt==='profile' && !p.linkedin) return false;
     if(filt==='followed' && !followed.has(String(p.id))) return false;
     return true;
   });
+  if(q){
+    // Search ALL fields (Steve list-build rule): every field the API returned on a row,
+    // incl. nested objects, via JSON.stringify. Space = AND.
+    const terms=q.toLowerCase().split(/\s+/).filter(Boolean);
+    rows=rows.filter(p=>{
+      const hay=Object.keys(p).map(k=>{const v=p[k];return v==null?'':(typeof v==='object'?JSON.stringify(v):String(v));}).join('  ').toLowerCase();
+      return terms.every(t=>hay.includes(t));
+    });
+  }
+  rows=rows.slice().sort((a,b)=>{
+    let x=a[sortKey], y=b[sortKey];
+    const xE=(x==null||x===''), yE=(y==null||y==='');
+    if(xE&&yE) return 0; if(xE) return 1; if(yE) return -1;
+    const xn=!isNaN(+x), yn=!isNaN(+y);
+    if(xn&&yn){ x=+x; y=+y; } else { x=String(x).toLowerCase(); y=String(y).toLowerCase(); }
+    if(x<y) return -sortDir; if(x>y) return sortDir; return 0;
+  });
+  return rows;
 }
 function render(){
   const rows=visible();
   $('#count').innerHTML=`<b>${followed.size}</b> followed · ${rows.length} shown · ${people.length} total`;
   if(!rows.length){ $('#list').innerHTML='<div class="empty">no matches</div>'; return; }
+  const dpCols=FIELDS.filter(f=>fVis(f.k) && f.k!=='firm'); // firm renders in the .who block
   $('#list').innerHTML=rows.map(p=>{
     const isF=followed.has(String(p.id));
     const link=p.linkedin
       ? `<a class="open" href="${esc(p.linkedin)}" target="_blank" rel="noopener noreferrer">in · Open profile</a>`
       : `<a class="open srch" href="${esc(p.search)}" target="_blank" rel="noopener noreferrer">🔎 Find on LinkedIn</a>`;
-    return `<div class="row${isF?' followed':''}">
-      <div class="who"><span class="nm">${esc(p.name)}</span>${atype(p.agent_type)}<div class="fm">${esc(p.firm||'—')}</div></div>
-      <span class="lc">${p.listings||0} listing${p.listings===1?'':'s'}</span>
+    const dps=dpCols.map(f=>`<span class="dp">${esc(f.l)}: ${esc(dpFmt(p,f))}</span>`).join('');
+    const firmLine=fVis('firm')?`<div class="fm">${esc(p.firm||'—')}</div>`:'';
+    return `<div class="row clk${isF?' followed':''}" data-id="${esc(p.id)}">
+      <div class="who"><span class="nm">${esc(p.name)}</span>${atype(p.agent_type)}${firmLine}</div>
+      ${dps}
       ${link}
-      <button class="fbtn${isF?' on':''}" onclick="toggle('${p.id}')">${isF?'✓ Followed':'+ Follow'}</button>
+      <button class="fbtn${isF?' on':''}" onclick="event.stopPropagation();toggle('${p.id}')">${isF?'✓ Followed':'+ Follow'}</button>
     </div>`;
   }).join('');
 }
-let t;
+// ── card-click → detail modal (ALL fields) ──
+function openDetail(id){
+  const p=people.find(x=>String(x.id)===String(id)); if(!p) return;
+  const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on');
+  const skip=new Set(['name','firm','agent_type']);
+  const link=p.linkedin
+    ? `<a class="open" href="${esc(p.linkedin)}" target="_blank" rel="noopener noreferrer">in · Open profile</a>`
+    : `<a class="open srch" href="${esc(p.search)}" target="_blank" rel="noopener noreferrer">🔎 Find on LinkedIn</a>`;
+  const isF=followed.has(String(p.id));
+  const rowsHtml=Object.keys(p).filter(k=>!skip.has(k)).map(k=>{
+    let v=p[k]; if(v==null||v==='') v='—';
+    else if(typeof v==='object') v=JSON.stringify(v);
+    else if(/^https?:\/\//.test(v)) v=`<a href="${esc(v)}" target="_blank" rel="noopener noreferrer">${esc(v)}↗</a>`;
+    else v=esc(v);
+    return `<div class="litem"><span class="m">${esc(k)}</span><span>${v}</span></div>`;
+  }).join('');
+  mb.innerHTML=`<h3>${esc(p.name)}${atype(p.agent_type)}</h3>`
+    +`<div class="mfirm">${esc(p.firm||'—')}</div>`
+    +rowsHtml
+    +`<div class="mact">${link}<button class="fbtn${isF?' on':''}" onclick="toggle('${p.id}');document.getElementById('ov').classList.remove('on')">${isF?'✓ Followed':'+ Follow'}</button></div>`;
+}
 async function load(){
   $('#count').textContent='loading…';
-  try{ const d=await (await fetch('/api/brokers/linkedin?limit=800'+(q?'&q='+encodeURIComponent(q):''))).json();
+  try{ const d=await (await fetch('/api/brokers/linkedin?limit=800')).json();
     people=d.people||[]; render();
   }catch(e){ $('#list').innerHTML='<div class="empty">failed to load — is the server up? '+e.message+'</div>'; }
 }
-$('#q').oninput=()=>{ q=$('#q').value.trim(); clearTimeout(t); t=setTimeout(load,250); };
+// Search is client-side now (search ALL fields) — no re-fetch; the API also filters by name/firm
+// but we keep the full 800-row set client-side so JSON.stringify search covers every field.
+$('#q').oninput=()=>{ q=$('#q').value.trim(); render(); };
 $('#filt').onclick=e=>{ const b=e.target.closest('button'); if(!b) return;
   document.querySelectorAll('#filt button').forEach(x=>x.classList.toggle('on',x===b)); filt=b.dataset.f; render(); };
+
+// ── sort <select> (Steve list-build rule: every grid gets a sort control) ──
+(function(){
+  const sel=$('#sortsel');
+  const opts=[
+    {k:'listings',dir:-1,l:'Listings ↓'},
+    {k:'listings',dir:1,l:'Listings ↑'},
+    {k:'name',dir:1,l:'Name A→Z'},
+    {k:'name',dir:-1,l:'Name Z→A'},
+    {k:'firm',dir:1,l:'Firm A→Z'},
+    {k:'agent_type',dir:1,l:'Type'},
+  ];
+  sel.innerHTML=opts.map((o,i)=>`<option value="${i}">${esc(o.l)}</option>`).join('');
+  const sv=localStorage.getItem('liSort'); if(sv!==null && opts[+sv]){ sel.value=sv; sortKey=opts[+sv].k; sortDir=opts[+sv].dir; }
+  sel.onchange=()=>{ const o=opts[+sel.value]; sortKey=o.k; sortDir=o.dir; localStorage.setItem('liSort',sel.value); render(); };
+})();
+
+// ── data-points show/hide popover ──
+(function(){
+  const pop=$('#dppop'), btn=$('#dpbtn');
+  function build(){
+    pop.innerHTML=FIELDS.map(f=>`<label class="dptog"><input type="checkbox" data-fk="${f.k}" ${fVis(f.k)?'checked':''}><span>${esc(f.l)}</span></label>`).join('')
+      +`<div><button class="minibtn" id="dpAll">All</button><button class="minibtn" id="dpNone">None</button><button class="minibtn" id="dpReset">↺ Reset</button></div>`;
+  }
+  build();
+  btn.onclick=e=>{ e.stopPropagation(); pop.classList.toggle('on'); };
+  pop.onclick=e=>{ e.stopPropagation();
+    const cb=e.target.closest('input[data-fk]');
+    if(cb){ VISF[cb.dataset.fk]=cb.checked; saveFields(); render(); return; }
+    if(e.target.id==='dpAll'){ FIELDS.forEach(f=>VISF[f.k]=true); saveFields(); build(); render(); }
+    if(e.target.id==='dpNone'){ FIELDS.forEach(f=>VISF[f.k]=false); saveFields(); build(); render(); }
+    if(e.target.id==='dpReset'){ VISF={}; saveFields(); build(); render(); }
+  };
+  document.addEventListener('click',()=>pop.classList.remove('on'));
+})();
+
+// ── card click → detail modal ──
+$('#list').addEventListener('click',e=>{
+  if(e.target.closest('a')||e.target.closest('button')) return;
+  const row=e.target.closest('.row'); if(row&&row.dataset.id) openDetail(row.dataset.id);
+});
+$('#ov').addEventListener('click',e=>{ if(e.target.id==='ov') $('#ov').classList.remove('on'); });
+document.addEventListener('keydown',e=>{ if(e.key==='Escape') $('#ov').classList.remove('on'); });
+
 load();
 </script>
 </body>

← 4efa70a auto-save: 2026-07-31T08:55:43 (5 files) — public/brokers.ht  ·  back to Commercialrealestate  ·  broker mind-map: add firm-relationship graph (/api/graph?his 7c67770 →