[object Object]

← back to Commercialrealestate

condos.html: add compact-list view (3rd mode) + shared three-view helper — grid/list/table + all-col sort, facets/search/notes/modal/drill preserved

badc059553c109c4e2e43b91c1e71e3c775e381c · 2026-08-13 08:14:05 -0700 · steve

Files touched

Diff

commit badc059553c109c4e2e43b91c1e71e3c775e381c
Author: steve <steve@designerwallcoverings.com>
Date:   Thu Aug 13 08:14:05 2026 -0700

    condos.html: add compact-list view (3rd mode) + shared three-view helper — grid/list/table + all-col sort, facets/search/notes/modal/drill preserved
---
 public/condos.html    |  47 ++++++++++++---
 public/three-view.css |  37 ++++++++++++
 public/three-view.js  | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 239 insertions(+), 8 deletions(-)

diff --git a/public/condos.html b/public/condos.html
index beb8fa5..1b7326f 100644
--- a/public/condos.html
+++ b/public/condos.html
@@ -132,6 +132,8 @@
   #modal .litem a.dl,#modal .maddr a.dl{color:var(--blue);text-decoration:none;} #modal .litem a.dl:hover,#modal .maddr a.dl:hover{text-decoration:underline;}
   #modal .src{font-size:12px;color:var(--mut);margin-top:12px;border-top:1px solid var(--line);padding-top:10px;}
 </style>
+  <link rel="stylesheet" href="/three-view.css">
+  <script src="/three-view.js"></script>
   <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
@@ -182,7 +184,7 @@
 <main>
   <div class="topbar">
     <input type="text" id="q" placeholder="search all fields — project, city, zip, address, broker, firm, status… (space = AND)">
-    <span class="viewseg" id="viewseg"><button data-view="grid" class="active">▦ Grid</button><button data-view="list">☰ List</button></span>
+    <span class="viewseg tv-seg" id="viewseg"></span>
     <button id="shortbtn" title="Show only your shortlisted (starred) listings — shared with the Explorer & MLS">⭐ Shortlist (0)</button>
     <button id="exportbtn" title="Download the current filtered + sorted set as a CSV">⬇ Export CSV</button>
     <button id="draftbtn" title="Queue DRAFT outreach emails for the current FHA-matched set in the Solicitation Studio — nothing sends">🎯 Draft outreach</button>
@@ -201,7 +203,7 @@
     <span class="count" id="count">…</span>
   </div>
   <div class="statsbar" id="statsbar"></div>
-  <div class="gridwrap"><div class="grid" id="grid"><div class="empty">loading…</div></div></div>
+  <div class="gridwrap"><div class="grid" id="grid"><div class="empty">loading…</div></div><div id="condoList"></div></div>
 </main>
 </div>
 <div id="ov"><div id="modal"><button class="x" aria-label="Close" onclick="document.getElementById('ov').classList.remove('on')">✕</button><div id="mbody"></div></div></div>
@@ -212,7 +214,11 @@ const $$=s=>Array.from(document.querySelectorAll(s));
 const esc=s=>String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
 const PROXY='FHA/VA-approval-based proxy, NOT lender-verified Fannie/Freddie warrantability.';
 const NOW_YEAR=new Date().getFullYear();
-let DATA=[], CURRENT=[], MODE='db', VIEW=localStorage.getItem('condoView')||'grid', bannerDefault='';
+// v2 3-view migration (TK-10526): the old bespoke 'list' WAS the dense spreadsheet.
+// The new model is grid(cards) / list(compact) / table(dense). Re-point a stored
+// legacy 'list' → 'table' exactly once so returning users keep the dense view they had.
+(function(){ try{ if(localStorage.getItem('condoView')==='list' && !localStorage.getItem('condoView3')){ localStorage.setItem('condoView','table'); } localStorage.setItem('condoView3','1'); }catch(e){} })();
+let DATA=[], CURRENT=[], MODE='db', VIEW=localStorage.getItem('condoView')||'grid', bannerDefault='', TV=null;
 let SORTKEY=localStorage.getItem('condoSort')||'';   // hoisted so early apply() during boot never hits TDZ
 
 function statusBadge(s){
@@ -507,9 +513,14 @@ function dbList(rows){
     +cols.map(f=>`<td>${fcell(c,f)}</td>`).join('')+'</tr>').join('');
   return `<div class="listwrap"><table class="listtbl" data-no-sort><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
 }
-function paint(rows){ const g=$('#grid');
+// 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.
+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; }
-  if(VIEW==='list'){
+  if(VIEW==='table'){
     g.className='grid listhost'; g.innerHTML=dbList(rows);
     // 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(',');
@@ -521,7 +532,7 @@ function paint(rows){ const g=$('#grid');
 // applies a click-header sort on the raw field value, any type. Runs on top of the
 // top-bar sort select so a header click always wins in List view.
 function listSort(rows){
-  if(VIEW!=='list' || !LSORTKEY) return rows;
+  if(VIEW!=='table' || !LSORTKEY) return rows;
   const f=FIELDS.find(x=>x.k===LSORTKEY); if(!f) return rows;
   return rows.slice().sort((a,b)=>{
     let x=fval(a,f), y=fval(b,f);
@@ -700,8 +711,26 @@ function sortRows(rows){
 (function(){ const s=$('#csort'); if(s){ s.value=SORTKEY; s.addEventListener('change',()=>{ SORTKEY=s.value; localStorage.setItem('condoSort',SORTKEY); apply(); }); } })();
 $('#cols').oninput=e=>{ document.documentElement.style.setProperty('--cols',e.target.value); localStorage.setItem('condoCols',e.target.value); };
 const savedCols=localStorage.getItem('condoCols'); if(savedCols){ $('#cols').value=savedCols; document.documentElement.style.setProperty('--cols',savedCols); }
-$$('#viewseg button').forEach(b=>b.classList.toggle('active',b.dataset.view===VIEW));
-$('#viewseg').onclick=e=>{ const b=e.target.closest('button[data-view]'); if(!b) return; VIEW=b.dataset.view; localStorage.setItem('condoView',VIEW); $$('#viewseg button').forEach(x=>x.classList.toggle('active',x.dataset.view===VIEW)); apply(); };
+// ── 3-view toggle (grid cards / compact list / dense table) via the shared helper.
+// ThreeView owns the segmented buttons + the compact-list renderer into #condoList;
+// paint() still owns grid + table. All three modes stay in sync with the page's
+// filters (getRows→CURRENT) and column-visibility toggles (fvis). ──
+// compact-list cells reuse the page's rich fcell() (mailto / DRE-link / drill-span
+// HTML), so the List view matches the cards + table exactly (html:true tells the
+// helper the calc value is already-formatted HTML and passes it through unescaped).
+// ThreeView owns ONLY the compact-list container (#condoList) + the segmented
+// buttons; the page's paint() owns #grid (cards + dense table) show/hide + render.
+// onView flips VIEW then re-runs apply() so paint() re-renders grid/table on switch.
+TV=ThreeView.mount({
+  seg:'#viewseg', listMount:'#condoList',
+  storageKey:'condo', defaultView:VIEW, views:['grid','list','table'],
+  fields:FIELDS.map(f=>({k:f.k,l:f.l,badge:f.badge,html:true,calc:c=>fcell(c,f)})),
+  getRows:()=>CURRENT, fvis:fvis,
+  title:c=>({name:c.project_name||c.address||'Condo', sub:[c.address,c.city,c.zip].filter(Boolean).join(', ')}),
+  badgeHtml:c=>fvis('warrantable_status')?statusBadge(c.warrantable_status||c.warrant_signal):'',
+  onView:v=>{ VIEW=v; apply(); },
+  rowClick:c=>{ if(c && c.id!=null) openDetail(c.id); },
+});
 // shortlist stars (unified) + toggle
 $('#grid').addEventListener('click',e=>{ const st=e.target.closest('[data-star]'); if(st){ toggleStar(st.dataset.star); st.classList.toggle('on',SHORT.has(st.dataset.star)); updateShortBtn(); if(F.shortlist) apply(); } });
 $('#shortbtn').addEventListener('click',()=>{ F.shortlist=!F.shortlist; apply(); });
@@ -762,6 +791,8 @@ function openDetail(id){
 $('#ov').addEventListener('click',e=>{ if(e.target.id==='ov') $('#ov').classList.remove('on'); });
 document.addEventListener('keydown',e=>{ if(e.key==='Escape') $('#ov').classList.remove('on'); });
 // row/card click opens detail (but not when the click is a star, header, or link)
+// drill-to-filter also works from the compact-list rows (their cells reuse fcell → drillSpan)
+$('#condoList').addEventListener('click',e=>{ const dr=e.target.closest('[data-drill]'); if(dr){ e.stopPropagation(); applyDrill(dr.dataset.drill,dr.dataset.val); } },true);
 $('#grid').addEventListener('click',e=>{
   const dr=e.target.closest('[data-drill]');
   if(dr){ e.stopPropagation(); applyDrill(dr.dataset.drill,dr.dataset.val); return; }   // datum drill → filter; the return (same listener) suppresses the modal, stopPropagation guards future sibling listeners
diff --git a/public/three-view.css b/public/three-view.css
new file mode 100644
index 0000000..090ce93
--- /dev/null
+++ b/public/three-view.css
@@ -0,0 +1,37 @@
+/* three-view.css — styles for the additive 3-view helper (TK-10526). Namespaced
+ * .tv-* so it never collides with a host page. Colors read the host page's own CSS
+ * vars with safe fallbacks, so the compact list inherits each page's theme
+ * (condos dark, fha-loans liquid-glass, broker/mls) automatically. */
+
+/* segmented Grid/List/Table toggle — reuses whatever wrapper the page gives; the
+ * buttons themselves are styled so pages that had only a 2-view toggle look right. */
+[data-tvseg] , .tv-seg { display:inline-flex; border:1px solid var(--tv-line,var(--line,var(--glassb,#2a313c))); border-radius:8px; overflow:hidden; }
+[data-tvseg] button, .tv-seg button {
+  background:var(--tv-card,var(--card,var(--glass,rgba(255,255,255,.05))));
+  color:var(--tv-mut,var(--mut,var(--muted,#8b949e)));
+  border:0; border-right:1px solid var(--tv-line,var(--line,var(--glassb,#2a313c)));
+  padding:7px 12px; font-size:12px; cursor:pointer; }
+[data-tvseg] button:last-child, .tv-seg button:last-child { border-right:0; }
+[data-tvseg] button.active, .tv-seg button.active { background:var(--tv-acc,var(--blue,#1f6feb)); color:#fff; }
+
+/* compact list — one scannable row per record, inline key/value pairs */
+.tv-rows { display:flex; flex-direction:column; }
+.tv-lrow {
+  display:flex; align-items:baseline; gap:14px; flex-wrap:wrap;
+  padding:10px 16px; border-bottom:1px solid var(--tv-line,var(--line,var(--glassb,rgba(255,255,255,.07))));
+  cursor:pointer; }
+.tv-lrow:hover { background:var(--tv-hover,var(--hover,rgba(120,160,255,.06))); }
+.tv-lmain { display:flex; align-items:baseline; gap:8px; flex-wrap:wrap; min-width:210px; flex:0 0 auto; }
+.tv-la { font-weight:600; color:var(--tv-ink,var(--ink,#e6edf3)); }
+.tv-la a { color:inherit; text-decoration:none; }
+.tv-la a:hover { text-decoration:underline; }
+.tv-lsub { font-size:11.5px; color:var(--tv-mut,var(--mut,var(--muted,#8b949e))); }
+.tv-lmeta { display:flex; flex-wrap:wrap; gap:6px 16px; flex:1 1 320px; min-width:0; }
+.tv-lm { font-size:12.5px; color:var(--tv-ink,var(--ink,#e6edf3)); white-space:nowrap; }
+.tv-lmk { color:var(--tv-mut,var(--mut,var(--muted,#8b949e))); font-size:10px; text-transform:uppercase; letter-spacing:.5px; margin-right:5px; }
+.tv-lm a { color:var(--tv-acc,var(--blue,#58a6ff)); text-decoration:none; }
+.tv-lm a:hover { text-decoration:underline; }
+.tv-empty { padding:50px; text-align:center; color:var(--tv-mut,var(--mut,var(--muted,#8b949e))); }
+
+/* density: the page's existing --cols / --tsz slider also tightens the list rows */
+@media (max-width:820px){ .tv-lmain{ min-width:100%; } }
diff --git a/public/three-view.js b/public/three-view.js
new file mode 100644
index 0000000..3c87c50
--- /dev/null
+++ b/public/three-view.js
@@ -0,0 +1,163 @@
+/* three-view.js — additive 3-view + all-column-sort helper for the CRCP bespoke
+ * browse pages (condos, broker-grid, mls, fha-loans). TK-10526, DTD verdict B
+ * (additive enhancement — DON'T rip-and-replace the bespoke pages' notes / modal /
+ * drill / find-email / outreach features, just ADD the missing view modes + a
+ * guaranteed all-field sort menu).
+ *
+ * A bespoke page already renders cards ("grid") and/or a dense spreadsheet ("table").
+ * What it usually lacks is (1) a distinct COMPACT LIST view — one scannable row per
+ * record, every visible field as inline key/value pairs — and (2) a single topbar
+ * Sort <select> covering EVERY field. This helper owns those two, plus the
+ * Grid / List / Table segmented toggle, and delegates grid/table rendering back to
+ * the page. Zero dependencies. Theme-agnostic (reads the page's own CSS with
+ * .tv-* fallbacks in three-view.css).
+ *
+ * ThreeView.mount({
+ *   seg:      '#viewseg',            // container the segmented [Grid|List|Table] buttons render into
+ *   sortSel:  '#tvSort',             // OPTIONAL <select> el/selector to populate with an all-field sort menu
+ *   listMount:'#tvList',            // container the compact-list DOM renders into (helper owns it)
+ *   gridMount:'#gridView',          // page's existing cards container (helper shows/hides)
+ *   tableMount:'#tableView',        // page's existing table container (helper shows/hides)
+ *   fields:   [{k,l,calc,money,num,pct,date,badge,html}],  // field metadata; html:true -> value is raw HTML
+ *   getRows:  () => rows,           // CURRENT filtered rows (in the order the page wants for grid/table)
+ *   title:    row => ({name,sub,href}),  // compact-list row title + subline
+ *   badgeHtml:row => '<span>…'|'',   // optional status badge for the list row
+ *   fvis:     k => bool,            // is field k currently visible (mirrors the page's column toggles)
+ *   renderGrid: rows => {},          // page renders its cards into gridMount
+ *   renderTable:rows => {},          // page renders its table into tableMount
+ *   onView:   view => {},           // OPTIONAL notified after a view switch
+ *   onSort:   (k,dir) => {},        // called when the all-field Sort select changes (dir 1|-1, k='' = default)
+ *   rowClick: (row,el) => {},        // OPTIONAL compact-list row click handler
+ *   storageKey:'condo',            // localStorage namespace (View + Sort persisted)
+ *   defaultView:'grid',
+ *   views:    ['grid','list','table'],  // which segments to show (default all three)
+ * })
+ * -> returns { render(), setView(v), getView() }.
+ */
+(function (global) {
+  const $ = (s, r) => (r || document).querySelector(s);
+  const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
+
+  function fmt(v, f) {
+    if (f.html) return v == null ? '—' : String(v);            // page-formatted HTML passes through
+    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 ThreeView(O) {
+    const KEY = O.storageKey || 'tv';
+    const VIEWS = O.views || ['grid', 'list', 'table'];
+    const LABELS = { grid: '▦ Grid', list: '☰ List', table: '▤ Table' };
+    const fval = (r, f) => f.calc ? f.calc(r) : r[f.k];
+    const fvis = O.fvis || (() => true);
+
+    let VIEW = (function () {
+      let s = null; try { s = localStorage.getItem(KEY + 'View'); } catch (e) {}
+      if (VIEWS.includes(s)) return s;
+      return VIEWS.includes(O.defaultView) ? O.defaultView : VIEWS[0];
+    })();
+    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) {}
+
+    // ---- segmented view toggle ----
+    function buildSeg() {
+      const el = $(O.seg); if (!el) return;
+      el.innerHTML = VIEWS.map(v => `<button type="button" data-tvview="${v}" class="${VIEW === v ? 'active' : ''}">${LABELS[v] || v}</button>`).join('');
+      el.addEventListener('click', e => {
+        const b = e.target.closest('button[data-tvview]'); if (!b) return;
+        setView(b.dataset.tvview);
+      });
+    }
+
+    // ---- all-field sort <select> ----
+    function buildSort() {
+      const sel = $(O.sortSel); if (!sel) return;
+      const sortable = O.fields.filter(f => !f.badge && f.k !== 'title');
+      sel.innerHTML = `<option value="">Sort…</option>` + sortable.map(f =>
+        `<option value="${esc(f.k)}:desc">${esc(f.l)} ↓</option><option value="${esc(f.k)}:asc">${esc(f.l)} ↑</option>`).join('');
+      sel.value = SORT && SORT.k ? SORT.k + ':' + (SORT.dir < 0 ? 'desc' : 'asc') : '';
+      sel.addEventListener('change', e => {
+        const v = e.target.value;
+        if (!v) SORT = null; else { const [k, d] = v.split(':'); SORT = { k, dir: d === 'desc' ? -1 : 1 }; }
+        try { localStorage.setItem(KEY + 'Sort', JSON.stringify(SORT)); } catch (e2) {}
+        if (O.onSort) O.onSort(SORT ? SORT.k : '', SORT ? SORT.dir : 1);
+        render();
+      });
+    }
+
+    // sort applied by the helper so the all-field Sort select works in EVERY view,
+    // including the helper-owned compact list. The page can still apply its own
+    // secondary sort (e.g. header-click in table view) on top via getRows().
+    function applySort(rows) {
+      if (!SORT || !SORT.k) return rows;
+      const f = O.fields.find(x => x.k === SORT.k); if (!f) return rows;
+      const dir = SORT.dir || 1;
+      return rows.slice().sort((a, b) => {
+        let x = fval(a, f), y = fval(b, f);
+        const na = x == null || x === '', nb = y == null || y === '';
+        if (na && nb) return 0; if (na) return 1; if (nb) return -1;   // nulls last
+        const xn = !isNaN(+x) && x !== true, yn = !isNaN(+y) && y !== true;
+        if ((typeof x === 'number' || typeof y === 'number' || f.money || f.num || f.pct) || (xn && yn))
+          return ((+x) - (+y)) * dir;
+        return String(x).localeCompare(String(y)) * dir;
+      });
+    }
+
+    // ---- compact-list renderer (the missing 3rd mode) ----
+    function listCompact(rows) {
+      const mount = $(O.listMount); if (!mount) return;
+      const vis = O.fields.filter(f => fvis(f.k) && !f.badge && f.k !== 'title');
+      if (!rows.length) { mount.innerHTML = '<div class="tv-empty">No matches for these filters.</div>'; return; }
+      mount.innerHTML = `<div class="tv-rows">` + rows.map((r, i) => {
+        const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
+        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer">${esc(t.name)}</a>` : esc(t.name);
+        return `<div class="tv-lrow" data-tvri="${i}">` +
+          `<div class="tv-lmain"><span class="tv-la">${nm}</span>${t.sub ? `<span class="tv-lsub">${esc(t.sub)}</span>` : ''}${O.badgeHtml ? (O.badgeHtml(r) || '') : ''}</div>` +
+          `<div class="tv-lmeta">` +
+          vis.map(f => `<span class="tv-lm"><span class="tv-lmk">${esc(f.l)}</span>${fmt(fval(r, f), f)}</span>`).join('') +
+          `</div></div>`;
+      }).join('') + `</div>`;
+      if (O.rowClick) {
+        mount.querySelectorAll('.tv-lrow').forEach(el => el.addEventListener('click', e => {
+          if (e.target.closest('a')) return;
+          O.rowClick(rows[+el.dataset.tvri], el);
+        }));
+      }
+    }
+
+    function showHide() {
+      const set = (sel, on) => { const el = sel && $(sel); if (el) el.style.display = on ? '' : 'none'; };
+      set(O.gridMount, VIEW === 'grid');
+      set(O.listMount, VIEW === 'list');
+      set(O.tableMount, VIEW === 'table');
+    }
+
+    function render() {
+      const raw = O.getRows ? (O.getRows() || []) : [];
+      const rows = applySort(raw);
+      showHide();
+      if (VIEW === 'grid' && O.renderGrid) O.renderGrid(rows);
+      else if (VIEW === 'table' && O.renderTable) O.renderTable(rows);
+      else if (VIEW === 'list') listCompact(rows);
+      // 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') : '';
+    }
+
+    function setView(v) {
+      if (!VIEWS.includes(v)) return;
+      VIEW = v; try { localStorage.setItem(KEY + 'View', v); } catch (e) {}
+      if (O.onView) O.onView(v);
+      render();
+    }
+
+    buildSeg(); buildSort();
+    return { render, setView, getView: () => VIEW, getSort: () => SORT, applySort };
+  }
+
+  global.ThreeView = { mount: O => new ThreeView(O) };
+})(window);

← 680d442 nav-agent: add universal grid-controls drop-in (field-toggle  ·  back to Commercialrealestate  ·  broker-grid.html: add compact-list view (3rd mode) — grid/li ca654ac →