[object Object]

← back to Commercialrealestate

fha-loans: Amazon left-rail + field toggles + grid/list via shared crcp-grid engine

1117168d09f16edb898d46cb6c1d69b071d5e351 · 2026-07-04 13:24:01 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit 1117168d09f16edb898d46cb6c1d69b071d5e351
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Sat Jul 4 13:24:01 2026 -0700

    fha-loans: Amazon left-rail + field toggles + grid/list via shared crcp-grid engine
---
 public/crcp-grid.css  |  56 +++++++++++++++
 public/crcp-grid.js   | 189 ++++++++++++++++++++++++++++++++++++++++++++++++++
 public/fha-loans.html | 127 ++++++++++++---------------------
 3 files changed, 289 insertions(+), 83 deletions(-)

diff --git a/public/crcp-grid.css b/public/crcp-grid.css
new file mode 100644
index 0000000..9642cd2
--- /dev/null
+++ b/public/crcp-grid.css
@@ -0,0 +1,56 @@
+/* crcp-grid.css — styles for the reusable CrcpGrid engine. Namespaced .cg-* so it never
+ * collides with a host page. Colors read the host's CSS vars with safe fallbacks, so the
+ * engine inherits each page's theme (condos dark, fha-loans glass, sales gold) automatically. */
+.cg-shell{display:flex;align-items:flex-start;}
+.cg-rail{flex:0 0 250px;align-self:stretch;border-right:1px solid var(--cg-line,#2a313c);background:var(--cg-railbg,rgba(0,0,0,.18));position:sticky;top:0;max-height:100vh;overflow-y:auto;padding-bottom:30px;}
+.cg-main{flex:1 1 auto;min-width:0;--cg-cols:3;}
+.cg-sec{padding:12px 15px;border-bottom:1px solid var(--cg-line,#2a313c);}
+.cg-sec h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--cg-mut,#8b949e);cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;}
+.cg-sec h4::after{content:'▾';font-size:11px;margin-left:8px;transition:transform .15s;}
+.cg-sec.collapsed h4::after{transform:rotate(-90deg);}
+.cg-sec.collapsed > *:not(h4){display:none !important;}
+.cg-chips{display:flex;flex-wrap:wrap;gap:6px;}
+.cg-chip{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:1px solid var(--cg-line,#2a313c);border-radius:16px;padding:4px 10px;font-size:12px;cursor:pointer;user-select:none;}
+.cg-chip:hover{color:var(--cg-ink,#e6edf3);}
+.cg-chip.active{border-color:var(--cg-acc,#58a6ff);color:var(--cg-acc,#58a6ff);background:var(--cg-accbg,rgba(88,166,255,.10));}
+.cg-ct{color:var(--cg-mut,#8b949e);font-size:10px;margin-left:4px;}
+.cg-row2{display:flex;gap:8px;}
+.cg-fld{flex:1;}
+.cg-fld label{display:block;font-size:10px;color:var(--cg-mut,#8b949e);margin-bottom:3px;}
+.cg-fld input{width:100%;background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:7px 9px;font-size:13px;}
+.cg-ftogs{display:flex;flex-direction:column;gap:1px;max-height:300px;overflow-y:auto;}
+.cg-ftog{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--cg-ink,#e6edf3);cursor:pointer;padding:3px 4px;border-radius:6px;}
+.cg-ftog:hover{background:var(--cg-accbg,rgba(88,166,255,.06));}
+.cg-mini{margin-top:8px;display:flex;gap:5px;}
+.cg-mini button{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:1px solid var(--cg-line,#2a313c);border-radius:7px;padding:4px 9px;font-size:11px;cursor:pointer;}
+.cg-mini button:hover{border-color:var(--cg-acc,#58a6ff);color:var(--cg-ink,#e6edf3);}
+.cg-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:13px 18px;border-bottom:1px solid var(--cg-line,#2a313c);position:sticky;top:0;background:var(--cg-topbg,rgba(14,17,22,.94));backdrop-filter:blur(6px);z-index:6;}
+.cg-q{min-width:240px;background:var(--cg-card,#161b22);color:var(--cg-ink,#e6edf3);border:1px solid var(--cg-line,#2a313c);border-radius:8px;padding:8px 11px;font-size:13px;}
+.cg-seg{display:inline-flex;border:1px solid var(--cg-line,#2a313c);border-radius:8px;overflow:hidden;}
+.cg-seg button{background:var(--cg-card,#161b22);color:var(--cg-mut,#8b949e);border:0;border-right:1px solid var(--cg-line,#2a313c);padding:7px 12px;font-size:12px;cursor:pointer;}
+.cg-seg button:last-child{border-right:0;}
+.cg-seg button.active{background:var(--cg-acc,#1f6feb);color:#fff;}
+.cg-dens{font-size:12px;color:var(--cg-mut,#8b949e);display:flex;align-items:center;gap:7px;}
+.cg-dens input{accent-color:var(--cg-acc,#58a6ff);}
+.cg-count{margin-left:auto;font-size:12px;color:var(--cg-mut,#8b949e);}
+.cg-count b{color:var(--cg-ink,#e6edf3);}
+.cg-grid{display:grid;gap:16px;padding:18px;grid-template-columns:repeat(var(--cg-cols,3),minmax(0,1fr));}
+.cg-grid.cg-listhost{display:block;}
+.cg-card{background:var(--cg-card,#161b22);border:1px solid var(--cg-line,#2a313c);border-radius:12px;padding:15px;display:flex;flex-direction:column;gap:5px;}
+.cg-card.red{border-color:var(--cg-red,#f85149);}
+.cg-name{font-weight:600;font-size:14px;color:var(--cg-ink,#e6edf3);}
+.cg-sub{font-size:12px;color:var(--cg-mut,#8b949e);}
+.cg-r{display:flex;justify-content:space-between;font-size:12px;gap:10px;}
+.cg-r .cg-k{color:var(--cg-mut,#8b949e);}
+.cg-empty{padding:50px;text-align:center;color:var(--cg-mut,#8b949e);}
+.cg-listwrap{overflow-x:auto;margin:18px;border:1px solid var(--cg-line,#2a313c);border-radius:12px;}
+.cg-tbl{width:100%;border-collapse:collapse;font-size:12.5px;white-space:nowrap;}
+.cg-tbl th,.cg-tbl td{text-align:right;padding:9px 13px;border-top:1px solid var(--cg-line,#2a313c);}
+.cg-tbl th{position:sticky;top:0;background:var(--cg-railbg,#0b0e13);color:var(--cg-mut,#8b949e);font-size:10px;text-transform:uppercase;letter-spacing:.5px;border-top:0;}
+.cg-tbl td.cg-stick,.cg-tbl th.cg-stick{text-align:left;position:sticky;left:0;background:var(--cg-card,#161b22);}
+.cg-tbl th.cg-stick{background:var(--cg-railbg,#0b0e13);}
+.cg-tbl tr:hover td{background:var(--cg-accbg,rgba(88,166,255,.06));}
+.cg-tbl tr.red td{background:rgba(248,81,73,.07);}
+.cg-la{font-weight:600;color:var(--cg-ink,#e6edf3);}
+.cg-lc{color:var(--cg-mut,#8b949e);font-size:11px;}
+@media(max-width:820px){.cg-rail{display:none;}}
diff --git a/public/crcp-grid.js b/public/crcp-grid.js
new file mode 100644
index 0000000..321499f
--- /dev/null
+++ b/public/crcp-grid.js
@@ -0,0 +1,189 @@
+/* crcp-grid.js — reusable Amazon-style faceted grid engine for the CRE fleet.
+ * Config-driven, theme-agnostic (inherits the host page's CSS vars via cg-grid.css).
+ * One page = one CrcpGrid.mount(opts) call. Mirrors the proven condos.html engine so
+ * every list page gets the SAME behavior: left-rail facets, per-field show/hide toggles,
+ * grid/list view toggle, density slider, live search, localStorage persistence.
+ *
+ * opts = {
+ *   data:      [row,...],            // all rows (client-side faceting)
+ *   fields:    [{k,l,def,col,badge,money,num,date,calc,suf,pct}],  // every data point (card rows + list cols)
+ *   facets:    [{k,l,type:'chip'|'range',calc,order,max}],         // rail filters (auto-derived if omitted)
+ *   mount:     '#cgMain',            // container for topbar + grid
+ *   rail:      '#cgRail',            // container for the left rail
+ *   title:     row => ({name, sub}),// card/list title + subline
+ *   search:    row => 'text blob',   // searchable string
+ *   badgeHtml: row => '<span>...'|'',// optional status badge html (card + list)
+ *   storageKey:'fha',               // localStorage namespace
+ *   cols:      3,                   // default density
+ *   onRow:     row => ({cls}),      // optional per-row class hook (e.g. red flag)
+ * }
+ */
+(function (global) {
+  const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
+  const $ = (sel, root) => (root || document).querySelector(sel);
+
+  function fmt(v, f) {
+    if (v == null || v === '') return '—';
+    if (f.money) return '$' + (+v).toLocaleString() + (f.suf || '');
+    if (f.pct)   return (+v).toFixed(2) + '%';
+    if (f.num)   return (+v).toLocaleString() + (f.suf || '');
+    if (f.date)  { try { return new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch (e) { return '—'; } }
+    return esc(v) + (f.suf || '');
+  }
+
+  function CrcpGrid(opts) {
+    const O = opts;
+    const KEY = O.storageKey || 'cg';
+    const fields = O.fields;
+    const data = O.data || [];
+    const fval = (row, f) => f.calc ? f.calc(row) : row[f.k];
+
+    // ---- field visibility (persisted) ----
+    const VIS = (function () {
+      let s = {}; try { s = JSON.parse(localStorage.getItem(KEY + 'Fields') || '{}'); } catch (e) {}
+      const o = {}; fields.forEach(f => o[f.k] = (f.k in s) ? !!s[f.k] : !!f.def); return o;
+    })();
+    const saveVis = () => { try { localStorage.setItem(KEY + 'Fields', JSON.stringify(VIS)); } catch (e) {} };
+
+    // ---- auto-derive facets if not provided ----
+    let facets = O.facets;
+    if (!facets) {
+      facets = [];
+      fields.forEach(f => {
+        if (f.badge || f.k === 'title') return;
+        const vals = data.map(r => fval(r, f)).filter(v => v != null && v !== '');
+        if (!vals.length) return;
+        const numeric = f.money || f.num || f.pct || vals.every(v => typeof v === 'number' || /^\d+(\.\d+)?$/.test(v));
+        const uniq = new Set(vals.map(String));
+        if (!numeric && uniq.size <= 24) facets.push({ k: f.k, l: f.l, type: 'chip', calc: f.calc });
+        else if (numeric) facets.push({ k: f.k, l: f.l, type: 'range', calc: f.calc, money: f.money });
+      });
+    }
+
+    // ---- filter state ----
+    const F = { q: '', chips: {}, ranges: {} };
+    facets.forEach(fc => { if (fc.type === 'chip') F.chips[fc.k] = new Set(); else F.ranges[fc.k] = { min: null, max: null }; });
+
+    const gv = (row, fc) => fc.calc ? fc.calc(row) : row[fc.k];
+    function pass(row) {
+      for (const fc of facets) {
+        if (fc.type === 'chip') { const set = F.chips[fc.k]; if (set.size && !set.has(String(gv(row, fc)))) return false; }
+        else { const r = F.ranges[fc.k], v = gv(row, fc);
+          if (r.min != null && !(+v >= r.min)) return false;
+          if (r.max != null && !(+v <= r.max)) return false; }
+      }
+      if (F.q) { const blob = (O.search ? O.search(row) : fields.map(f => fval(row, f)).join(' ')).toLowerCase(); if (!blob.includes(F.q)) return false; }
+      return true;
+    }
+
+    // ---- rail ----
+    function buildRail() {
+      const rail = $(O.rail);
+      let html = '';
+      facets.forEach(fc => {
+        if (fc.type === 'chip') {
+          const counts = {};
+          data.forEach(r => { const v = gv(r, fc); if (v != null && v !== '') counts[v] = (counts[v] || 0) + 1; });
+          const ent = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, fc.max || 20);
+          if (!ent.length) return;
+          html += `<div class="cg-sec collapsed"><h4>${esc(fc.l)}</h4><div class="cg-chips" data-facet="${esc(fc.k)}">` +
+            ent.map(([k, c]) => `<span class="cg-chip" data-v="${esc(k)}">${esc(k)}<span class="cg-ct">${c}</span></span>`).join('') + `</div></div>`;
+        } else {
+          html += `<div class="cg-sec collapsed"><h4>${esc(fc.l)}</h4><div class="cg-row2" data-facet="${esc(fc.k)}">` +
+            `<div class="cg-fld"><label>Min</label><input type="number" class="cg-min" placeholder="any"></div>` +
+            `<div class="cg-fld"><label>Max</label><input type="number" class="cg-max" placeholder="any"></div></div></div>`;
+        }
+      });
+      html += `<div class="cg-sec"><h4>Fields — show / hide</h4><div class="cg-ftogs">` +
+        fields.map(f => `<label class="cg-ftog"><input type="checkbox" data-fk="${esc(f.k)}" ${VIS[f.k] ? 'checked' : ''}><span>${esc(f.l)}</span>${f.col ? '<span class="cg-ct" title="List column">▤</span>' : ''}</label>`).join('') +
+        `</div><div class="cg-mini"><button data-all="1">All</button><button data-none="1">None</button><button data-def="1">Default</button></div></div>`;
+      rail.innerHTML = html;
+
+      // wire facets
+      rail.querySelectorAll('.cg-chips').forEach(box => box.addEventListener('click', e => {
+        const c = e.target.closest('.cg-chip'); if (!c) return;
+        const set = F.chips[box.dataset.facet]; const v = c.dataset.v;
+        set.has(v) ? set.delete(v) : set.add(v); c.classList.toggle('active'); render();
+      }));
+      rail.querySelectorAll('.cg-row2').forEach(box => {
+        const k = box.dataset.facet;
+        box.querySelector('.cg-min').addEventListener('input', e => { F.ranges[k].min = e.target.value ? +e.target.value : null; render(); });
+        box.querySelector('.cg-max').addEventListener('input', e => { F.ranges[k].max = e.target.value ? +e.target.value : null; render(); });
+      });
+      // field toggles
+      rail.querySelector('.cg-ftogs').addEventListener('change', e => { const cb = e.target.closest('input[data-fk]'); if (!cb) return; VIS[cb.dataset.fk] = cb.checked; saveVis(); render(); });
+      const mini = rail.querySelector('.cg-mini');
+      mini.addEventListener('click', e => {
+        const b = e.target.closest('button'); if (!b) return;
+        if (b.dataset.all) fields.forEach(f => VIS[f.k] = true);
+        else if (b.dataset.none) fields.forEach(f => VIS[f.k] = false);
+        else if (b.dataset.def) fields.forEach(f => VIS[f.k] = !!f.def);
+        saveVis(); buildRail(); render();
+      });
+      // collapsible headers
+      rail.querySelectorAll('.cg-sec > h4').forEach(h => h.addEventListener('click', () => h.parentElement.classList.toggle('collapsed')));
+    }
+
+    // ---- renderers ----
+    function cards(rows) {
+      const vis = fields.filter(f => VIS[f.k] && !f.badge && f.k !== 'title');
+      return rows.map(r => {
+        const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
+        const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+        return `<div class="cg-card ${rc}">` +
+          `<div class="cg-name">${esc(t.name)}</div>${t.sub ? `<div class="cg-sub">${esc(t.sub)}</div>` : ''}` +
+          (O.badgeHtml ? O.badgeHtml(r) : '') +
+          vis.map(f => `<div class="cg-r"><span class="cg-k">${esc(f.l)}</span><span>${fmt(fval(r, f), f)}</span></div>`).join('') +
+          `</div>`;
+      }).join('');
+    }
+    function list(rows) {
+      const cols = fields.filter(f => f.col && VIS[f.k] && !f.badge);
+      const head = `<th class="cg-stick">${esc((O.titleCol || 'Name'))}</th>` + cols.map(f => `<th>${esc(f.l)}</th>`).join('');
+      const body = rows.map(r => {
+        const t = O.title ? O.title(r) : { name: '', sub: '' };
+        const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+        return `<tr class="${rc}"><td class="cg-stick"><span class="cg-la">${esc(t.name)}</span>${t.sub ? `<div class="cg-lc">${esc(t.sub)}</div>` : ''}</td>` +
+          cols.map(f => `<td>${fmt(fval(r, f), f)}</td>`).join('') + `</tr>`;
+      }).join('');
+      return `<div class="cg-listwrap"><table class="cg-tbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
+    }
+
+    let VIEW = localStorage.getItem(KEY + 'View') || O.view || 'grid';
+    const CAP = O.cap || 1500; // DOM render cap for perf; count still reflects the full match set
+    function render() {
+      const rows = data.filter(pass);
+      const shown = rows.length > CAP ? rows.slice(0, CAP) : rows;
+      const g = $(O.mount + ' .cg-grid');
+      if (!rows.length) { g.className = 'cg-grid'; g.innerHTML = '<div class="cg-empty">no matches for these filters.</div>'; }
+      else if (VIEW === 'list') { g.className = 'cg-grid cg-listhost'; g.innerHTML = list(shown); }
+      else { g.className = 'cg-grid'; g.innerHTML = cards(shown); }
+      const cnt = $(O.mount + ' .cg-count');
+      if (cnt) cnt.innerHTML = `<b>${rows.length.toLocaleString()}</b> of ${data.length.toLocaleString()}` + (rows.length > CAP ? ` <span style="opacity:.7">(first ${CAP.toLocaleString()} shown)</span>` : '');
+    }
+
+    // ---- topbar ----
+    function buildTop() {
+      const m = $(O.mount);
+      m.insertAdjacentHTML('afterbegin',
+        `<div class="cg-top">` +
+        `<input type="text" class="cg-q" placeholder="search…">` +
+        `<span class="cg-seg"><button data-view="grid" class="${VIEW === 'grid' ? 'active' : ''}">▦ Grid</button><button data-view="list" class="${VIEW === 'list' ? 'active' : ''}">☰ List</button></span>` +
+        `<label class="cg-dens">Density <input type="range" class="cg-cols" min="1" max="12" step="1" value="${O.cols || 3}"></label>` +
+        `<span class="cg-count"></span></div><div class="cg-grid"></div>`);
+      const q = m.querySelector('.cg-q');
+      q.addEventListener('input', () => { clearTimeout(q._t); q._t = setTimeout(() => { F.q = q.value.trim().toLowerCase(); render(); }, 160); });
+      m.querySelector('.cg-seg').addEventListener('click', e => { const b = e.target.closest('button[data-view]'); if (!b) return; VIEW = b.dataset.view; localStorage.setItem(KEY + 'View', VIEW); m.querySelectorAll('.cg-seg button').forEach(x => x.classList.toggle('active', x.dataset.view === VIEW)); render(); });
+      const cols = m.querySelector('.cg-cols');
+      const setCols = v => { m.style.setProperty('--cg-cols', v); localStorage.setItem(KEY + 'Cols', v); };
+      cols.addEventListener('input', e => setCols(e.target.value));
+      const sc = localStorage.getItem(KEY + 'Cols'); if (sc) { cols.value = sc; }
+      setCols(cols.value);
+    }
+
+    buildTop(); buildRail(); render();
+    return { render, setData(d) { O.data.length = 0; d.forEach(x => O.data.push(x)); buildRail(); render(); } };
+  }
+
+  global.CrcpGrid = { mount: opts => new CrcpGrid(opts) };
+})(window);
diff --git a/public/fha-loans.html b/public/fha-loans.html
index 17a3b28..e87cf24 100644
--- a/public/fha-loans.html
+++ b/public/fha-loans.html
@@ -5,11 +5,17 @@
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%23c8a24b'%3E%24%3C/text%3E%3C/svg%3E">
 <title>FHA / Low-Rate Loans — LA County CRE</title>
+<link rel="stylesheet" href="/crcp-grid.css">
+<script src="/crcp-grid.js"></script>
 <style>
   :root{
     --gold:#e9c46a; --g2:#c8a24b; --violet:#8b7cf6; --teal:#2dd4bf; --blue:#60a5fa; --pink:#f472b6; --orange:#fb923c;
     --red:#f85149;
     --ink:#eef1f7; --muted:#9aa3b4; --glass:rgba(255,255,255,.055); --glassb:rgba(255,255,255,.12);
+    /* crcp-grid engine theme mapping (liquid-glass) */
+    --cg-line:rgba(255,255,255,.12); --cg-mut:#9aa3b4; --cg-ink:#eef1f7; --cg-card:rgba(255,255,255,.06);
+    --cg-acc:#e9c46a; --cg-accbg:rgba(233,196,106,.14); --cg-railbg:rgba(255,255,255,.035);
+    --cg-topbg:rgba(7,10,16,.72); --cg-red:#f85149;
   }
   *{box-sizing:border-box;margin:0;padding:0;}
   html,body{min-height:100%;background:#070a10;color:var(--ink);font:15px/1.5 -apple-system,'SF Pro Display',Segoe UI,Roboto,sans-serif;-webkit-font-smoothing:antialiased;}
@@ -138,25 +144,11 @@
 
   <div class="tiles" id="tiles"></div>
 
-  <div class="glass controls" style="animation-delay:.06s">
-    <input type="text" id="q" placeholder="search property / city / state / zip / lender…">
-    <label>Show
-      <select id="filter">
-        <option value="all">All ≤3.5% loans</option>
-        <option value="red">RED only (flagged)</option>
-        <option value="below">Below market</option>
-        <option value="era">Historic-low era</option>
-      </select>
-    </label>
-    <label>Density <input type="range" id="dens" min="11" max="16" step="1" value="13"></label>
-    <div class="count" id="count"></div>
-  </div>
-
-  <div class="glass tablewrap" style="animation-delay:.1s">
-    <table id="tbl">
-      <thead><tr id="head"></tr></thead>
-      <tbody id="body"><tr><td class="empty">loading real HUD data…</td></tr></tbody>
-    </table>
+  <div class="glass" style="animation-delay:.1s;padding:0;overflow:hidden">
+    <div class="cg-shell">
+      <aside class="cg-rail" id="cgRail"></aside>
+      <div class="cg-main" id="cgMain"><div class="empty" style="padding:50px">loading real HUD data…</div></div>
+    </div>
   </div>
 
   <div class="foot">
@@ -173,16 +165,29 @@ const F={
   num:n=>{n=Number(n);return isFinite(n)?n.toLocaleString():'—';},
   pct:n=>(n==null||!isFinite(n))?'—':(+n).toFixed(2)+'%',
 };
-let DATA=null, ROWS=[], SORT={key:'rate',dir:1};
+let DATA=null, ROWS=[], GRID=null;
 
-const COLS=[
-  {key:'property',  label:'Property',           cls:'prop', get:r=>r.property},
-  {key:'rate',      label:'Orig. Rate',         cls:'num',  get:r=>r.rate},
-  {key:'pmmsBenchmark',label:'Market avg',      cls:'num',  get:r=>r.pmmsBenchmark},
-  {key:'originalAmount',label:'Loan amount',    cls:'num',  get:r=>r.originalAmount},
-  {key:'originationDate',label:'Originated',    cls:'',     get:r=>r.originationDate},
-  {key:'units',     label:'Units',              cls:'num',  get:r=>r.units},
-  {key:'why',       label:'Why red',            cls:'',     get:r=>(r.redReasons||[]).length},
+// Every data point — each toggleable (drives cards + list columns) for the crcp-grid engine.
+const FIELDS=[
+  {k:'rate',           l:'Orig. Rate',  pct:1,   col:1, def:1},
+  {k:'pmmsBenchmark',  l:'Market avg',  pct:1,   col:1, def:1},
+  {k:'originalAmount', l:'Loan amount', money:1, col:1, def:1},
+  {k:'originationDate',l:'Originated',           col:1, def:1},
+  {k:'units',          l:'Units',       num:1,   col:1, def:1},
+  {k:'flag',           l:'Flag',                 col:1, def:1, calc:r=> r.red ? (r.belowMarket&&r.historicLowEra?'Below + Era':r.belowMarket?'Below market':'Historic-low era') : '—'},
+  {k:'city',           l:'City',                 col:1, def:0},
+  {k:'state',          l:'State',                col:1, def:0},
+  {k:'zip',            l:'ZIP',                  col:1, def:0},
+  {k:'holder',         l:'Lender',               col:1, def:0},
+];
+const FACETS=[
+  {k:'flag',  l:'Flag',          type:'chip', calc:r=> r.red ? (r.belowMarket&&r.historicLowEra?'Below + Era':r.belowMarket?'Below market':'Historic-low era') : 'Not flagged'},
+  {k:'state', l:'State',         type:'chip'},
+  {k:'city',  l:'City',          type:'chip', max:20},
+  {k:'year',  l:'Year closed',   type:'chip', calc:r=>String(r.originationDate||'').slice(0,4), max:24},
+  {k:'rate',  l:'Orig. rate %',  type:'range'},
+  {k:'originalAmount', l:'Loan amount $', type:'range', money:1},
+  {k:'units', l:'Units',         type:'range'},
 ];
 
 function badge(r){
@@ -200,55 +205,18 @@ function badge(r){
   return html;
 }
 
-function renderHead(){
-  $('#head').innerHTML=COLS.map(c=>{
-    const on=SORT.key===c.key;
-    const ar=on?(SORT.dir>0?'▲':'▼'):'↕';
-    return `<th data-k="${c.key}" class="${on?'sorted':''}${c.cls==='num'?' tnum':''}" style="${c.cls==='num'?'text-align:right':''}">${c.label}<span class="ar">${ar}</span></th>`;
-  }).join('');
-  $('#head').querySelectorAll('th').forEach(th=>th.onclick=()=>{
-    const k=th.dataset.k;
-    if(SORT.key===k) SORT.dir*=-1; else {SORT.key=k;SORT.dir = (k==='property'||k==='originationDate')?1:1;}
-    apply();
+function mountGrid(rows){
+  GRID=CrcpGrid.mount({
+    data:rows, fields:FIELDS, facets:FACETS,
+    mount:'#cgMain', rail:'#cgRail', view:'list', cols:2,
+    storageKey:'fha', titleCol:'Property',
+    title:r=>({ name:r.property||'(name n/a)', sub:[r.city,r.state].filter(Boolean).join(', ')+' '+(r.zip||'')+(r.holder?' · '+r.holder:'') }),
+    search:r=>((r.property||'')+' '+(r.city||'')+' '+(r.state||'')+' '+(r.zip||'')+' '+(r.holder||'')),
+    badgeHtml:r=> r.red ? `<div style="margin:2px 0">${badge(r)}</div>` : '',
+    onRow:r=>({cls:r.red?'red':''}),
   });
 }
 
-function passesFilter(r,f){
-  if(f==='red') return r.red;
-  if(f==='below') return r.belowMarket;
-  if(f==='era') return r.historicLowEra;
-  return true;
-}
-
-function apply(){
-  const f=$('#filter').value, q=$('#q').value.trim().toLowerCase();
-  let rows=ROWS.filter(r=>passesFilter(r,f));
-  if(q) rows=rows.filter(r=>((r.property||'')+' '+(r.city||'')+' '+(r.state||'')+' '+(r.zip||'')+' '+(r.holder||'')).toLowerCase().includes(q));
-  const col=COLS.find(c=>c.key===SORT.key)||COLS[1];
-  rows.sort((a,b)=>{
-    let x=col.get(a), y=col.get(b);
-    if(typeof x==='number'||typeof y==='number'){ x=(x==null||!isFinite(x))?-Infinity:x; y=(y==null||!isFinite(y))?-Infinity:y; return (x-y)*SORT.dir; }
-    return String(x||'').localeCompare(String(y||''))*SORT.dir;
-  });
-  renderHead();
-  const tb=$('#body');
-  if(!rows.length){ tb.innerHTML='<tr><td class="empty" colspan="'+COLS.length+'">no loans match the current filter.</td></tr>'; }
-  else{
-    tb.innerHTML=rows.slice(0,2000).map(r=>{
-      return `<tr class="${r.red?'red':''}">`+
-        `<td class="prop">${r.property||'(name n/a)'}<span class="loc">${[r.city,r.state].filter(Boolean).join(', ')} ${r.zip||''}${r.holder?' · '+r.holder:''}</span></td>`+
-        `<td class="num ${r.red?'red-txt':''} rate">${F.pct(r.rate)}</td>`+
-        `<td class="num">${F.pct(r.pmmsBenchmark)}</td>`+
-        `<td class="num">${F.money(r.originalAmount)}</td>`+
-        `<td>${r.originationDate||'—'}</td>`+
-        `<td class="num">${r.units!=null?F.num(r.units):'—'}</td>`+
-        `<td>${badge(r)}</td>`+
-      `</tr>`;
-    }).join('');
-  }
-  $('#count').innerHTML=`<b>${F.num(rows.length)}</b> loans${rows.length>2000?' (showing first 2,000)':''}`;
-}
-
 function renderTiles(d){
   const tiles=[
     {k:'Loans ≤3.5%',v:F.num(d.count),cls:'gold',s:'original interest rate at or under 3.5%'},
@@ -264,7 +232,7 @@ function renderTiles(d){
 async function load(){
   let d;
   try{ d=await (await fetch('/data/fha-loans.json')).json(); }
-  catch(e){ $('#asof').textContent='data load error — run scripts/fetch-fha-loans.js to populate data/fha-loans.json'; $('#body').innerHTML='<tr><td class="empty" colspan="'+COLS.length+'">No data file. Run <code>node scripts/fetch-fha-loans.js</code> to fetch real HUD + PMMS data.</td></tr>'; return; }
+  catch(e){ $('#asof').textContent='data load error — run scripts/fetch-fha-loans.js to populate data/fha-loans.json'; $('#cgMain').innerHTML='<div class="empty" style="padding:50px">No data file. Run <code>node scripts/fetch-fha-loans.js</code> to fetch real HUD + PMMS data.</div>'; return; }
   DATA=d; ROWS=d.rows||[];
   renderTiles(d);
   // source links
@@ -275,15 +243,8 @@ async function load(){
   $('#windows').textContent=w?(' · historic-low windows: '+w):'';
   const gen=d.generatedAt?new Date(d.generatedAt).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'';
   $('#asof').textContent=`${F.num(d.count)} HUD/FHA multifamily loans ≤3.5% · ${F.num(d.redCount)} red · benchmarked vs Freddie Mac PMMS · updated ${gen}`;
-  apply();
+  mountGrid(ROWS);
 }
-
-$('#q').oninput=()=>{ clearTimeout(window._t); window._t=setTimeout(apply,160); };
-$('#filter').onchange=apply;
-$('#dens').oninput=e=>{ document.documentElement.style.setProperty('--tsz',e.target.value+'px'); localStorage.setItem('fhaDens',e.target.value); };
-const sd=localStorage.getItem('fhaDens'); if(sd){ $('#dens').value=sd; document.documentElement.style.setProperty('--tsz',sd+'px'); }
-const sf=localStorage.getItem('fhaFilter'); if(sf){ $('#filter').value=sf; }
-$('#filter').addEventListener('change',()=>localStorage.setItem('fhaFilter',$('#filter').value));
 load();
 </script>
 </body>

← 10edd44 condos api: explicit column projection (drop SELECT *, satis  ·  back to Commercialrealestate  ·  sales: Amazon rail + field toggles + grid/list via crcp-grid adc85c1 →