[object Object]

← back to Nationalrealestate

usre: add ?sort=&order= column whitelist to /api/deals, /api/commercial/search+feed, /api/contractors (SQL-injection-safe, defaults preserved)

16ce66fcd1726f2351a9326de4d14ecf3755ef8b · 2026-08-13 08:00:34 -0700 · steve

Files touched

Diff

commit 16ce66fcd1726f2351a9326de4d14ecf3755ef8b
Author: steve <steve@designerwallcoverings.com>
Date:   Thu Aug 13 08:00:34 2026 -0700

    usre: add ?sort=&order= column whitelist to /api/deals, /api/commercial/search+feed, /api/contractors (SQL-injection-safe, defaults preserved)
---
 public/admin.html              |   2 +
 public/brokers.html            |   2 +-
 public/commercial-brokers.html |   2 +
 public/commercial.html         |   2 +
 public/compare.html            |   2 +
 public/contractors.html        |   2 +
 public/crcp-grid.css           |  12 ++
 public/crcp-grid.js            |  47 ++++-
 public/deals.html              |   2 +
 public/health.html             |   2 +
 public/listings.html           |   2 +
 public/market.html             |   2 +
 public/markets.html            |   2 +-
 public/nav-agent/nav-agent.css |  93 ++++++++++
 public/nav-agent/nav-agent.js  | 411 +++++++++++++++++++++++++++++++++++++++++
 public/parcels.html            |   2 +
 public/property.html           |   2 +
 public/sources.html            |   2 +
 src/server/commercial.ts       |  39 +++-
 src/server/contractors.ts      |  29 ++-
 src/server/deals.ts            |  32 +++-
 21 files changed, 678 insertions(+), 13 deletions(-)

diff --git a/public/admin.html b/public/admin.html
index a389bb5..0910969 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -36,6 +36,7 @@
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
 <link rel="stylesheet" href="/geo-search.css">
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -106,5 +107,6 @@ setInterval(loadRuns, 30000);
 </script>
 <script src="/nav-drawer.js"></script>
 <script src="/geo-search.js"></script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/brokers.html b/public/brokers.html
index b8716d8..6d5b7f3 100644
--- a/public/brokers.html
+++ b/public/brokers.html
@@ -156,7 +156,7 @@ async function loadPage() {
     rail: '#cgRail',
     storageKey: 'usreBrk',
     cols: 4,
-    view: 'list',
+    view: 'table',
     title: r => ({ name: r.name, sub: (r.firm_name ? r.firm_name + ' · ' : '') + r.license_state }),
     titleCol: 'Broker',
     titleSortK: 'name',
diff --git a/public/commercial-brokers.html b/public/commercial-brokers.html
index 4a16f44..70a3de2 100644
--- a/public/commercial-brokers.html
+++ b/public/commercial-brokers.html
@@ -33,6 +33,7 @@
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
 <link rel="stylesheet" href="/geo-search.css">
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -100,5 +101,6 @@ load();
 </script>
 <script src="/nav-drawer.js"></script>
 <script src="/geo-search.js"></script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/commercial.html b/public/commercial.html
index 4f06439..e343605 100644
--- a/public/commercial.html
+++ b/public/commercial.html
@@ -42,6 +42,7 @@
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
 <link rel="stylesheet" href="/geo-search.css">
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -135,5 +136,6 @@ $('#sort').onchange=()=>{state.sort=$('#sort').value;localStorage.setItem('cre_s
 </script>
 <script src="/nav-drawer.js"></script>
 <script src="/geo-search.js"></script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/compare.html b/public/compare.html
index 2c57142..f31f7a4 100644
--- a/public/compare.html
+++ b/public/compare.html
@@ -39,6 +39,7 @@
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
 <link rel="stylesheet" href="/geo-search.css">
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -198,5 +199,6 @@ loadRegions().then(render);
 </script>
 <script src="/nav-drawer.js"></script>
 <script src="/geo-search.js"></script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/contractors.html b/public/contractors.html
index 84677bb..276bbe3 100644
--- a/public/contractors.html
+++ b/public/contractors.html
@@ -61,6 +61,7 @@
   #status button:hover:not(:disabled){color:var(--fg);border-color:var(--muted);} #status button:disabled{opacity:.4;cursor:default;}
   .empty{color:var(--muted);font-size:13px;padding:50px;text-align:center;grid-column:1/-1;}
 </style>
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -310,5 +311,6 @@ window.onpopstate=()=>{ state=readState(); syncControls(); renderActive(); loadF
   loadClasses(); renderFieldToggles(); syncControls(); renderActive(); loadFeed();
 })();
 </script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/crcp-grid.css b/public/crcp-grid.css
index f3842c4..9071c95 100644
--- a/public/crcp-grid.css
+++ b/public/crcp-grid.css
@@ -96,3 +96,15 @@
 .cg-tbl th,.cg-tbl td{padding:calc(14px - var(--cg-dens,3)*1px) calc(16px - var(--cg-dens,3)*0.8px);}
 .cg-tbl tr[data-ri]{cursor:pointer;}
 .cg-tbl tr.dim td,.cg-card.dim{opacity:.45;}
+/* --- compact LIST view (one scannable row per record; density-aware) --- */
+.cg-rows{display:flex;flex-direction:column;gap:calc(10px - var(--cg-dens,3)*0.9px);padding:16px;}
+.cg-lrow{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;background:var(--cg-card,#161b22);border:1px solid var(--cg-line,#2a313c);border-radius:10px;padding:calc(13px - var(--cg-dens,3)*0.8px) 14px;cursor:pointer;}
+.cg-lrow:hover{border-color:var(--cg-acc,#58a6ff);}
+.cg-lrow.red{border-color:var(--cg-red,#f85149);}
+.cg-lrow.dim{opacity:.45;}
+.cg-lmain{flex:0 0 clamp(180px,22vw,280px);min-width:180px;display:flex;flex-direction:column;gap:2px;}
+.cg-lmain .cg-la{font-weight:600;color:var(--cg-ink,#e6edf3);font-size:calc(15px - var(--cg-dens,3)*0.25px);}
+.cg-lsub{color:var(--cg-mut,#8b949e);font-size:11.5px;}
+.cg-lmeta{flex:1 1 auto;min-width:0;display:flex;flex-wrap:wrap;gap:5px 18px;align-items:baseline;font-size:calc(13.5px - var(--cg-dens,3)*0.4px);font-variant-numeric:tabular-nums;color:var(--cg-ink,#e6edf3);}
+.cg-lm{white-space:nowrap;}
+.cg-lmk{color:var(--cg-mut,#8b949e);font-size:10px;text-transform:uppercase;letter-spacing:.4px;margin-right:4px;}
diff --git a/public/crcp-grid.js b/public/crcp-grid.js
index 206b394..574bd9a 100644
--- a/public/crcp-grid.js
+++ b/public/crcp-grid.js
@@ -2,7 +2,10 @@
  * 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.
+ * grid / list / table view toggle, density slider, live search, localStorage persistence.
+ * Three view modes: 'grid' (image/data cards), 'list' (one compact scannable row per
+ * record), 'table' (dense all-columns spreadsheet with click-to-sort headers). EVERY
+ * non-badge field is sortable via the topbar Sort select (both directions).
  *
  * opts = {
  *   data:      [row,...],            // all rows (client-side faceting)
@@ -199,7 +202,7 @@
           `</div>`;
       }).join('');
     }
-    function list(rows) {
+    function tableView(rows) {
       const cols = fields.filter(f => f.col && VIS[f.k] && !f.badge);
       const arrow = k => (SORT && SORT.k === k) ? (SORT.dir < 0 ? ' ▼' : ' ▲') : '';
       const titleK = O.titleSortK || '';
@@ -214,6 +217,22 @@
       }).join('');
       return `<div class="cg-listwrap"><table class="cg-tbl"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
     }
+    // compact single-row-per-record list: title on the left, every visible field
+    // flowing as inline key/value pairs on the right. Denser than cards, scannable,
+    // no fixed columns. Sorted via the topbar Sort select (all fields).
+    function listCompact(rows) {
+      const vis = fields.filter(f => VIS[f.k] && !f.badge && f.k !== 'title');
+      return `<div class="cg-rows">` + rows.map((r, i) => {
+        const t = O.title ? O.title(r) : { name: r.name || '', sub: '' };
+        const rc = O.onRow ? (O.onRow(r).cls || '') : '';
+        const nm = t.href ? `<a href="${esc(t.href)}" target="_blank" rel="noopener noreferrer" style="color:inherit">${esc(t.name)}</a>` : esc(t.name);
+        return `<div class="cg-lrow ${rc}" data-ri="${i}">` +
+          `<div class="cg-lmain"><span class="cg-la">${nm}</span>${t.sub ? `<span class="cg-lsub">${esc(t.sub)}</span>` : ''}${O.badgeHtml ? O.badgeHtml(r) : ''}</div>` +
+          `<div class="cg-lmeta">` +
+          vis.map(f => `<span class="cg-lm"><span class="cg-lmk">${esc(f.l)}</span>${fmt(fval(r, f), f)}</span>`).join('') +
+          `</div></div>`;
+      }).join('') + `</div>`;
+    }
 
     // ---- active-filter summary bar (removable chips + clear all) ----
     function activeFilterHTML() {
@@ -252,7 +271,15 @@
       buildRail(); render(); // buildRail re-renders the rail from F so it stays in sync
     }
 
-    let VIEW = localStorage.getItem(KEY + 'View') || O.view || 'grid';
+    let stored = null; try { stored = localStorage.getItem(KEY + 'View'); } catch (e) {}
+    // v2 migration: the old engine's 'list' WAS the dense table. The new engine renames
+    // that spreadsheet to 'table' and adds a distinct compact 'list'. Re-point a *stored*
+    // legacy 'list' to 'table' exactly once so returning users keep the view they had.
+    try {
+      if (stored === 'list' && !localStorage.getItem(KEY + 'ViewV2')) { stored = 'table'; localStorage.setItem(KEY + 'View', 'table'); }
+      localStorage.setItem(KEY + 'ViewV2', '1');
+    } catch (e) {}
+    let VIEW = stored || O.view || 'grid';
     const CAP = O.cap || 1500; // DOM render cap for perf; count still reflects the full match set
     let lastShown = [];
     function render() {
@@ -267,7 +294,8 @@
           '<div class="cg-empty-s">No results match the current filters.</div>' +
           '<button class="cg-af-clear cg-empty-btn" data-clearall="1">Clear all filters</button></div>';
       }
-      else if (VIEW === 'list') { g.className = 'cg-grid cg-listhost'; g.innerHTML = list(shown); }
+      else if (VIEW === 'table') { g.className = 'cg-grid cg-listhost'; g.innerHTML = tableView(shown); }
+      else if (VIEW === 'list') { g.className = 'cg-grid cg-listhost'; g.innerHTML = listCompact(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>` : '');
@@ -285,15 +313,20 @@
     // ---- topbar ----
     function buildTop() {
       const m = $(O.mount);
-      const sortable = fields.filter(f => f.col && !f.badge);
-      const sortOpts = `<option value="">Sort…</option>` + sortable.map(f =>
+      // ALL data points are sortable — not just the curated table columns.
+      const titleSK = O.titleSortK || '';
+      const sortable = fields.filter(f => !f.badge && f.k !== 'title' && f.k !== titleSK);
+      const titleOpts = titleSK
+        ? `<option value="${esc(titleSK)}:asc">${esc(O.titleCol || 'Name')} A→Z</option><option value="${esc(titleSK)}:desc">${esc(O.titleCol || 'Name')} Z→A</option>`
+        : '';
+      const sortOpts = `<option value="">Sort…</option>` + titleOpts + sortable.map(f =>
         `<option value="${esc(f.k)}:desc">${esc(f.l)} ↓</option><option value="${esc(f.k)}:asc">${esc(f.l)} ↑</option>`).join('');
       m.insertAdjacentHTML('afterbegin',
         `<div class="cg-top">` +
         `<button type="button" class="cg-filt-btn" aria-label="Filters">☰ Filters</button>` +
         `<input type="text" class="cg-q" placeholder="search…">` +
         `<select class="cg-sort" title="Sort">${sortOpts}</select>` +
-        `<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>` +
+        `<span class="cg-seg"><button data-view="grid" class="${VIEW === 'grid' ? 'active' : ''}">▦ Grid</button><button data-view="list" class="${VIEW === 'list' ? 'active' : ''}">☰ List</button><button data-view="table" class="${VIEW === 'table' ? 'active' : ''}">▤ Table</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-active"></div><div class="cg-grid"></div>`);
       const q = m.querySelector('.cg-q');
diff --git a/public/deals.html b/public/deals.html
index 8e8d73d..1d3190b 100644
--- a/public/deals.html
+++ b/public/deals.html
@@ -72,6 +72,7 @@
   .btnrow a{font-size:12px;border:1px solid var(--line);border-radius:8px;padding:6px 11px;color:var(--fg);}
   .btnrow a:hover{border-color:var(--gold);text-decoration:none;color:var(--gold);}
 </style>
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -361,5 +362,6 @@ function syncControls(){ $('#q').value=state.q||''; $('#sort').value=state.sort|
   const id=new URLSearchParams(location.search).get('id'); if(id) setTimeout(()=>openDeal(id),200);
 })();
 </script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/health.html b/public/health.html
index 9a12f70..cadc3e6 100644
--- a/public/health.html
+++ b/public/health.html
@@ -31,6 +31,7 @@
   .muted{color:var(--muted)}
   a{color:#5aa9ff;text-decoration:none}
 </style>
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <header>
@@ -128,5 +129,6 @@ async function load(){
 }
 load(); setInterval(load, 30000);
 </script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/listings.html b/public/listings.html
index 01437f6..334e6f7 100644
--- a/public/listings.html
+++ b/public/listings.html
@@ -70,6 +70,7 @@
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
 <link rel="stylesheet" href="/geo-search.css">
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -306,5 +307,6 @@ load();
 </script>
 <script src="/nav-drawer.js"></script>
 <script src="/geo-search.js"></script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/market.html b/public/market.html
index 2d78873..a3cca2a 100644
--- a/public/market.html
+++ b/public/market.html
@@ -43,6 +43,7 @@
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
 <link rel="stylesheet" href="/geo-search.css">
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -221,5 +222,6 @@ function chart(elId, entries, fmt){
 </script>
 <script src="/nav-drawer.js"></script>
 <script src="/geo-search.js"></script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/markets.html b/public/markets.html
index 29329f1..af2d28b 100644
--- a/public/markets.html
+++ b/public/markets.html
@@ -132,7 +132,7 @@ async function load(type){
     rail: '#cgRail',
     storageKey: 'usreMkt',
     cols: 4,
-    view: 'list',
+    view: 'table',
     cap: 3500, // render all ~3.2k counties
     title: r => ({ name: r.name, sub: (r.state || '') + ' · ' + r.key }),
     titleCol: 'Market',
diff --git a/public/nav-agent/nav-agent.css b/public/nav-agent/nav-agent.css
new file mode 100644
index 0000000..702094c
--- /dev/null
+++ b/public/nav-agent/nav-agent.css
@@ -0,0 +1,93 @@
+/* ============================================================================
+ * Nav Agent — universal grid-controls drop-in  (v1.0)
+ * Left field-toggle panel + sort-every-field + grouping + movable/removable/
+ * expandable data panels.  Zero dependencies. Additive & defensive.
+ * Honors the house internal-line-viewer spec (left panel, density type-scale,
+ * tight cards, localStorage-persisted).
+ * ==========================================================================*/
+
+:root{
+  --na-acc:var(--acc,#1f9e8f);
+  --na-line:var(--line,#2a2f3a);
+  --na-bg:var(--panel,#12151c);
+  --na-bg2:var(--bg,#0c0e13);
+  --na-fg:var(--fg,#e7e9ee);
+  --na-mut:var(--mut,#8b93a3);
+}
+
+/* ---- launcher (bottom-left cog) --------------------------------------- */
+.na-launch{position:fixed;left:14px;bottom:14px;z-index:2147483000;
+  width:42px;height:42px;border-radius:50%;border:1px solid var(--na-line);
+  background:var(--na-bg);color:var(--na-fg);font-size:19px;cursor:pointer;
+  display:flex;align-items:center;justify-content:center;
+  box-shadow:0 4px 18px rgba(0,0,0,.45);transition:transform .15s}
+.na-launch:hover{transform:scale(1.08);border-color:var(--na-acc)}
+
+/* ---- left field/sort/group panel -------------------------------------- */
+.na-panel{position:fixed;left:0;top:0;bottom:0;width:290px;z-index:2147483001;
+  background:var(--na-bg);border-right:1px solid var(--na-line);color:var(--na-fg);
+  display:flex;flex-direction:column;font:13px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
+  transform:translateX(-100%);transition:transform .2s ease;box-shadow:2px 0 24px rgba(0,0,0,.4)}
+.na-panel.open{transform:translateX(0)}
+.na-panel header{display:flex;align-items:center;gap:8px;padding:12px 14px;
+  border-bottom:1px solid var(--na-line);font-weight:600;letter-spacing:.3px}
+.na-panel header .na-title{flex:1}
+.na-panel header button{background:none;border:0;color:var(--na-mut);cursor:pointer;font-size:16px;padding:2px 6px;border-radius:4px}
+.na-panel header button:hover{color:var(--na-fg);background:rgba(255,255,255,.06)}
+.na-body{flex:1;overflow:auto;padding:8px 12px 40px}
+.na-sec{border-top:1px solid var(--na-line);padding:10px 0}
+.na-sec:first-child{border-top:0}
+.na-sec>.na-h{display:flex;align-items:center;gap:6px;cursor:pointer;
+  font-weight:600;color:var(--na-mut);text-transform:uppercase;font-size:11px;letter-spacing:.6px;user-select:none}
+.na-sec>.na-h::before{content:'▸';font-size:9px;transition:transform .15s}
+.na-sec.open>.na-h::before{transform:rotate(90deg)}
+.na-sec .na-c{display:none;padding:8px 2px 2px}
+.na-sec.open .na-c{display:block}
+.na-search{width:100%;box-sizing:border-box;background:var(--na-bg2);border:1px solid var(--na-line);
+  color:var(--na-fg);border-radius:6px;padding:8px 10px;margin:4px 0 10px;font-size:13px}
+.na-fieldrow{display:flex;align-items:center;gap:8px;padding:4px 2px;cursor:pointer;border-radius:4px}
+.na-fieldrow:hover{background:rgba(255,255,255,.04)}
+.na-fieldrow input{accent-color:var(--na-acc);cursor:pointer}
+.na-fieldrow .na-cnt{margin-left:auto;color:var(--na-mut);font-size:11px}
+.na-row{display:flex;gap:6px;align-items:center;margin:6px 0}
+.na-row label{color:var(--na-mut);min-width:52px}
+.na-row select,.na-row input[type=range]{flex:1;background:var(--na-bg2);border:1px solid var(--na-line);
+  color:var(--na-fg);border-radius:6px;padding:6px 8px;font-size:13px}
+.na-mini{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px}
+.na-mini button{background:var(--na-bg2);border:1px solid var(--na-line);color:var(--na-fg);
+  border-radius:6px;padding:5px 9px;font-size:12px;cursor:pointer}
+.na-mini button:hover,.na-mini button.on{border-color:var(--na-acc);color:var(--na-acc)}
+.na-reset{width:100%;margin-top:14px;background:var(--na-bg2);border:1px solid var(--na-line);
+  color:var(--na-fg);border-radius:6px;padding:9px;cursor:pointer;font-size:13px}
+.na-reset:hover{border-color:#c0563f;color:#e08a76}
+.na-note{color:var(--na-mut);font-size:11px;padding:2px;line-height:1.5}
+
+/* ---- grouping headers injected into the grid -------------------------- */
+.na-group-h{grid-column:1/-1;display:flex;align-items:center;gap:8px;
+  padding:8px 4px;margin:6px 0 2px;border-bottom:1px solid var(--na-line);
+  color:var(--na-acc);font-weight:600;font-size:13px;cursor:pointer;user-select:none}
+.na-group-h .na-gcount{color:var(--na-mut);font-weight:400}
+.na-group-h.collapsed + .na-group-body{display:none}
+table .na-group-h{display:table-row}
+table .na-group-h td{padding:8px 6px;color:var(--na-acc);font-weight:600;border-bottom:1px solid var(--na-line)}
+
+/* ---- movable / resizable / removable data-panel chrome ---------------- */
+.na-wrap{position:relative}
+.na-bar{display:flex;align-items:center;gap:6px;padding:4px 8px;cursor:move;
+  background:var(--na-bg);border:1px solid var(--na-line);border-bottom:0;
+  border-radius:6px 6px 0 0;color:var(--na-mut);font-size:12px;user-select:none}
+.na-bar .na-bt{flex:1;font-weight:600;color:var(--na-fg)}
+.na-bar button{background:none;border:0;color:var(--na-mut);cursor:pointer;font-size:14px;padding:1px 5px;border-radius:4px}
+.na-bar button:hover{color:var(--na-fg);background:rgba(255,255,255,.08)}
+.na-wrap.na-float{position:fixed;z-index:2000;box-shadow:0 10px 40px rgba(0,0,0,.55);
+  background:var(--na-bg2);border-radius:6px;resize:both;overflow:auto;min-width:280px;min-height:160px}
+.na-wrap.na-collapsed .na-inner{display:none}
+.na-wrap.na-removed{display:none}
+.na-wrap.na-max{position:fixed;inset:12px;z-index:2001;background:var(--na-bg2);
+  box-shadow:0 10px 60px rgba(0,0,0,.6);overflow:auto;resize:none}
+.na-restorebar{position:fixed;left:14px;bottom:66px;z-index:2147483000;display:flex;flex-direction:column;gap:6px}
+.na-restorebar button{background:var(--na-bg);border:1px solid var(--na-line);color:var(--na-fg);
+  border-radius:6px;padding:6px 10px;font-size:12px;cursor:pointer}
+.na-restorebar button:hover{border-color:var(--na-acc)}
+
+@media (max-width:640px){.na-panel{width:86vw}}
diff --git a/public/nav-agent/nav-agent.js b/public/nav-agent/nav-agent.js
new file mode 100644
index 0000000..8b8faeb
--- /dev/null
+++ b/public/nav-agent/nav-agent.js
@@ -0,0 +1,411 @@
+/* ============================================================================
+ * Nav Agent — universal grid-controls drop-in  (v1.0)
+ *
+ * Drop one <script src=".../nav-agent.js" defer></script> (+ the CSS) onto any
+ * page. It auto-detects the primary data grid (a <table> or a .grid/.card list),
+ * then injects:
+ *   • a LEFT collapsible panel: text search, per-FIELD show/hide toggles,
+ *     sort-by-any-field (asc/desc), group-by-any-field, density slider
+ *   • movable / resizable / collapsible / removable / maximizable panel chrome
+ *     on the grid itself
+ * All state persists to localStorage, namespaced per page path, so every
+ * surface remembers its own layout. Strictly additive & wrapped in try/catch —
+ * if it can't understand a page it degrades (panel chrome + search + density)
+ * and never throws into the host page.
+ *
+ * Manual control:  window.NavAgent.mount({ target:'#grid', title:'Deals' })
+ * Opt-out a page:  <body data-nav-agent="off">
+ * Force a target:  <div id="grid" data-nav-grid> ... </div>
+ * ==========================================================================*/
+(function () {
+  'use strict';
+  if (window.NavAgent) return;
+
+  var _uid = 0;
+  var navKey = function (target) {
+    if (!target.getAttribute('data-na-uid')) target.setAttribute('data-na-uid', target.id || ('g' + (++_uid)));
+    return 'navagent:' + location.pathname + ':' + target.getAttribute('data-na-uid');
+  };
+  var load = function (key) { try { return JSON.parse(localStorage.getItem(key)) || {}; } catch (e) { return {}; } };
+  var save = function (key, s) { try { localStorage.setItem(key, JSON.stringify(s)); } catch (e) {} };
+  var slug = function (t) { return String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'f'; };
+  var txt = function (el) { return (el && el.textContent || '').trim(); };
+  // magnitude-aware numeric parse — $2.1B > $780M > 42.  Handles K/M/B/T + commas.
+  var num = function (v) {
+    var s = String(v).replace(/,/g, '').trim();
+    var m = s.match(/-?\d*\.?\d+/); if (!m) return null;
+    var n = parseFloat(m[0]); if (isNaN(n)) return null;
+    var suf = (s.slice(m.index + m[0].length).match(/[kmbt]/i) || [''])[0].toLowerCase();
+    var mult = { k: 1e3, m: 1e6, b: 1e9, t: 1e12 }[suf] || 1;
+    return n * mult;
+  };
+  var el = function (tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
+
+  /* -- target detection -------------------------------------------------- */
+  function findTarget(opt) {
+    if (opt && opt.target) return document.querySelector(opt.target);
+    var q = document.querySelector('[data-nav-grid]');
+    if (q) return q;
+    // biggest visible table wins, else #grid, else first .grid, else .cards
+    var tables = [].slice.call(document.querySelectorAll('table'))
+      .filter(function (t) { return t.querySelector('tbody tr') && t.offsetParent !== null; })
+      .sort(function (a, b) { return b.querySelectorAll('tr').length - a.querySelectorAll('tr').length; });
+    if (tables[0] && tables[0].querySelectorAll('tr').length > 2) return tables[0];
+    return document.querySelector('#grid') || document.querySelector('.grid') ||
+           document.querySelector('.cards') || tables[0] || null;
+  }
+
+  /* -- field model ------------------------------------------------------- */
+  // Returns { kind:'table'|'cards', fields:[{key,label,idx?,cls?}], items() }
+  function model(target) {
+    if (target.tagName === 'TABLE') {
+      var ths = [].slice.call(target.querySelectorAll('thead th, thead td'));
+      if (!ths.length) { // header-less table: synthesize from first row
+        var r0 = target.querySelector('tbody tr, tr');
+        ths = r0 ? [].slice.call(r0.children).map(function (_, i) { var s = el('th'); s.textContent = 'Col ' + (i + 1); return s; }) : [];
+      }
+      var fields = ths.map(function (th, i) { return { key: slug(txt(th)) + '-' + i, label: txt(th) || ('Col ' + (i + 1)), idx: i }; });
+      return {
+        kind: 'table', fields: fields,
+        rows: function () { return [].slice.call(target.querySelectorAll('tbody tr')).filter(function (r) { return !r.classList.contains('na-group-h'); }); },
+        cellText: function (row, f) { var c = row.children[f.idx]; return txt(c); }
+      };
+    }
+    // card grid — infer fields from repeated child element classes / [data-field]
+    var cards = [].slice.call(target.children).filter(function (c) { return c.nodeType === 1 && !c.classList.contains('na-group-h') && !c.classList.contains('na-group-body'); });
+    var sample = cards.slice(0, 24), seen = {}, order = [];
+    sample.forEach(function (card) {
+      [].slice.call(card.querySelectorAll('[data-field]')).forEach(function (n) {
+        var k = n.getAttribute('data-field'); if (!seen[k]) { seen[k] = { key: slug(k), label: k, cls: '[data-field="' + k + '"]', df: k, hits: 0 }; order.push(seen[k]); } seen[k].hits++;
+      });
+      // also collect direct-child element classes as coarse fields when no data-field present
+      [].slice.call(card.children).forEach(function (n) {
+        var cls = (n.className && typeof n.className === 'string') ? n.className.split(/\s+/)[0] : '';
+        if (!cls || cls.indexOf('na-') === 0) return;
+        var k = 'c-' + cls;
+        if (!seen[k]) { seen[k] = { key: slug(cls), label: cls.replace(/[-_]/g, ' '), cls: '.' + cls, hits: 0 }; order.push(seen[k]); } seen[k].hits++;
+      });
+    });
+    // keep fields present on a majority of sampled cards (stable structure)
+    var min = Math.max(1, Math.floor(sample.length * 0.4));
+    var fields = order.filter(function (f) { return f.hits >= min; });
+    return {
+      kind: 'cards', cards: true, fields: fields,
+      rows: function () { return [].slice.call(target.children).filter(function (c) { return c.nodeType === 1 && !c.classList.contains('na-group-h') && !c.classList.contains('na-group-body') && c.offsetParent !== null || (c.nodeType === 1 && !c.classList.contains('na-group-h')); }); },
+      cellText: function (card, f) { var n = f.df ? card.querySelector('[data-field="' + f.df + '"]') : card.querySelector(f.cls); return txt(n); }
+    };
+  }
+
+  /* -- one instance ------------------------------------------------------ */
+  function Agent(target, opt) {
+    var key = navKey(target);
+    var st = load(key);
+    var M = model(target);
+    var self = { target: target, model: M, state: st, key: key };
+    var applyingSort = false;
+
+    /* field show/hide via CONTAINER classes (survives host re-renders) */
+    function fieldStyleTag() {
+      var id = 'na-style-' + key.replace(/[^a-z0-9]/gi, '');
+      var tag = document.getElementById(id) || (function () { var t = el('style'); t.id = id; document.head.appendChild(t); return t; })();
+      var css = '';
+      (st.hidden || []).forEach(function (key) {
+        var f = M.fields.filter(function (x) { return x.key === key; })[0]; if (!f) return;
+        if (M.kind === 'table') {
+          css += 'table[data-na] thead th:nth-child(' + (f.idx + 1) + '),table[data-na] tbody td:nth-child(' + (f.idx + 1) + '){display:none}';
+        } else {
+          css += target.getAttribute('data-na-sel') + ' ' + f.cls + '{display:none}';
+        }
+      });
+      tag.textContent = css;
+    }
+
+    function applySort() {
+      if (!st.sort) return;
+      var f = M.fields.filter(function (x) { return x.key === st.sort; })[0]; if (!f) return;
+      var dir = st.dir === 'desc' ? -1 : 1;
+      var rows = M.rows();
+      if (!rows.length) return;
+      applyingSort = true;
+      // detect numeric
+      var numeric = rows.slice(0, 12).every(function (r) { var v = M.cellText(r, f); return v === '' || num(v) !== null; });
+      rows.sort(function (a, b) {
+        var va = M.cellText(a, f), vb = M.cellText(b, f);
+        if (numeric) { var na = num(va), nb = num(vb); na = na == null ? -Infinity : na; nb = nb == null ? -Infinity : nb; return (na - nb) * dir; }
+        return va.localeCompare(vb, undefined, { numeric: true }) * dir;
+      });
+      var parent = M.kind === 'table' ? target.querySelector('tbody') : target;
+      rows.forEach(function (r) { parent.appendChild(r); });
+      applyGroup(); // grouping depends on sorted order
+      applyingSort = false;
+    }
+
+    function clearGroups() {
+      [].slice.call(target.querySelectorAll('.na-group-h')).forEach(function (n) { n.remove(); });
+    }
+
+    function applyGroup() {
+      clearGroups();
+      if (!st.group) return;
+      var f = M.fields.filter(function (x) { return x.key === st.group; })[0]; if (!f) return;
+      var rows = M.rows(), last = null, parent = M.kind === 'table' ? target.querySelector('tbody') : target;
+      var counts = {};
+      rows.forEach(function (r) { var v = M.cellText(r, f) || '—'; counts[v] = (counts[v] || 0) + 1; });
+      rows.forEach(function (r) {
+        var v = M.cellText(r, f) || '—';
+        if (v !== last) {
+          var h;
+          if (M.kind === 'table') { h = el('tr', 'na-group-h'); var td = el('td'); td.colSpan = M.fields.length; td.innerHTML = '▸ ' + esc(v) + ' <span class="na-gcount">(' + counts[v] + ')</span>'; h.appendChild(td); }
+          else { h = el('div', 'na-group-h', '▸ ' + esc(v) + ' <span class="na-gcount">(' + counts[v] + ')</span>'); }
+          parent.insertBefore(h, r); last = v;
+        }
+      });
+    }
+
+    function applySearch() {
+      var qraw = (st.q || '').toLowerCase();
+      M.rows().forEach(function (r) {
+        var show = !qraw || r.textContent.toLowerCase().indexOf(qraw) >= 0;
+        r.style.display = show ? '' : 'none';
+      });
+    }
+
+    function applyAll() { fieldStyleTag(); applySort(); applySearch(); }
+
+    /* re-apply after host re-renders (debounced, ignores our own mutations) */
+    var t;
+    var mo = new MutationObserver(function () {
+      if (applyingSort) return;
+      clearTimeout(t); t = setTimeout(function () {
+        M = model(target); // structure may have changed
+        // keep only still-valid selections
+        applyAll();
+      }, 120);
+    });
+    try { mo.observe(M.kind === 'table' ? (target.querySelector('tbody') || target) : target, { childList: true }); } catch (e) {}
+
+    self.applyAll = applyAll;
+    self.applySort = applySort;
+    self.applyGroup = applyGroup;
+    self.applySearch = applySearch;
+    self.save = function () { save(key, st); };
+    return self;
+  }
+
+  function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }); }
+
+  /* -- panel UI ---------------------------------------------------------- */
+  function buildPanel(agent, opt) {
+    var M = agent.model, st = agent.state;
+    var panel = el('aside', 'na-panel');
+    panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'View controls');
+    var title = (opt && opt.title) || document.title || 'View';
+    panel.appendChild(el('header', null,
+      '<span class="na-title">⚙ View · ' + esc(title) + '</span>' +
+      '<button class="na-x" title="Close">✕</button>'));
+    var body = el('div', 'na-body'); panel.appendChild(body);
+
+    // search
+    var search = el('input', 'na-search'); search.type = 'search'; search.placeholder = 'Search all fields…';
+    search.value = st.q || '';
+    search.addEventListener('input', function () { st.q = search.value; agent.save(); agent.applySearch(); });
+    body.appendChild(search);
+
+    function section(label, openDefault) {
+      var s = el('div', 'na-sec' + (openDefault ? ' open' : ''));
+      s.appendChild(el('div', 'na-h', label));
+      var c = el('div', 'na-c'); s.appendChild(c);
+      s.querySelector('.na-h').addEventListener('click', function () { s.classList.toggle('open'); });
+      body.appendChild(s); return c;
+    }
+
+    var haveFields = M.fields && M.fields.length;
+
+    // FIELDS toggle
+    if (haveFields) {
+      var fc = section('Fields (' + M.fields.length + ')', true);
+      st.hidden = st.hidden || [];
+      M.fields.forEach(function (f) {
+        var row = el('label', 'na-fieldrow');
+        var cb = el('input'); cb.type = 'checkbox'; cb.checked = st.hidden.indexOf(f.key) < 0;
+        cb.addEventListener('change', function () {
+          var i = st.hidden.indexOf(f.key);
+          if (cb.checked && i >= 0) st.hidden.splice(i, 1);
+          else if (!cb.checked && i < 0) st.hidden.push(f.key);
+          agent.save(); agent.applyAll();
+        });
+        row.appendChild(cb);
+        row.appendChild(el('span', null, esc(f.label)));
+        fc.appendChild(row);
+      });
+      var mini = el('div', 'na-mini',
+        '<button data-all>All</button><button data-none>None</button>');
+      mini.querySelector('[data-all]').onclick = function () { st.hidden = []; agent.save(); agent.applyAll(); refreshChecks(); };
+      mini.querySelector('[data-none]').onclick = function () { st.hidden = M.fields.map(function (f) { return f.key; }); agent.save(); agent.applyAll(); refreshChecks(); };
+      fc.appendChild(mini);
+      function refreshChecks() { [].slice.call(fc.querySelectorAll('.na-fieldrow input')).forEach(function (cb, i) { cb.checked = st.hidden.indexOf(M.fields[i].key) < 0; }); }
+    }
+
+    // SORT
+    if (haveFields) {
+      var sc = section('Sort', true);
+      var srow = el('div', 'na-row', '<label>Field</label>');
+      var sel = el('select'); sel.appendChild(new Option('— none —', ''));
+      M.fields.forEach(function (f) { sel.appendChild(new Option(f.label, f.key)); });
+      sel.value = st.sort || ''; srow.appendChild(sel); sc.appendChild(srow);
+      var dir = el('div', 'na-mini', '<button data-asc>▲ Asc</button><button data-desc>▼ Desc</button>');
+      function markDir() { dir.querySelector('[data-asc]').classList.toggle('on', st.dir !== 'desc'); dir.querySelector('[data-desc]').classList.toggle('on', st.dir === 'desc'); }
+      sel.addEventListener('change', function () { st.sort = sel.value; agent.save(); agent.applySort(); });
+      dir.querySelector('[data-asc]').onclick = function () { st.dir = 'asc'; markDir(); agent.save(); agent.applySort(); };
+      dir.querySelector('[data-desc]').onclick = function () { st.dir = 'desc'; markDir(); agent.save(); agent.applySort(); };
+      markDir(); sc.appendChild(dir);
+    }
+
+    // GROUP
+    if (haveFields) {
+      var gc = section('Group by', false);
+      var grow = el('div', 'na-row', '<label>Field</label>');
+      var gsel = el('select'); gsel.appendChild(new Option('— none —', ''));
+      M.fields.forEach(function (f) { gsel.appendChild(new Option(f.label, f.key)); });
+      gsel.value = st.group || ''; grow.appendChild(gsel); gc.appendChild(grow);
+      gsel.addEventListener('change', function () { st.group = gsel.value; agent.save(); agent.applyGroup(); });
+    }
+
+    // DENSITY (only if the target reacts to --cols or has a page density var)
+    var dc = section('Density', false);
+    var drow = el('div', 'na-row', '<label>Cols</label>');
+    var rng = el('input'); rng.type = 'range'; rng.min = 2; rng.max = 12; rng.value = st.cols || getComputedCols(agent.target) || 5;
+    rng.addEventListener('input', function () { st.cols = +rng.value; agent.save(); setCols(agent.target, +rng.value); });
+    drow.appendChild(rng); dc.appendChild(drow);
+    dc.appendChild(el('div', 'na-note', 'Adjusts grid columns where the layout supports it.'));
+    if (st.cols) setCols(agent.target, st.cols);
+
+    if (!haveFields) body.appendChild(el('div', 'na-note',
+      'No structured fields detected on this grid — field toggles, sort & group need <code>&lt;table&gt;</code> headers or <code>[data-field]</code>/classed card elements. Search, density and the movable panel are active.'));
+
+    var reset = el('button', 'na-reset', '↺ Reset this page’s view');
+    reset.onclick = function () { try { localStorage.removeItem(agent.key); } catch (e) {} location.reload(); };
+    body.appendChild(reset);
+
+    panel.querySelector('.na-x').onclick = function () { panel.classList.remove('open'); };
+    document.body.appendChild(panel);
+    return panel;
+  }
+
+  function getComputedCols(t) { try { var v = getComputedStyle(t).getPropertyValue('--cols'); return v ? parseInt(v) : null; } catch (e) { return null; } }
+  function setCols(t, n) {
+    try {
+      t.style.setProperty('--cols', n);
+      // fallback for grids without a --cols hook
+      if (!/grid-template-columns/.test(t.style.cssText) && getComputedStyle(t).display === 'grid')
+        t.style.gridTemplateColumns = 'repeat(' + n + ',minmax(0,1fr))';
+    } catch (e) {}
+  }
+
+  /* -- movable / resizable / removable panel chrome ---------------------- */
+  function chrome(agent, opt) {
+    var t = agent.target;
+    if (t.closest('.na-wrap')) return;
+    var wrap = el('div', 'na-wrap');
+    var title = (opt && opt.title) || document.title || 'Data';
+    var bar = el('div', 'na-bar',
+      '<span class="na-bt">▤ ' + esc(title) + '</span>' +
+      '<button data-collapse title="Collapse">▁</button>' +
+      '<button data-max title="Maximize / float">⤢</button>' +
+      '<button data-remove title="Remove panel">✕</button>');
+    var inner = el('div', 'na-inner');
+    t.parentNode.insertBefore(wrap, t);
+    wrap.appendChild(bar); wrap.appendChild(inner); inner.appendChild(t);
+
+    var g = agent.state.geo || {};
+    bar.querySelector('[data-collapse]').onclick = function () { wrap.classList.toggle('na-collapsed'); persist(); };
+    bar.querySelector('[data-max]').onclick = function () { wrap.classList.toggle('na-max'); wrap.classList.remove('na-float'); persist(); };
+    bar.querySelector('[data-remove]').onclick = function () { wrap.classList.add('na-removed'); addRestore(); persist(); };
+
+    // drag to float/move
+    var down = null;
+    bar.addEventListener('mousedown', function (e) {
+      if (e.target.tagName === 'BUTTON') return;
+      var r = wrap.getBoundingClientRect();
+      if (!wrap.classList.contains('na-float')) { wrap.classList.add('na-float'); wrap.style.left = r.left + 'px'; wrap.style.top = r.top + 'px'; wrap.style.width = r.width + 'px'; }
+      down = { x: e.clientX, y: e.clientY, l: parseFloat(wrap.style.left) || r.left, t: parseFloat(wrap.style.top) || r.top };
+      e.preventDefault();
+    });
+    window.addEventListener('mousemove', function (e) {
+      if (!down) return;
+      wrap.style.left = (down.l + e.clientX - down.x) + 'px';
+      wrap.style.top = Math.max(0, down.t + e.clientY - down.y) + 'px';
+    });
+    window.addEventListener('mouseup', function () { if (down) { down = null; persist(); } });
+
+    function persist() {
+      agent.state.geo = {
+        float: wrap.classList.contains('na-float'), max: wrap.classList.contains('na-max'),
+        collapsed: wrap.classList.contains('na-collapsed'), removed: wrap.classList.contains('na-removed'),
+        left: wrap.style.left, top: wrap.style.top, width: wrap.style.width, height: wrap.style.height
+      };
+      agent.save();
+    }
+    function addRestore() {
+      var rb = document.querySelector('.na-restorebar') || (function () { var b = el('div', 'na-restorebar'); document.body.appendChild(b); return b; })();
+      var btn = el('button', null, '⟲ ' + esc(title));
+      btn.onclick = function () { wrap.classList.remove('na-removed'); btn.remove(); persist(); };
+      rb.appendChild(btn);
+    }
+    // restore saved geometry
+    if (g.float) { wrap.classList.add('na-float'); ['left', 'top', 'width', 'height'].forEach(function (k) { if (g[k]) wrap.style[k] = g[k]; }); }
+    if (g.max) wrap.classList.add('na-max');
+    if (g.collapsed) wrap.classList.add('na-collapsed');
+    if (g.removed) { wrap.classList.add('na-removed'); addRestore(); }
+  }
+
+  /* -- public mount ------------------------------------------------------ */
+  var API = {
+    instances: [],
+    mount: function (opt) {
+      opt = opt || {};
+      try {
+        var target = findTarget(opt);
+        if (!target) return null;
+        if (target.__naMounted) return target.__naAgent;
+        target.__naMounted = true;
+        if (target.tagName === 'TABLE') target.setAttribute('data-na', '1');
+        else target.setAttribute('data-na-sel', target.id ? '#' + target.id : '.' + (target.className.split(/\s+/)[0] || 'grid'));
+        var agent = Agent(target, opt);
+        target.__naAgent = agent;
+        var panel = buildPanel(agent, opt);
+        chrome(agent, opt);
+        agent.applyAll();
+
+        // launcher
+        if (!document.querySelector('.na-launch')) {
+          var b = el('button', 'na-launch', '⚙'); b.title = 'View controls';
+          b.onclick = function () { panel.classList.toggle('open'); };
+          document.body.appendChild(b);
+        } else {
+          document.querySelector('.na-launch').addEventListener('click', function () { panel.classList.toggle('open'); });
+        }
+        API.instances.push(agent);
+        return agent;
+      } catch (e) { if (window.console) console.warn('[nav-agent] mount skipped:', e); return null; }
+    }
+  };
+  window.NavAgent = API;
+
+  function boot() {
+    try {
+      var body = document.body;
+      if (!body || body.getAttribute('data-nav-agent') === 'off') return;
+      // wait a tick for API-driven grids to render their first batch
+      var tries = 0;
+      (function attempt() {
+        var t = findTarget();
+        var populated = t && (t.tagName === 'TABLE' ? t.querySelector('tbody tr') : t.children.length);
+        if (populated || tries++ > 20) { API.mount(); return; }
+        setTimeout(attempt, 250);
+      })();
+    } catch (e) {}
+  }
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+  else boot();
+})();
diff --git a/public/parcels.html b/public/parcels.html
index e55f035..d254ca3 100644
--- a/public/parcels.html
+++ b/public/parcels.html
@@ -73,6 +73,7 @@
   .badge.p{color:var(--gold);border-color:var(--gold);}
   .empty{color:var(--muted);font-size:12px;padding:30px;text-align:center;}
 </style>
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -337,5 +338,6 @@ document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeDetail(); });
   if(dlCounty){ state.county=dlCounty; const sid=p.get('sid')?String(p.get('sid')).trim().slice(0,64):null; loadCoverage().then(()=>selectCounty(dlCounty)); if(sid) setTimeout(()=>openDetail(dlCounty,sid),600); }
 })();
 </script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/property.html b/public/property.html
index 8eb247d..42f6b5f 100644
--- a/public/property.html
+++ b/public/property.html
@@ -63,6 +63,7 @@
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
 <link rel="stylesheet" href="/geo-search.css">
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -360,5 +361,6 @@ else if (prefillQ) { // deep-linked from the top location search — prefill + a
 </script>
 <script src="/nav-drawer.js"></script>
 <script src="/geo-search.js"></script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/public/sources.html b/public/sources.html
index 3f6b0a9..e9bb24b 100644
--- a/public/sources.html
+++ b/public/sources.html
@@ -33,6 +33,7 @@
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
 <link rel="stylesheet" href="/geo-search.css">
+  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
 </head>
 <body>
 <div id="topbar">
@@ -131,5 +132,6 @@ const int = n => (+n).toLocaleString();
 </script>
 <script src="/nav-drawer.js"></script>
 <script src="/geo-search.js"></script>
+  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
 </body>
 </html>
diff --git a/src/server/commercial.ts b/src/server/commercial.ts
index 58fc602..662b743 100644
--- a/src/server/commercial.ts
+++ b/src/server/commercial.ts
@@ -34,6 +34,7 @@ function buildFilter(q: Request['query']): { where: string; params: unknown[] }
   return { where: parts.join(' AND '), params };
 }
 
+// Named legacy sort presets (kept for backward-compat with existing links).
 const SORTS: Record<string, string> = {
   value_desc: 'assessed_total DESC NULLS LAST',
   value_asc: 'assessed_total ASC NULLS LAST',
@@ -41,6 +42,38 @@ const SORTS: Record<string, string> = {
   sqft_desc: 'sqft DESC NULLS LAST',
 };
 
+// Column whitelist for ?sort=<col>&order=asc|desc over EVERY column the shape returns
+// (SQL-injection-safe: raw input never reaches SQL — only these fixed fragments do).
+// `pfx` optionally prefixes the column (the /feed + /marquee queries alias the table `p`).
+const SORT_COLS: Record<string, string> = {
+  ain: 'ain',
+  address: 'address',
+  city: 'city',
+  zip: 'zip',
+  type: 'ctype',
+  use_desc: 'use_desc',
+  assessed_total: 'assessed_total',
+  assessed_land: 'assessed_land',
+  assessed_improvement: 'assessed_imp',
+  roll_year: 'roll_year',
+  recording_date: 'recording_date',
+  sqft: 'sqft',
+  year_built: 'year_built',
+  units: 'units',
+};
+
+/** Resolve ORDER BY from ?sort=&order= (whitelist) or a legacy ?sort=<preset>.
+ *  Falls back to `def` when nothing matches. `pfx` (e.g. 'p.') prefixes whitelist cols. */
+function resolveOrderBy(q: Request['query'], def: string, pfx = ''): string {
+  const sort = String(q.sort || '');
+  const col = SORT_COLS[sort];
+  if (col) {
+    const desc = String(q.order || 'desc').toLowerCase() !== 'asc';
+    return `${pfx}${col} ${desc ? 'DESC' : 'ASC'} NULLS LAST`;
+  }
+  return SORTS[sort] || def;
+}
+
 function shape(r: any) {
   return {
     ain: r.ain, address: r.address, city: r.city, zip: r.zip,
@@ -81,7 +114,7 @@ export function mountCommercial(app: Express) {
   app.get('/api/commercial/search', async (req: Request, res: Response) => {
     try {
       const { where, params } = buildFilter(req.query);
-      const sort = SORTS[String(req.query.sort || 'value_desc')] || SORTS.value_desc;
+      const sort = resolveOrderBy(req.query, SORTS.value_desc);
       const limit = clampLimit(req.query.limit, 60, 200);
       const offset = Math.max(0, Number(req.query.offset) || 0);
       const total = await query<{ n: string }>(`SELECT count(*)::text n FROM commercial_parcel WHERE ${where}`, params);
@@ -97,12 +130,14 @@ export function mountCommercial(app: Express) {
     try {
       const { where, params } = buildFilter(req.query);
       const limit = clampLimit(req.query.limit, 40, 100);
+      // default keeps the "newest recorded first" news order; ?sort=&order= overrides (whitelist, p.-prefixed)
+      const orderBy = resolveOrderBy(req.query, 'p.recording_date DESC, p.assessed_total DESC NULLS LAST', 'p.');
       const rows = await query(
         `SELECT p.*, b.brief FROM commercial_parcel p
            LEFT JOIN commercial_brief b ON b.county_fips=p.county_fips AND b.ain=p.ain
          WHERE ${where.replace(/\b(county_fips|ctype|city|address|assessed_total)\b/g, 'p.$1')}
            AND p.recording_date IS NOT NULL
-         ORDER BY p.recording_date DESC, p.assessed_total DESC NULLS LAST LIMIT $${params.length + 1}`,
+         ORDER BY ${orderBy} LIMIT $${params.length + 1}`,
         [...params, limit]);
       res.json({ count: rows.rows.length, results: rows.rows.map(r => ({ ...shape(r), brief: r.brief || null })) });
     } catch (e: any) { res.status(500).json({ error: String(e.message || e) }); }
diff --git a/src/server/contractors.ts b/src/server/contractors.ts
index afeab51..a9fdb5b 100644
--- a/src/server/contractors.ts
+++ b/src/server/contractors.ts
@@ -44,6 +44,32 @@ const DEAL_TRADES = ['C-8', 'C-10', 'C-20', 'C-36', 'C-39', 'C-16', 'C-35', 'C-1
 
 const PER_GROUP_CAP = 25;
 
+// Column whitelist for ?sort=<col>&order=asc|desc on /api/contractors (SQL-injection-safe:
+// raw input never reaches SQL — only these fixed fragments do). business_name is the stable
+// tiebreaker so paging stays deterministic. Keys mirror the shaped JSON field names.
+const SORT_COLS: Record<string, string> = {
+  business_name: 'c.business_name',
+  license_no: 'c.license_no',
+  city: 'c.city',
+  county: 'c.county',
+  state: 'c.state_code',
+  zip: 'c.zip',
+  license_status: 'c.license_status',
+  issue_date: 'c.issue_date',
+  expire_date: 'c.expire_date',
+  primary_class: 'c.primary_class',
+};
+
+/** Resolve ORDER BY from ?sort=&order= (whitelist); defaults to business_name ASC. */
+function resolveContractorOrderBy(q: Request['query']): string {
+  const col = SORT_COLS[String(q.sort || '')];
+  if (col) {
+    const desc = String(q.order || 'asc').toLowerCase() === 'desc';
+    return `${col} ${desc ? 'DESC' : 'ASC'} NULLS LAST, c.business_name ASC`;
+  }
+  return 'c.business_name ASC';
+}
+
 // Columns selected for the public contractor shape (+ optional distance alias).
 const CONTRACTOR_COLS = `
   c.id, c.license_no, c.business_name, c.address, c.city, c.county,
@@ -122,10 +148,11 @@ export function mountContractors(app: Express) {
       const { where, params } = buildFilter(req.query);
       const limit = clamp(req.query.limit, 50, 200);
       const offset = Math.max(0, Math.floor(Number(req.query.offset) || 0));
+      const orderBy = resolveContractorOrderBy(req.query);
       const rows = await query<any>(
         `SELECT ${CONTRACTOR_COLS} FROM ca_contractors c
           WHERE ${where}
-          ORDER BY c.business_name ASC
+          ORDER BY ${orderBy}
           LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
         [...params, limit, offset]);
       const cnt = await query<{ n: number }>(
diff --git a/src/server/deals.ts b/src/server/deals.ts
index b05b797..67319d6 100644
--- a/src/server/deals.ts
+++ b/src/server/deals.ts
@@ -32,6 +32,7 @@ const BANDS: Record<string, [number, number | null]> = {
   '100m+': [100_000_000, null],
 };
 
+// Named legacy sort presets (kept for backward-compat with existing deep links).
 const SORTS: Record<string, string> = {
   recent: 'pe.event_date DESC, pe.amount DESC',
   oldest: 'pe.event_date ASC, pe.amount DESC',
@@ -40,6 +41,35 @@ const SORTS: Record<string, string> = {
   sqft_desc: 'cp.sqft DESC NULLS LAST, pe.amount DESC',
 };
 
+// Column whitelist for ?sort=<col>&order=asc|desc — one fixed ORDER-BY fragment per
+// EVERY column the deals feed returns (SQL-injection-safe: raw input never reaches SQL,
+// only these fixed strings do). event_date is the stable tiebreaker for deterministic paging.
+const SORT_COLS: Record<string, string> = {
+  sale_date: 'pe.event_date',
+  sale_price: 'pe.amount',
+  ctype: 'cp.ctype',
+  address: 'cp.address',
+  city: 'cp.city',
+  county_name: 'r.name',
+  sqft: 'cp.sqft',
+  year_built: 'cp.year_built',
+  doc_number: 'pe.doc_number',
+  grantor: "pe.detail->>'grantor'",
+  grantee: "pe.detail->>'grantee'",
+};
+
+/** Resolve ORDER BY from ?sort=&order= (whitelist) or the legacy ?sort=<preset>. */
+function resolveOrderBy(q: Request['query']): string {
+  const sort = String(q.sort || '');
+  const col = SORT_COLS[sort];
+  if (col) {
+    const desc = String(q.order || 'desc').toLowerCase() !== 'asc';
+    const dir = desc ? 'DESC' : 'ASC';
+    return `${col} ${dir} NULLS LAST, pe.event_date DESC, pe.id DESC`;
+  }
+  return SORTS[sort] || SORTS.recent;
+}
+
 /** shared WHERE builder over the deals base (parcel_event pe JOIN commercial_parcel cp) */
 function buildFilter(q: Request['query']): { where: string; params: unknown[] } {
   const params: unknown[] = [];
@@ -129,7 +159,7 @@ export function mountDeals(app: Express) {
   app.get('/api/deals', async (req: Request, res: Response) => {
     try {
       const { where, params } = buildFilter(req.query);
-      const orderBy = SORTS[String(req.query.sort || 'recent')] || SORTS.recent;
+      const orderBy = resolveOrderBy(req.query);
       const limit = clamp(req.query.limit, 60, 200);
       const offset = Math.max(0, Math.floor(Number(req.query.offset) || 0));
       const rows = await query<any>(

← f820780 auto-data-snapshot: 2026-08-12T15:01:25 (1 data files) — pac  ·  back to Nationalrealestate  ·  nav-agent: add universal grid-controls drop-in (field-toggle 846689f →