[object Object]

← back to Commercialrealestate

CRCP: infinite scroll across all grid pages (replace hard row caps / all-at-once renders)

38e48205285051236a41dae3f5246300ab94d996 · 2026-08-19 11:41:37 -0700 · Steve

Reveal a batch and load more as a sentinel scrolls near view, instead of dumping
the whole set (which froze heavy pages) or capping at 600/2000/120 with no way to
see the rest. Scroll-aware: window-scroll pages root the observer to the viewport;
inner-scroll panes (broker-grid/fha-loans/residential-brokers #tableView) root to
the pane. All 13 grid pages verified in-browser (reveal→grow, no page errors).

- infinite-scroll.js (new): reusable windowed helper for custom-render pages
- three-view.js: built-in windowing + scroll-container-aware sentinel (covers the 7 three-view pages)
- deals-flow/condos/contractors/direct-listings/broker-grid/fha-loans: wired to the helper
- fha-leads/just-listed/residential-brokers/licensed-agents: lifted redundant slice(0,600) caps so windowing reveals the full set
- index/mls: converted the existing 'show more' button to auto-trigger on scroll

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 38e48205285051236a41dae3f5246300ab94d996
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 19 11:41:37 2026 -0700

    CRCP: infinite scroll across all grid pages (replace hard row caps / all-at-once renders)
    
    Reveal a batch and load more as a sentinel scrolls near view, instead of dumping
    the whole set (which froze heavy pages) or capping at 600/2000/120 with no way to
    see the rest. Scroll-aware: window-scroll pages root the observer to the viewport;
    inner-scroll panes (broker-grid/fha-loans/residential-brokers #tableView) root to
    the pane. All 13 grid pages verified in-browser (reveal→grow, no page errors).
    
    - infinite-scroll.js (new): reusable windowed helper for custom-render pages
    - three-view.js: built-in windowing + scroll-container-aware sentinel (covers the 7 three-view pages)
    - deals-flow/condos/contractors/direct-listings/broker-grid/fha-loans: wired to the helper
    - fha-leads/just-listed/residential-brokers/licensed-agents: lifted redundant slice(0,600) caps so windowing reveals the full set
    - index/mls: converted the existing 'show more' button to auto-trigger on scroll
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/broker-grid.html         | 41 ++++++++++--------
 public/condos.html              | 16 +++++--
 public/contractors.html         | 24 +++++++----
 public/deals-flow.html          | 30 +++++++++----
 public/direct-listings.html     |  8 +++-
 public/fha-leads.html           |  6 +--
 public/fha-loans.html           | 35 ++++++++-------
 public/index.html               | 11 +++++
 public/infinite-scroll.js       | 96 +++++++++++++++++++++++++++++++++++++++++
 public/just-listed.html         |  4 +-
 public/licensed-agents.html     |  2 +-
 public/mls.html                 | 11 +++++
 public/residential-brokers.html |  2 +-
 public/three-view.js            | 82 ++++++++++++++++++++++++++++++++---
 14 files changed, 300 insertions(+), 68 deletions(-)

diff --git a/public/broker-grid.html b/public/broker-grid.html
index fc35661..907950a 100644
--- a/public/broker-grid.html
+++ b/public/broker-grid.html
@@ -99,6 +99,8 @@
 </style>
   <link rel="stylesheet" href="/three-view.css">
   <script src="/three-view.js"></script>
+  <script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
+
   <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 html[data-theme] wins -->
 </head>
@@ -308,36 +310,25 @@ function filtered(){
   });
   return rows;
 }
-function render(){
-  const rows=filtered(); const cols=visCols();
-  $('#count').textContent=`${rows.length} of ${DATA.length} brokers/agents`;
-  $('#thead').innerHTML='<tr>'+cols.map(c=>`<th data-k="${c.k}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey===c.k?(sortDir>0?' asc':' desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
-  const show=id=>['#tableView','#gridView','#listView'].forEach(s=>$(s).classList.toggle('hide',s!==id));
+let _bgInf=null,_bgRows=[],_bgCols=[];
+// Render one window (slice) of brokers into the active view container.
+function _bgPaint(win){ const cols=_bgCols;
   if(view==='table'){
-    show('#tableView');
-    $('#tbody').innerHTML=rows.map(r=>`<tr class="brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">`+cols.map(c=>{const cls=(c.t==='n')?' class="n"':'';return `<td${cls}>${cell(r,c)}</td>`;}).join('')+'</tr>').join('');
+    $('#tbody').innerHTML=win.map(r=>`<tr class="brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">`+cols.map(c=>{const cls=(c.t==='n')?' class="n"':'';return `<td${cls}>${cell(r,c)}</td>`;}).join('')+'</tr>').join('');
     const csig=cols.map(c=>c.k).join(',');
-    // Column SET/ORDER changed → full colgroup rebuild. Same columns (a sort /
-    // search / facet re-render) → just re-wire the resize handles the innerHTML
-    // rebuild just wiped, preserving current widths. Without this, column resize
-    // works on first paint then dies after the first re-render (TK-10088).
+    // Column SET/ORDER changed → full colgroup rebuild. Same columns (a sort / search / grow
+    // re-render) → just re-wire the resize handles the innerHTML rebuild wiped (TK-10088).
     if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
     else if(window.ColResize && window.ColResize.reattach){ window.ColResize.reattach(document.querySelector('#tableView table.g')); }
   } else if(view==='list'){
-    // COMPACT LIST — one scannable row per broker, every VISIBLE column as inline
-    // key/value pairs (reuses cell() so mailto/find-email/DRE/geo HTML matches the
-    // table + cards exactly). TK-10526 additive 3rd view. Rows share the .brow class
-    // so the existing modal/find-email click delegation on document just works.
-    show('#listView');
     const metaCols=cols.filter(c=>c.k!=='name');
-    $('#listView').innerHTML='<div class="tv-rows">'+rows.map(r=>`<div class="tv-lrow brow" data-id="${r.id}" data-name="${esc(r.name)}">`
+    $('#listView').innerHTML='<div class="tv-rows">'+win.map(r=>`<div class="tv-lrow brow" data-id="${r.id}" data-name="${esc(r.name)}">`
       +`<div class="tv-lmain"><span class="tv-la">${esc(r.name)}</span><span class="tv-lsub">${esc(r.firm||'—')}${r.title?' · '+esc(r.title):''}</span>${r.agent_type==='residential'?'<span class="atype res">Res</span>':'<span class="atype com">Com</span>'}</div>`
       +`<div class="tv-lmeta">`+metaCols.map(c=>`<span class="tv-lm"><span class="tv-lmk">${esc(c.l)}</span>${cell(r,c)}</span>`).join('')+`</div></div>`).join('')+'</div>';
   } else {
-    show('#gridView');
     const skipGrid=new Set(['name','firm','agent_type','phone','email','website','linkedin','title']);
     const bodyCols=cols.filter(c=>!skipGrid.has(c.k));
-    $('#gridView').innerHTML=rows.map(r=>`<div class="gc brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">
+    $('#gridView').innerHTML=win.map(r=>`<div class="gc brow" data-id="${r.id}" data-name="${esc(r.name)}" style="cursor:pointer">
       <div class="nm">${esc(r.name)} ${r.agent_type==='residential'?'<span class="atype res">Res</span>':'<span class="atype com">Com</span>'}</div>
       <div class="fm">${esc(r.firm||'—')}${r.title?' · '+esc(r.title):''}</div>`
       +bodyCols.map(c=>`<div class="row"><span class="k">${esc(c.l)}</span><span>${cell(r,c)}</span></div>`).join('')
@@ -345,6 +336,18 @@ function render(){
     </div>`).join('');
   }
 }
+function render(){
+  const rows=filtered(); const cols=visCols();
+  $('#count').textContent=`${rows.length} of ${DATA.length} brokers/agents`;
+  $('#thead').innerHTML='<tr>'+cols.map(c=>`<th data-k="${c.k}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey===c.k?(sortDir>0?' asc':' desc'):''}">${esc(c.l)}</th>`).join('')+'</tr>';
+  const activeSel=view==='table'?'#tableView':view==='list'?'#listView':'#gridView';
+  ['#tableView','#gridView','#listView'].forEach(s=>$(s).classList.toggle('hide',s!==activeSel));
+  // Windowed infinite scroll (was rendering ALL ~2,500 brokers at once): reveal a batch,
+  // load more on scroll. reset() repaints the active view from the top on filter/sort/view change.
+  _bgRows=rows; _bgCols=cols;
+  if(!_bgInf&&window.InfScroll) _bgInf=InfScroll.attach({batch:120,getRows:()=>_bgRows,getMount:()=>$(view==='table'?'#tableView':view==='list'?'#listView':'#gridView'),render:_bgPaint});
+  if(_bgInf) _bgInf.reset(); else _bgPaint(rows);
+}
 const money=n=>(n==null||n==='')?'—':'$'+Number(n).toLocaleString();
 const fdate=s=>{ if(!s) return ''; try{ return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return ''; } };
 // Date+TIME — only meaningful on our genuine capture timestamps (listed_at). Public-record
diff --git a/public/condos.html b/public/condos.html
index 29c5d3c..8b84d31 100644
--- a/public/condos.html
+++ b/public/condos.html
@@ -138,6 +138,7 @@
 </style>
   <link rel="stylesheet" href="/three-view.css">
   <script src="/three-view.js"></script>
+  <script src="/infinite-scroll.js"></script><!-- windowed infinite scroll for grid/table -->
   <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
@@ -549,17 +550,26 @@ function dbList(rows){
 // paint owns the GRID (cards) + TABLE (dense spreadsheet) views; the COMPACT LIST
 // view is owned by ThreeView into #condoList. The active container is shown/hidden
 // by ThreeView.render(); paint just fills #grid for grid/table.
+let _condoInf=null, _condoRows=[];
 function paint(rows){ const g=$('#grid'), lst=$('#condoList');
   if(VIEW==='list'){ if(TV) TV.render(); if(g) g.style.display='none'; if(lst) lst.style.display=''; return; }
   if(lst) lst.style.display='none'; if(g) g.style.display='';
-  if(!rows.length){ g.className='grid'; g.innerHTML='<div class="empty">no condos match these filters.</div>'; lastColSig=''; return; }
+  // Windowed infinite scroll for grid + table (the whole set once froze the page). The helper
+  // reveals a batch and loads more as you scroll; reset() repaints from the top on filter/sort change.
+  _condoRows=rows;
+  if(!_condoInf && window.InfScroll) _condoInf=InfScroll.attach({batch:120,getRows:()=>_condoRows,getMount:()=>$('#grid'),render:paintCondoWindow});
+  if(_condoInf) _condoInf.reset(); else paintCondoWindow(rows);   // fallback if helper missing
+}
+// Render one window (slice) of condo rows into #grid for the current grid/table view.
+function paintCondoWindow(win){ const g=$('#grid');
+  if(!win.length){ g.className='grid'; g.innerHTML='<div class="empty">no condos match these filters.</div>'; lastColSig=''; return; }
   if(VIEW==='table'){
-    g.className='grid listhost'; g.innerHTML=dbList(rows);
+    g.className='grid listhost'; g.innerHTML=dbList(win);
     // hand the freshly-rendered table to col-resize.js when the visible columns/order change
     const csig=orderedFields().filter(f=>f.col&&fvis(f.k)).map(f=>f.k).join(',');
     if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
   }
-  else { g.className='grid'; g.innerHTML=dbCards(rows); lastColSig=''; }
+  else { g.className='grid'; g.innerHTML=dbCards(win); lastColSig=''; }
 }
 // page-owned list-view sort (Steve rule: all sortable; survives search re-render) —
 // applies a click-header sort on the raw field value, any type. Runs on top of the
diff --git a/public/contractors.html b/public/contractors.html
index 9772c40..eee55e9 100644
--- a/public/contractors.html
+++ b/public/contractors.html
@@ -90,6 +90,7 @@ input.dealf{background:var(--card);border:1px solid var(--line);color:var(--ink)
 .lk{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-right:5px}
 </style>  <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 html[data-theme] wins -->
+<script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
 </head><body>
 <header>
   <h1><b>Contractors for this deal</b> · CSLB licensed</h1>
@@ -278,20 +279,27 @@ function buildRail(){
   $('#fCity').innerHTML=chip(ci,'city',14)||'<span style="font-size:11px;color:var(--mut)">—</span>';
   $('#fClass').innerHTML=chip(cl,'class',20)||'<span style="font-size:11px;color:var(--mut)">—</span>';
 }
+let _ctrInf=null,_ctrRows=[];
+// Render one window (slice) of contractors into #body for the current view.
+function _ctrPaint(win){
+  if(view==='list'){
+    $('#body').innerHTML=`<div class="lst">`+win.map(c=>`<div class="lrow crow" data-lic="${esc(String(c.license_no||'').replace(/\D/g,''))}" style="cursor:pointer"><div class="lnm">${esc(c.business_name||'—')}</div><div class="lmeta">`+CCOLS.slice(1).map(col=>`<span class="lm"><span class="lk">${esc(col.l)}</span>${cfmt(c,col)}</span>`).join('')+`</div></div>`).join('')+`</div>`;
+  } else if(view==='table'){
+    $('#body').innerHTML=`<div class="cttable"><table><thead><tr>`+CCOLS.map(col=>`<th data-k="${col.k}" style="cursor:pointer" title="Click to sort">${esc(col.l)}${sortKey===col.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+`</tr></thead><tbody>`+win.map(c=>`<tr class="crow" data-lic="${esc(String(c.license_no||'').replace(/\D/g,''))}" style="cursor:pointer">`+CCOLS.map(col=>`<td>${cfmt(c,col)}</td>`).join('')+`</tr>`).join('')+`</tbody></table></div>`;
+  } else {
+    $('#body').innerHTML=`<div class="grid">${win.map(card).join('')}</div>`;
+  }
+}
 function renderBrowse(down,reason){
   document.documentElement.style.setProperty('--cols',dens);
   if(down){$('#body').innerHTML=emptyState(reason);$('#count').textContent='upstream not reachable';return;}
   const rows=filteredSorted();
   $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()} shown`;
   if(!rows.length){$('#body').innerHTML=emptyState('No licensed contractors match these filters.','Loosen the county / city / class filters, or clear the search.');return;}
-  const slice=rows.slice(0,600);
-  if(view==='list'){
-    $('#body').innerHTML=`<div class="lst">`+slice.map(c=>`<div class="lrow crow" data-lic="${esc(String(c.license_no||'').replace(/\D/g,''))}" style="cursor:pointer"><div class="lnm">${esc(c.business_name||'—')}</div><div class="lmeta">`+CCOLS.slice(1).map(col=>`<span class="lm"><span class="lk">${esc(col.l)}</span>${cfmt(c,col)}</span>`).join('')+`</div></div>`).join('')+`</div>`;
-  } else if(view==='table'){
-    $('#body').innerHTML=`<div class="cttable"><table><thead><tr>`+CCOLS.map(col=>`<th data-k="${col.k}" style="cursor:pointer" title="Click to sort">${esc(col.l)}${sortKey===col.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+`</tr></thead><tbody>`+slice.map(c=>`<tr class="crow" data-lic="${esc(String(c.license_no||'').replace(/\D/g,''))}" style="cursor:pointer">`+CCOLS.map(col=>`<td>${cfmt(c,col)}</td>`).join('')+`</tr>`).join('')+`</tbody></table></div>`;
-  } else {
-    $('#body').innerHTML=`<div class="grid">${slice.map(card).join('')}</div>`;
-  }
+  // Windowed infinite scroll (was rows.slice(0,600)): reveal a batch, load more on scroll.
+  _ctrRows=rows;
+  if(!_ctrInf&&window.InfScroll) _ctrInf=InfScroll.attach({batch:120,getRows:()=>_ctrRows,getMount:()=>$('#body'),render:_ctrPaint});
+  if(_ctrInf) _ctrInf.reset(); else _ctrPaint(rows);
 }
 // Pull the registry through our own proxy (server injects CONTRACTORS_API_BASE). Server-side county/city
 // let the upstream do the heavy filter; we sort/search/paginate client-side.
diff --git a/public/deals-flow.html b/public/deals-flow.html
index 6af392c..7a67b19 100644
--- a/public/deals-flow.html
+++ b/public/deals-flow.html
@@ -121,6 +121,7 @@ select{background:var(--card);border:1px solid var(--line);color:var(--ink);bord
 .lk{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.5px;margin-right:5px}
 </style>  <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 html[data-theme] wins -->
+  <script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
 </head><body>
 <header>
   <h1><b>Closed Deal Flow</b> · LA Commercial</h1>
@@ -298,6 +299,22 @@ function card(r){
     <div class="provline">📋 ${esc(r.source)}</div>
   </div>`;
 }
+// Infinite scroll: paint only the windowed rows into the active view; the helper reveals
+// more as you scroll (replaces the old hard rows.slice(0,600) cap).
+const _EMPTY='<div style="color:var(--mut);padding:40px">No deals match.</div>';
+function activeDealMount(){ return view==='grid'?$('#grid'):view==='list'?$('#listView'):$('#tableView'); }
+function paintDealRows(win){
+  const g=$('#grid'),lv=$('#listView');
+  if(view==='grid'){
+    g.innerHTML=win.map(card).join('')||_EMPTY;
+  } else if(view==='list' && lv){
+    lv.innerHTML=win.map(r=>`<div class="lrow drow" data-k="${esc(dealKey(r))}" style="cursor:pointer"><div class="lnm">${esc(r.name||'—')}</div><div class="lmeta">`+DCOLS.slice(1).map(c=>`<span class="lm"><span class="lk">${esc(c.l)}</span>${dfmt(r,c)}</span>`).join('')+`</div></div>`).join('')||_EMPTY;
+  } else {
+    $('#dfthead').innerHTML='<tr>'+DCOLS.map(c=>`<th data-k="${c.k}" style="cursor:pointer" title="Click to sort">${esc(c.l)}${sortKey===c.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+'</tr>';
+    $('#dftbody').innerHTML=win.map(r=>`<tr class="drow" data-k="${esc(dealKey(r))}" style="cursor:pointer">`+DCOLS.map(c=>`<td>${dfmt(r,c)}</td>`).join('')+`</tr>`).join('')||`<tr><td colspan="${DCOLS.length}" style="color:var(--mut);padding:30px">No deals match.</td></tr>`;
+  }
+}
+let _dealInf=null;
 function render(){
   document.documentElement.style.setProperty('--cols',dens);
   const rows=filtered();
@@ -314,15 +331,10 @@ function render(){
   g.classList.toggle('hide',view!=='grid');
   if(tv) tv.classList.toggle('hide',view!=='table');
   if(lv) lv.classList.toggle('hide',view!=='list');
-  const slice=rows.slice(0,600);
-  if(view==='grid'){
-    g.innerHTML=slice.map(card).join('')||'<div style="color:var(--mut);padding:40px">No deals match.</div>';
-  } else if(view==='list' && lv){
-    lv.innerHTML=slice.map(r=>`<div class="lrow drow" data-k="${esc(dealKey(r))}" style="cursor:pointer"><div class="lnm">${esc(r.name||'—')}</div><div class="lmeta">`+DCOLS.slice(1).map(c=>`<span class="lm"><span class="lk">${esc(c.l)}</span>${dfmt(r,c)}</span>`).join('')+`</div></div>`).join('')||'<div style="color:var(--mut);padding:40px">No deals match.</div>';
-  } else if(tv){
-    $('#dfthead').innerHTML='<tr>'+DCOLS.map(c=>`<th data-k="${c.k}" style="cursor:pointer" title="Click to sort">${esc(c.l)}${sortKey===c.k?(sortDir<0?' ↓':' ↑'):''}</th>`).join('')+'</tr>';
-    $('#dftbody').innerHTML=slice.map(r=>`<tr class="drow" data-k="${esc(dealKey(r))}" style="cursor:pointer">`+DCOLS.map(c=>`<td>${dfmt(r,c)}</td>`).join('')+`</tr>`).join('')||`<tr><td colspan="${DCOLS.length}" style="color:var(--mut);padding:30px">No deals match.</td></tr>`;
-  }
+  // Windowed infinite scroll (replaces the old rows.slice(0,600) cap): reveal a batch,
+  // load more as the sentinel scrolls into view. reset() repaints the active view from the top.
+  if(!_dealInf) _dealInf=InfScroll.attach({batch:150,getRows:()=>render._rows,getMount:activeDealMount,render:paintDealRows});
+  _dealInf.reset();
 }
 $('#fSrc').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.src,c.dataset.s);c.classList.toggle('active');writeURLFilters(true);render();});
 $('#fUse').addEventListener('click',e=>{const c=e.target.closest('.chip');if(!c)return;tog(F.use,c.dataset.u);c.classList.toggle('active');writeURLFilters(true);render();});
diff --git a/public/direct-listings.html b/public/direct-listings.html
index 4186d8a..88bf631 100644
--- a/public/direct-listings.html
+++ b/public/direct-listings.html
@@ -40,6 +40,7 @@ td.price{color:var(--gold);font-weight:600}
 .empty{color:var(--mut);padding:40px;text-align:center}
 </style>
 <link rel="stylesheet" href="/crcp-theme.css">
+<script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
 </head><body>
 <header>
   <h1><b>Direct Listings</b> · our-own dbase</h1>
@@ -87,11 +88,16 @@ function filtered(){
     if(!isNaN(+x)&&!isNaN(+y)){x=+x;y=+y;}else{x=String(x).toLowerCase();y=String(y).toLowerCase();}
     return x<y?-1*sortDir:x>y?sortDir:0;});
 }
+let _dlInf=null,_dlRows=[];
+function _dlRow(x){ return '<tr>'+COLS.map(c=>`<td class="${c.num?'num ':''}${c.cls||''}">${c.r(x)}</td>`).join('')+'</tr>'; }
 function render(){
   document.documentElement.style.setProperty('--rowpad','7px');
   const rows=filtered();
   $('#thead').innerHTML='<tr>'+COLS.map(c=>`<th data-k="${c.k}" class="${c.num?'num':''}${c.k===sortKey?' sorted':''}">${c.l}${c.k===sortKey?(sortDir===1?' ▲':' ▼'):''}</th>`).join('')+'</tr>';
-  $('#tbody').innerHTML=rows.slice(0,2000).map(x=>'<tr>'+COLS.map(c=>`<td class="${c.num?'num ':''}${c.cls||''}">${c.r(x)}</td>`).join('')+'</tr>').join('');
+  // Windowed infinite scroll (was rows.slice(0,2000)): reveal a batch, load more on scroll.
+  _dlRows=rows;
+  if(!_dlInf&&window.InfScroll) _dlInf=InfScroll.attach({batch:150,getRows:()=>_dlRows,getMount:()=>{const b=$('#tbody');return (b&&b.closest('table'))||b;},render:win=>{$('#tbody').innerHTML=win.map(_dlRow).join('');}});
+  if(_dlInf) _dlInf.reset(); else $('#tbody').innerHTML=rows.map(_dlRow).join('');
   $('#count').textContent=`${rows.length.toLocaleString()} of ${DATA.length.toLocaleString()}`;
   $('#empty').hidden=rows.length>0;
 }
diff --git a/public/fha-leads.html b/public/fha-leads.html
index 2dbff48..ff8f5d6 100644
--- a/public/fha-leads.html
+++ b/public/fha-leads.html
@@ -140,12 +140,12 @@ function buildRail(){
 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('');
+  $('#body').innerHTML=rows.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=>{
+  gv.innerHTML=rows.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>`+
@@ -192,7 +192,7 @@ $('#dens').value=dens;
 // 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.slice(0,600),   // bound every view's DOM to the top-600 by current sort (count still reports the full match total)
+  getRows:()=>CURRENT,   // full set — three-view.js windows it via infinite scroll (count reports the full match total)
   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>':'',
diff --git a/public/fha-loans.html b/public/fha-loans.html
index dde3bdc..ccf5ab7 100644
--- a/public/fha-loans.html
+++ b/public/fha-loans.html
@@ -13,6 +13,7 @@
 <link rel="stylesheet" href="/crcp-grid.css">
 <link rel="stylesheet" href="/three-view.css">
 <script src="/three-view.js"></script>
+<script src="/infinite-scroll.js"></script><!-- windowed infinite scroll -->
 <script src="/crcp-grid.js"></script>
 <style>
   :root{
@@ -347,18 +348,13 @@ function filtered(){
 }
 
 // ── render: grid (cards) / list (compact) / table (dense) ─────────────────────
-function render(){
-  const rows=filtered(), cols=visCols();
-  $('#count').innerHTML=`<b>${rows.length.toLocaleString()}</b> of ${ROWS.length.toLocaleString()} loans`;
-  const show=id=>['#ftableView','#fgridView','#flistView'].forEach(s=>$(s).classList.toggle('hide',s!==id));
-  const ss=$('#fsort'); if(ss) ss.value = sortKey ? sortKey+':'+(sortDir<0?'desc':'asc') : '';
-  if(!rows.length){ $('#femptymsg').style.display='block'; $('#femptymsg').textContent='no matches for these filters.'; }
-  else $('#femptymsg').style.display='none';
+let _flInf=null,_flRows=[],_flCols=[];
+// Render one window (slice) of loans into the active view (infinite scroll windows the ~7,400 rows).
+function _flPaint(win){ const cols=_flCols;
   const propCell=r=>esc(r.property||'(name n/a)')+(r.city||r.state?`<span class="loc">${esc([r.city,r.state].filter(Boolean).join(', ')+' '+(r.zip||''))}${r.holder?' · '+esc(r.holder):''}</span>`:'');
   if(VIEW==='table'){
-    show('#ftableView');
     $('#fthead').innerHTML='<tr>'+cols.map(c=>`<th data-k="${c.k}" draggable="true" title="Drag to reorder · click to sort" class="dragcol${sortKey===c.k?(sortDir>0?' asc sorted':' desc sorted'):''}">${esc(c.l)}<span class="ar"></span></th>`).join('')+'</tr>';
-    $('#ftbody').innerHTML=rows.map(r=>`<tr class="frow${r.red?' red':''}" data-id="${esc(r.__i)}">`
+    $('#ftbody').innerHTML=win.map(r=>`<tr class="frow${r.red?' red':''}" data-id="${esc(r.__i)}">`
       +cols.map(c=>{const numT=(c.t==='pct'||c.t==='money'||c.t==='num');return `<td class="${c.k==='property'?'prop':''}${numT?' num':''}">`
         +(c.k==='property'?propCell(r):cell(r,c))
         +'</td>';}).join('')+'</tr>').join('');
@@ -366,18 +362,13 @@ function render(){
     if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
     else if(window.ColResize && window.ColResize.reattach){ window.ColResize.reattach(document.querySelector('#ftable')); } // resize handles survive sort/search re-render (TK-10089)
   } else if(VIEW==='list'){
-    // COMPACT LIST — one scannable row per loan; every visible non-property column
-    // as inline key/value pairs (reuses cell() so red-flag badges etc. match).
-    show('#flistView');
     const metaCols=cols.filter(c=>c.k!=='property');
-    $('#flistView').innerHTML='<div class="tv-rows">'+rows.map(r=>`<div class="tv-lrow frow${r.red?' red':''}" data-id="${esc(r.__i)}">`
+    $('#flistView').innerHTML='<div class="tv-rows">'+win.map(r=>`<div class="tv-lrow frow${r.red?' red':''}" data-id="${esc(r.__i)}">`
       +`<div class="tv-lmain"><span class="tv-la">${esc(r.property||'(name n/a)')}</span><span class="tv-lsub">${esc([r.city,r.state].filter(Boolean).join(', ')+' '+(r.zip||''))}${r.holder?' · '+esc(r.holder):''}</span>${r.red?badge(r):''}</div>`
       +`<div class="tv-lmeta">`+metaCols.map(c=>`<span class="tv-lm"><span class="tv-lmk">${esc(c.l)}</span>${cell(r,c)}</span>`).join('')+`</div></div>`).join('')+'</div>';
   } else {
-    // GRID — data cards, property title + visible fields as rows.
-    show('#fgridView');
     const bodyCols=cols.filter(c=>c.k!=='property');
-    $('#fgridView').innerHTML=rows.map(r=>`<div class="glass tile frow${r.red?' red':''}" data-id="${esc(r.__i)}" style="padding:16px 18px;cursor:pointer;border:1px solid var(--glassb)">`
+    $('#fgridView').innerHTML=win.map(r=>`<div class="glass tile frow${r.red?' red':''}" data-id="${esc(r.__i)}" style="padding:16px 18px;cursor:pointer;border:1px solid var(--glassb)">`
       +`<div style="font-weight:700;color:#fff;margin-bottom:2px">${esc(r.property||'(name n/a)')}</div>`
       +`<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px">${esc([r.city,r.state].filter(Boolean).join(', ')+' '+(r.zip||''))}${r.holder?' · '+esc(r.holder):''}</div>`
       +(r.red?`<div style="margin-bottom:8px">${badge(r)}</div>`:'')
@@ -385,6 +376,18 @@ function render(){
       +`</div>`).join('');
   }
 }
+function render(){
+  const rows=filtered(), cols=visCols();
+  $('#count').innerHTML=`<b>${rows.length.toLocaleString()}</b> of ${ROWS.length.toLocaleString()} loans`;
+  ['#ftableView','#fgridView','#flistView'].forEach(s=>$(s).classList.toggle('hide',s!==(VIEW==='table'?'#ftableView':VIEW==='list'?'#flistView':'#fgridView')));
+  const ss=$('#fsort'); if(ss) ss.value = sortKey ? sortKey+':'+(sortDir<0?'desc':'asc') : '';
+  if(!rows.length){ $('#femptymsg').style.display='block'; $('#femptymsg').textContent='no matches for these filters.'; }
+  else $('#femptymsg').style.display='none';
+  // Windowed infinite scroll (was rendering ALL ~7,400 loans at once).
+  _flRows=rows; _flCols=cols;
+  if(!_flInf&&window.InfScroll) _flInf=InfScroll.attach({batch:120,getRows:()=>_flRows,getMount:()=>$(VIEW==='table'?'#ftableView':VIEW==='list'?'#flistView':'#fgridView'),render:_flPaint});
+  if(_flInf) _flInf.reset(); else _flPaint(rows);
+}
 
 // ── expandable row → detail modal (all fields) ───────────────────────────────
 function openDetail(i){
diff --git a/public/index.html b/public/index.html
index e4d086d..18547df 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1617,6 +1617,16 @@ function printCompare(){ document.body.classList.add('printing-compare'); window
 let _rows=[], _shown=0;
 const PAGE=()=> view==='list'?200:60;
 function moreBtn(){ return _shown<_rows.length ? `<div class="showmore-wrap"><button class="showmore" id="showmore">Show ${Math.min(PAGE(),_rows.length-_shown)} more · showing ${_shown} of ${_rows.length}</button></div>` : ''; }
+// Infinite scroll: auto-trigger the "show more" grow when it scrolls near view (reuses the
+// existing #showmore click handler → _shown+=PAGE → paintPage). Viewport-rooted so it works
+// whether the page or an inner pane scrolls. Re-pointed at the fresh button after each repaint.
+let _moreIO=null, _moreBusy=false;
+function wireAutoMore(){
+  const btn=document.getElementById('showmore'); if(!btn) return;
+  if(typeof IntersectionObserver==='undefined') return;
+  if(!_moreIO) _moreIO=new IntersectionObserver(es=>{ if(_moreBusy) return; if(es.some(e=>e.isIntersecting)){ const b=document.getElementById('showmore'); if(b){ _moreBusy=true; b.click(); setTimeout(()=>{_moreBusy=false;},60); } } },{rootMargin:'500px 0px'});
+  _moreIO.disconnect(); _moreIO.observe(btn);
+}
 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; }
@@ -1628,6 +1638,7 @@ function paintPage(){
   else { g.className='grid'; g.innerHTML=slice.map(card).join('')+moreBtn(); enrichAssessor(); }
   applyHeat(slice);
   resolveContacts(slice);   // fetch REAL phones for the visible agents, then repaint (TK-10687)
+  wireAutoMore();           // convert the show-more button into infinite scroll
 }
 // ---- saved searches (name + restore the full shareable-URL state) ----
 const SAVED=(function(){ try{ return JSON.parse(localStorage.getItem('cre_saved')||'[]'); }catch(e){ return []; } })();
diff --git a/public/infinite-scroll.js b/public/infinite-scroll.js
new file mode 100644
index 0000000..71e5e55
--- /dev/null
+++ b/public/infinite-scroll.js
@@ -0,0 +1,96 @@
+/* infinite-scroll.js — reusable windowed infinite scroll for the CRCP custom-render
+ * grid pages (deals-flow, contractors, broker-grid, direct-listings) that DON'T go
+ * through three-view.js (which has its own equivalent windowing built in).
+ *
+ * Instead of dumping the whole filtered set into the DOM (which froze the heavy
+ * pages), reveal BATCH rows and load the next batch as a sentinel scrolls near view.
+ *
+ * Usage:
+ *   const inf = InfScroll.attach({
+ *     batch: 120,
+ *     getRows:  () => currentFilteredSortedRows,   // full array
+ *     getMount: () => document.querySelector(activeContainerSelector), // visible list/grid/table el
+ *     render:   (windowRows) => { mount.innerHTML = windowRows.map(...).join(''); },
+ *   });
+ *   // after ANY filter / sort / view change:
+ *   inf.reset();            // resets the window to the first batch and repaints
+ *   inf.shownCount();       // how many are currently revealed (for a "showing N of M" label)
+ *
+ * Degrades to "render everything" where IntersectionObserver is unavailable.
+ * Zero deps. $0, local.
+ */
+(function (global) {
+  const IO_OK = typeof IntersectionObserver !== 'undefined';
+
+  function attach(O) {
+    const BATCH = O.batch || 120;
+    let shown = BATCH;
+    let last = [];
+    let sentinel = null, io = null, growing = false;
+
+    function ensureSentinel() {
+      if (!IO_OK) return null;
+      if (!sentinel) {
+        sentinel = document.createElement('div');
+        sentinel.className = 'inf-sentinel';
+        sentinel.setAttribute('aria-hidden', 'true');
+        sentinel.style.cssText = 'height:1px;width:100%;';
+      }
+      return sentinel;
+    }
+    // Find the element that actually scrolls (the mount itself or an ancestor with an
+    // overflow:auto/scroll AND real overflow). Returns null when the WINDOW scrolls — the
+    // common case (condos/deals-flow/contractors/direct-listings), where the observer roots
+    // to the viewport and the sentinel sits AFTER the mount. Inner-scroll panes (broker-grid's
+    // fixed-height #tableView) return that pane so we root the observer to it and drop the
+    // sentinel INSIDE it, or window-scroll would never reach the sentinel.
+    function findScroller(el) {
+      let n = el;
+      while (n && n !== document.body && n !== document.documentElement) {
+        const cs = getComputedStyle(n);
+        if (/(auto|scroll)/.test(cs.overflowY) && n.scrollHeight > n.clientHeight + 4) return n;
+        n = n.parentElement;
+      }
+      return null;
+    }
+    let curRoot = undefined;   // the IO root we last built for (undefined = none yet)
+    function positionSentinel(hasMore) {
+      const s = ensureSentinel(); if (!s) return;
+      if (!hasMore) { if (s.parentNode) s.parentNode.removeChild(s); if (io) io.unobserve(s); return; }
+      const mount = O.getMount && O.getMount();
+      if (!mount || !mount.parentNode) return;
+      const scroller = findScroller(mount);   // null → window scroll
+      // (re)build the observer if the scroll root changed (view switch can change the pane)
+      if (scroller !== curRoot) {
+        if (io) io.disconnect();
+        io = new IntersectionObserver(es => { if (es.some(e => e.isIntersecting)) grow(); }, { root: scroller || null, rootMargin: '600px 0px' });
+        curRoot = scroller;
+      }
+      if (scroller) {
+        // inner-scroll pane → sentinel lives at the BOTTOM of the scrolling content
+        if (s.parentNode !== scroller || scroller.lastElementChild !== s) scroller.appendChild(s);
+      } else {
+        // window scroll → sentinel sits right after the mount in normal flow
+        if (mount.nextSibling !== s) mount.parentNode.insertBefore(s, mount.nextSibling);
+      }
+      io.observe(s);
+    }
+    function paint() {
+      last = (O.getRows && O.getRows()) || [];
+      if (shown > last.length) shown = Math.max(BATCH, Math.min(shown, last.length));
+      const win = IO_OK ? last.slice(0, shown) : last;
+      if (O.render) O.render(win);
+      positionSentinel(IO_OK && shown < last.length);
+      if (O.onPaint) O.onPaint(Math.min(shown, last.length), last.length);
+    }
+    function grow() {
+      if (growing || shown >= last.length) return;
+      growing = true; shown = Math.min(shown + BATCH, last.length); paint(); growing = false;
+    }
+    function reset() { shown = BATCH; paint(); }
+
+    return { reset, paint, grow, shownCount: () => Math.min(shown, last.length), total: () => last.length };
+  }
+
+  global.InfScroll = { attach };
+})(window);
diff --git a/public/just-listed.html b/public/just-listed.html
index 005f13c..8875478 100644
--- a/public/just-listed.html
+++ b/public/just-listed.html
@@ -322,7 +322,7 @@ 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 cap=rows;   // three-view.js windows the set via infinite scroll; render all it hands us
   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)}">
@@ -370,7 +370,7 @@ function rowHtml(r){
 }
 function renderTable(rows){
   renderHead();
-  $('#tbody').innerHTML=rows.slice(0,600).map(rowHtml).join('');
+  $('#tbody').innerHTML=rows.map(rowHtml).join('');   // three-view.js windows via infinite scroll
 }
 
 // ── stats + count (shared across views) ──
diff --git a/public/licensed-agents.html b/public/licensed-agents.html
index ac70448..4c10ebf 100644
--- a/public/licensed-agents.html
+++ b/public/licensed-agents.html
@@ -304,7 +304,7 @@ function renderAgentTable(rows){
   const cols=TABLE_COLS();
   const t=$('#tableView').querySelector('table');
   t.querySelector('thead').innerHTML='<tr>'+cols.map(c=>`<th>${esc(c.l)}</th>`).join('')+'</tr>';
-  t.querySelector('tbody').innerHTML=rows.slice(0,600).map(a=>{
+  t.querySelector('tbody').innerHTML=rows.map(a=>{
     return `<tr data-idx="${DATA.indexOf(a)}" style="cursor:pointer">`+cols.map(c=>`<td>${c.calc(a)}</td>`).join('')+'</tr>';
   }).join('');
   $('#count').textContent=rows.length+' / '+DATA.length+' agents';
diff --git a/public/mls.html b/public/mls.html
index 3f66288..20a0141 100644
--- a/public/mls.html
+++ b/public/mls.html
@@ -344,6 +344,17 @@ function paintBody(){
   }
   const mw=$('#moreWrap'); if(mw) mw.innerHTML=_shown<_rows.length?`<button class="csvbtn" id="showmore">Show ${Math.min(PAGE(),_rows.length-_shown)} more · showing ${_shown} of ${_rows.length}</button>`:'';
   applyHeat(rows);
+  wireAutoMore();   // infinite scroll: auto-load the next page as #showmore nears view
+}
+// Convert the "show more" button into infinite scroll — auto-click it when it scrolls near
+// view (reuses the existing #showmore handler → _shown+=PAGE → paintBody). The manual button
+// still works as a fallback where IntersectionObserver is unavailable.
+let _moreIO=null, _moreBusy=false;
+function wireAutoMore(){
+  const btn=document.getElementById('showmore'); if(!btn) return;
+  if(typeof IntersectionObserver==='undefined') return;
+  if(!_moreIO) _moreIO=new IntersectionObserver(es=>{ if(_moreBusy) return; if(es.some(e=>e.isIntersecting)){ const b=document.getElementById('showmore'); if(b){ _moreBusy=true; b.click(); setTimeout(()=>{_moreBusy=false;},60); } } },{rootMargin:'500px 0px'});
+  _moreIO.disconnect(); _moreIO.observe(btn);
 }
 // heat cues + copy summary (parity with the Explorer)
 let heat=localStorage.getItem('cre_heat_mls')||'none';
diff --git a/public/residential-brokers.html b/public/residential-brokers.html
index ce8dc30..9dfef83 100644
--- a/public/residential-brokers.html
+++ b/public/residential-brokers.html
@@ -159,7 +159,7 @@ function tableCols(){ return FIELDS.filter(f=>f.lock || (!state.off[f.k] && f.k!
 function renderTable(rows){
   const cols=tableCols(), t=$('tableView').querySelector('table');
   t.querySelector('thead').innerHTML='<tr>'+cols.map(c=>`<th>${esc(c.label)}</th>`).join('')+'</tr>';
-  t.querySelector('tbody').innerHTML=rows.slice(0,600).map(r=>
+  t.querySelector('tbody').innerHTML=rows.map(r=>
     '<tr>'+cols.map(c=>{
       const cell=c.render?c.render(r):(c.get(r)||'—');
       return `<td>${c.render?cell:esc(cell)}</td>`;
diff --git a/public/three-view.js b/public/three-view.js
index 3c87c50..4355d7a 100644
--- a/public/three-view.js
+++ b/public/three-view.js
@@ -63,6 +63,69 @@
     let SORT = null; // {k, dir}
     try { const s = JSON.parse(localStorage.getItem(KEY + 'Sort') || 'null'); if (s && typeof s.k === 'string') SORT = s; } catch (e) {}
 
+    // ---- infinite scroll (windowed reveal) ----------------------------------
+    // Instead of dumping the whole filtered set (which froze heavy pages), reveal
+    // BATCH rows at a time and load more as a sentinel scrolls into view. All three
+    // views (grid/list/table) share one `shown` window; it resets to BATCH whenever
+    // the page re-filters (public render()), re-sorts, or switches view. Degrades to
+    // "show everything" where IntersectionObserver is unavailable.
+    const BATCH = O.batch || 120;
+    const IO_OK = typeof IntersectionObserver !== 'undefined';
+    let shown = BATCH;      // how many rows are currently revealed
+    let LAST_ROWS = [];     // the full sorted set from the last paint (for grow())
+    let sentinel = null, io = null, growing = false;
+
+    function ensureSentinel() {
+      if (!IO_OK) return null;
+      if (!sentinel) {
+        sentinel = document.createElement('div');
+        sentinel.className = 'tv-sentinel';
+        sentinel.setAttribute('aria-hidden', 'true');
+        sentinel.style.cssText = 'height:1px;width:100%;';
+      }
+      return sentinel;
+    }
+    // The element that actually scrolls (the mount or an ancestor with real overflow), or null
+    // when the WINDOW scrolls. Inner-scroll panes (e.g. residential-brokers' #tableView with
+    // max-height+overflow:auto) must root the observer to the pane and hold the sentinel INSIDE
+    // it, or window-scroll would never reach a sentinel parked after the pane.
+    function findScroller(el) {
+      let n = el;
+      while (n && n !== document.body && n !== document.documentElement) {
+        const cs = getComputedStyle(n);
+        if (/(auto|scroll)/.test(cs.overflowY) && n.scrollHeight > n.clientHeight + 4) return n;
+        n = n.parentElement;
+      }
+      return null;
+    }
+    let curRoot = undefined;
+    // Park the sentinel at the bottom of the revealed content so the observer fires as it nears view.
+    function positionSentinel(hasMore) {
+      const s = ensureSentinel(); if (!s) return;
+      if (!hasMore) { if (s.parentNode) s.parentNode.removeChild(s); if (io) io.unobserve(s); return; }
+      const activeMount = $(VIEW === 'grid' ? O.gridMount : VIEW === 'table' ? O.tableMount : O.listMount);
+      if (!activeMount || !activeMount.parentNode) return;
+      const scroller = findScroller(activeMount);   // null → window scroll
+      if (scroller !== curRoot) {
+        if (io) io.disconnect();
+        io = new IntersectionObserver(entries => { if (entries.some(e => e.isIntersecting)) grow(); }, { root: scroller || null, rootMargin: '600px 0px' });
+        curRoot = scroller;
+      }
+      if (scroller) {
+        if (s.parentNode !== scroller || scroller.lastElementChild !== s) scroller.appendChild(s);
+      } else if (s.nextSibling !== activeMount.nextSibling || s.previousSibling !== activeMount) {
+        activeMount.parentNode.insertBefore(s, activeMount.nextSibling);
+      }
+      io.observe(s);
+    }
+    function grow() {
+      if (growing || shown >= LAST_ROWS.length) return;
+      growing = true;
+      shown = Math.min(shown + BATCH, LAST_ROWS.length);
+      paint();               // re-render the larger window (append-at-bottom keeps scroll pos)
+      growing = false;
+    }
+
     // ---- segmented view toggle ----
     function buildSeg() {
       const el = $(O.seg); if (!el) return;
@@ -136,18 +199,27 @@
       set(O.tableMount, VIEW === 'table');
     }
 
-    function render() {
+    // paint the CURRENT window (rows 0..shown). Does NOT reset `shown` — grow() and
+    // the internal re-renders call this so scrolling accumulates instead of snapping back.
+    function paint() {
       const raw = O.getRows ? (O.getRows() || []) : [];
-      const rows = applySort(raw);
+      LAST_ROWS = applySort(raw);
+      if (shown > LAST_ROWS.length) shown = Math.max(BATCH, Math.min(shown, LAST_ROWS.length));
+      const win = IO_OK ? LAST_ROWS.slice(0, shown) : LAST_ROWS;   // no IO → show all
       showHide();
-      if (VIEW === 'grid' && O.renderGrid) O.renderGrid(rows);
-      else if (VIEW === 'table' && O.renderTable) O.renderTable(rows);
-      else if (VIEW === 'list') listCompact(rows);
+      if (VIEW === 'grid' && O.renderGrid) O.renderGrid(win);
+      else if (VIEW === 'table' && O.renderTable) O.renderTable(win);
+      else if (VIEW === 'list') listCompact(win);
       // keep the seg buttons + sort select reflecting current state
       const seg = $(O.seg); if (seg) seg.querySelectorAll('button[data-tvview]').forEach(b => b.classList.toggle('active', b.dataset.tvview === VIEW));
       const sel = $(O.sortSel); if (sel) sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
+      positionSentinel(IO_OK && shown < LAST_ROWS.length);
     }
 
+    // public render() — called by the page after a filter change, and internally on
+    // sort/view change. Resets the reveal window to the first batch (back to the top).
+    function render() { shown = BATCH; paint(); }
+
     function setView(v) {
       if (!VIEWS.includes(v)) return;
       VIEW = v; try { localStorage.setItem(KEY + 'View', v); } catch (e) {}

← 77b9299 broker snapshot: active-listing recency gate + size-ceiling  ·  back to Commercialrealestate  ·  broker profile item 3: honest 'listings as of <date>' snapsh d4447f7 →