[object Object]

← back to Commercialrealestate

just-listed: rebuild to canonical CRCP UI (grid/list/table, day/night, collapsed rail, chat, new-drip, always-on broker contact by address)

30278bcc6e8daa787dacbd2a837dd1fbb7373590 · 2026-08-18 17:22:55 -0700 · Steve Abrams

Files touched

Diff

commit 30278bcc6e8daa787dacbd2a837dd1fbb7373590
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 18 17:22:55 2026 -0700

    just-listed: rebuild to canonical CRCP UI (grid/list/table, day/night, collapsed rail, chat, new-drip, always-on broker contact by address)
---
 public/fha-leads.html   |  70 ++++++-
 public/filter-modal.js  | 183 +++++++++++++++++
 public/just-listed.html | 512 +++++++++++++++++++++++++++++++++++++-----------
 scripts/serve.js        |  15 ++
 4 files changed, 658 insertions(+), 122 deletions(-)

diff --git a/public/fha-leads.html b/public/fha-leads.html
index 9a26bcf..dc16d63 100644
--- a/public/fha-leads.html
+++ b/public/fha-leads.html
@@ -36,7 +36,22 @@ tr:hover td{background:#131922}
 .prov{font-size:11px;color:var(--mut);margin:2px 0 12px;line-height:1.5}
 .stat{display:inline-block;background:var(--card);border:1px solid var(--line);border-radius:9px;padding:8px 12px;margin:0 8px 10px 0}
 .stat b{font-size:19px;color:#fff}.stat span{display:block;font-size:10px;text-transform:uppercase;color:var(--mut);letter-spacing:.4px}
-</style>  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
+/* grid/list/table view toggle + FHA lead cards (three-view) */
+.viewseg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
+.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:var(--blue);color:#0e1116;font-weight:600}
+.fha-grid{display:grid;grid-template-columns:repeat(var(--cols,4),minmax(0,1fr));gap:12px;margin-top:4px}
+.fcard{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px 15px;display:flex;flex-direction:column;gap:8px}
+.fcard:hover{border-color:var(--blue)}
+.fcard-h{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}
+.fcard-t{font-weight:700;color:#fff;font-size:13px;line-height:1.25}
+.fcard-sub{color:var(--mut);font-size:11px;margin-top:-4px}
+.fcard-spread{color:var(--gold);font-weight:700;font-size:13px}
+.fcard-spread span{color:var(--mut);font-weight:400;font-size:10px;text-transform:uppercase;letter-spacing:.4px;margin-left:5px}
+.fcard-kv{display:grid;grid-template-columns:1fr 1fr;gap:5px 12px;font-size:12px}
+.fcard-kv div{display:flex;justify-content:space-between;gap:8px;border-bottom:1px solid #20262f;padding-bottom:3px}
+.fcard-kv em{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.3px;font-style:normal}
+</style>  <link rel="stylesheet" href="/three-view.css"><link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head><body>
 <header>
   <h1><b>Assumable-Loan Leads</b> · FHA Multifamily</h1>
@@ -59,12 +74,16 @@ tr:hover td{background:#131922}
   <div class="prov">Source: <b>HUD/FHA Multifamily Insured Mortgages (Active)</b> — U.S. government public data ($0, no license, resellable). Rate spread = market average (Freddie PMMS) at the loan's origination month − the loan's actual rate. A large spread = a below-market note a qualified buyer may be able to assume. Single-family FHA per-loan rates are NOT public (borrower privacy), so this is the commercial multifamily universe.</div>
   <div class="toolbar">
     <input class="search" id="q" placeholder="search property, city, lender, zip…">
+    <span class="viewseg tv-seg" id="viewseg"></span>
     <select id="sort"></select>
     <label style="font-size:11px;color:var(--mut)">density <input type="range" id="dens" min="1" max="8" step="1"></label>
     <span class="count" id="count"></span>
   </div>
-  <table><thead id="head"></thead><tbody id="body"></tbody></table>
+  <div id="tableView"><table><thead id="head"></thead><tbody id="body"></tbody></table></div>
+  <div id="gridView" class="fha-grid"></div>
+  <div id="listView"></div>
 </div></div>
+<script src="/three-view.js"></script>
 <script>
 const $=s=>document.querySelector(s);
 const money=n=>n==null||n===''||isNaN(+n)?'—':'$'+Math.round(+n).toLocaleString();
@@ -75,7 +94,7 @@ const COLS=[
   {k:'pmmsBenchmark',l:'Mkt @ orig',t:'pct'},{k:'spread',l:'Spread',t:'spread',calc:r=>(r.pmmsBenchmark!=null&&r.rate!=null)?+(r.pmmsBenchmark-r.rate).toFixed(2):null},
   {k:'holder',l:'Lender',t:'s'},{k:'originationDate',l:'Originated',t:'s'},{k:'belowMarket',l:'Assumable?',t:'flag',calc:r=>r.belowMarket}
 ];
-let DATA=[],sortKey='spread',sortDir=-1,q='';
+let DATA=[],sortKey='spread',sortDir=-1,q='',TV=null,CURRENT=[];
 const F={state:new Set(),city:new Set(),holder:new Set(),qual:new Set(),uMin:null,uMax:null,rMax:null};
 let dens=(function(){const v=parseInt(localStorage.getItem('fhaDens'),10);return(v>=1&&v<=8)?v:4;})();
 const cval=(r,c)=>c.calc?c.calc(r):r[c.k];
@@ -115,8 +134,34 @@ function buildRail(){
   DATA.forEach(r=>{if(r.state)sc[r.state]=(sc[r.state]||0)+1;if(r.city)cc[r.city]=(cc[r.city]||0)+1;if(r.holder)hc[r.holder]=(hc[r.holder]||0)+1;});
   $('#fState').innerHTML=chip(sc,'st',12);$('#fCity').innerHTML=chip(cc,'cy',18);$('#fHolder').innerHTML=chip(hc,'ho',14);
 }
+// ── the three view renderers (delegated to by ThreeView; each receives the current filtered+sorted rows) ──
+function renderTable(rows){
+  $('#head').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}">${esc(c.l)}${sortKey===c.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+'</tr>';
+  const fs=Math.max(10,15-dens);
+  $('#body').innerHTML=rows.slice(0,600).map(r=>`<tr style="font-size:${fs}px">`+COLS.map(c=>`<td class="${c.t==='money'||c.t==='n'||c.t==='pct'?'mono':''}">${cell(r,c)}</td>`).join('')+'</tr>').join('');
+}
+function renderGrid(rows){
+  const gv=$('#gridView'); gv.style.setProperty('--cols',dens);
+  const kv=(l,v)=>`<div><em>${l}</em><span>${v}</span></div>`;
+  gv.innerHTML=rows.slice(0,400).map(r=>{
+    const spread=(r.pmmsBenchmark!=null&&r.rate!=null)?+(r.pmmsBenchmark-r.rate).toFixed(2):null;
+    return `<div class="fcard">`+
+      `<div class="fcard-h"><span class="fcard-t">${esc(r.property||'—')}</span>${r.belowMarket?'<span class="pill gold">ASSUMABLE ◆</span>':''}</div>`+
+      `<div class="fcard-sub">${esc(r.city||'')}${r.state?', '+esc(r.state):''}${r.zip?' · '+esc(r.zip):''}</div>`+
+      (spread!=null&&spread>0?`<div class="fcard-spread">▼ ${spread} pts<span>below market</span></div>`:'')+
+      `<div class="fcard-kv">`+
+        kv('Units',r.units!=null?(+r.units).toLocaleString():'—')+
+        kv('Orig $',money(r.originalAmount))+
+        kv('Rate',r.rate!=null?(+r.rate).toFixed(2)+'%':'—')+
+        kv('Mkt @ orig',r.pmmsBenchmark!=null?(+r.pmmsBenchmark).toFixed(2)+'%':'—')+
+        kv('Lender',esc(r.holder||'—'))+
+        kv('Originated',esc(r.originationDate||'—'))+
+      `</div></div>`;
+  }).join('')||'<div style="color:var(--mut);padding:30px">No matches for these filters.</div>';
+}
 function render(){
-  const rows=filtered();
+  CURRENT=filtered();
+  const rows=CURRENT;
   const below=rows.filter(r=>r.belowMarket).length;
   const avgSpread=rows.filter(r=>r.belowMarket&&r.pmmsBenchmark!=null&&r.rate!=null);
   const avg=avgSpread.length?(avgSpread.reduce((s,r)=>s+(r.pmmsBenchmark-r.rate),0)/avgSpread.length).toFixed(1):'—';
@@ -125,9 +170,7 @@ function render(){
     `<div class="stat"><b>${avg}${avg==='—'?'':' pts'}</b><span>avg assumable spread</span></div>`+
     `<div class="stat"><b>${new Set(rows.map(r=>r.city)).size}</b><span>cities</span></div>`;
   $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
-  $('#head').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}">${esc(c.l)}${sortKey===c.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+'</tr>';
-  const fs=Math.max(10,15-dens);
-  $('#body').innerHTML=rows.slice(0,600).map(r=>`<tr style="font-size:${fs}px">`+COLS.map(c=>`<td class="${c.t==='money'||c.t==='n'||c.t==='pct'?'mono':''}">${cell(r,c)}</td>`).join('')+'</tr>').join('');
+  if(TV) TV.render(); else renderTable(rows);
 }
 $('#head').addEventListener('click',e=>{const th=e.target.closest('th');if(!th)return;const k=th.dataset.k;if(sortKey===k)sortDir*=-1;else{sortKey=k;sortDir=(k==='property'||k==='city'||k==='holder'||k==='state')?1:-1;}render();});
 $('#fState').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.state,c.dataset.st);c.classList.toggle('active');render();});
@@ -142,8 +185,21 @@ $('#sort').addEventListener('change',()=>{sortKey=$('#sort').value;sortDir=(sort
 $('#dens').addEventListener('input',()=>{dens=+$('#dens').value;localStorage.setItem('fhaDens',dens);render();});
 $('#sort').innerHTML=COLS.map(c=>`<option value="${c.k}"${c.k==='spread'?' selected':''}>Sort: ${esc(c.l)}</option>`).join('');
 $('#dens').value=dens;
+// grid / list / table toggle + compact-list view (three-view). Sort stays owned by the
+// page's own #sort select + header clicks (getRows returns already-sorted rows), so we
+// don't pass a sortSel — ThreeView just adds the view modes + the compact list.
+TV=ThreeView.mount({
+  seg:'#viewseg', gridMount:'#gridView', listMount:'#listView', tableMount:'#tableView',
+  getRows:()=>CURRENT,
+  fields:COLS.filter(c=>!['property','city','state'].includes(c.k)).map(c=>({k:c.k,l:c.l,html:true,calc:r=>cell(r,c)})),
+  title:r=>({name:r.property||'—', sub:(r.city||'')+(r.state?', '+r.state:'')}),
+  badgeHtml:r=>r.belowMarket?'<span class="pill gold">ASSUMABLE ◆</span>':'',
+  renderGrid:renderGrid, renderTable:renderTable,
+  storageKey:'fha', defaultView:'table', views:['grid','list','table']
+});
 fetch('/data/fha-loans.json').then(r=>r.json()).then(j=>{DATA=(j.rows||[]).filter(r=>r.rate!=null);buildRail();render();})
   .catch(()=>{$('#count').textContent='failed to load /data/fha-loans.json';});
 </script><script src="/corner-nav.js" defer></script>
   <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
+  <script src="/filter-modal.js" defer></script><!-- filters → collapsed modal panels -->
 </body></html>
diff --git a/public/filter-modal.js b/public/filter-modal.js
new file mode 100644
index 0000000..39cdea2
--- /dev/null
+++ b/public/filter-modal.js
@@ -0,0 +1,183 @@
+/* filter-modal.js — CRCP standard (2026-08-18): collapse the always-open left filter
+ * rail (aside.rail full of .rsec sections) into ONE "⚙ Filters" modal made of
+ * collapsible panels. Single-line drop-in — <script src="/filter-modal.js" defer></script>.
+ *
+ * What it does, with ZERO changes to the host page's own filter JS:
+ *   1. Finds `aside.rail` (or `.rail`) and the page toolbar (`.toolbar` or `.topbar`).
+ *   2. Injects a "⚙ Filters (N)" trigger button into the toolbar; N = live count of
+ *      active filters (active chips + non-empty number/text inputs inside the rail).
+ *   3. Moves every `.rsec` filter section into an overlay modal — element IDs and their
+ *      already-bound event listeners travel with the nodes, so #fState/#fCity/etc. and
+ *      all the page's filtering keep working exactly as before.
+ *   4. Turns each `.rsec > h4` into an accordion header (collapsed by default; per-page,
+ *      per-section state persisted in localStorage).
+ *   5. "Clear all" un-sets every active filter by driving the page's OWN controls
+ *      (click active chips off, blank inputs + dispatch input) so the page re-renders.
+ *
+ * Fully reversible: it only relocates DOM + injects a modal shell; remove the <script>
+ * and the rail renders inline again (nothing about the page's data/logic is touched).
+ * Idempotent: guarded so double-inclusion is a no-op.
+ */
+(function () {
+  if (window.__crcpFilterModal) return; window.__crcpFilterModal = true;
+
+  function boot() {
+    var rail = document.querySelector('aside.rail') || document.querySelector('.rail');
+    if (!rail) return;                                   // page has no filter rail — nothing to do
+    var secs = Array.prototype.slice.call(rail.querySelectorAll('.rsec'));
+    if (!secs.length) return;
+    var toolbar = document.querySelector('.toolbar') || document.querySelector('.topbar') ||
+                  document.querySelector('main .toolbar, main .topbar') || document.querySelector('.main');
+    if (!toolbar) return;
+
+    var PAGE = (location.pathname.split('/').pop() || 'index').replace(/\.html?$/, '') || 'index';
+    var LSK = 'crcpFilterPanels:' + PAGE;                // {sectionIndex: open?} persisted collapse state
+
+    injectStyle();
+
+    // ---- build the modal shell ----
+    var ov = document.createElement('div'); ov.className = 'fm-ov'; ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'Filters');
+    var modal = document.createElement('div'); modal.className = 'fm-modal';
+    var head = document.createElement('div'); head.className = 'fm-head';
+    head.innerHTML = '<h3>Filters</h3><div class="fm-hactions"><button type="button" class="fm-clear">Clear all</button><button type="button" class="fm-x" aria-label="Close">✕</button></div>';
+    var bodyWrap = document.createElement('div'); bodyWrap.className = 'fm-body';
+    modal.appendChild(head); modal.appendChild(bodyWrap); ov.appendChild(modal);
+    document.body.appendChild(ov);
+
+    // ---- move each .rsec into the modal as a collapsible panel ----
+    var openState = {}; try { openState = JSON.parse(localStorage.getItem(LSK) || '{}') || {}; } catch (e) {}
+    secs.forEach(function (sec, i) {
+      var h = sec.querySelector('h4');
+      var label = h ? (h.textContent || '').trim() : ('Filter ' + (i + 1));
+      sec.classList.add('fm-panel');
+      var startOpen = openState[i] === true;             // collapsed by default
+      if (startOpen) sec.classList.add('fm-open');
+      // rebuild the h4 as an accordion toggle (keep its text; add a chevron + per-panel count)
+      if (h) {
+        h.classList.add('fm-ph');
+        h.innerHTML = '<span class="fm-ptitle">' + escapeHtml(label) + '</span><span class="fm-pcount"></span><span class="fm-chev">▸</span>';
+        h.setAttribute('role', 'button'); h.setAttribute('tabindex', '0');
+        h.addEventListener('click', function () { togglePanel(sec, i); });
+        h.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); togglePanel(sec, i); } });
+      }
+      bodyWrap.appendChild(sec);
+    });
+
+    // the rail is now empty — hide it so the main content goes full-width
+    rail.classList.add('fm-railhidden');
+
+    // ---- trigger button in the toolbar ----
+    var trig = document.createElement('button'); trig.type = 'button'; trig.className = 'fm-trigger';
+    trig.innerHTML = '<span class="fm-tico">⚙</span> Filters<span class="fm-badge" hidden>0</span>';
+    // put it first so it reads left-to-right before search on most toolbars
+    if (toolbar.firstChild) toolbar.insertBefore(trig, toolbar.firstChild); else toolbar.appendChild(trig);
+
+    function togglePanel(sec, i) {
+      var open = sec.classList.toggle('fm-open');
+      openState[i] = open;
+      try { localStorage.setItem(LSK, JSON.stringify(openState)); } catch (e) {}
+    }
+    function openModal() { ov.classList.add('on'); recount(); document.addEventListener('keydown', onEsc); }
+    function closeModal() { ov.classList.remove('on'); document.removeEventListener('keydown', onEsc); }
+    function onEsc(e) { if (e.key === 'Escape') closeModal(); }
+
+    trig.addEventListener('click', openModal);
+    head.querySelector('.fm-x').addEventListener('click', closeModal);
+    ov.addEventListener('click', function (e) { if (e.target === ov) closeModal(); });
+    head.querySelector('.fm-clear').addEventListener('click', clearAll);
+
+    // recount active filters whenever the user interacts inside the modal
+    bodyWrap.addEventListener('click', function () { setTimeout(recount, 0); });
+    bodyWrap.addEventListener('input', function () { setTimeout(recount, 0); });
+    bodyWrap.addEventListener('change', function () { setTimeout(recount, 0); });
+
+    function countActive(scope) {
+      var n = 0;
+      n += scope.querySelectorAll('.chip.active').length;
+      Array.prototype.forEach.call(scope.querySelectorAll('input'), function (inp) {
+        var t = (inp.type || 'text').toLowerCase();
+        if (t === 'range') return;                        // density etc. isn't a "filter"
+        if (t === 'checkbox' || t === 'radio') { if (inp.checked) n++; return; }
+        if ((inp.value || '').trim() !== '') n++;
+      });
+      Array.prototype.forEach.call(scope.querySelectorAll('select'), function (s) {
+        if (s.value && s.value !== '' && s.selectedIndex > 0) n++;
+      });
+      return n;
+    }
+    function recount() {
+      var total = countActive(bodyWrap);
+      var badge = trig.querySelector('.fm-badge');
+      badge.textContent = total; badge.hidden = total === 0;
+      trig.classList.toggle('fm-has', total > 0);
+      // per-panel counts on the accordion headers
+      secs.forEach(function (sec) {
+        var pc = sec.querySelector('.fm-pcount'); if (!pc) return;
+        var c = countActive(sec); pc.textContent = c ? c : ''; pc.classList.toggle('on', c > 0);
+      });
+    }
+    function clearAll() {
+      // click every active chip off (drives the page's own toggle handlers)
+      Array.prototype.forEach.call(bodyWrap.querySelectorAll('.chip.active'), function (ch) { ch.click(); });
+      // blank inputs + notify the page
+      Array.prototype.forEach.call(bodyWrap.querySelectorAll('input'), function (inp) {
+        var t = (inp.type || 'text').toLowerCase();
+        if (t === 'range') return;
+        if (t === 'checkbox' || t === 'radio') { if (inp.checked) { inp.checked = false; inp.dispatchEvent(new Event('change', { bubbles: true })); inp.dispatchEvent(new Event('input', { bubbles: true })); } return; }
+        if ((inp.value || '').trim() !== '') { inp.value = ''; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); }
+      });
+      Array.prototype.forEach.call(bodyWrap.querySelectorAll('select'), function (s) {
+        if (s.selectedIndex > 0) { s.selectedIndex = 0; s.dispatchEvent(new Event('change', { bubbles: true })); }
+      });
+      setTimeout(recount, 0);
+    }
+
+    // keep the badge live even when filters change from outside the modal (e.g. chip
+    // datum-drills elsewhere on the page): observe the modal subtree for class flips.
+    try {
+      var mo = new MutationObserver(function () { recount(); });
+      mo.observe(bodyWrap, { subtree: true, attributes: true, attributeFilter: ['class'], childList: true });
+    } catch (e) {}
+
+    recount();
+  }
+
+  function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
+
+  function injectStyle() {
+    if (document.getElementById('fm-style')) return;
+    var css =
+    '.fm-railhidden{display:none!important}' +
+    '.fm-trigger{display:inline-flex;align-items:center;gap:6px;background:var(--card,#161b22);color:var(--ink,#e6edf3);border:1px solid var(--line,#2a313c);border-radius:8px;padding:8px 12px;font-size:12px;font-weight:600;cursor:pointer;line-height:1;white-space:nowrap}' +
+    '.fm-trigger:hover{border-color:var(--blue,#58a6ff)}' +
+    '.fm-trigger.fm-has{border-color:var(--blue,#58a6ff);color:var(--blue,#58a6ff)}' +
+    '.fm-tico{font-size:13px}' +
+    '.fm-badge{background:var(--blue,#58a6ff);color:#0e1116;border-radius:20px;padding:1px 7px;font-size:10px;font-weight:700;min-width:16px;text-align:center}' +
+    '.fm-ov{position:fixed;inset:0;background:rgba(2,6,12,.62);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);display:none;align-items:flex-start;justify-content:center;z-index:9999;padding:5vh 16px 16px}' +
+    '.fm-ov.on{display:flex}' +
+    '.fm-modal{width:min(560px,96vw);max-height:88vh;display:flex;flex-direction:column;background:var(--card,#161b22);border:1px solid var(--line,#2a313c);border-radius:16px;box-shadow:0 24px 70px rgba(0,0,0,.55);overflow:hidden}' +
+    '.fm-head{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:15px 18px;border-bottom:1px solid var(--line,#2a313c)}' +
+    '.fm-head h3{margin:0;font-size:16px;color:var(--ink,#e6edf3)}' +
+    '.fm-hactions{display:flex;align-items:center;gap:8px}' +
+    '.fm-clear{background:none;border:1px solid var(--line,#2a313c);color:var(--mut,#8b949e);border-radius:7px;padding:6px 11px;font-size:11.5px;cursor:pointer}' +
+    '.fm-clear:hover{color:var(--ink,#e6edf3);border-color:var(--blue,#58a6ff)}' +
+    '.fm-x{background:none;border:0;color:var(--mut,#8b949e);font-size:20px;line-height:1;cursor:pointer;padding:2px 4px}' +
+    '.fm-x:hover{color:var(--ink,#e6edf3)}' +
+    '.fm-body{overflow:auto;padding:6px 6px 12px}' +
+    '.fm-panel.rsec{border-bottom:1px solid var(--line,#2a313c);padding:0 12px}' +
+    '.fm-panel.rsec:last-child{border-bottom:0}' +
+    '.fm-ph{display:flex;align-items:center;gap:8px;margin:0!important;padding:13px 2px;cursor:pointer;user-select:none;color:var(--mut,#8b949e)!important;font-size:11px!important;text-transform:uppercase;letter-spacing:.5px}' +
+    '.fm-ph .fm-ptitle{flex:1}' +
+    '.fm-ph .fm-chev{transition:transform .16s ease;font-size:11px;color:var(--mut,#8b949e)}' +
+    '.fm-panel.fm-open .fm-ph .fm-chev{transform:rotate(90deg)}' +
+    '.fm-ph .fm-pcount{background:var(--blue,#58a6ff);color:#0e1116;border-radius:20px;padding:0 6px;font-size:9.5px;font-weight:700;display:none}' +
+    '.fm-ph .fm-pcount.on{display:inline-block}' +
+    '.fm-panel:not(.fm-open) > *:not(.fm-ph){display:none!important}' +   /* collapsed: hide everything but the header; open state falls through to the page\'s own CSS so chip/flex layouts are preserved */
+    '.fm-panel .chips{padding-bottom:12px}' +
+    '@media(max-width:640px){.fm-modal{width:100%;max-height:92vh}.fm-ov{padding:2vh 8px 8px}}';
+    var st = document.createElement('style'); st.id = 'fm-style'; st.textContent = css; document.head.appendChild(st);
+  }
+
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+  else boot();
+})();
diff --git a/public/just-listed.html b/public/just-listed.html
index c7c013e..e7a5023 100644
--- a/public/just-listed.html
+++ b/public/just-listed.html
@@ -1,66 +1,113 @@
 <!doctype html><html lang="en"><head>
 <script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch (with or without a leading slash) against a credential-free base so creds embedded in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw "Request cannot be constructed from a URL that includes credentials". */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}catch(e){}})();</script>
+<!-- CRCP day/night: apply the saved theme BEFORE first paint (single key 'crcp-theme', matching crcp-theme.js). Default = dark. -->
+<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='light'?'light':'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>
 <!-- Self-heal: nav-agent grid controls can persist a "removed"/"collapsed" state that hides the
      whole grid with no obvious way back. Clear it before nav-agent runs so a grid can never stay hidden. -->
 <script>(function(){try{for(var i=localStorage.length-1;i>=0;i--){var k=localStorage.key(i);if(k&&k.indexOf('navagent:')===0){var v=localStorage.getItem(k);if(v&&v.indexOf('"removed":true')>=0){localStorage.removeItem(k);}}}}catch(e){}})();</script>
 <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
 <title>Just Listed — Active Inventory · CRCP</title>
 <style>
-:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--violet:#bc8cff;--rowpad:6px}
+/* Local dark fallbacks; /crcp-theme.css (loaded LAST) overrides these per html[data-theme]. */
+:root{--bg:#0e1116;--card:#161b22;--line:#2a313c;--ink:#e6edf3;--mut:#8b949e;--acc:#3fb950;--blue:#58a6ff;--gold:#ffa600;--violet:#bc8cff;--rowpad:6px;--cols:4}
 *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:-apple-system,Helvetica,Arial,sans-serif;font-size:13px}
+a{color:var(--blue)}
 header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
-h1{font-size:17px;margin:0}h1 b{color:#fff}
+h1{font-size:17px;margin:0}h1 b{color:var(--ink)}
 .badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#0e1116;background:var(--acc);padding:3px 8px;border-radius:6px}
+.nav{display:flex;align-items:center;gap:2px;flex-wrap:wrap}
 .nav a{color:var(--blue);text-decoration:none;font-size:12px;margin-left:12px}
-/* ticker (shared look with deals-flow) */
-.ticker{display:flex;align-items:stretch;background:#0b0e13;border-bottom:1px solid var(--line);overflow:hidden;height:32px}
+/* ticker */
+.ticker{display:flex;align-items:stretch;background:var(--bg2,#0b0e13);border-bottom:1px solid var(--line);overflow:hidden;height:32px}
 .ticker .lbl{flex:0 0 auto;display:flex;align-items:center;gap:6px;background:var(--acc);color:#0b0e13;font-weight:800;font-size:10px;letter-spacing:.6px;text-transform:uppercase;padding:0 13px;white-space:nowrap}
 .ticker .lbl::before{content:"";width:7px;height:7px;border-radius:50%;background:#0b0e13;animation:blip 1.1s steps(1) infinite}
 @keyframes blip{50%{opacity:.25}}
 .ticker .vp{flex:1;overflow:hidden;position:relative}
 .ticker .track{display:inline-flex;align-items:center;height:100%;white-space:nowrap;animation:tick 90s linear infinite;will-change:transform}
 .ticker .vp:hover .track{animation-play-state:paused}
-.ticker .it{display:inline-flex;align-items:center;gap:8px;padding:0 20px;font-size:12px;color:var(--ink);border-right:1px solid #20262f}
+.ticker .it{display:inline-flex;align-items:center;gap:8px;padding:0 20px;font-size:12px;color:var(--ink);border-right:1px solid var(--line)}
 .ticker .it .t{font-weight:800;font-size:9px;letter-spacing:.4px;padding:2px 6px;border-radius:5px}
 .ticker .it .t.listed{background:rgba(63,185,80,.18);color:var(--acc)}
 .ticker .it .t.closed{background:rgba(255,166,0,.16);color:var(--gold)}
 @keyframes tick{from{transform:translateX(0)}to{transform:translateX(-50%)}}
 @media (prefers-reduced-motion:reduce){.ticker .track{animation:none}}
 .shell{display:flex;align-items:flex-start}
-.rail{width:230px;flex:0 0 230px;border-right:1px solid var(--line);padding:12px;height:calc(100vh - 84px);overflow:auto;position:sticky;top:0}
-.rsec{border-bottom:1px solid var(--line);padding:9px 0}
-.rsec h4{margin:0 0 7px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut)}
+/* ── left rail: ALL sections start collapsed (CRCP .panel.collapsed pattern) ── */
+.rail{width:238px;flex:0 0 238px;border-right:1px solid var(--line);padding:10px;height:calc(100vh - 84px);overflow:auto;position:sticky;top:0}
+.panel{border:1px solid var(--line);border-radius:10px;margin-bottom:8px;background:var(--card);overflow:hidden}
+.panel h4{margin:0;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);padding:9px 11px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none}
+.panel h4 .caret{transition:transform .15s;font-size:9px;opacity:.7}
+.panel.collapsed h4 .caret{transform:rotate(-90deg)}
+.panel .body{padding:9px 11px;border-top:1px solid var(--line)}
+.panel.collapsed .body{display:none}
+.panel h4 .n{color:var(--mut);font-weight:600;font-size:10px}
 .chips{display:flex;flex-wrap:wrap;gap:5px}
-.chip{border:1px solid var(--line);border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer;background:var(--card);user-select:none}
+.chip{border:1px solid var(--line);border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer;background:var(--bg2,var(--card));user-select:none;color:var(--ink)}
 .chip.active{background:var(--blue);color:#0e1116;border-color:var(--blue);font-weight:600}
 .chip .ct{color:var(--mut);margin-left:4px;font-size:10px}.chip.active .ct{color:#0e1116}
-.rail input[type=number]{width:100%;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
+.rail input[type=number]{width:100%;background:var(--bg2,var(--card));border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
+/* column-picker inside the rail */
+.colgrid{display:grid;grid-template-columns:1fr 1fr;gap:1px 12px}
+.colgrid .ph{grid-column:1/-1;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin:0 0 4px;display:flex;justify-content:space-between}
+.colgrid .ph a{color:var(--blue);cursor:pointer}
+.colgrid label{display:flex;align-items:center;gap:6px;font-size:12px;padding:2px 2px;cursor:pointer;white-space:nowrap;color:var(--ink);border-radius:6px}
+.colgrid label:hover{background:var(--hover,rgba(88,166,255,.08))}
+.colgrid input{accent-color:var(--blue)}
 .main{flex:1;padding:12px 16px;min-width:0}
 .toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:10px}
 .search{flex:1;min-width:200px;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:8px 11px;font-size:13px}
 select{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 9px;font-size:12px}
 .btn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:7px 11px;font-size:12px;cursor:pointer;white-space:nowrap}
 .btn:hover{border-color:var(--blue)}
+.btn.on{background:var(--blue);color:#0e1116;border-color:var(--blue);font-weight:600}
 .count{color:var(--mut);font-size:12px}
 .prov{font-size:11px;color:var(--mut);margin:0 0 12px;line-height:1.5}
 .stat{display:inline-block;background:var(--card);border:1px solid var(--line);border-radius:9px;padding:7px 12px;margin:0 8px 10px 0}
-.stat b{font-size:18px;color:#fff}.stat span{display:block;font-size:10px;text-transform:uppercase;color:var(--mut);letter-spacing:.4px}
+.stat b{font-size:18px;color:var(--ink)}.stat span{display:block;font-size:10px;text-transform:uppercase;color:var(--mut);letter-spacing:.4px}
 .st{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 6px;border-radius:6px;white-space:nowrap;display:inline-block}
 .st.active{background:rgba(63,185,80,.16);color:var(--acc)}
 .st.pending{background:rgba(255,166,0,.16);color:var(--gold)}
 .st.other{background:rgba(139,148,158,.16);color:var(--mut)}
-/* ── column-picker dropdown ── */
-.colmenu{position:relative}
-.colpanel{position:absolute;right:0;top:calc(100% + 4px);background:var(--card);border:1px solid var(--line);border-radius:10px;padding:9px 10px;z-index:30;display:grid;grid-template-columns:1fr 1fr;gap:1px 16px;box-shadow:0 10px 30px rgba(0,0,0,.5);min-width:300px}
-.colpanel .ph{grid-column:1/-1;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin:0 0 4px;display:flex;justify-content:space-between;align-items:center}
-.colpanel .ph a{color:var(--blue);cursor:pointer;text-transform:none;letter-spacing:0}
-.colpanel label{display:flex;align-items:center;gap:7px;font-size:12px;padding:3px 3px;cursor:pointer;white-space:nowrap;color:var(--ink);border-radius:6px}
-.colpanel label:hover{background:rgba(88,166,255,.08)}
-.colpanel input{accent-color:var(--blue)}
-/* ── the list / table ── */
-.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--card);max-height:calc(100vh - 300px)}
+.newpill{font-size:9px;font-weight:800;letter-spacing:.4px;color:#0e1116;background:var(--acc);padding:2px 6px;border-radius:5px;text-transform:uppercase}
+/* ── inline chat bar (in the middle of the page) ── */
+.chatbar{border:1px solid var(--line);border-radius:12px;background:var(--card);margin:0 0 12px;overflow:hidden}
+.chatbar .ch-hd{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;user-select:none;border-bottom:1px solid transparent}
+.chatbar.open .ch-hd{border-bottom-color:var(--line)}
+.chatbar .ch-hd .dot{width:8px;height:8px;border-radius:50%;background:var(--acc)}
+.chatbar .ch-hd b{font-size:12.5px}
+.chatbar .ch-hd .hint{color:var(--mut);font-size:11px;margin-left:auto}
+.chatbar .ch-body{display:none;padding:10px 12px}
+.chatbar.open .ch-body{display:block}
+.chatlog{max-height:230px;overflow:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:9px}
+.msg{padding:8px 11px;border-radius:10px;font-size:12.5px;line-height:1.45;max-width:88%;white-space:pre-wrap}
+.msg.u{align-self:flex-end;background:var(--blue);color:#0e1116}
+.msg.a{align-self:flex-start;background:var(--bg2,rgba(255,255,255,.05));border:1px solid var(--line)}
+.msg.a.think{color:var(--mut);font-style:italic}
+.chatform{display:flex;gap:8px}
+.chatform input{flex:1;background:var(--bg2,var(--card));border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:9px 11px;font-size:13px}
+/* ── view containers (Grid / List / Table via ThreeView) ── */
+#gridView{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:12px}
+.gcard{border:1px solid var(--line);border-radius:12px;background:var(--card);padding:12px;cursor:pointer;position:relative;opacity:0;transform:translateY(8px);animation:cardin .4s ease forwards}
+@keyframes cardin{to{opacity:1;transform:none}}
+@media (prefers-reduced-motion:reduce){.gcard{animation:none;opacity:1;transform:none}}
+.gcard:hover{border-color:var(--blue)}
+.gcard .addr{font-weight:700;font-size:13.5px;margin:0 0 2px;color:var(--ink)}
+.gcard .sub{color:var(--mut);font-size:11.5px;margin-bottom:8px}
+.gcard .price{color:var(--gold);font-weight:800;font-size:16px}
+.gcard .meta{display:flex;flex-wrap:wrap;gap:5px 14px;margin:7px 0;font-size:12px}
+.gcard .meta .k{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.4px;margin-right:4px}
+.gcard .up{color:var(--mut);font-size:11.5px;line-height:1.4;margin-top:6px;border-top:1px solid var(--line);padding-top:7px}
+.gcard .newflag{position:absolute;top:10px;right:10px}
+/* broker contact block — ALWAYS visible (Steve hard rule) */
+.broker{margin-top:8px;border-top:1px solid var(--line);padding-top:7px;font-size:12px}
+.broker .bf{font-weight:600;color:var(--ink)}
+.broker .bc{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:3px}
+.broker .bc a{color:var(--blue);text-decoration:none}
+.broker .bc a:hover{text-decoration:underline}
+.broker .bc .none{color:var(--mut)}
+.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--card);max-height:calc(100vh - 320px)}
 table{border-collapse:collapse;width:100%;font-size:12.5px}
-thead th{position:sticky;top:0;background:#11161d;z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;cursor:pointer;user-select:none;color:var(--mut);font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px}
+thead th{position:sticky;top:0;background:var(--headbg,#11161d);z-index:2;text-align:left;padding:9px 11px;border-bottom:1px solid var(--line);white-space:nowrap;cursor:pointer;user-select:none;color:var(--mut);font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.4px}
 thead th:hover{color:var(--ink)}
 thead th.sorted{color:var(--blue)}
 thead th[draggable=true]{cursor:grab}
@@ -69,15 +116,32 @@ thead th.dropzone{box-shadow:inset 3px 0 0 var(--blue);color:var(--ink)}
 thead th .ar{opacity:.35;font-size:9px;margin-left:4px}
 thead th.sorted .ar{opacity:1}
 thead th.num{text-align:right}
-tbody td{padding:var(--rowpad) 11px;border-bottom:1px solid #20262f;vertical-align:top;line-height:1.35}
+tbody td{padding:var(--rowpad) 11px;border-bottom:1px solid var(--line);vertical-align:top;line-height:1.35}
 tbody td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
-tbody tr:hover{background:rgba(88,166,255,.06)}
+tbody tr{cursor:pointer}
+tbody tr:hover{background:var(--hover,rgba(88,166,255,.06))}
 td.price{color:var(--gold);font-weight:600}
-td.wrap{white-space:normal;min-width:220px;max-width:380px;color:var(--mut)}
+td.wrap{white-space:normal;min-width:200px;max-width:360px;color:var(--mut)}
 td a.f{color:var(--blue);text-decoration:none}
 td a.f:hover{text-decoration:underline}
 .empty{color:var(--mut);padding:40px;text-align:center}
-</style>  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
+/* full-spec modal (CRCP #ov/#modal) */
+#ov{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:9990;display:none;align-items:flex-start;justify-content:center;padding:5vh 12px;overflow:auto}
+#ov.on{display:flex}
+#modal{width:min(720px,96vw);background:var(--card);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative}
+#modal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;line-height:1}
+#modal h2{margin:0 4px 2px 0;font-size:20px;color:var(--ink)}
+#modal .msub{color:var(--gold);font-size:13px;margin-bottom:12px}
+#modal .specs{display:grid;grid-template-columns:1fr 1fr;gap:8px 18px;margin:0 0 12px}
+#modal .specs div{font-size:13px;border-bottom:1px solid var(--line);padding:5px 0}
+#modal .specs .k{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.4px;display:block}
+#modal .mbroker{border:1px solid var(--line);border-radius:10px;padding:11px 13px;background:var(--bg2,rgba(255,255,255,.03));margin-bottom:10px}
+#modal .mbroker h3{margin:0 0 5px;font-size:12px;text-transform:uppercase;letter-spacing:.4px;color:var(--mut)}
+#modal .mbroker a{color:var(--blue);text-decoration:none;margin-right:16px}
+#modal .note{color:var(--mut);font-size:12.5px;line-height:1.5}
+</style>  <link rel="stylesheet" href="/three-view.css">
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
+  <link rel="stylesheet" href="/crcp-theme.css"><!-- CRCP day/night tokens: load LAST so it wins -->
 </head><body>
 <header>
   <h1><b>Just Listed</b> · Active Inventory</h1>
@@ -87,52 +151,93 @@ td a.f:hover{text-decoration:underline}
 </header>
 <div class="ticker" id="ticker" style="display:none" aria-label="Breaking real estate headlines"><div class="lbl" aria-hidden="true">Breaking</div><div class="vp"><div class="track" id="tickTrack"></div></div></div>
 <div class="shell">
-<aside class="rail">
-  <div class="rsec"><h4>Status</h4><div class="chips" id="fStatus"></div></div>
-  <div class="rsec"><h4>Property type</h4><div class="chips" id="fType"></div></div>
-  <div class="rsec"><h4>City</h4><div class="chips" id="fCity"></div></div>
-  <div class="rsec"><h4>Min price $</h4><input type="number" id="pMin" placeholder="e.g. 1000000" step="250000"></div>
-  <div class="rsec"><h4>Max price $</h4><input type="number" id="pMax" placeholder="e.g. 5000000" step="250000"></div>
-  <div class="rsec"><h4>Min units</h4><input type="number" id="uMin" placeholder="e.g. 5"></div>
-  <div class="rsec"><h4>Max units</h4><input type="number" id="uMax" placeholder="any"></div>
+<aside class="rail" id="rail">
+  <div class="panel collapsed" data-p="status"><h4>Status <span><span class="n" id="nStatus"></span> <span class="caret">▼</span></span></h4><div class="body"><div class="chips" id="fStatus"></div></div></div>
+  <div class="panel collapsed" data-p="type"><h4>Property type <span><span class="n" id="nType"></span> <span class="caret">▼</span></span></h4><div class="body"><div class="chips" id="fType"></div></div></div>
+  <div class="panel collapsed" data-p="city"><h4>City <span><span class="n" id="nCity"></span> <span class="caret">▼</span></span></h4><div class="body"><div class="chips" id="fCity"></div></div></div>
+  <div class="panel collapsed" data-p="price"><h4>Price range $ <span class="caret">▼</span></h4><div class="body">
+     <label style="font-size:10px;color:var(--mut)">min</label><input type="number" id="pMin" placeholder="e.g. 1000000" step="250000">
+     <label style="font-size:10px;color:var(--mut);margin-top:6px;display:block">max</label><input type="number" id="pMax" placeholder="e.g. 5000000" step="250000"></div></div>
+  <div class="panel collapsed" data-p="units"><h4>Units <span class="caret">▼</span></h4><div class="body">
+     <label style="font-size:10px;color:var(--mut)">min</label><input type="number" id="uMin" placeholder="e.g. 5">
+     <label style="font-size:10px;color:var(--mut);margin-top:6px;display:block">max</label><input type="number" id="uMax" placeholder="any"></div></div>
+  <div class="panel collapsed" data-p="cols"><h4>Columns shown <span class="caret">▼</span></h4><div class="body"><div class="colgrid" id="colGrid"></div></div></div>
 </aside>
 <div class="main">
   <div id="stats"></div>
   <div class="prov" id="prov"></div>
-  <div class="toolbar">
-    <input class="search" id="q" placeholder="search address, city, type, firm, upside…  (space = AND)">
-    <select id="sort"></select>
-    <div class="colmenu">
-      <button class="btn" id="colBtn" aria-expanded="false">⚙ Columns ▾</button>
-      <div class="colpanel" id="colPanel" hidden></div>
+
+  <!-- CHAT BAR — in the middle of the page, grounded in the current filtered listings -->
+  <div class="chatbar" id="chatbar">
+    <div class="ch-hd" id="chHd"><span class="dot"></span><b>Ask the CRE analyst</b><span class="hint" id="chHint">grounded in the listings shown ▾</span></div>
+    <div class="ch-body">
+      <div class="chatlog" id="chatlog"></div>
+      <form class="chatform" id="chatform"><input id="chatin" placeholder="e.g. which of these has the best cap rate under $2M?" autocomplete="off"><button class="btn" type="submit">Send</button></form>
     </div>
-    <button class="btn" id="csvBtn" title="Download the current filtered/sorted view (visible columns) as CSV">⭳ CSV</button>
-    <label style="font-size:11px;color:var(--mut)">row height <input type="range" id="dens" min="2" max="10" step="1"></label>
-    <span class="count" id="count"></span>
   </div>
-  <div class="tablewrap">
-    <table>
-      <thead id="thead"></thead>
-      <tbody id="tbody"></tbody>
-    </table>
+
+  <div class="toolbar">
+    <input class="search" id="q" placeholder="search address, city, type, firm, broker, upside…  (space = AND)">
+    <span data-tvseg id="viewseg"></span>
+    <select id="tvSort"></select>
+    <button class="btn" id="newBtn" title="Show only listings first seen recently">🆕 New only</button>
+    <label style="font-size:11px;color:var(--mut)">crawl <input type="range" id="drip" min="0" max="10" step="1" title="0 = instant · 10 = slow crawl reveal"></label>
+    <button class="btn" id="csvBtn" title="Download the current filtered/sorted view as CSV">⭳ CSV</button>
+    <label style="font-size:11px;color:var(--mut)">density <input type="range" id="dens" min="1" max="8" step="1"></label>
+    <span class="count" id="count"></span>
   </div>
+
+  <div id="gridView"></div>
+  <div id="tvList"></div>
+  <div class="tablewrap" id="tableView"><table><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
   <div class="empty" id="empty" hidden>No listings match.</div>
 </div></div>
+
+<!-- full-spec modal -->
+<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 esc=s=>(s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
 const money=n=>n==null||isNaN(+n)?null:'$'+Math.round(+n).toLocaleString();
-let DATA=[],META={},q='',sortKey='price',sortDir=-1,ME=null;
-let dens=(function(){const v=parseInt(localStorage.getItem('jlDens'),10);return(v>=2&&v<=10)?v:6;})();
+let DATA=[],META={},q='',sortKey='price',sortDir=-1,ME=null,TV=null;
+let BROKERS={};           // firm_key/lc-firm -> {name,firm,phone,email,website,linkedin}  (weak fallback)
+let ADDRC={};             // normalized-address -> {name,phone,email,website}  (REAL per-listing agent, primary)
+let SEEN={};              // localStorage first-seen ledger (id -> iso)
+let DATES={};             // /api/listing-dates (id -> iso created_at)
+let onlyNew=false;
+const NEW_DAYS=14;
+let dens=(function(){const v=parseInt(localStorage.getItem('jlDens'),10);return(v>=1&&v<=8)?v:4;})();
+let dripLvl=(function(){const v=parseInt(localStorage.getItem('jlDrip'),10);return(v>=0&&v<=10)?v:3;})();
 const F={status:new Set(),type:new Set(),city:new Set(),pMin:null,pMax:null,uMin:null,uMax:null};
 const tog=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
 const statusClass=s=>{const t=String(s||'').toLowerCase();return t.startsWith('active')?'active':(t.includes('pending')||t.includes('contract'))?'pending':'other';};
 const statusShort=s=>String(s||'—').replace(/\s*\(.*\)$/,'').trim();
+const norm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'').replace(/(inc|llc|corp|corporation|company|co|realty|realestate|group|properties|partners|associates)$/,'');
+// address normalizer — drops street-type + directional words so listings.json ⇄ CRCP DB addresses join
+const na=s=>String(s||'').toLowerCase().replace(/\b(street|st|avenue|ave|boulevard|blvd|road|rd|drive|dr|lane|ln|place|pl|court|ct|suite|ste|unit|#|north|south|east|west|n|s|e|w)\b/g,'').replace(/[^a-z0-9]+/g,'');
+
+// ── broker contact join (Steve HARD RULE: always show broker phone + email) ──
+// Priority: (1) REAL agent matched by property address from CRCP's broker graph, then
+// (2) a weak firm-name fallback. listings.json's own firm is just "Crexi", so (1) is what delivers real numbers.
+function brokerFor(r){
+  if(!r) return null;
+  const a=ADDRC[na(r.address)]; if(a) return a;
+  const keys=[r.firm_key, r.firm].filter(Boolean).map(norm);
+  for(const k of keys){ if(BROKERS[k]) return BROKERS[k]; }
+  return null;
+}
+// first-seen recency: DB created_at if present, else this-viewer's first-seen ledger
+function firstSeen(r){ return DATES[r.id] || SEEN[r.id] || null; }
+function isNew(r){
+  const fs=firstSeen(r); if(!fs) return false;
+  const age=(Date.now()-new Date(fs).getTime())/86400000;
+  return age>=0 && age<=NEW_DAYS;
+}
 
-// ── COLUMN MODEL — one source of truth for header, cells, sort comparator + picker ──
-// num:true → right-aligned + numeric sort.  val() → custom sort/derived value.  render() → cell HTML.  on → visible by default.
+// ── COLUMN MODEL — header, cells, sort comparator, picker, + ThreeView fields ──
 const COLS=[
-  {k:'address',label:'Address',on:true,render:r=>r.source&&/^https?:/.test(r.source)?`<a class="f" href="${esc(r.source)}" target="_blank" rel="noopener noreferrer">${esc(r.address||'—')} ↗</a>`:esc(r.address||'—')},
+  {k:'address',label:'Address',on:true,render:r=>r.source&&/^https?:/.test(r.source)?`<a class="f" href="${esc(r.source)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${esc(r.address||'—')} ↗</a>`:esc(r.address||'—')},
   {k:'city',label:'City',on:true},
   {k:'zip',label:'ZIP',on:false},
   {k:'type',label:'Type',on:true},
@@ -142,14 +247,16 @@ const COLS=[
   {k:'cap_rate',label:'Cap %',on:true,num:true,render:r=>(r.cap_rate!=null&&r.cap_rate!=='')?(+r.cap_rate).toFixed(2)+'%':'—'},
   {k:'sqft',label:'Sq Ft',on:false,num:true,render:r=>r.sqft?(+r.sqft).toLocaleString():'—'},
   {k:'year_built',label:'Built',on:false,num:true,render:r=>r.year_built||'—'},
-  {k:'status',label:'Status',on:true,render:r=>`<span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span>`},
+  {k:'status',label:'Status',on:true,render:r=>`<span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span>${isNew(r)?' <span class="newpill">new</span>':''}`},
+  {k:'firm',label:'Firm',on:true,render:r=>esc(String(r.firm||'—').slice(0,42))},
+  {k:'broker_phone',label:'Broker phone',on:true,val:r=>{const b=brokerFor(r);return b&&b.phone||'';},render:r=>{const b=brokerFor(r);return b&&b.phone?`<a class="f" href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">${esc(b.phone)}</a>`:'<span style="color:var(--mut)">via source ↗</span>';}},
+  {k:'broker_email',label:'Broker email',on:true,val:r=>{const b=brokerFor(r);return b&&b.email||'';},render:r=>{const b=brokerFor(r);return b&&b.email?`<a class="f" href="mailto:${esc(b.email)}" onclick="event.stopPropagation()">${esc(b.email)}</a>`:'<span style="color:var(--mut)">via source ↗</span>';}},
   {k:'rent_control',label:'Rent control',on:false,cls:'wrap',render:r=>esc(r.rent_control||'—')},
-  {k:'firm',label:'Firm',on:false,render:r=>esc(String(r.firm||'—').slice(0,42))},
   {k:'metro',label:'Metro',on:false,render:r=>esc(r.metro||'—')},
   {k:'verified',label:'Verified',on:false,render:r=>r.verified?'✓':'—'},
+  {k:'first_seen',label:'First seen',on:false,val:r=>firstSeen(r)||'',render:r=>{const fs=firstSeen(r);return fs?new Date(fs).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'—';}},
   {k:'upside_note',label:'Upside',on:true,cls:'wrap',render:r=>esc(String(r.upside_note||'—').slice(0,200))},
 ];
-// restore saved column ORDER, then visibility
 try{
   const savedOrder=JSON.parse(localStorage.getItem('jlColOrder')||'null');
   if(Array.isArray(savedOrder)&&savedOrder.length)COLS.sort((a,b)=>{const ia=savedOrder.indexOf(a.k),ib=savedOrder.indexOf(b.k);return(ia<0?999:ia)-(ib<0?999:ib);});
@@ -160,13 +267,14 @@ const visCols=()=>COLS.filter(c=>c.on);
 const sortVal=(r,key)=>{const c=COLS.find(x=>x.k===key);return c&&c.val?c.val(r):r[key];};
 const saveCols=()=>{localStorage.setItem('jlCols',JSON.stringify(COLS.filter(c=>c.on).map(c=>c.k)));localStorage.setItem('jlColOrder',JSON.stringify(COLS.map(c=>c.k)));};
 
-// space-separated search = AND across every field (Steve standing rule)
 function passSearch(r){
   if(!q)return true;
-  const hay=[r.address,r.city,r.zip,r.type,r.status,r.firm,r.upside_note,r.rent_control,r.year_built,r.units,r.price].map(v=>v==null?'':String(v)).join(' ').toLowerCase();
+  const b=brokerFor(r);
+  const hay=[r.address,r.city,r.zip,r.type,r.status,r.firm,r.upside_note,r.rent_control,r.year_built,r.units,r.price,b&&b.name,b&&b.phone,b&&b.email].map(v=>v==null?'':String(v)).join(' ').toLowerCase();
   return q.toLowerCase().split(/\s+/).filter(Boolean).every(tok=>hay.includes(tok));
 }
 function pass(r){
+  if(onlyNew&&!isNew(r))return false;
   if(F.status.size&&!F.status.has(statusShort(r.status)))return false;
   if(F.type.size&&!F.type.has(r.type||'—'))return false;
   if(F.city.size&&!F.city.has(r.city||'—'))return false;
@@ -188,59 +296,177 @@ function chip(obj,attr,lim){return Object.entries(obj).sort((a,b)=>b[1]-a[1]).sl
 function buildRail(){
   const sc={},tc={},cc={};
   DATA.forEach(r=>{const s=statusShort(r.status);sc[s]=(sc[s]||0)+1;const t=r.type||'—';tc[t]=(tc[t]||0)+1;const c=r.city||'—';cc[c]=(cc[c]||0)+1;});
-  $('#fStatus').innerHTML=chip(sc,'s');$('#fType').innerHTML=chip(tc,'t',10);$('#fCity').innerHTML=chip(cc,'c',18);
+  $('#fStatus').innerHTML=chip(sc,'s');$('#fType').innerHTML=chip(tc,'t',12);$('#fCity').innerHTML=chip(cc,'c',20);
+  $('#nStatus').textContent=Object.keys(sc).length;$('#nType').textContent=Object.keys(tc).length;$('#nCity').textContent=Object.keys(cc).length;
+}
+
+// ── broker cell shared between grid card + modal ──
+function brokerBlock(r){
+  const b=brokerFor(r);
+  const src=r.source&&/^https?:/.test(r.source)?`<a href="${esc(r.source)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">listing source ↗</a>`:'<span class="none">no source link</span>';
+  if(b){
+    const ph=b.phone?`<a href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}" onclick="event.stopPropagation()">📞 ${esc(b.phone)}</a>`:'<span class="none">phone: via source ↗</span>';
+    const em=b.email?`<a href="mailto:${esc(b.email)}" onclick="event.stopPropagation()">✉️ ${esc(b.email)}</a>`:'<span class="none">email: via source ↗</span>';
+    return `<div class="broker"><div class="bf">${esc(b.name||r.firm||'—')}${b.name&&r.firm&&norm(b.name)!==norm(r.firm)?` · ${esc(r.firm)}`:''}</div><div class="bc">${ph}${em}${src}</div></div>`;
+  }
+  return `<div class="broker"><div class="bf">${esc(r.firm||'Listing brokerage')}</div><div class="bc"><span class="none">📞 phone: via source</span><span class="none">✉️ email: via source</span>${src}</div></div>`;
+}
+
+// ── GRID cards (with slow drip/crawl reveal) ──
+let dripTimer=null;
+function renderGrid(rows){
+  const host=$('#gridView');
+  if(dripTimer){clearInterval(dripTimer);dripTimer=null;}
+  if(!rows.length){host.innerHTML='';return;}
+  const cap=rows.slice(0,300);
+  const cardHtml=r=>{
+    const ppu=(r.price&&r.units)?Math.round(r.price/r.units):null;
+    return `<div class="gcard" data-id="${esc(r.id)}">
+      ${isNew(r)?'<span class="newpill newflag">new</span>':''}
+      <div class="addr">${esc(r.address||'—')}</div>
+      <div class="sub">${esc(r.city||'')}${r.zip?' · '+esc(r.zip):''} · ${esc(r.type||'—')}</div>
+      <div class="price">${money(r.price)||'—'}</div>
+      <div class="meta">
+        <span><span class="k">Units</span>${r.units!=null?esc(r.units):'—'}</span>
+        <span><span class="k">$/Unit</span>${ppu?money(ppu):'—'}</span>
+        <span><span class="k">Cap</span>${(r.cap_rate!=null&&r.cap_rate!=='')?(+r.cap_rate).toFixed(2)+'%':'—'}</span>
+        <span><span class="k">Status</span><span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span></span>
+      </div>
+      ${r.upside_note?`<div class="up">${esc(String(r.upside_note).slice(0,160))}</div>`:''}
+      ${brokerBlock(r)}
+    </div>`;
+  };
+  // drip level -> ms between reveals (0 = instant dump, 10 = slow crawl)
+  const dripMs=dripLvl===0?0:Math.round(30+dripLvl*dripLvl*7);
+  if(dripMs===0){ host.innerHTML=cap.map(cardHtml).join(''); return; }
+  host.innerHTML='';
+  let i=0;const batch=Math.max(1,Math.round(6-dripLvl/2));
+  dripTimer=setInterval(()=>{
+    if(i>=cap.length){clearInterval(dripTimer);dripTimer=null;return;}
+    const frag=document.createElement('div');
+    frag.innerHTML=cap.slice(i,i+batch).map(cardHtml).join('');
+    while(frag.firstChild)host.appendChild(frag.firstChild);
+    i+=batch;
+  },dripMs);
 }
-// ── header (click to sort) ──
+
+// ── header (click to sort) — table view ──
 function renderHead(){
   $('#thead').innerHTML='<tr>'+visCols().map(c=>{
     const sorted=c.k===sortKey,ar=sorted?(sortDir===1?'▲':'▼'):'↕';
     return `<th draggable="true" data-k="${c.k}" class="${c.num?'num':''}${sorted?' sorted':''}" title="Click to sort · drag to reorder">${esc(c.label)}<span class="ar">${ar}</span></th>`;
   }).join('')+'</tr>';
 }
-// ── body ──
 function rowHtml(r){
-  return '<tr>'+visCols().map(c=>{
+  return `<tr data-id="${esc(r.id)}">`+visCols().map(c=>{
     const cls=((c.num?'num ':'')+(c.cls||'')).trim();
     const content=c.render?c.render(r):(r[c.k]==null||r[c.k]===''?'—':esc(r[c.k]));
     return `<td${cls?` class="${cls}"`:''}>${content}</td>`;
   }).join('')+'</tr>';
 }
-function render(){
-  document.documentElement.style.setProperty('--rowpad',dens+'px');
-  const rows=filtered();
+function renderTable(rows){
+  renderHead();
+  $('#tbody').innerHTML=rows.slice(0,600).map(rowHtml).join('');
+}
+
+// ── stats + count (shared across views) ──
+function renderStats(rows){
+  document.documentElement.style.setProperty('--rowpad',(dens+2)+'px');
+  document.documentElement.style.setProperty('--cols',Math.max(2,7-dens));
   const tot=rows.reduce((s,r)=>s+(+r.price||0),0);
   const units=rows.reduce((s,r)=>s+(+r.units||0),0);
+  const newN=rows.filter(isNew).length;
   $('#stats').innerHTML=
     `<div class="stat"><b>${rows.length.toLocaleString()}</b><span>listings shown</span></div>`+
+    `<div class="stat"><b style="color:var(--acc)">${newN.toLocaleString()}</b><span>new (≤${NEW_DAYS}d)</span></div>`+
     `<div class="stat"><b style="color:var(--gold)">$${(tot/1e9).toFixed(2)}B</b><span>total ask</span></div>`+
     `<div class="stat"><b>${units.toLocaleString()}</b><span>units</span></div>`+
     `<div class="stat"><b>${new Set(rows.map(r=>r.city)).size}</b><span>cities</span></div>`;
-  const shown=rows.slice(0,600);
-  $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`+(rows.length>600?' (showing first 600)':'');
-  renderHead();
-  $('#tbody').innerHTML=shown.map(rowHtml).join('');
+  $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
   $('#empty').hidden=rows.length>0;
 }
-// ── set sort + keep the preset <select> in sync ──
-function setSort(key,dir){
-  sortKey=key;sortDir=dir;
-  const want=key+':'+(dir===1?'asc':'desc');
-  if([...$('#sort').options].some(o=>o.value===want))$('#sort').value=want;
-  render();
+
+// ── ThreeView: Grid / List / Table + all-field sort ──
+function mountThreeView(){
+  const fields=COLS.map(c=>({k:c.k,l:c.label,num:!!c.num,html:true,
+    calc:r=>c.render?c.render(r):(r[c.k]==null?'':r[c.k])}));
+  TV=ThreeView.mount({
+    seg:'#viewseg', sortSel:'#tvSort',
+    listMount:'#tvList', gridMount:'#gridView', tableMount:'#tableView',
+    fields,
+    getRows:()=>filtered(),
+    title:r=>({name:r.address||'—',sub:`${r.city||''}${r.type?' · '+r.type:''}`,href:(r.source&&/^https?:/.test(r.source))?r.source:null}),
+    badgeHtml:r=>`<span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span>${isNew(r)?' <span class="newpill">new</span>':''}`,
+    fvis:k=>{const c=COLS.find(x=>x.k===k);return c?c.on:false;},
+    renderGrid, renderTable,
+    onSort:(k,dir)=>{ if(k){sortKey=k;sortDir=dir;} render(); },
+    rowClick:(row)=>openSpec(row),
+    storageKey:'jl', defaultView:'grid', views:['grid','list','table'],
+  });
 }
-// header click: same col flips direction; new col defaults desc for numbers, asc for text
+function render(){
+  const rows=filtered();
+  renderStats(rows);
+  if(TV){TV.render();}else{renderGrid(rows);renderTable(rows);}
+}
+function setSort(key,dir){ sortKey=key;sortDir=dir; render(); }
+
+// ── full-spec modal (reuse CRCP #ov/#modal). Pulls the fullest spec CRCP has. ──
+async function openSpec(r){
+  const b=brokerFor(r),ppu=(r.price&&r.units)?Math.round(r.price/r.units):null,fs=firstSeen(r);
+  const spec=(k,v)=>`<div><span class="k">${esc(k)}</span>${v==null||v===''?'—':v}</div>`;
+  const bc=b?`${b.phone?`<a href="tel:${esc(b.phone.replace(/[^0-9+]/g,''))}">📞 ${esc(b.phone)}</a>`:'<span style="color:var(--mut)">phone via source</span>'} ${b.email?`<a href="mailto:${esc(b.email)}">✉️ ${esc(b.email)}</a>`:'<span style="color:var(--mut)">email via source</span>'}${b.website?` <a href="${esc(b.website)}" target="_blank" rel="noopener noreferrer">🌐 site</a>`:''}`
+    :`<span style="color:var(--mut)">Firm contact not in the broker graph — reach the listing agent via the source page.</span>`;
+  $('#mbody').innerHTML=
+    `<h2>${esc(r.address||'—')}</h2><div class="msub">${esc(r.city||'')}${r.zip?' '+esc(r.zip):''} · ${esc(r.type||'—')} · <span class="st ${statusClass(r.status)}">${esc(statusShort(r.status))}</span>${isNew(r)?' <span class="newpill">new</span>':''}</div>`+
+    `<div class="mbroker"><h3>Listing brokerage${b&&b.name?` — ${esc(b.name)}`:''}</h3><div><b>${esc((b&&b.firm)||r.firm||'—')}</b></div><div style="margin-top:6px">${bc}</div>${r.source&&/^https?:/.test(r.source)?`<div style="margin-top:6px"><a href="${esc(r.source)}" target="_blank" rel="noopener noreferrer">Open the full listing ↗</a></div>`:''}</div>`+
+    `<div class="specs">`+
+      spec('Price',money(r.price)?`<b style="color:var(--gold)">${money(r.price)}</b>`:'—')+
+      spec('Units',r.units)+spec('$ / Unit',ppu?money(ppu):'—')+
+      spec('Cap rate',(r.cap_rate!=null&&r.cap_rate!=='')?(+r.cap_rate).toFixed(2)+'%':'—')+
+      spec('Cap (projected)',(r.cap_rate_projected!=null&&r.cap_rate_projected!=='')?(+r.cap_rate_projected).toFixed(2)+'%':'—')+
+      spec('NOI (actual)',money(r.noi_actual))+spec('NOI (seller)',money(r.noi_seller))+
+      spec('Gross (actual)',money(r.gross_actual))+spec('Gross (proforma)',money(r.gross_proforma))+
+      spec('Expenses',money(r.expenses))+
+      spec('Sq Ft',r.sqft?(+r.sqft).toLocaleString():'—')+spec('Year built',r.year_built)+
+      spec('Rent control',esc(r.rent_control||'—'))+spec('Metro',esc(r.metro||'—'))+
+      spec('ZIP',esc(r.zip||'—'))+spec('First seen',fs?new Date(fs).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}):'—')+
+      spec('Verified',r.verified?'✓ yes':'—')+
+    `</div>`+
+    (r.upside_note?`<div class="note"><b style="color:var(--ink)">Upside:</b> ${esc(r.upside_note)}</div>`:'')+
+    `<div class="note" id="mhist" style="margin-top:10px;color:var(--mut)">Loading property history…</div>`;
+  $('#ov').classList.add('on');
+  try{
+    const h=await fetch('/api/property-history?address='+encodeURIComponent(r.address||'')+'&city='+encodeURIComponent(r.city||'')).then(x=>x.json()).catch(()=>null);
+    const el=$('#mhist'); if(!el)return;
+    if(h&&Array.isArray(h.history)&&h.history.length){
+      el.innerHTML='<b style="color:var(--ink)">Property history (CRCP):</b><br>'+h.history.slice(0,8).map(e=>`• ${esc(e.date||e.event_date||'')} — ${esc(e.event||e.type||'')} ${e.price?money(e.price):''}`).join('<br>');
+    }else if(h&&h.assessor){
+      const a=h.assessor;el.innerHTML=`<b style="color:var(--ink)">Assessor (CRCP):</b> ${esc(a.use_desc1||'')} · built ${esc(a.year_built||'—')} · ${a.sqft_main?(+a.sqft_main).toLocaleString()+' sf':''} · roll ${a.roll_total_value?money(a.roll_total_value):'—'}`;
+    }else{ el.textContent='No additional CRCP history matched this address.'; }
+  }catch(e){ const el=$('#mhist'); if(el)el.textContent='No additional CRCP history available.'; }
+}
+function idFromEvent(e){const el=e.target.closest('[data-id]');return el?el.getAttribute('data-id'):null;}
+$('#gridView').addEventListener('click',e=>{if(e.target.closest('a'))return;const id=idFromEvent(e);if(id){const r=DATA.find(x=>String(x.id)===id);if(r)openSpec(r);}});
+$('#tbody').addEventListener('click',e=>{if(e.target.closest('a'))return;const id=idFromEvent(e);if(id){const r=DATA.find(x=>String(x.id)===id);if(r)openSpec(r);}});
+document.addEventListener('keydown',e=>{if(e.key==='Escape')$('#ov').classList.remove('on');});
+$('#ov').addEventListener('click',e=>{if(e.target.id==='ov')$('#ov').classList.remove('on');});
+
+// ── table header click-sort + drag-reorder ──
 $('#thead').addEventListener('click',e=>{
   const th=e.target.closest('th');if(!th)return;
   const key=th.dataset.k,col=COLS.find(c=>c.k===key);
   if(key===sortKey)setSort(key,-sortDir);
   else setSort(key,col&&col.num?-1:1);
 });
-// header drag-to-reorder (a real drag suppresses the trailing click, so click-sort still works)
 let dragK=null;
 $('#thead').addEventListener('dragstart',e=>{const th=e.target.closest('th');if(!th)return;dragK=th.dataset.k;th.classList.add('dragging');e.dataTransfer.effectAllowed='move';try{e.dataTransfer.setData('text/plain',dragK);}catch(_){}});
 $('#thead').addEventListener('dragover',e=>{if(dragK==null)return;e.preventDefault();e.dataTransfer.dropEffect='move';const th=e.target.closest('th');$('#thead').querySelectorAll('th').forEach(t=>t.classList.remove('dropzone'));if(th&&th.dataset.k!==dragK)th.classList.add('dropzone');});
-$('#thead').addEventListener('drop',e=>{e.preventDefault();const th=e.target.closest('th');if(!th||dragK==null)return;const destK=th.dataset.k;if(destK!==dragK){const from=COLS.findIndex(c=>c.k===dragK);const[m]=COLS.splice(from,1);const to=COLS.findIndex(c=>c.k===destK);COLS.splice(to,0,m);saveCols();renderColPanel();render();}dragK=null;});
+$('#thead').addEventListener('drop',e=>{e.preventDefault();const th=e.target.closest('th');if(!th||dragK==null)return;const destK=th.dataset.k;if(destK!==dragK){const from=COLS.findIndex(c=>c.k===dragK);const[m]=COLS.splice(from,1);const to=COLS.findIndex(c=>c.k===destK);COLS.splice(to,0,m);saveCols();renderColGrid();render();}dragK=null;});
 $('#thead').addEventListener('dragend',()=>{dragK=null;$('#thead').querySelectorAll('th').forEach(t=>t.classList.remove('dropzone','dragging'));});
+
+// ── left rail: collapse/expand panels (start collapsed) ──
+$('#rail').addEventListener('click',e=>{const h=e.target.closest('.panel > h4');if(!h)return;h.parentElement.classList.toggle('collapsed');});
 // rail filters
 $('#fStatus').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.status,c.dataset.s);c.classList.toggle('active');render();});
 $('#fType').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.type,c.dataset.t);c.classList.toggle('active');render();});
@@ -250,11 +476,28 @@ $('#pMax').addEventListener('input',()=>{F.pMax=$('#pMax').value?+$('#pMax').val
 $('#uMin').addEventListener('input',()=>{F.uMin=$('#uMin').value?+$('#uMin').value:null;render();});
 $('#uMax').addEventListener('input',()=>{F.uMax=$('#uMax').value?+$('#uMax').value:null;render();});
 $('#q').addEventListener('input',()=>{q=$('#q').value.trim();render();});
-$('#sort').addEventListener('change',()=>{const v=$('#sort').value.split(':');setSort(v[0],v[1]==='asc'?1:-1);});
+$('#newBtn').addEventListener('click',()=>{onlyNew=!onlyNew;$('#newBtn').classList.toggle('on',onlyNew);render();});
+$('#drip').addEventListener('input',()=>{dripLvl=+$('#drip').value;localStorage.setItem('jlDrip',dripLvl);if(TV&&TV.getView()==='grid')render();});
 $('#dens').addEventListener('input',()=>{dens=+$('#dens').value;localStorage.setItem('jlDens',dens);render();});
-$('#sort').innerHTML=[['price:desc','Price ↓'],['price:asc','Price ↑'],['units:desc','Units ↓'],['ppu:asc','$/Unit ↑'],['cap_rate:desc','Cap rate ↓'],['year_built:desc','Newest built'],['city:asc','City A→Z'],['address:asc','Address A→Z']].map(([k,l])=>`<option value="${k}"${k==='price:desc'?' selected':''}>Sort: ${l}</option>`).join('');
-$('#dens').value=dens;
-// ── CSV export: current visible columns (in order) × full filtered+sorted set (no 600 cap) ──
+$('#drip').value=dripLvl;$('#dens').value=dens;
+
+// ── column picker in the rail (remove / restore any column) ──
+function renderColGrid(){
+  $('#colGrid').innerHTML=
+    `<div class="ph"><span>${visCols().length}/${COLS.length} shown</span><a id="colAll">all</a></div>`+
+    COLS.map(c=>`<label><input type="checkbox" data-k="${c.k}"${c.on?' checked':''}>${esc(c.label)}</label>`).join('');
+}
+$('#colGrid').addEventListener('click',e=>{
+  if(e.target.id==='colAll'){e.preventDefault();COLS.forEach(c=>c.on=true);renderColGrid();saveCols();render();return;}
+  const cb=e.target.closest('input');if(!cb)return;
+  const col=COLS.find(c=>c.k===cb.dataset.k);if(!col)return;
+  col.on=cb.checked;
+  if(!visCols().length){col.on=true;cb.checked=true;return;}
+  if(sortKey===col.k&&!col.on){setSort(visCols()[0].k,-1);}
+  saveCols();renderColGrid();render();
+});
+
+// ── CSV export ──
 function csvVal(r,c){let v=c.k==='status'?statusShort(r.status):(c.val?c.val(r):r[c.k]);return v==null?'':String(v);}
 function exportCSV(){
   const cols=visCols(),rows=filtered();
@@ -266,43 +509,82 @@ function exportCSV(){
   document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1500);
 }
 $('#csvBtn').addEventListener('click',exportCSV);
-// ── column picker (remove / restore any column) ──
-function renderColPanel(){
-  $('#colPanel').innerHTML=
-    `<div class="ph"><span>Columns shown</span><a id="colAll">all</a></div>`+
-    COLS.map(c=>`<label><input type="checkbox" data-k="${c.k}"${c.on?' checked':''}>${esc(c.label)}</label>`).join('');
+
+// ── inline chat bar (POST /api/claude-chat, grounded in the current filtered listings) ──
+const CHAT=[];
+$('#chHd').addEventListener('click',()=>{$('#chatbar').classList.toggle('open');if($('#chatbar').classList.contains('open'))$('#chatin').focus();});
+function chatContext(){
+  const all=filtered();
+  const rows=all.slice(0,60).map(r=>{const b=brokerFor(r);return{address:r.address,city:r.city,type:r.type,price:r.price,units:r.units,cap_rate:r.cap_rate,status:statusShort(r.status),firm:r.firm,broker_phone:b&&b.phone||null,broker_email:b&&b.email||null,upside:r.upside_note};});
+  return 'LIVE VIEW — the '+rows.length+' listings currently shown (of '+all.length+' filtered):\n'+JSON.stringify(rows);
 }
-renderColPanel();
-$('#colBtn').addEventListener('click',e=>{e.stopPropagation();const p=$('#colPanel');p.hidden=!p.hidden;$('#colBtn').setAttribute('aria-expanded',String(!p.hidden));});
-$('#colPanel').addEventListener('click',e=>{
-  e.stopPropagation();
-  if(e.target.id==='colAll'){COLS.forEach(c=>c.on=true);renderColPanel();saveCols();render();return;}
-  const cb=e.target.closest('input');if(!cb)return;
-  const col=COLS.find(c=>c.k===cb.dataset.k);if(!col)return;
-  col.on=cb.checked;
-  if(!visCols().length){col.on=true;cb.checked=true;return;} // never allow zero columns
-  if(sortKey===col.k&&!col.on){setSort(visCols()[0].k,-1);} // if we hid the sorted col, re-anchor sort
-  saveCols();render();
-});
-document.addEventListener('click',()=>{$('#colPanel').hidden=true;$('#colBtn').setAttribute('aria-expanded','false');});
-document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!$('#colPanel').hidden){$('#colPanel').hidden=true;$('#colBtn').setAttribute('aria-expanded','false');}});
-// ── auth pill (same account layer as deals-flow) ──
+async function sendChat(text){
+  CHAT.push({role:'user',content:text});
+  const log=$('#chatlog');
+  log.insertAdjacentHTML('beforeend',`<div class="msg u">${esc(text)}</div>`);
+  const think=document.createElement('div');think.className='msg a think';think.textContent='thinking…';log.appendChild(think);log.scrollTop=log.scrollHeight;
+  try{
+    const r=await fetch('/api/claude-chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:CHAT.slice(-8),context:chatContext(),system:'You are a commercial-real-estate analyst embedded in the CRCP "Just Listed" active-inventory viewer. Answer ONLY from the listings in the context. Be concise, give numbers, and when useful cite the broker phone/email shown. Never invent listings or contacts.'})}).then(x=>x.json());
+    think.remove();
+    const reply=(r&&r.reply)||'(no reply)';CHAT.push({role:'assistant',content:reply});
+    log.insertAdjacentHTML('beforeend',`<div class="msg a">${esc(reply)}</div>`);
+  }catch(e){ think.remove(); log.insertAdjacentHTML('beforeend',`<div class="msg a">⚠️ chat unavailable</div>`); }
+  log.scrollTop=log.scrollHeight;
+}
+$('#chatform').addEventListener('submit',e=>{e.preventDefault();const v=$('#chatin').value.trim();if(!v)return;$('#chatin').value='';sendChat(v);});
+
+// ── auth pill ──
 const api=(u,o)=>fetch(u,Object.assign({headers:{'Content-Type':'application/json'}},o||{})).then(r=>r.json());
-function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);el.innerHTML=`<b style="color:#fff">${who}</b> · <a style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('a').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=()=>location.href='/deals-flow.html';}}
+function renderAuth(){const el=$('#auth');if(ME&&ME.email){const who=esc(ME.name||ME.username||ME.email);el.innerHTML=`<b style="color:var(--ink)">${who}</b> · <a style="color:var(--blue);cursor:pointer">sign out</a>`;el.querySelector('a').onclick=async()=>{await fetch('/auth/logout',{method:'POST'});location.reload();};el.onclick=null;}else{el.textContent='Sign in';el.onclick=()=>location.href='/deals-flow.html';}}
 api('/api/me').then(m=>{ME=m;renderAuth();}).catch(()=>{});
-// ── breaking-news ticker ──
+// ticker
 async function loadTicker(){try{const r=await api('/api/ticker');const items=(r&&r.items)||[];if(!items.length)return;const html=items.map(i=>`<span class="it"><span class="t ${i.kind}">${esc(i.tag)}</span>${esc(i.text)}</span>`).join('');const dup=html.replace(/<span class="it">/g,'<span class="it" aria-hidden="true">');$('#tickTrack').innerHTML=html+dup;$('#ticker').style.display='';}catch(e){}}
 loadTicker();
-// ── load active inventory ──
-fetch('/data/listings.json').then(r=>r.json()).then(d=>{
-  META=d.meta||{};
-  DATA=(d.listings||[]).filter(l=>String(l.status||'').toLowerCase().startsWith('active'));
-  $('#srcBadge').textContent=`${DATA.length.toLocaleString()} active`;
-  $('#prov').innerHTML=`Active listings sourced from broker/portal feeds${META.sourced?` · batch sourced <b>${esc(String(META.sourced).slice(0,10))}</b>`:''}. Click any column header to sort, <b>drag headers</b> to reorder, and use <b>⚙ Columns</b> to show/hide fields. This feed carries no per-listing "date listed", so there's no true recency sort — use <b>Cap rate</b>, <b>Price</b>, or <b>Newest built</b>. Verify status against the source before acting.`;
-  buildRail();render();
-}).catch(()=>{$('#count').textContent='failed to load /data/listings.json';});
-</script>  <script src="/global-search.js" defer></script>
-  <script src="/sort-bar.js" defer></script>
-<script src="/corner-nav.js" defer></script>
+
+// ── boot: load brokers (contacts) + first-seen dates, then listings ──
+function startViews(){
+  if(typeof ThreeView==='undefined'){return setTimeout(startViews,40);} // three-view.js is deferred
+  mountThreeView();render();
+}
+async function boot(){
+  try{
+    const r=await api('/api/brokers/all');
+    const arr=(r&&(r.brokers||r.results||r))||[];
+    (Array.isArray(arr)?arr:[]).forEach(b=>{
+      const key=norm(b.firm||b.name); if(!key)return;
+      if(!BROKERS[key] || ((b.phone||b.email) && !(BROKERS[key].phone||BROKERS[key].email)))
+        BROKERS[key]={name:b.name,firm:b.firm,phone:b.phone,email:b.email,website:b.website,linkedin:b.linkedin};
+    });
+  }catch(e){}
+  // REAL per-listing agent contact by address (primary source — see brokerFor)
+  try{
+    const lb=await api('/api/listing-brokers');
+    ((lb&&lb.rows)||[]).forEach(row=>{
+      const k=na(row.address); if(!k)return;
+      if(!ADDRC[k] || ((row.phone||row.email) && !(ADDRC[k].phone||ADDRC[k].email)))
+        ADDRC[k]={name:row.name,firm:null,phone:row.phone,email:row.email,website:row.website};
+    });
+  }catch(e){}
+  try{ const d=await api('/api/listing-dates'); DATES=(d&&d.dates)||{}; }catch(e){ DATES={}; }
+  try{ SEEN=JSON.parse(localStorage.getItem('jlSeen')||'{}'); }catch(e){ SEEN={}; }
+
+  fetch('/data/listings.json').then(r=>r.json()).then(d=>{
+    META=d.meta||{};
+    DATA=(d.listings||[]).filter(l=>String(l.status||'').toLowerCase().startsWith('active'));
+    const nowIso=new Date().toISOString();let changed=false;
+    DATA.forEach(r=>{ if(!SEEN[r.id] && !DATES[r.id]){ SEEN[r.id]=nowIso; changed=true; } });
+    if(changed){ try{ localStorage.setItem('jlSeen',JSON.stringify(SEEN)); }catch(e){} }
+    $('#srcBadge').textContent=`${DATA.length.toLocaleString()} active`;
+    const withContact=DATA.filter(r=>{const b=brokerFor(r);return b&&(b.phone||b.email);}).length;
+    $('#prov').innerHTML=`Active listings from broker/portal feeds${META.sourced?` · batch sourced <b>${esc(String(META.sourced).slice(0,10))}</b>`:''}. Broker phone/email shown from the CRCP broker graph where the firm matches (<b>${withContact.toLocaleString()}</b> of ${DATA.length.toLocaleString()} joined); the rest link through to the listing source. <b>New</b> = first seen ≤${NEW_DAYS} days (DB capture date, else first seen in this browser). Switch <b>Grid / List / Table</b>, drag the <b>crawl</b> slider to slow the reveal, click any listing for full specs.`;
+    buildRail();renderColGrid();startViews();
+  }).catch(()=>{$('#count').textContent='failed to load /data/listings.json';});
+}
+boot();
+</script>
+  <script src="/three-view.js" defer></script>
+  <script src="/global-search.js" defer></script>
+  <script src="/corner-nav.js" defer></script>
+  <script src="/crcp-theme.js" defer></script><!-- day/night toggle (auto-injects into header .nav) -->
   <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body></html>
diff --git a/scripts/serve.js b/scripts/serve.js
index e5455ec..d1bd253 100644
--- a/scripts/serve.js
+++ b/scripts/serve.js
@@ -176,6 +176,21 @@ app.get('/api/listing-dates', async (req, res) => {
     res.json({ dates: m });
   } catch (e) { res.json({ dates: {} }); }
 });
+// TK-10703: per-listing REAL broker contact by ADDRESS. listings.json only knows the portal
+// ("Crexi") as firm, so a firm-name join yields ~0 contacts; the honest source is the broker
+// graph's listing→broker_listing→broker chain, matched on address. Returns the rows; the client
+// normalizes the address the same way it does locally so the join keys line up. Empty when no DB.
+// (Honors Steve's hard rule that every page always surfaces broker phone/email where it's known.)
+app.get('/api/listing-brokers', async (req, res) => {
+  try {
+    if (!brokerdb) return res.json({ rows: [] });
+    const rows = await brokerdb.pool.query(
+      `SELECT l.address, l.city, b.name, b.phone, b.email, b.website
+         FROM listing l JOIN broker_listing bl ON bl.listing_id = l.id JOIN broker b ON b.id = bl.broker_id
+        WHERE b.phone IS NOT NULL OR b.email IS NOT NULL`).then(r => r.rows);
+    res.json({ rows });
+  } catch (e) { res.json({ rows: [] }); }
+});
 // On-demand: the $0 lazy path a deal's broker cell fires when it has no agent site yet.
 app.post('/api/agent/discover', async (req, res) => {
   try {

← 0914826 snapshot before CRCP UI standardization  ·  back to Commercialrealestate  ·  just-listed: Cody-gate fixes — recency join by address (new- 8cdf838 →