[object Object]

← back to Rentv Sheet Enrich

RENTV Live Console: two-way live Sheet viewer (pull+green write-back) over lib.py; Nav Agent grid chrome (left field panel/sort/group/density/movable) + List/Table/Grid; click-to-draft composer with channel-adaptive letters (email/LinkedIn/phone) + local-AI enhance; pm2 rentv-live-console :9797

45a7dbab96531b0c55185b29cfdd28ef39457c89 · 2026-08-13 12:46:01 -0700 · Steve Abrams

Files touched

Diff

commit 45a7dbab96531b0c55185b29cfdd28ef39457c89
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 13 12:46:01 2026 -0700

    RENTV Live Console: two-way live Sheet viewer (pull+green write-back) over lib.py; Nav Agent grid chrome (left field panel/sort/group/density/movable) + List/Table/Grid; click-to-draft composer with channel-adaptive letters (email/LinkedIn/phone) + local-AI enhance; pm2 rentv-live-console :9797
---
 .gitignore                          |   3 +
 data/ocbatches/oc_00_li.json        |   1 +
 data/ocbatches/oc_01_li.json        |   1 +
 data/ocbatches/oc_02_li.json        |   1 +
 live_server.py                      | 158 ++++++++++++
 public_live/index.html              | 370 ++++++++++++++++++++++++++++
 public_live/nav-agent/nav-agent.css | 100 ++++++++
 public_live/nav-agent/nav-agent.js  | 478 ++++++++++++++++++++++++++++++++++++
 8 files changed, 1112 insertions(+)

diff --git a/.gitignore b/.gitignore
index 49021c1..9a673bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,6 @@ dist/
 build/
 data/*.csv
 data/*.json
+
+# live console
+.liveviewer.port
diff --git a/data/ocbatches/oc_00_li.json b/data/ocbatches/oc_00_li.json
new file mode 100644
index 0000000..ff502b3
--- /dev/null
+++ b/data/ocbatches/oc_00_li.json
@@ -0,0 +1 @@
+[{"company": "Norris Realty Advisors", "company_li": "https://www.linkedin.com/company/norris-realty-advisors"}, {"company": "Celtic Bank", "company_li": "https://www.linkedin.com/company/celtic-bank"}, {"company": "Bridgeton Capital", "company_li": "https://www.linkedin.com/company/bridgeton-holdings"}, {"company": "Harvest Small Business Finance", "company_li": "https://www.linkedin.com/company/harvest-small-business-finance-llc"}, {"company": "R.W. Selby & Co", "company_li": "https://www.linkedin.com/company/rw-selby"}, {"company": "Kane Consulting", "company_li": "https://www.linkedin.com/company/kane-consulting-"}, {"company": "Pacific Property partners", "company_li": "https://www.linkedin.com/company/pacific-partners-real-estate-investments"}, {"company": "The Saywitz Company", "company_li": "https://www.linkedin.com/company/the-saywitz-company"}, {"company": "Bridgeport Investments", "company_li": "https://www.linkedin.com/company/bridgeport-investments"}, {"company": "DJM Capital Partners", "company_li": "https://www.linkedin.com/company/djm-capital-partners"}, {"company": "Regis Corporation", "company_li": "https://www.linkedin.com/company/regis-corporation"}, {"company": "Gold Coast Mortgage Group", "company_li": "https://www.linkedin.com/company/goldcoastfinancialcorp"}, {"company": "Triumph Properties Group", "company_li": "https://www.linkedin.com/company/triumph-properties-group-llc"}, {"company": "Sunbelt Holdings", "company_li": "https://www.linkedin.com/company/sunbelt-investment-holdings-inc"}, {"company": "Infinity Real Estate", "company_li": "https://www.linkedin.com/company/infinity-real-estate-services"}, {"company": "Reliable Properties", "company_li": "https://www.linkedin.com/company/reliable-properties"}, {"company": "Standard Communities", "company_li": "https://www.linkedin.com/company/standard-communities"}, {"company": "Trammell Crow Company Phx", "company_li": "https://www.linkedin.com/company/trammell-crow-company"}, {"company": "US Bank", "company_li": "https://www.linkedin.com/company/us-bank"}, {"company": "West Hive Capital", "company_li": "https://www.linkedin.com/company/west-hive-capital"}, {"company": "The Dinerstein Companies", "company_li": "https://www.linkedin.com/company/the-dinerstein-companies"}, {"company": "Ellis & Ellis Sign Systems", "company_li": "https://www.linkedin.com/company/ellis-"}, {"company": "Land Advisors Org", "company_li": "https://www.linkedin.com/company/land-advisors-organization"}, {"company": "Arizona bank & trust", "company_li": "https://www.linkedin.com/company/enterprise-bank-"}, {"company": "Future Capital", "company_li": "https://www.linkedin.com/company/future-capital-wealth"}, {"company": "iBorrow", "company_li": "https://www.linkedin.com/company/iborrow"}, {"company": "The REMM Group", "company_li": "https://www.linkedin.com/company/the-remm-group"}, {"company": "Casita Capital", "company_li": "https://www.linkedin.com/company/casita-coalition"}, {"company": "Bridge Development", "company_li": "https://www.linkedin.com/company/bridgeindustrial"}, {"company": "PRG Investment & Management", "company_li": "https://www.linkedin.com/company/prg-capital"}, {"company": "Cypress Equity Investments", "company_li": "https://www.linkedin.com/company/cypress-equity-investments"}]
\ No newline at end of file
diff --git a/data/ocbatches/oc_01_li.json b/data/ocbatches/oc_01_li.json
new file mode 100644
index 0000000..2b4f131
--- /dev/null
+++ b/data/ocbatches/oc_01_li.json
@@ -0,0 +1 @@
+[{"company": "Evergreen Development | EMAIL", "company_li": "https://www.linkedin.com/company/evergreendotcom"}, {"company": "Gillett Commercial", "company_li": "https://www.linkedin.com/company/gillett-commercial"}, {"company": "The L&R Group of Companies", "company_li": "https://www.linkedin.com/company/tlrgc"}, {"company": "Palisades Equity Partners", "company_li": "https://www.linkedin.com/company/palisades-equity-partners-llc"}, {"company": "StackSource", "company_li": "https://www.linkedin.com/company/stacksource"}, {"company": "Kimco  - Tustin Shopping CTr", "company_li": "https://www.linkedin.com/company/kimco-realty-corporation"}, {"company": "Embarcadero Capital Partners", "company_li": "https://www.linkedin.com/company/embarcadero-capital-partners"}, {"company": "CDL Associates", "company_li": "https://www.linkedin.com/company/cdldesigngroup"}, {"company": "The Nikols Company", "company_li": "https://www.linkedin.com/company/the-nikols-company"}, {"company": "The Bronstein Company", "company_li": "https://www.linkedin.com/company/bronstein-law-group"}, {"company": "Heslin Holdings", "company_li": "https://www.linkedin.com/company/heslin-holdings-inc"}, {"company": "Trammell Crow | Trammell Crow Company", "company_li": "https://www.linkedin.com/company/trammell-crow-company"}, {"company": "Citywide Commercial", "company_li": "https://www.linkedin.com/company/citywide-commercial"}, {"company": "The Helvetica Group | Helvetica Group", "company_li": "https://www.linkedin.com/company/the-helvetica-group"}, {"company": "HRP Capital", "company_li": "https://www.linkedin.com/company/hr-property-group"}, {"company": "Invesco Real Estate | INVESCO", "company_li": "https://www.linkedin.com/company/invesco-ltd"}, {"company": "Law Depot", "company_li": "https://www.linkedin.com/company/lawdepot"}, {"company": "Kimco Realty", "company_li": "https://www.linkedin.com/company/kimco-realty-corporation"}, {"company": "HomeFed Corp", "company_li": "https://www.linkedin.com/company/homefed-corporation"}, {"company": "Coreland", "company_li": "https://www.linkedin.com/company/coreland-companies"}, {"company": "Rising Realty Partners", "company_li": "https://www.linkedin.com/company/rising-realty-partners"}, {"company": "Ares Industrial Management Company", "company_li": "https://www.linkedin.com/company/ares-management"}, {"company": "Capstone Commercial Properties", "company_li": "https://www.linkedin.com/company/capstone-commercial-properties-inc-"}, {"company": "GPI Companies", "company_li": "https://www.linkedin.com/company/gpi-companies"}, {"company": "Heritage Bank of Commerce", "company_li": "https://www.linkedin.com/company/heritage-bank-of-commerce"}, {"company": "Northmarq Capital", "company_li": "https://www.linkedin.com/company/northmarq"}, {"company": "Empire Property Group", "company_li": "https://www.linkedin.com/company/empirepropertygroupllc"}, {"company": "Savills", "company_li": "https://www.linkedin.com/company/savills-north-america"}, {"company": "Trammell Crow | Trammell Crow Company | CBRE", "company_li": "https://www.linkedin.com/company/trammell-crow-company"}, {"company": "Five West Partners", "company_li": "https://www.linkedin.com/company/west-fifth-partners"}, {"company": "Wentworth Property Co", "company_li": "https://www.linkedin.com/company/wentworth-prop"}, {"company": "Better Commercial", "company_li": "https://www.linkedin.com/company/we-are-better-co"}]
\ No newline at end of file
diff --git a/data/ocbatches/oc_02_li.json b/data/ocbatches/oc_02_li.json
new file mode 100644
index 0000000..b104103
--- /dev/null
+++ b/data/ocbatches/oc_02_li.json
@@ -0,0 +1 @@
+[{"company": "Sterling Organization", "company_li": "https://www.linkedin.com/company/sterling-organization"}, {"company": "Sunrise Real Estate Group", "company_li": "https://www.linkedin.com/company/sunrise-property-management-group"}, {"company": "Shatar Capital", "company_li": "https://www.linkedin.com/company/stara-capital-partners"}, {"company": "US Metro Bank", "company_li": "https://www.linkedin.com/company/us-metro-bank-1"}, {"company": "Arise Investments", "company_li": "https://www.linkedin.com/company/ariseinvestments"}, {"company": "Hudson Partners", "company_li": "https://www.linkedin.com/company/hudson-partners-group"}, {"company": "Goldhawk Capital", "company_li": "https://www.linkedin.com/company/goldhawkcapital"}, {"company": "Union Bank", "company_li": "https://www.linkedin.com/company/union-bank"}, {"company": "IDS Real Estate | IDS  Real Estate Group", "company_li": "https://www.linkedin.com/company/ids-real-estate-group"}, {"company": "Southwestern Properties", "company_li": "https://www.linkedin.com/company/southwestern-property-corp"}, {"company": "California Bank & Trust", "company_li": "https://www.linkedin.com/company/calbanktrust"}, {"company": "Red Oak Financial", "company_li": "https://www.linkedin.com/company/redoak-financial-group"}, {"company": "Dominium", "company_li": "https://www.linkedin.com/company/dominium"}, {"company": "Dasher Tabata", "company_li": "https://www.linkedin.com/company/dasher-"}, {"company": "Sharp Capital", "company_li": "https://www.linkedin.com/company/sharp-capital-group_2"}, {"company": "Landmark Capital Advisors", "company_li": "https://www.linkedin.com/company/landmark-partners"}, {"company": "Centennial Bank", "company_li": "https://www.linkedin.com/company/my100bank"}, {"company": "First Financial Bancorp", "company_li": "https://www.linkedin.com/company/first-financial-creditunion"}, {"company": "David B. Norton Inc.", "company_li": "https://www.linkedin.com/company/david-b-norton-inc-"}, {"company": "Rosenthal Land Advocates", "company_li": "https://www.linkedin.com/company/rosenthal-real-estate-advocates"}, {"company": "Provident Bank", "company_li": "https://www.linkedin.com/company/provident-savings-bank"}, {"company": "Greenlaw Partners", "company_li": "https://www.linkedin.com/company/greenlaw-partners"}, {"company": "Arizona Builder Sales", "company_li": "https://www.linkedin.com/company/arizona-builder-sales"}, {"company": "Sanctuary Camelback Mountain", "company_li": "https://www.linkedin.com/company/sanctuary-camelback-mountain"}, {"company": "Cushman & Wakefield Phx", "company_li": "https://www.linkedin.com/company/cushman-"}, {"company": "Investors Title Company", "company_li": "https://www.linkedin.com/company/investors-title-company-and-affiliates"}, {"company": "Bomel Construction", "company_li": "https://www.linkedin.com/company/bomel-construction-company-inc"}, {"company": "PGIM | EMAIL", "company_li": "https://www.linkedin.com/company/pgim"}, {"company": "Pacific National Property Services", "company_li": "https://www.linkedin.com/company/pacific-national-property-services"}]
\ No newline at end of file
diff --git a/live_server.py b/live_server.py
new file mode 100644
index 0000000..19c0aff
--- /dev/null
+++ b/live_server.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""
+live_server.py — LIVE two-way web console over the RENTV CRE contacts Google Sheet.
+
+Reuses lib.py (OAuth token + read_tab + green-stamping batch_fill), so no new auth.
+  GET  /                     -> the console UI (public_live/index.html)
+  GET  /api/meta             -> [{gid,title}]  (all tabs)
+  GET  /api/tab?gid=N        -> {gid,title,headers,rows}   (pull latest — Sheet->UI)
+  POST /api/write            -> {gid,row0,col0,value} write one cell green (UI->Sheet)
+  POST /api/enhance          -> {prompt} optional local-Ollama draft polish ($0, if up)
+
+Two independent sync directions: writes are event-driven (on cell edit); the UI polls
+/api/tab to pull external Sheet edits. Basic-auth admin/DW2024! per the viewer convention.
+All contacts are Steve Bloom's (RENTV) — ADD only, never bulk-overwrite. $0 (local).
+"""
+import base64, json, os, socket, sys, threading, urllib.request
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import lib
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+USER, PW = "admin", "DW2024!"
+OLLAMA = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/generate")
+OLLAMA_MODEL = os.environ.get("DRAFT_MODEL", "qwen3:14b")
+
+# token is cached process-wide and refreshed lazily by lib.access_token()
+_tok_lock = threading.Lock()
+def tok():
+    with _tok_lock:
+        return lib.access_token()
+
+def meta():
+    return [{"gid": s["properties"]["sheetId"], "title": s["properties"]["title"]}
+            for s in lib.get_meta(tok())["sheets"]]
+
+def tab(gid):
+    gid = int(gid)
+    title = {s["properties"]["sheetId"]: s["properties"]["title"]
+             for s in lib.get_meta(tok())["sheets"]}[gid]
+    rows = lib.read_tab(tok(), title)
+    headers = [h.strip() for h in rows[0]] if rows else []
+    body = rows[1:] if rows else []
+    # normalize ragged rows to header width
+    w = len(headers)
+    body = [[(r[i] if i < len(r) else "") for i in range(w)] for r in body]
+    return {"gid": gid, "title": title, "headers": headers, "rows": body}
+
+def write_cell(gid, row0, col0, value, formula=False):
+    # row0 is 0-based over the DATA rows shown in the UI; +1 for the header row.
+    cell = {"row0": int(row0) + 1, "col0": int(col0), "value": value}
+    if formula:
+        cell["formula"] = True
+    lib.batch_fill(tok(), int(gid), [cell])
+    return {"ok": True}
+
+def enhance(prompt):
+    try:
+        body = json.dumps({"model": OLLAMA_MODEL, "prompt": prompt, "stream": False,
+                           "options": {"temperature": 0.5}}).encode()
+        r = urllib.request.urlopen(urllib.request.Request(
+            OLLAMA, data=body, headers={"Content-Type": "application/json"}), timeout=60)
+        out = json.load(r).get("response", "").strip()
+        # strip any <think> blocks qwen emits
+        if "</think>" in out:
+            out = out.split("</think>", 1)[1].strip()
+        return {"ok": True, "text": out}
+    except Exception as e:
+        return {"ok": False, "error": f"local model unavailable ({e})"}
+
+class H(BaseHTTPRequestHandler):
+    def _auth(self):
+        h = self.headers.get("Authorization", "")
+        if h.startswith("Basic "):
+            try:
+                u, p = base64.b64decode(h[6:]).decode().split(":", 1)
+                if u == USER and p == PW:
+                    return True
+            except Exception:
+                pass
+        self.send_response(401)
+        self.send_header("WWW-Authenticate", 'Basic realm="RENTV Live Console"')
+        self.end_headers()
+        return False
+
+    def _send(self, code, ctype, data):
+        if isinstance(data, str):
+            data = data.encode()
+        self.send_response(code)
+        self.send_header("Content-Type", ctype)
+        self.send_header("Content-Length", str(len(data)))
+        self.end_headers()
+        self.wfile.write(data)
+
+    def _json(self, obj, code=200):
+        self._send(code, "application/json", json.dumps(obj))
+
+    def log_message(self, *a):  # quiet
+        pass
+
+    def do_GET(self):
+        if not self._auth():
+            return
+        path = self.path.split("?", 1)[0]
+        q = {}
+        if "?" in self.path:
+            for kv in self.path.split("?", 1)[1].split("&"):
+                if "=" in kv:
+                    k, v = kv.split("=", 1)
+                    q[k] = urllib.parse.unquote(v)
+        try:
+            if path == "/" or path == "/index.html":
+                with open(os.path.join(HERE, "public_live", "index.html"), "rb") as f:
+                    self._send(200, "text/html; charset=utf-8", f.read())
+            elif path == "/api/meta":
+                self._json(meta())
+            elif path == "/api/tab":
+                self._json(tab(q.get("gid", "0")))
+            elif "/../" not in path and (path.startswith("/nav-agent/") or path.endswith((".css", ".js"))):
+                fp = os.path.join(HERE, "public_live", path.lstrip("/"))
+                if os.path.isfile(fp):
+                    ct = "text/css" if fp.endswith(".css") else "application/javascript"
+                    with open(fp, "rb") as f:
+                        self._send(200, ct + "; charset=utf-8", f.read())
+                else:
+                    self._json({"error": "not found"}, 404)
+            else:
+                self._json({"error": "not found"}, 404)
+        except Exception as e:
+            self._json({"error": str(e)}, 500)
+
+    def do_POST(self):
+        if not self._auth():
+            return
+        try:
+            n = int(self.headers.get("Content-Length", 0))
+            body = json.loads(self.rfile.read(n) or b"{}")
+            if self.path == "/api/write":
+                self._json(write_cell(body["gid"], body["row0"], body["col0"],
+                                      body.get("value", ""), body.get("formula", False)))
+            elif self.path == "/api/enhance":
+                self._json(enhance(body.get("prompt", "")))
+            else:
+                self._json({"error": "not found"}, 404)
+        except Exception as e:
+            self._json({"error": str(e)}, 500)
+
+import urllib.parse  # noqa: E402 (used in handlers)
+
+def main():
+    port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
+    srv = ThreadingHTTPServer(("127.0.0.1", port), H)
+    real = srv.server_address[1]
+    open(os.path.join(HERE, ".liveviewer.port"), "w").write(str(real))
+    print(f"RENTV Live Console  ->  http://127.0.0.1:{real}   (admin / DW2024!)   $0 local", flush=True)
+    srv.serve_forever()
+
+if __name__ == "__main__":
+    main()
diff --git a/public_live/index.html b/public_live/index.html
new file mode 100644
index 0000000..9bb2f53
--- /dev/null
+++ b/public_live/index.html
@@ -0,0 +1,370 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>RENTV Live Console</title>
+<link rel="stylesheet" href="/nav-agent/nav-agent.css">
+<style>
+  :root{
+    --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2b333d;
+    --ink:#e6edf3; --dim:#9aa7b4; --accent:#4c9be8; --green:#cbe6b4; --greenink:#1a2e10;
+    --chip:#232c37; --fs:14px;
+  }
+  *{box-sizing:border-box}
+  body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
+       background:var(--bg);color:var(--ink);font-size:var(--fs)}
+  header{position:sticky;top:0;z-index:40;background:var(--panel);border-bottom:1px solid var(--line);
+         display:flex;align-items:center;gap:14px;padding:10px 16px;flex-wrap:wrap}
+  header h1{font-size:16px;margin:0;font-weight:700;letter-spacing:.3px}
+  header h1 span{color:var(--accent)}
+  select,button,input{font:inherit;color:var(--ink);background:var(--panel2);border:1px solid var(--line);
+         border-radius:8px;padding:6px 10px}
+  button{cursor:pointer}
+  button:hover{border-color:var(--accent)}
+  .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
+  .seg button{border:0;border-radius:0;background:var(--panel2)}
+  .seg button.on{background:var(--accent);color:#04121f;font-weight:700}
+  .grow{flex:1}
+  .sync{display:flex;align-items:center;gap:8px;color:var(--dim);font-size:12px}
+  .dot{width:9px;height:9px;border-radius:50%;background:#3fb950;box-shadow:0 0 8px #3fb95088}
+  .dot.stale{background:#d29922;box-shadow:none}
+  .dot.err{background:#f85149}
+  #wrap{padding:14px 16px 120px}
+  /* table */
+  table{border-collapse:collapse;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
+  thead th{position:sticky;top:56px;background:var(--panel2);text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);
+           font-size:12px;color:var(--dim);white-space:nowrap;z-index:5}
+  tbody td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top;max-width:340px}
+  tbody tr:hover{background:#1a212b}
+  td[contenteditable]{outline:none}
+  td[contenteditable]:focus{background:#0d2b3f;box-shadow:inset 0 0 0 2px var(--accent)}
+  td.saved{background:var(--green);color:var(--greenink);transition:background .2s}
+  td a{color:var(--accent);text-decoration:none}
+  .rowbtn{background:var(--accent);color:#04121f;border:0;border-radius:6px;padding:3px 9px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap}
+  .namecell{font-weight:600}
+  /* cards (list + grid modes) */
+  .cards{display:grid;gap:12px}
+  .cards.grid{grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
+  .cards.list{grid-template-columns:1fr}
+  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative}
+  .card:hover{border-color:var(--accent)}
+  .card .co{font-weight:700;font-size:15px}
+  .card .nm{color:var(--ink);margin-top:2px}
+  .card .ro{color:var(--dim);font-size:13px}
+  .card .meta{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px}
+  .badge{background:var(--chip);border:1px solid var(--line);border-radius:20px;padding:2px 9px;font-size:11px;color:var(--dim)}
+  .badge.ok{color:var(--green);border-color:#3a5228}
+  .card .act{position:absolute;top:12px;right:12px}
+  .empty{color:var(--dim);padding:40px;text-align:center}
+  /* composer drawer */
+  #scrim{position:fixed;inset:0;background:#000a;z-index:60;display:none}
+  #drawer{position:fixed;top:0;right:0;height:100%;width:min(560px,100%);background:var(--panel);
+          border-left:1px solid var(--line);z-index:61;transform:translateX(100%);transition:transform .22s;
+          display:flex;flex-direction:column}
+  #drawer.open{transform:none}
+  #drawer header{position:static;background:var(--panel2)}
+  .dbody{padding:16px;overflow:auto;flex:1}
+  .dbody h2{margin:0 0 2px;font-size:18px}
+  .dbody .sub{color:var(--dim);font-size:13px;margin-bottom:12px}
+  .fieldgrid{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:13px;margin-bottom:14px}
+  .fieldgrid .k{color:var(--dim)}
+  .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:10px 0}
+  label.lbl{color:var(--dim);font-size:12px;min-width:64px}
+  textarea{width:100%;min-height:230px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;
+           color:var(--ink);padding:12px;font:inherit;line-height:1.5;resize:vertical}
+  .subj{width:100%;margin-bottom:8px}
+  .actions{display:flex;gap:8px;flex-wrap:wrap;padding:12px 16px;border-top:1px solid var(--line);background:var(--panel2)}
+  .actions .primary{background:var(--accent);color:#04121f;font-weight:700;border:0}
+  .chdis{opacity:.4;pointer-events:none}
+  .hint{color:var(--dim);font-size:12px}
+</style>
+</head>
+<body>
+<header>
+  <h1>RENTV <span>Live Console</span></h1>
+  <select id="tab" title="Sheet tab"></select>
+  <div class="seg" id="viewseg" title="View mode">
+    <button data-v="table">▤ Table</button>
+    <button data-v="list">☰ List</button>
+    <button data-v="grid">▦ Grid</button>
+  </div>
+  <span class="grow"></span>
+  <span class="sync"><span class="dot" id="dot"></span><span id="syncmsg">connecting…</span></span>
+  <button id="refresh" title="Pull latest now">↻ Refresh</button>
+</header>
+
+<div id="wrap"><div class="empty">Loading the sheet…</div></div>
+
+<div id="scrim"></div>
+<div id="drawer">
+  <header>
+    <strong>✍ Draft outreach</strong><span class="grow"></span>
+    <button id="dclose">✕ Close</button>
+  </header>
+  <div class="dbody">
+    <h2 id="d-company">—</h2>
+    <div class="sub" id="d-who">—</div>
+    <div class="fieldgrid" id="d-fields"></div>
+
+    <div class="row">
+      <label class="lbl">Contact via</label>
+      <div class="seg" id="chanseg">
+        <button data-c="email">✉ Email</button>
+        <button data-c="linkedin">in LinkedIn</button>
+        <button data-c="phone">☎ Phone</button>
+      </div>
+    </div>
+    <div class="row">
+      <label class="lbl">Purpose</label>
+      <select id="purpose">
+        <option value="partnership">Partnership / intro</option>
+        <option value="sponsorship">Conference sponsorship</option>
+        <option value="speaker">Speaker / panel invite</option>
+        <option value="followup">Follow-up</option>
+      </select>
+      <label class="lbl">Tone</label>
+      <select id="tone">
+        <option value="warm">Warm</option>
+        <option value="direct">Direct</option>
+        <option value="formal">Formal</option>
+      </select>
+      <button id="regen">↻ Regenerate</button>
+    </div>
+
+    <input class="subj" id="d-subject" placeholder="Subject line">
+    <textarea id="d-body"></textarea>
+    <div class="row"><span class="hint" id="d-target"></span></div>
+    <div class="row">
+      <button id="enhance">✨ Enhance (local AI · $0)</button>
+      <span class="hint">Polishes the draft with a local model — nothing sent.</span>
+    </div>
+  </div>
+  <div class="actions">
+    <button class="primary" id="act-go">Open in Email</button>
+    <button id="act-copy">⧉ Copy draft</button>
+    <span class="grow"></span>
+    <span class="hint">Draft only — you send it. Contacts © Steve Bloom / RENTV.</span>
+  </div>
+</div>
+
+<script src="/nav-agent/nav-agent.js" defer></script>
+<script>
+const $ = s => document.querySelector(s);
+const LS = { get:(k,d)=>{try{return JSON.parse(localStorage.getItem('rentv.'+k))??d}catch(e){return d}},
+             set:(k,v)=>localStorage.setItem('rentv.'+k,JSON.stringify(v)) };
+let DATA={gid:0,title:'',headers:[],rows:[]}, ROLE={}, VIEW=LS.get('view','table'), GID=LS.get('gid',null);
+let editing=false, lastPull=0, curRow=null, curChan='email';
+
+// ---- field role detection (map header labels -> semantic roles) --------------
+function findCol(preds){ for(const p of preds){ const i=DATA.headers.findIndex(h=>p.test(h)); if(i>=0) return i; } return -1; }
+function detectRoles(){
+  ROLE = {
+    name:    findCol([/^contact name$/i,/\bname\b/i]),
+    company: findCol([/company\/venue/i,/^company$/i,/venue/i,/firm/i]),
+    position:findCol([/^position$/i,/title/i,/role/i]),
+    email:   findCol([/^email$/i]),
+    email2:  findCol([/updated email/i]),
+    phone:   findCol([/^phone 1$/i,/^phone$/i]),
+    cphone:  findCol([/company phone/i]),
+    liC:     findCol([/linkedin \(contact\)/i]),
+    liCo:    findCol([/linkedin \(company\)/i]),
+    mvpN:    findCol([/marketing\/vp name/i]),
+    mvpP:    findCol([/marketing\/vp position/i]),
+    mvpL:    findCol([/marketing\/vp linkedin/i]),
+  };
+}
+const g=(r,i)=> (i>=0 && i<r.length)? String(r[i]||'').trim() : '';
+function contact(r){
+  const nm=g(r,ROLE.name), co=g(r,ROLE.company);
+  return {
+    name:nm, first:(nm.split(/[\s,]+/)[0]||'').trim(), company:co, position:g(r,ROLE.position),
+    email:g(r,ROLE.email)||g(r,ROLE.email2), phone:g(r,ROLE.phone)||g(r,ROLE.cphone),
+    liC:hrefOf(g(r,ROLE.liC)), liCo:hrefOf(g(r,ROLE.liCo)),
+    mvpN:g(r,ROLE.mvpN), mvpP:g(r,ROLE.mvpP), mvpL:hrefOf(g(r,ROLE.mvpL)),
+  };
+}
+function hrefOf(v){ const m=String(v).match(/https?:\/\/[^\s"')]+/i); return m?m[0]:(/linkedin\.com/i.test(v)?('https://'+v.replace(/^https?:\/\//,'')):''); }
+
+// ---- load / live pull --------------------------------------------------------
+async function loadMeta(){
+  const tabs=await (await fetch('/api/meta')).json();
+  const sel=$('#tab'); sel.innerHTML='';
+  tabs.forEach(t=>{const o=document.createElement('option');o.value=t.gid;o.textContent=t.title;sel.appendChild(o);});
+  if(GID==null || !tabs.find(t=>t.gid==GID)) GID=tabs[0].gid;
+  sel.value=GID;
+}
+async function pull(force){
+  if(editing && !force) return;
+  try{
+    const d=await (await fetch('/api/tab?gid='+GID)).json();
+    if(d.error) throw new Error(d.error);
+    const changed = JSON.stringify(d)!==JSON.stringify(DATA);
+    DATA=d; detectRoles(); lastPull=Date.now();
+    setSync('ok');
+    if(changed) render();
+  }catch(e){ setSync('err',e.message); }
+}
+function setSync(state,msg){
+  const dot=$('#dot');
+  dot.className='dot'+(state==='err'?' err':state==='stale'?' stale':'');
+  $('#syncmsg').textContent = state==='err' ? ('sync error: '+(msg||'')) :
+     ('live · synced '+Math.max(0,Math.round((Date.now()-lastPull)/1000))+'s ago · '+DATA.rows.length+' rows');
+}
+setInterval(()=>{ if(Date.now()-lastPull>25000) $('#dot').classList.add('stale'); if(!editing) setSync('ok'); },1000);
+
+// ---- render: table | list | grid --------------------------------------------
+const MAXROWS=1200;
+function render(){
+  const wrap=$('#wrap');
+  if(!DATA.rows.length){ wrap.innerHTML='<div class="empty">No rows in this tab.</div>'; return; }
+  document.querySelectorAll('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.v===VIEW));
+  const capped = DATA.rows.length>MAXROWS;
+  const banner = capped ? '<div class="empty" style="padding:10px;color:#d29922">Showing first '+MAXROWS+' of '+DATA.rows.length+' rows — use ⚙ search/filter (bottom-left) to narrow, or pick a smaller tab.</div>' : '';
+  wrap.innerHTML = banner + (VIEW==='table' ? tableHTML() : cardsHTML(VIEW));
+  wireGrid();
+  // let Nav Agent (re)model the new grid
+  if(window.NavAgent && window.NavAgent.mount){ try{ window.NavAgent.mount({target: VIEW==='table'?'#grid':'#cards', title:DATA.title}); }catch(e){} }
+}
+function tableHTML(){
+  let h='<table id="grid" data-nav-grid><thead><tr><th></th>';
+  DATA.headers.forEach(hd=> h+='<th>'+esc(hd)+'</th>'); h+='</tr></thead><tbody>';
+  DATA.rows.slice(0,MAXROWS).forEach((r,ri)=>{
+    h+='<tr><td><button class="rowbtn" data-r="'+ri+'">✍ Draft</button></td>';
+    r.forEach((c,ci)=>{
+      const isName=ci===ROLE.name;
+      h+='<td data-field="'+esc(DATA.headers[ci]||'')+'" data-r="'+ri+'" data-c="'+ci+'" contenteditable="true"'+
+         (isName?' class="namecell"':'')+'>'+cellRender(c)+'</td>';
+    });
+    h+='</tr>';
+  });
+  return h+'</tbody></table>';
+}
+function cardsHTML(mode){
+  let h='<div class="cards '+mode+'" id="cards" data-nav-grid>';
+  DATA.rows.slice(0,MAXROWS).forEach((r,ri)=>{
+    const c=contact(r);
+    h+='<div class="card" data-r="'+ri+'">'+
+       '<button class="rowbtn act" data-r="'+ri+'">✍ Draft</button>'+
+       '<div class="co" data-field="Company/Venue">'+esc(c.company||'—')+'</div>'+
+       (c.name?'<div class="nm" data-field="Contact Name">'+esc(c.name)+'</div>':'')+
+       (c.position?'<div class="ro" data-field="Position">'+esc(c.position)+'</div>':'')+
+       '<div class="meta">'+
+         badge('✉', c.email, 'Email')+ badge('☎', c.phone, 'Phone')+
+         badge('in', c.liC||c.liCo, 'LinkedIn')+ badge('★', c.mvpN, 'Mktg/VP')+
+       '</div></div>';
+  });
+  return h+'</div>';
+}
+function badge(ic,val,label){ return '<span class="badge'+(val?' ok':'')+'" data-field="'+label+'">'+ic+' '+esc(label)+(val?' ✓':'')+'</span>'; }
+function cellRender(v){ const u=hrefOf(v); if(u&&/^https?:/i.test(v)) return '<a href="'+esc(u)+'" target="_blank" rel="noopener">'+esc(v.length>40?u.replace(/^https?:\/\/(www\.)?/,''):v)+'</a>'; return esc(v); }
+function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m])); }
+
+function wireGrid(){
+  document.querySelectorAll('.rowbtn').forEach(b=> b.onclick=e=>{e.stopPropagation();openDraft(+b.dataset.r);});
+  document.querySelectorAll('.card').forEach(c=> c.onclick=()=>openDraft(+c.dataset.r));
+  document.querySelectorAll('td[contenteditable]').forEach(td=>{
+    td.addEventListener('focus',()=>{editing=true; td._orig=td.textContent;});
+    td.addEventListener('blur',()=>saveCell(td));
+    td.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();td.blur();} });
+  });
+}
+async function saveCell(td){
+  editing=false;
+  const val=td.textContent.trim();
+  if(val===String(td._orig||'').trim()) return;
+  const r=+td.dataset.r, c=+td.dataset.c;
+  try{
+    const res=await (await fetch('/api/write',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({gid:GID,row0:r,col0:c,value:val})})).json();
+    if(res.ok){ DATA.rows[r][c]=val; td.classList.add('saved'); setTimeout(()=>td.classList.remove('saved'),1200); }
+    else throw new Error(res.error||'write failed');
+  }catch(e){ td.textContent=td._orig||''; setSync('err',e.message); }
+}
+
+// ---- draft composer ----------------------------------------------------------
+function openDraft(ri){
+  curRow=ri; const c=contact(DATA.rows[ri]);
+  $('#d-company').textContent=c.company||'(no company)';
+  $('#d-who').textContent=[c.name,c.position].filter(Boolean).join(' · ')||'—';
+  const F=$('#d-fields'); F.innerHTML='';
+  const add=(k,v,link)=>{ if(!v)return; F.insertAdjacentHTML('beforeend','<div class="k">'+k+'</div><div>'+(link?'<a href="'+esc(link)+'" target="_blank" rel="noopener">'+esc(v)+'</a>':esc(v))+'</div>'); };
+  add('Email',c.email,c.email?'mailto:'+c.email:''); add('Phone',c.phone,c.phone?'tel:'+c.phone.replace(/[^0-9+]/g,''):'');
+  add('LinkedIn',c.liC?'Contact profile':(c.liCo?'Company page':''),c.liC||c.liCo);
+  add('Marketing / VP',[c.mvpN,c.mvpP].filter(Boolean).join(' — '),c.mvpL);
+  // enable channels that have data; pick a sensible default
+  const avail={email:!!c.email, linkedin:!!(c.liC||c.mvpL||c.liCo), phone:!!c.phone};
+  document.querySelectorAll('#chanseg button').forEach(b=>{ b.classList.toggle('chdis',!avail[b.dataset.c]); });
+  curChan = avail[curChan]?curChan : (avail.email?'email':avail.linkedin?'linkedin':avail.phone?'phone':'email');
+  syncChanUI(); regen();
+  $('#scrim').style.display='block'; $('#drawer').classList.add('open');
+}
+function closeDraft(){ $('#scrim').style.display='none'; $('#drawer').classList.remove('open'); }
+function syncChanUI(){ document.querySelectorAll('#chanseg button').forEach(b=>b.classList.toggle('on',b.dataset.c===curChan));
+  $('#d-subject').style.display = curChan==='email'?'block':'none';
+  $('#act-go').textContent = curChan==='email'?'Open in Email':curChan==='linkedin'?'Open LinkedIn':'Call';
+}
+function regen(){
+  const c=contact(DATA.rows[curRow]); const p=$('#purpose').value, tone=$('#tone').value;
+  const d=buildDraft(c,curChan,p,tone);
+  $('#d-subject').value=d.subject||''; $('#d-body').value=d.body;
+  $('#d-target').textContent = d.target?('→ '+d.target):'';
+}
+function buildDraft(c,chan,purpose,tone){
+  const first=(c.first && /^[A-Za-z]/.test(c.first))?c.first:'there', co=c.company||'your team';
+  const hi = tone==='formal'?('Dear '+(c.name||first)+','): (tone==='direct'?('Hi '+first+' —'):('Hi '+first+','));
+  const val={
+    partnership:`I run partnerships for RENTV (Real Estate News Television), the CRE industry's video news network. I'd love to explore ways ${co} and RENTV could work together — from featuring your deals and leadership on our broadcasts to co-marketing across our commercial-real-estate audience.`,
+    sponsorship:`RENTV is lining up sponsors for our upcoming commercial real estate conference, and ${co} stood out as a strong fit. Sponsors get on-camera visibility, stage presence, and direct access to an audience of CRE principals, brokers, and capital sources.`,
+    speaker:`We're building the panel lineup for RENTV's upcoming CRE conference and I'd like to invite ${(c.mvpN||c.name)?('someone from '+co):(co)} to join as a speaker. Your perspective would resonate with our audience of commercial real estate leaders.`,
+    followup:`Following up on RENTV's outreach to ${co} — I wanted to make sure this reached the right person and see whether it's worth a short call this week.`
+  }[purpose];
+  const close = tone==='formal'?'Kind regards,\nSteve Bloom\nRENTV — Real Estate News Television':'Best,\nSteve Bloom\nRENTV — Real Estate News Television';
+  if(chan==='email'){
+    const subj={partnership:'RENTV × '+co+' — partnership',sponsorship:co+' as a RENTV CRE conference sponsor',
+                speaker:'Speaker invite — RENTV CRE conference',followup:'Following up — RENTV × '+co}[purpose];
+    return {subject:subj, body:`${hi}\n\n${val}\n\nWould you be open to a 15-minute call this week? Happy to work around your schedule.\n\n${close}`,
+            target:c.email||'(no email on file)'};
+  }
+  if(chan==='linkedin'){
+    const who = c.mvpN?`${c.mvpN}${c.mvpP?' ('+c.mvpP+')':''} at ${co}`:co;
+    const note=`${hi.replace(/ —$/,'')} — I run partnerships at RENTV (CRE video news). ${purpose==='sponsorship'?'We\'re selecting sponsors for our CRE conference and '+co+' is a great fit.':purpose==='speaker'?'I\'d love to invite '+co+' onto a panel at our CRE conference.':'I\'d love to connect about ways RENTV and '+co+' could work together.'} Open to a quick chat?`;
+    return {subject:'', body:note.slice(0,600), target:(c.liC||c.mvpL||c.liCo)||'(no LinkedIn on file)'};
+  }
+  // phone script
+  return {subject:'', target:c.phone||'(no phone on file)',
+    body:`CALL SCRIPT — ${co}${c.name?' ('+c.name+')':''}\n\n• Opener: "Hi${c.name?' '+first:''}, this is Steve Bloom with RENTV, the commercial real estate news network."\n• Reason: ${val}\n• Ask: "Is there a good 15 minutes this week to walk through it?"\n• If gatekeeper: ask for ${c.mvpN||'the marketing or partnerships lead'}.\n• Leave-behind: offer to email details${c.email?' to '+c.email:''}.`};
+}
+async function doEnhance(){
+  const btn=$('#enhance'); const old=btn.textContent; btn.textContent='✨ thinking…'; btn.disabled=true;
+  const c=contact(DATA.rows[curRow]);
+  const prompt=`Rewrite this ${curChan} outreach so it sounds natural, concise and professional. Keep it truthful, keep the sender "Steve Bloom, RENTV". Return ONLY the rewritten message, no preamble.\n\n---\n${$('#d-body').value}`;
+  try{ const r=await (await fetch('/api/enhance',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt})})).json();
+    if(r.ok && r.text) $('#d-body').value=r.text; else alert(r.error||'local model unavailable'); }
+  catch(e){ alert('enhance failed: '+e.message); }
+  finally{ btn.textContent=old; btn.disabled=false; }
+}
+function doGo(){
+  const c=contact(DATA.rows[curRow]);
+  if(curChan==='email'){ const to=c.email; if(!to)return alert('No email on file.');
+    location.href='mailto:'+to+'?subject='+encodeURIComponent($('#d-subject').value)+'&body='+encodeURIComponent($('#d-body').value); }
+  else if(curChan==='linkedin'){ const u=c.liC||c.mvpL||c.liCo; if(!u)return alert('No LinkedIn on file.'); navigator.clipboard?.writeText($('#d-body').value); window.open(u,'_blank','noopener'); }
+  else { const p=c.phone; if(!p)return alert('No phone on file.'); location.href='tel:'+p.replace(/[^0-9+]/g,''); }
+}
+
+// ---- events ------------------------------------------------------------------
+$('#tab').onchange=e=>{ GID=+e.target.value; LS.set('gid',GID); pull(true); };
+document.querySelectorAll('#viewseg button').forEach(b=> b.onclick=()=>{ VIEW=b.dataset.v; LS.set('view',VIEW); render(); });
+$('#refresh').onclick=()=>pull(true);
+$('#dclose').onclick=closeDraft; $('#scrim').onclick=closeDraft;
+document.querySelectorAll('#chanseg button').forEach(b=> b.onclick=()=>{ if(b.classList.contains('chdis'))return; curChan=b.dataset.c; syncChanUI(); regen(); });
+$('#purpose').onchange=regen; $('#tone').onchange=regen; $('#regen').onclick=regen;
+$('#enhance').onclick=doEnhance; $('#act-go').onclick=doGo;
+$('#act-copy').onclick=()=>{ navigator.clipboard?.writeText(($('#d-subject').value?('Subject: '+$('#d-subject').value+'\n\n'):'')+$('#d-body').value); $('#act-copy').textContent='✓ Copied'; setTimeout(()=>$('#act-copy').textContent='⧉ Copy draft',1200); };
+document.addEventListener('keydown',e=>{ if(e.key==='Escape')closeDraft(); });
+
+// ---- boot --------------------------------------------------------------------
+(async()=>{ await loadMeta(); await pull(true); setInterval(()=>pull(false),12000); })();
+</script>
+</body>
+</html>
diff --git a/public_live/nav-agent/nav-agent.css b/public_live/nav-agent/nav-agent.css
new file mode 100644
index 0000000..844d787
--- /dev/null
+++ b/public_live/nav-agent/nav-agent.css
@@ -0,0 +1,100 @@
+/* ============================================================================
+ * 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)}
+
+/* ---- merged mode: folded INTO an existing house panel ----------------- */
+.na-merge{border-top:1px dashed var(--na-line);margin-top:12px;padding-top:10px}
+.na-merge .na-mh{display:flex;align-items:center;gap:6px;font-weight:600;color:var(--na-acc);
+  text-transform:uppercase;font-size:11px;letter-spacing:.6px;margin-bottom:8px}
+.na-merge .na-row label{color:var(--na-mut);min-width:48px}
+.na-merge select{background:var(--na-bg2);border:1px solid var(--na-line);color:var(--na-fg);border-radius:6px;padding:6px 8px;font-size:13px;flex:1}
+
+@media (max-width:640px){.na-panel{width:86vw}}
diff --git a/public_live/nav-agent/nav-agent.js b/public_live/nav-agent/nav-agent.js
new file mode 100644
index 0000000..991a82f
--- /dev/null
+++ b/public_live/nav-agent/nav-agent.js
@@ -0,0 +1,478 @@
+/* ============================================================================
+ * 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).
+       If the FIELD SET changed (async data arrived, filter repopulated), tell
+       mount() to rebuild the panel UI. */
+    function fieldSig(m) { return m.kind + ':' + m.fields.map(function (f) { return f.key; }).join(','); }
+    var sig = fieldSig(M);
+    var t;
+    var mo = new MutationObserver(function () {
+      if (applyingSort) return;
+      clearTimeout(t); t = setTimeout(function () {
+        M = model(target); self.model = M;
+        var ns = fieldSig(M);
+        if (ns !== sig) { sig = ns; if (self.onFields) try { self.onFields(); } catch (e) {} }
+        applyAll();
+      }, 140);
+    });
+    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(); }
+  }
+
+  /* -- merged mode: fold into an existing house panel ("2 into 1") ------- */
+  function findHousePanel(target) {
+    var asides = [].slice.call(document.querySelectorAll('aside, .sidebar, .panel-left, [data-house-panel]'));
+    for (var i = 0; i < asides.length; i++) {
+      var a = asides[i];
+      if (a.classList.contains('na-panel')) continue;
+      if (a.contains(target)) continue;            // a real control sidebar won't contain the grid
+      if (a.querySelector('input, select, details, .toggle, .chip')) return a; // has controls
+    }
+    return null;
+  }
+
+  function buildMergeSection(agent, house, opt) {
+    var M = agent.model, st = agent.state;
+    var old = house.querySelector('.na-merge'); if (old) old.remove();
+    var box = el('div', 'na-merge');
+    box.appendChild(el('div', 'na-mh', '⚙ Nav Agent'));
+    if (M.fields && M.fields.length) {
+      // sort ANY field (superset of the house's fixed sort options)
+      var srow = el('div', 'na-row', '<label>Sort</label>');
+      var ssel = el('select'); ssel.appendChild(new Option('— none —', ''));
+      M.fields.forEach(function (f) { ssel.appendChild(new Option(f.label, f.key)); });
+      ssel.value = st.sort || ''; srow.appendChild(ssel); box.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'); }
+      ssel.onchange = function () { st.sort = ssel.value; agent.save(); agent.applySort(); };
+      dir.querySelector('[data-asc]').onclick = function () { st.dir = 'asc'; agent.save(); agent.applySort(); markDir(); };
+      dir.querySelector('[data-desc]').onclick = function () { st.dir = 'desc'; agent.save(); agent.applySort(); markDir(); };
+      markDir(); box.appendChild(dir);
+      // group ANY field
+      var grow = el('div', 'na-row', '<label>Group</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 || ''; gsel.onchange = function () { st.group = gsel.value; agent.save(); agent.applyGroup(); };
+      grow.appendChild(gsel); box.appendChild(grow);
+    }
+    box.appendChild(el('div', 'na-note', 'Drag the grid’s ▤ bar to float · ▁ collapse · ⤢ maximize · ✕ remove.'));
+    house.appendChild(box);
+    return box;
+  }
+
+  /* -- 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;
+        // "2 into 1": if the page already has a house control panel, fold into it
+        var house = opt.merge === false ? null : findHousePanel(target);
+        // (re)build the controls — called again by the observer when fields change
+        function makePanel() {
+          if (house) { agent._merge = buildMergeSection(agent, house, opt); return; }
+          var wasOpen = agent._panel && agent._panel.classList.contains('open');
+          if (agent._panel) agent._panel.remove();
+          agent._panel = buildPanel(agent, opt);
+          if (wasOpen) agent._panel.classList.add('open');
+        }
+        agent.onFields = makePanel;
+        agent.merged = !!house;
+        makePanel();
+        chrome(agent, opt);
+        agent.applyAll();
+
+        // standalone launcher only when NOT merged into a house panel
+        if (!house && !document.querySelector('.na-launch')) {
+          var b = el('button', 'na-launch', '⚙'); b.title = 'View controls';
+          b.onclick = function () {
+            var anyOpen = document.querySelector('.na-panel.open');
+            [].slice.call(document.querySelectorAll('.na-panel')).forEach(function (p) { p.classList.toggle('open', !anyOpen); });
+          };
+          document.body.appendChild(b);
+        }
+        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 = false;
+        if (t) {
+          if (t.tagName === 'TABLE') populated = !!t.querySelector('tbody tr');
+          else {
+            var kids = [].slice.call(t.children).filter(function (c) { return c.nodeType === 1; });
+            // ignore an empty-state placeholder (a lone div, often "No … match")
+            populated = !!t.querySelector('.card') || kids.length >= 2;
+          }
+        }
+        if (populated || tries++ > 32) { API.mount(); return; } // mount anyway after ~8s; observer fills fields when data arrives
+        setTimeout(attempt, 250);
+      })();
+    } catch (e) {}
+  }
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+  else boot();
+})();

← cd3467b session close: openclaw first slice landed (+31 company-LI,  ·  back to Rentv Sheet Enrich  ·  Live Console: reflect enriched (green) cells — /api/format r 50ef157 →