[object Object]

← back to Rentv Sheet Enrich Refine

Rearchitect console: serve from OUR OWN local snapshot (snapshot.py, whole workbook in ~3 batch reads) instead of live-reading the sheet per tab-switch — kills the 60/min Sheets read-quota that blanked tabs (tab reads 0.9s->0.001s); rebuilt UI with full left filter panel (search + sort + group + density + per-field toggles + per-value click-to-filter facets), List/Table/Grid, live write-back + snapshot patch, /api/refresh; smart header-row detection for title-row tabs

ef8ce09fb83bbdc77b1980139890b1827e613356 · 2026-08-13 13:57:54 -0700 · Steve Abrams

Files touched

Diff

commit ef8ce09fb83bbdc77b1980139890b1827e613356
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 13 13:57:54 2026 -0700

    Rearchitect console: serve from OUR OWN local snapshot (snapshot.py, whole workbook in ~3 batch reads) instead of live-reading the sheet per tab-switch — kills the 60/min Sheets read-quota that blanked tabs (tab reads 0.9s->0.001s); rebuilt UI with full left filter panel (search + sort + group + density + per-field toggles + per-value click-to-filter facets), List/Table/Grid, live write-back + snapshot patch, /api/refresh; smart header-row detection for title-row tabs
---
 .gitignore                           |   2 +
 __pycache__/snapshot.cpython-314.pyc | Bin 0 -> 7775 bytes
 data/ocbatches/oc_10_li.json         |   1 +
 data/ocbatches/oc_11_li.json         |   1 +
 data/ocbatches/oc_12_li.json         |   1 +
 live_server.py                       | 119 ++++---
 public_live/index.html               | 583 +++++++++++++++--------------------
 snapshot.py                          |  72 +++++
 8 files changed, 406 insertions(+), 373 deletions(-)

diff --git a/.gitignore b/.gitignore
index 9a673bd..811e5b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,5 @@ data/*.json
 
 # live console
 .liveviewer.port
+
+data/snapshot.json
diff --git a/__pycache__/snapshot.cpython-314.pyc b/__pycache__/snapshot.cpython-314.pyc
new file mode 100644
index 0000000..d8d224f
Binary files /dev/null and b/__pycache__/snapshot.cpython-314.pyc differ
diff --git a/data/ocbatches/oc_10_li.json b/data/ocbatches/oc_10_li.json
new file mode 100644
index 0000000..063b356
--- /dev/null
+++ b/data/ocbatches/oc_10_li.json
@@ -0,0 +1 @@
+[{"company": "Triwell Properties", "company_li": "https://www.linkedin.com/company/triwell-properties-inc"}, {"company": "Plaza Bank", "company_li": "https://www.linkedin.com/company/southwestusa-bank"}, {"company": "Rio Company", "company_li": "https://www.linkedin.com/company/rio-company"}, {"company": "Caladan Investments/Mount Auburn Partners", "company_li": "https://www.linkedin.com/company/montana-avenue-capital-partners"}, {"company": "Greenbridge Finance", "company_li": "https://www.linkedin.com/company/greenbridge-capital-llc"}, {"company": "Woodbridge Realty Advisors", "company_li": "https://www.linkedin.com/company/woodbridge-real-estate"}, {"company": "HPA", "company_li": "https://www.linkedin.com/company/hpaonline"}, {"company": "Signal Retail Properties", "company_li": "https://www.linkedin.com/company/signalbrands"}, {"company": "Price Mortgage", "company_li": "https://www.linkedin.com/company/price-mortgage-llc"}, {"company": "Marshall Property & Dev", "company_li": "https://www.linkedin.com/company/marshall-property-"}, {"company": "Upland Group", "company_li": "https://www.linkedin.com/company/usgi-upland-group"}, {"company": "ValueRock Realty Partners", "company_li": "https://www.linkedin.com/company/valuerock-realty-partners"}, {"company": "Arizona Partners", "company_li": "https://www.linkedin.com/company/arizona-partners"}, {"company": "Landmark Commercial Group", "company_li": "https://www.linkedin.com/company/california-landmark-group"}, {"company": "Majestic Asset Management", "company_li": "https://www.linkedin.com/company/majestic-asset-management-inc"}, {"company": "Property Nine Development", "company_li": "https://www.linkedin.com/company/property-nine-development"}, {"company": "Sandstone", "company_li": "https://www.linkedin.com/company/sandstonela"}, {"company": "Graystone Capital Advisors | Matthews", "company_li": "https://www.linkedin.com/company/graystoneca"}, {"company": "Carlton West Properties", "company_li": "https://www.linkedin.com/company/carlton-group"}, {"company": "Cashen Commercial", "company_li": "https://www.linkedin.com/company/cashen-realty-advisors"}, {"company": "GHP", "company_li": "https://www.linkedin.com/company/ghp-management"}, {"company": "Albany Road RE Partners", "company_li": "https://www.linkedin.com/company/albany-road-real-estate-partners"}, {"company": "Cap Rate Commercial", "company_li": "https://www.linkedin.com/company/mahercommercialrealty"}, {"company": "Clearinghouse CDFI", "company_li": "https://www.linkedin.com/company/clearinghouse-cdfi"}, {"company": "EN Capital", "company_li": "https://www.linkedin.com/company/en-capital"}, {"company": "Hasz Companies", "company_li": "https://www.linkedin.com/company/hasz-fund-control-inc"}, {"company": "Westminster Capital", "company_li": "https://www.linkedin.com/company/westminster-capital-inc"}, {"company": "CalmWater | EMAIL", "company_li": "https://www.linkedin.com/company/calmwater-capital"}, {"company": "Schnitzer | Schnitzer Investment Properties", "company_li": "https://www.linkedin.com/company/schnitzer-properties"}]
\ No newline at end of file
diff --git a/data/ocbatches/oc_11_li.json b/data/ocbatches/oc_11_li.json
new file mode 100644
index 0000000..7385e23
--- /dev/null
+++ b/data/ocbatches/oc_11_li.json
@@ -0,0 +1 @@
+[{"company": "DRA Collective", "company_li": "https://www.linkedin.com/company/dra-collective"}, {"company": "Arroyo Seco Dev Group", "company_li": "https://www.linkedin.com/company/arroyo-seco-development-group"}, {"company": "Barrett Real Estate", "company_li": "https://www.linkedin.com/company/barrettrealestate"}, {"company": "Bullseye Property management", "company_li": "https://www.linkedin.com/company/bullseye-property-management"}, {"company": "Caliber Construction", "company_li": "https://www.linkedin.com/company/caliber-construction-inc"}, {"company": "Chandler Partners", "company_li": "https://www.linkedin.com/company/p-chandler-partners"}, {"company": "Chicago Title", "company_li": "https://www.linkedin.com/company/chicago-title-national-commercial-services-southern-california"}, {"company": "Columbia Pacific", "company_li": "https://www.linkedin.com/company/columbia-pacific-management-inc"}, {"company": "CommerceWest Bank", "company_li": "https://www.linkedin.com/company/commercewest-bank"}, {"company": "Condor Commercial", "company_li": "https://www.linkedin.com/company/condor-entertainment-group"}, {"company": "Duncan Bergman Mandell", "company_li": "https://www.linkedin.com/company/duncan-bergman-mandell"}, {"company": "Empire USA LLC", "company_li": "https://www.linkedin.com/company/empire-usa-llc"}, {"company": "Frontline United Real Estate", "company_li": "https://www.linkedin.com/company/united-real-estate-los-angeles"}, {"company": "GDM Realty Advisors", "company_li": "https://www.linkedin.com/company/gm-realty-advisors-llc"}, {"company": "identity", "company_li": "https://www.linkedin.com/company/therealidentity"}, {"company": "Interpacific Asset Management", "company_li": "https://www.linkedin.com/company/pacificassetmanagement"}, {"company": "JFA Real Estate", "company_li": "https://www.linkedin.com/company/jfa-realty-company"}, {"company": "Keller Williams realty Utah / Arizona", "company_li": "https://www.linkedin.com/company/keller-williams-arizona-realty"}, {"company": "Live Oak Properties", "company_li": "https://www.linkedin.com/company/live-oak-cre"}, {"company": "Marabella Commercial Finance", "company_li": "https://www.linkedin.com/company/marabella-commercial-finance-inc-"}, {"company": "Mark II Properties", "company_li": "https://www.linkedin.com/company/mark-2-developments"}, {"company": "Modus Development", "company_li": "https://www.linkedin.com/company/modus-create"}, {"company": "Orum Capital", "company_li": "https://www.linkedin.com/company/orum-capital"}, {"company": "Pacific Reach", "company_li": "https://www.linkedin.com/company/pacific-reach"}, {"company": "Pegasus Retail", "company_li": "https://www.linkedin.com/company/pegasusinvestments"}, {"company": "Pinnacle Lending", "company_li": "https://www.linkedin.com/company/pinnacle-capital-mortgage"}, {"company": "RCG Ventures", "company_li": "https://www.linkedin.com/company/rcgventures"}]
\ No newline at end of file
diff --git a/data/ocbatches/oc_12_li.json b/data/ocbatches/oc_12_li.json
new file mode 100644
index 0000000..bc69eaf
--- /dev/null
+++ b/data/ocbatches/oc_12_li.json
@@ -0,0 +1 @@
+[{"company": "Rein & Grossoehme CRE", "company_li": "https://www.linkedin.com/company/rein-"}, {"company": "Rellion Inc", "company_li": "https://www.linkedin.com/company/rellion-inc"}, {"company": "Shapell Properties", "company_li": "https://www.linkedin.com/company/shapell-properties-inc"}, {"company": "Skyline Capital Ventures", "company_li": "https://www.linkedin.com/company/skyline-capital-management"}, {"company": "Stoneridge Development Services", "company_li": "https://www.linkedin.com/company/stoneridgemgmt"}, {"company": "Susank Capital", "company_li": "https://www.linkedin.com/company/susank-capital"}, {"company": "The Molasky Group", "company_li": "https://www.linkedin.com/company/molasky-corporate-center-owner"}, {"company": "Theopacific Holdings", "company_li": "https://www.linkedin.com/company/pacific-holdings-group"}, {"company": "Venture West Funding", "company_li": "https://www.linkedin.com/company/venture-west-funding"}, {"company": "Wilshire Skyline", "company_li": "https://www.linkedin.com/company/wilshire-skyline-inc"}, {"company": "WR Gorman & Associates", "company_li": "https://www.linkedin.com/company/gorman-"}, {"company": "xyz", "company_li": "https://www.linkedin.com/company/wearexyz"}]
\ No newline at end of file
diff --git a/live_server.py b/live_server.py
index 042e421..a9dd189 100644
--- a/live_server.py
+++ b/live_server.py
@@ -29,59 +29,81 @@ def tok():
     with _tok_lock:
         return lib.access_token()
 
+class QuotaError(Exception):
+    """Raised when the Sheets read/write quota (429) is hit after retries."""
+
+def retry(fn, *a, tries=5):
+    """Call a Sheets-reading fn, retrying on a 429/RESOURCE_EXHAUSTED. lib._req raises
+    SystemExit (a BaseException) on any API error, so we catch BaseException, inspect the
+    message, back off on quota errors, and re-raise everything else."""
+    import time as _t
+    for i in range(tries):
+        try:
+            return fn(*a)
+        except BaseException as e:  # noqa: BLE001 - lib._req raises SystemExit
+            m = str(e)
+            if ("429" in m or "RESOURCE_EXHAUSTED" in m or "RATE_LIMIT" in m) and i < tries - 1:
+                _t.sleep(1.5 * (i + 1)); continue
+            if "429" in m or "RESOURCE_EXHAUSTED" in m or "RATE_LIMIT" in m:
+                raise QuotaError("Sheets read quota (60/min) hit — retrying shortly")
+            raise
+
+import snapshot as snap_mod  # our own local-copy builder
+
+SNAP = None
+_snap_lock = threading.Lock()
+
+def load_snap():
+    """Load our OWN local copy (data/snapshot.json) into memory — no live Sheets reads."""
+    global SNAP
+    with open(os.path.join(HERE, "data", "snapshot.json")) as f:
+        s = json.load(f)
+    s["_by_gid"] = {t["gid"]: t for t in s["tabs"]}
+    SNAP = s
+
+def refresh_snap():
+    """Rebuild the local copy from the live sheet (a few batch reads) and swap it in."""
+    global SNAP
+    s = snap_mod.build(tok())
+    s["_by_gid"] = {t["gid"]: t for t in s["tabs"]}
+    with _snap_lock:
+        SNAP = s
+    json.dump({k: v for k, v in s.items() if k != "_by_gid"},
+              open(os.path.join(HERE, "data", "snapshot.json"), "w"))
+    return {"ok": True, "generated": s["generated"], "tabs": len(s["tabs"]),
+            "rows": sum(len(t["rows"]) for t in s["tabs"])}
+
 def meta():
-    return [{"gid": s["properties"]["sheetId"], "title": s["properties"]["title"]}
-            for s in lib.get_meta(tok())["sheets"]]
+    return {"generated": SNAP["generated"],
+            "tabs": [{"gid": t["gid"], "title": t["title"], "rows": len(t["rows"])} for t in SNAP["tabs"]]}
 
 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}
+    t = SNAP["_by_gid"][int(gid)]
+    return {"gid": t["gid"], "title": t["title"], "headers": t["headers"],
+            "rows": t["rows"], "green": t["green"], "generated": SNAP["generated"]}
 
 def green_map(gid):
-    """Return sparse list of 'r,c' (0-based over DATA rows) whose cell background is the
-    light-green auto-fill color — so the UI can mark enriched (added) vs original cells."""
-    gid = int(gid)
-    title = {s["properties"]["sheetId"]: s["properties"]["title"]
-             for s in lib.get_meta(tok())["sheets"]}[gid]
-    rng = urllib.parse.quote("'" + title.replace("'", "''") + "'", safe="")
-    url = (f"{lib.API}/{lib.SID}?ranges={rng}&includeGridData=true"
-           "&fields=sheets.data.rowData.values.effectiveFormat.backgroundColor")
-    res = lib._req("GET", url, tok())
-    G = lib.GREEN
-    def is_green(bg):
-        if not bg:
-            return False
-        return (abs(bg.get("red", 1) - G["red"]) < 0.06 and
-                abs(bg.get("green", 1) - G["green"]) < 0.06 and
-                abs(bg.get("blue", 1) - G["blue"]) < 0.06)
-    green = []
-    try:
-        rowData = res["sheets"][0]["data"][0].get("rowData", [])
-    except Exception:
-        rowData = []
-    for ri, row in enumerate(rowData):
-        if ri == 0:  # header row -> data row index is ri-1
-            continue
-        for ci, cell in enumerate(row.get("values", []) or []):
-            bg = (cell.get("effectiveFormat") or {}).get("backgroundColor")
-            if is_green(bg):
-                green.append(f"{ri-1},{ci}")
-    return {"gid": gid, "green": green}
+    """Enriched (green) cells for a tab — precomputed in the local snapshot."""
+    t = SNAP["_by_gid"][int(gid)]
+    return {"gid": t["gid"], "green": t["green"]}
 
 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.
+    # 1) LIVE write to the sheet (row0 is 0-based over DATA rows; +1 for the header)
     cell = {"row0": int(row0) + 1, "col0": int(col0), "value": value}
     if formula:
         cell["formula"] = True
-    lib.batch_fill(tok(), int(gid), [cell])
+    retry(lambda: lib.batch_fill(tok(), int(gid), [cell]))
+    # 2) patch the in-memory local copy so the UI reflects it instantly + marks green
+    t = SNAP["_by_gid"].get(int(gid))
+    if t:
+        r, c = int(row0), int(col0)
+        while len(t["rows"]) <= r:
+            t["rows"].append([])
+        while len(t["rows"][r]) <= c:
+            t["rows"][r].append("")
+        t["rows"][r][c] = value
+        if f"{r},{c}" not in t["green"]:
+            t["green"].append(f"{r},{c}")
     return {"ok": True}
 
 def enhance(prompt):
@@ -158,7 +180,9 @@ class H(BaseHTTPRequestHandler):
                     self._json({"error": "not found"}, 404)
             else:
                 self._json({"error": "not found"}, 404)
-        except Exception as e:
+        except QuotaError as e:
+            self._json({"error": str(e), "retry": True}, 503)
+        except BaseException as e:  # incl. SystemExit from lib._req on API errors
             self._json({"error": str(e)}, 500)
 
     def do_POST(self):
@@ -172,14 +196,19 @@ class H(BaseHTTPRequestHandler):
                                       body.get("value", ""), body.get("formula", False)))
             elif self.path == "/api/enhance":
                 self._json(enhance(body.get("prompt", "")))
+            elif self.path == "/api/refresh":
+                self._json(refresh_snap())
             else:
                 self._json({"error": "not found"}, 404)
-        except Exception as e:
+        except QuotaError as e:
+            self._json({"error": str(e), "retry": True}, 503)
+        except BaseException as e:  # incl. SystemExit from lib._req on API errors
             self._json({"error": str(e)}, 500)
 
 import urllib.parse  # noqa: E402 (used in handlers)
 
 def main():
+    load_snap()  # serve from OUR local copy, not the live sheet
     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]
diff --git a/public_live/index.html b/public_live/index.html
index 57b2d84..5e0dd18 100644
--- a/public_live/index.html
+++ b/public_live/index.html
@@ -4,394 +4,321 @@
 <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;
+    --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2b333d; --ink:#e6edf3;
+    --dim:#9aa7b4; --accent:#4c9be8; --green:#cbe6b4; --greenink:#1a2e10; --chip:#232c37; --fs:13px; --rail:300px;
   }
   *{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}
+  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:12px;padding:9px 14px;flex-wrap:wrap}
+  header h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.3px;white-space:nowrap}
   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)}
+  select,button,input{font:inherit;color:var(--ink);background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:5px 9px}
+  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}
+  .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}
+  .sync{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:12px}
+  .dot{width:9px;height:9px;border-radius:50%;background:#3fb950} .dot.stale{background:#d29922} .dot.err{background:#f85149}
+  .chips{display:flex;gap:6px;flex-wrap:wrap}
+  .fchip{background:var(--accent);color:#04121f;border-radius:14px;padding:2px 9px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap}
+  .fchip b{opacity:.7;margin-left:5px}
+  /* layout */
+  #main{display:flex;min-height:calc(100vh - 48px)}
+  #rail{width:var(--rail);flex:0 0 var(--rail);background:var(--panel);border-right:1px solid var(--line);overflow:auto;height:calc(100vh - 48px);position:sticky;top:48px;padding:10px 10px 80px}
+  #rail.hide{display:none}
+  #wrap{flex:1;padding:12px 14px 120px;overflow:auto}
+  .rrow{display:flex;gap:6px;align-items:center;margin:7px 2px}
+  .rrow label{color:var(--dim);font-size:11px;min-width:42px}
+  .rail input[type=search],.rail select{width:100%}
+  #search{width:100%;margin-bottom:6px}
+  .sec{border-top:1px solid var(--line);margin-top:8px;padding-top:6px}
+  .sec>summary{cursor:pointer;color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.5px;list-style:none;user-select:none;padding:3px 0}
+  .sec>summary::-webkit-details-marker{display:none}
+  .sec>summary:before{content:'▸ ';color:var(--accent)} .sec[open]>summary:before{content:'▾ '}
+  details.field{margin:2px 0;border:1px solid transparent;border-radius:6px}
+  details.field>summary{cursor:pointer;padding:4px 6px;border-radius:6px;list-style:none;display:flex;align-items:center;gap:6px;font-size:12px}
+  details.field>summary::-webkit-details-marker{display:none}
+  details.field>summary:hover{background:var(--panel2)}
+  details.field>summary .cnt{color:var(--dim);font-size:10px;margin-left:auto}
+  details.field.active>summary{color:var(--accent);font-weight:600}
+  .vals{padding:2px 6px 6px 14px;max-height:230px;overflow:auto}
+  .val{display:flex;align-items:center;gap:6px;padding:2px 4px;border-radius:5px;cursor:pointer;font-size:12px}
+  .val:hover{background:var(--panel2)} .val.on{color:var(--accent)}
+  .val .n{color:var(--dim);font-size:10px;margin-left:auto}
+  .val .bx{width:12px;height:12px;border:1px solid var(--line);border-radius:3px;flex:0 0 12px}
+  .val.on .bx{background:var(--accent);border-color:var(--accent)}
+  .ftog{display:flex;align-items:center;gap:6px;padding:2px 4px;font-size:12px;cursor:pointer}
+  .ftog input{margin:0}
+  .mini{font-size:10px;color:var(--dim);cursor:pointer} .mini:hover{color:var(--accent)}
   /* 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}
+  thead th{position:sticky;top:0;background:var(--panel2);text-align:left;padding:7px 9px;border-bottom:1px solid var(--line);font-size:11px;color:var(--dim);white-space:nowrap;z-index:5;cursor:pointer}
+  thead th:hover{color:var(--accent)}
+  tbody td{padding:6px 9px;border-bottom:1px solid var(--line);vertical-align:top;max-width:320px}
   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.enriched{box-shadow:inset 3px 0 0 #7bc043;background:rgba(123,192,67,.10)}
-  td.enriched:focus{box-shadow:inset 0 0 0 2px var(--accent)}
+  td[contenteditable]:focus{outline:none;background:#0d2b3f;box-shadow:inset 0 0 0 2px var(--accent)}
+  td.saved{background:var(--green);color:var(--greenink)}
+  td.enriched{box-shadow:inset 3px 0 0 #7bc043;background:rgba(123,192,67,.09)}
+  td a{color:var(--accent);text-decoration:none} .namecell{font-weight:600}
+  .rowbtn{background:var(--accent);color:#04121f;border:0;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}
+  .grouphdr td{background:#12161c;color:var(--accent);font-weight:700;font-size:12px;padding:6px 9px;position:sticky;top:26px}
+  /* cards */
+  .cards{display:grid;gap:11px} .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:13px 15px;position:relative} .card:hover{border-color:var(--accent)}
   .card.enriched-card{box-shadow:inset 3px 0 0 #7bc043}
-  .legend{display:inline-flex;align-items:center;gap:6px;color:var(--dim);font-size:12px}
-  .legend .sw{width:12px;height:12px;border-radius:3px;background:rgba(123,192,67,.25);box-shadow:inset 3px 0 0 #7bc043}
-  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}
+  .card .co{font-weight:700;font-size:15px} .card .nm{margin-top:2px} .card .ro{color:var(--dim);font-size:12px}
+  .card .meta{margin-top:8px;display:flex;flex-wrap:wrap;gap:5px} .badge{background:var(--chip);border:1px solid var(--line);border-radius:20px;padding:2px 8px;font-size:10px;color:var(--dim)} .badge.ok{color:var(--green);border-color:#3a5228}
+  .card .act{position:absolute;top:11px;right:11px}
+  .empty{color:var(--dim);padding:36px;text-align:center}
+  .legend{display:inline-flex;align-items:center;gap:5px;color:var(--dim);font-size:11px}
+  .legend .sw{width:11px;height:11px;border-radius:3px;background:rgba(123,192,67,.25);box-shadow:inset 3px 0 0 #7bc043}
+  /* composer */
+  #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 .2s;display:flex;flex-direction:column}
+  #drawer.open{transform:none} #drawer header{position:static;background:var(--panel2)}
+  .dbody{padding:15px;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:12px} .fieldgrid .k{color:var(--dim)}
+  .drow{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:9px 0} label.lbl{color:var(--dim);font-size:12px;min-width:60px}
+  textarea{width:100%;min-height:210px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;color:var(--ink);padding:11px;font:inherit;line-height:1.5;resize:vertical}
+  .subj{width:100%;margin-bottom:8px} .actions{display:flex;gap:8px;flex-wrap:wrap;padding:11px 15px;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>
+  <button id="railtog" title="Toggle filters">☰</button>
   <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>
+    <button data-v="table">▤ Table</button><button data-v="list">☰ List</button><button data-v="grid">▦ Grid</button>
   </div>
-  <span class="legend" title="Cells filled by enrichment (or edited here) are light-green in the sheet"><span class="sw"></span>enriched</span>
+  <span class="legend"><span class="sw"></span>enriched</span>
+  <span class="chips" id="chips"></span>
   <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>
+  <span class="sync"><span class="dot" id="dot"></span><span id="syncmsg">loading…</span></span>
+  <button id="refresh" title="Rebuild local copy from the sheet">↻ Refresh</button>
 </header>
 
-<div id="wrap"><div class="empty">Loading the sheet…</div></div>
+<div id="main">
+  <aside id="rail"></aside>
+  <div id="wrap"><div class="empty">Loading our local copy…</div></div>
+</div>
 
 <div id="scrim"></div>
 <div id="drawer">
-  <header>
-    <strong>✍ Draft outreach</strong><span class="grow"></span>
-    <button id="dclose">✕ Close</button>
-  </header>
+  <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>
+    <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>
-
+    <div class="drow"><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="drow"><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 class="drow"><span class="hint" id="d-target"></span></div>
+    <div class="drow"><button id="enhance">✨ Enhance (local AI · $0)</button><span class="hint">Polishes 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. 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';
-let GREENSET=new Set();  // "r,c" cells that are light-green (enriched/added) in the sheet
+const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s);
+const LS={get:(k,d)=>{try{return JSON.parse(localStorage.getItem('rc2.'+k))??d}catch(e){return d}},set:(k,v)=>localStorage.setItem('rc2.'+k,JSON.stringify(v))};
+let DATA={gid:0,title:'',headers:[],rows:[],green:[]}, GREENSET=new Set(), ROLE={}, META=null;
+let VIEW=LS.get('view','table'), GID=LS.get('gid',null), editing=false, curRow=null, curChan='email';
+let FILTERS={}, SEARCH='', SORT=LS.get('sort',null), GROUP=LS.get('group',''), HIDDEN=new Set(LS.get('hidden.'+(GID||''),[]));
+const MAXROWS=2500;
+function pk(){return 'p'+GID}  // per-tab localStorage namespace
 
-// ---- 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?:\/\//,'')):''); }
+// ---- field roles (for the composer) -----------------------------------------
+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,/contact name/i,/\bname\b/i]),company:findCol([/company\/venue/i,/^company$/i,/venue/i,/firm/i]),position:findCol([/^position$/i,/title/i]),email:findCol([/^email$/i]),email2:findCol([/updated email/i]),phone:findCol([/^phone 1$/i,/^phone\(s\)$/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 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 --------------------------------------------------------
+// ---- load (from OUR local copy) ---------------------------------------------
 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;
+  META=await (await fetch('/api/meta')).json();
+  const sel=$('#tab');sel.innerHTML='';
+  META.tabs.forEach(t=>{const o=document.createElement('option');o.value=t.gid;o.textContent=`${t.title} (${t.rows})`;sel.appendChild(o)});
+  if(GID==null||!META.tabs.find(t=>t.gid==GID))GID=META.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); }
+async function loadTab(){
+  setSync('load');
+  const d=await (await fetch('/api/tab?gid='+GID)).json();
+  DATA=d; GREENSET=new Set(d.green||[]); detectRoles();
+  FILTERS={}; SEARCH=''; $('#search')&&($('#search').value='');
+  HIDDEN=new Set(LS.get('hidden.'+GID,[]));
+  buildRail(); render(); setSync('ok');
 }
-async function pullFormat(){
-  // heavy (includeGridData) — only on tab-load / manual refresh, never on the 12s poll
-  try{
-    const d=await (await fetch('/api/format?gid='+GID)).json();
-    if(d && d.green){ GREENSET=new Set(d.green); applyGreen(); }
-  }catch(e){ /* non-fatal — enrichment tint is cosmetic */ }
+function setSync(state){
+  $('#dot').className='dot'+(state==='err'?' err':state==='load'?' stale':'');
+  const age=META?Math.round((Date.now()/1000)-META.generated):0;
+  $('#syncmsg').textContent = state==='load'?'loading…':state==='err'?'error':
+    `local copy · ${DATA.rows.length} rows · as of ${age<90?age+'s':Math.round(age/60)+'m'} ago`;
 }
-function applyGreen(){
-  document.querySelectorAll('td[data-r]').forEach(td=>{
-    td.classList.toggle('enriched', GREENSET.has(td.dataset.r+','+td.dataset.c));
-  });
-  // card mode: flag a card if any of its cells are enriched
-  document.querySelectorAll('.card[data-r]').forEach(c=>{
-    let any=false; const r=c.dataset.r;
-    GREENSET.forEach(k=>{ if(k.split(',')[0]===r) any=true; });
-    c.classList.toggle('enriched-card', any);
+
+// ---- filtering ---------------------------------------------------------------
+function norm(s){return String(s||'').trim()}
+function passes(r){
+  for(const ci in FILTERS){ const set=FILTERS[ci]; if(set.size && !set.has(norm(r[ci])||'(blank)')) return false; }
+  if(SEARCH){ const q=SEARCH.toLowerCase(); if(!r.some(c=>String(c||'').toLowerCase().includes(q))) return false; }
+  return true;
+}
+function filtered(){ let rows=DATA.rows.map((r,i)=>[r,i]).filter(([r])=>passes(r));
+  if(SORT){ const ci=SORT.col,dir=SORT.dir; rows.sort((a,b)=>{const x=g(a[0],ci).toLowerCase(),y=g(b[0],ci).toLowerCase(); return (x<y?-1:x>y?1:0)*dir}); }
+  return rows;
+}
+
+// ---- LEFT RAIL: search + sort + group + density + field toggles + per-field FILTERS ----
+function buildRail(){
+  const rail=$('#rail'); if(rail.classList.contains('hide')){return}
+  const H=DATA.headers, opts=H.map((h,i)=>h?`<option value="${i}">${esc(h)}</option>`:'').join('');
+  let html=`<input type="search" id="search" placeholder="Search all fields…" value="${esc(SEARCH)}">
+    <div class="rrow"><label>Sort</label><select id="sortsel"><option value="">— none —</option>${opts}</select>
+      <button id="sortdir" title="asc/desc">${SORT&&SORT.dir<0?'▼':'▲'}</button></div>
+    <div class="rrow"><label>Group</label><select id="groupsel"><option value="">— none —</option>${opts}</select></div>
+    <div class="rrow"><label>Density</label><input type="range" id="dens" min="10" max="18" value="${getComputedStyle(document.documentElement).getPropertyValue('--fs').trim().replace('px','')||13}"></div>
+    <details class="sec" open><summary>Fields (${H.filter(Boolean).length}) · <span class="mini" id="allf">all</span> / <span class="mini" id="nonef">none</span></summary>
+      <div id="ftogs"></div></details>
+    <details class="sec" open><summary>Filters — click a value</summary><div id="facets"></div></details>`;
+  rail.innerHTML=html;
+  $('#sortsel').value=SORT?SORT.col:''; $('#groupsel').value=GROUP;
+  // field toggles
+  $('#ftogs').innerHTML=H.map((h,i)=>h?`<label class="ftog"><input type="checkbox" data-c="${i}" ${HIDDEN.has(String(i))?'':'checked'}> ${esc(h)}</label>`:'').join('');
+  buildFacets();
+  wireRail();
+}
+function buildFacets(){
+  const H=DATA.headers, host=$('#facets'); if(!host)return;
+  // rows passing OTHER filters (so counts reflect current narrowing)
+  const base=DATA.rows.filter(passes);
+  let html='';
+  H.forEach((h,ci)=>{
+    if(!h) return;
+    const tally={};
+    for(const r of base){ let v=norm(r[ci])||'(blank)'; tally[v]=(tally[v]||0)+1; }
+    const vals=Object.entries(tally).sort((a,b)=>b[1]-a[1]);
+    if(vals.length<=1 && vals[0] && vals[0][0]==='(blank)') return; // skip all-blank fields
+    const active=FILTERS[ci]&&FILTERS[ci].size;
+    const shown=vals.slice(0,40);
+    html+=`<details class="field ${active?'active':''}" data-c="${ci}"><summary>${esc(h)}<span class="cnt">${vals.length}</span></summary>
+      <div class="vals">${shown.map(([v,n])=>`<div class="val ${active&&FILTERS[ci].has(v)?'on':''}" data-v="${esc(v)}"><span class="bx"></span>${esc(v.length>34?v.slice(0,33)+'…':v)}<span class="n">${n}</span></div>`).join('')}
+      ${vals.length>40?`<div class="mini">+${vals.length-40} more…</div>`:''}</div></details>`;
   });
+  host.innerHTML=html||'<div class="hint" style="padding:6px">No filterable fields</div>';
+  host.querySelectorAll('.val').forEach(el=>el.onclick=()=>toggleFilter(+el.closest('.field').dataset.c, el.dataset.v));
+}
+function toggleFilter(ci,v){ FILTERS[ci]=FILTERS[ci]||new Set(); FILTERS[ci].has(v)?FILTERS[ci].delete(v):FILTERS[ci].add(v); if(!FILTERS[ci].size)delete FILTERS[ci]; buildFacets(); renderChips(); render(); }
+function renderChips(){ const c=$('#chips'); let h='';
+  for(const ci in FILTERS){ for(const v of FILTERS[ci]){ h+=`<span class="fchip" data-c="${ci}" data-v="${esc(v)}">${esc(DATA.headers[ci])}: ${esc(v.length>18?v.slice(0,17)+'…':v)}<b>✕</b></span>`; } }
+  c.innerHTML=h; c.querySelectorAll('.fchip').forEach(el=>el.onclick=()=>toggleFilter(+el.dataset.c, el.dataset.v));
 }
-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');
+function wireRail(){
+  $('#search').oninput=e=>{SEARCH=e.target.value;clearTimeout(window._st);window._st=setTimeout(()=>{buildFacets();render()},180)};
+  $('#sortsel').onchange=e=>{SORT=e.target.value===''?null:{col:+e.target.value,dir:SORT?SORT.dir:1};LS.set('sort',SORT);render()};
+  $('#sortdir').onclick=()=>{if(SORT){SORT.dir*=-1;LS.set('sort',SORT);$('#sortdir').textContent=SORT.dir<0?'▼':'▲';render()}};
+  $('#groupsel').onchange=e=>{GROUP=e.target.value;LS.set('group',GROUP);render()};
+  $('#dens').oninput=e=>{document.documentElement.style.setProperty('--fs',e.target.value+'px')};
+  $('#ftogs').querySelectorAll('input').forEach(cb=>cb.onchange=()=>{const i=cb.dataset.c;cb.checked?HIDDEN.delete(i):HIDDEN.add(i);LS.set('hidden.'+GID,[...HIDDEN]);render()});
+  $('#allf').onclick=()=>{HIDDEN.clear();LS.set('hidden.'+GID,[]);buildRail();render()};
+  $('#nonef').onclick=()=>{HIDDEN=new Set(DATA.headers.map((h,i)=>h?String(i):null).filter(x=>x));LS.set('hidden.'+GID,[...HIDDEN]);buildRail();render()};
 }
-setInterval(()=>{ if(Date.now()-lastPull>25000) $('#dot').classList.add('stale'); if(!editing) setSync('ok'); },1000);
 
 // ---- render: table | list | grid --------------------------------------------
-const MAXROWS=1200;
+function visCols(){return DATA.headers.map((h,i)=>({h,i})).filter(o=>o.h&&!HIDDEN.has(String(o.i)))}
 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(); applyGreen();
-  // 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){} }
+  const wrap=$('#wrap'); $$('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.v===VIEW));
+  const rows=filtered();
+  setSync('ok');
+  if(!rows.length){wrap.innerHTML='<div class="empty">No rows match the current filters.</div>';return}
+  const capped=rows.length>MAXROWS, show=rows.slice(0,MAXROWS);
+  const banner=capped?`<div class="empty" style="padding:8px;color:#d29922">Showing ${MAXROWS} of ${rows.length} matches — narrow with filters (left).</div>`:'';
+  wrap.innerHTML=banner+(VIEW==='table'?tableHTML(show):cardsHTML(show,VIEW));
+  wireGrid();
 }
-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, gr=GREENSET.has(ri+','+ci);
-      const cls=[isName?'namecell':'', gr?'enriched':''].filter(Boolean).join(' ');
-      h+='<td data-field="'+esc(DATA.headers[ci]||'')+'" data-r="'+ri+'" data-c="'+ci+'" contenteditable="true"'+
-         (cls?' class="'+cls+'"':'')+'>'+cellRender(c)+'</td>';
-    });
+function tableHTML(rows){
+  const cols=visCols();
+  let h='<table id="grid"><thead><tr><th></th>'+cols.map(c=>`<th data-c="${c.i}">${esc(c.h)}${SORT&&SORT.col===c.i?(SORT.dir<0?' ▼':' ▲'):''}</th>`).join('')+'</tr></thead><tbody>';
+  let lastG=null;
+  for(const [r,ri] of rows){
+    if(GROUP!==''){ const gv=g(r,+GROUP)||'(blank)'; if(gv!==lastG){ lastG=gv; h+=`<tr class="grouphdr"><td colspan="${cols.length+1}">${esc(gv)}</td></tr>`; } }
+    h+=`<tr><td><button class="rowbtn" data-r="${ri}">✍</button></td>`;
+    for(const c of cols){ const gr=GREENSET.has(ri+','+c.i), isN=c.i===ROLE.name;
+      h+=`<td data-r="${ri}" data-c="${c.i}" contenteditable="true" class="${[isN?'namecell':'',gr?'enriched':''].filter(Boolean).join(' ')}">${cellRender(g(r,c.i))}</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>';
-  });
+function cardsHTML(rows,mode){
+  let h=`<div class="cards ${mode}" id="grid">`;
+  for(const [r,ri] of rows){ const c=contact(r), any=[...GREENSET].some(k=>k.split(',')[0]==ri);
+    h+=`<div class="card ${any?'enriched-card':''}" data-r="${ri}"><button class="rowbtn act" data-r="${ri}">✍ Draft</button>
+      <div class="co">${esc(c.company||'—')}</div>${c.name?`<div class="nm">${esc(c.name)}</div>`:''}${c.position?`<div class="ro">${esc(c.position)}</div>`:''}
+      <div class="meta">${badge('✉',c.email)}${badge('☎',c.phone)}${badge('in',c.liC||c.liCo)}${badge('★',c.mvpN)}</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 badge(ic,v){return `<span class="badge${v?' ok':''}">${ic}${v?' ✓':''}</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 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 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();} });
-  });
+  $$('.rowbtn').forEach(b=>b.onclick=e=>{e.stopPropagation();openDraft(+b.dataset.r)});
+  $$('.card').forEach(c=>c.onclick=()=>openDraft(+c.dataset.r));
+  $$('#grid thead th[data-c]').forEach(th=>th.onclick=()=>{const ci=+th.dataset.c;SORT=(SORT&&SORT.col===ci)?{col:ci,dir:-SORT.dir}:{col:ci,dir:1};LS.set('sort',SORT);render();$('#sortsel')&&($('#sortsel').value=ci)});
+  $$('td[contenteditable]').forEach(td=>{td.addEventListener('focus',()=>{editing=true;td._o=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; GREENSET.add(r+','+c); td.classList.add('saved');
-      setTimeout(()=>{td.classList.remove('saved'); td.classList.add('enriched');},1200); }
-    else throw new Error(res.error||'write failed');
-  }catch(e){ td.textContent=td._orig||''; setSync('err',e.message); }
+async function saveCell(td){ editing=false; const v=td.textContent.trim(); if(v===String(td._o||'').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:v})})).json();
+    if(res.ok){DATA.rows[r][c]=v;GREENSET.add(r+','+c);td.classList.add('saved');setTimeout(()=>{td.classList.remove('saved');td.classList.add('enriched')},1000)}else throw new Error(res.error)}
+  catch(e){td.textContent=td._o||'';setSync('err')}
 }
 
 // ---- 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 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,l)=>{if(!v)return;F.insertAdjacentHTML('beforeend',`<div class="k">${k}</div><div>${l?`<a href="${esc(l)}" 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':(c.liCo?'Company':''),c.liC||c.liCo);add('Marketing/VP',[c.mvpN,c.mvpP].filter(Boolean).join(' — '),c.mvpL);
+  const av={email:!!c.email,linkedin:!!(c.liC||c.mvpL||c.liCo),phone:!!c.phone};
+  $$('#chanseg button').forEach(b=>b.classList.toggle('chdis',!av[b.dataset.c]));
+  curChan=av[curChan]?curChan:(av.email?'email':av.linkedin?'linkedin':av.phone?'phone':'email');
+  syncChan();regen();$('#scrim').style.display='block';$('#drawer').classList.add('open');
 }
-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 closeDraft(){$('#scrim').style.display='none';$('#drawer').classList.remove('open')}
+function syncChan(){$$('#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]),d=buildDraft(c,curChan,$('#purpose').value,$('#tone').value);$('#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 — featuring your deals and leadership on our broadcasts and co-marketing to 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 — on-camera visibility, stage presence, and direct access to 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 ${co} to join as a speaker.`,followup:`Following up on RENTV's outreach to ${co} — wanted to make sure this reached the right person and see if a short call this week makes sense.`}[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?\n\n${close}`,target:c.email||'(no email)'}}
+  if(chan==='linkedin'){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)'}}
+  return{subject:'',target:c.phone||'(no phone)',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: "Any 15 minutes this week to walk through it?"\n• Gatekeeper: ask for ${c.mvpN||'the marketing or partnerships lead'}.`}
 }
-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); GREENSET=new Set(); pull(true).then(pullFormat); };
-document.querySelectorAll('#viewseg button').forEach(b=> b.onclick=()=>{ VIEW=b.dataset.v; LS.set('view',VIEW); render(); });
-$('#refresh').onclick=()=>pull(true).then(pullFormat);
-$('#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(); });
+async function doEnhance(){const b=$('#enhance'),o=b.textContent;b.textContent='✨ thinking…';b.disabled=true;
+  try{const r=await (await fetch('/api/enhance',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt:`Rewrite this ${curChan} outreach so it sounds natural, concise, professional. Keep it truthful, sender "Steve Bloom, RENTV". Return ONLY the message.\n\n---\n${$('#d-body').value}`})})).json();
+    if(r.ok&&r.text)$('#d-body').value=r.text;else alert(r.error||'local model unavailable')}catch(e){alert('enhance failed')}finally{b.textContent=o;b.disabled=false}}
+function doGo(){const c=contact(DATA.rows[curRow]);if(curChan==='email'){if(!c.email)return alert('No email');location.href='mailto:'+c.email+'?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');navigator.clipboard?.writeText($('#d-body').value);window.open(u,'_blank','noopener')}else{if(!c.phone)return alert('No phone');location.href='tel:'+c.phone.replace(/[^0-9+]/g,'')}}
 
-// ---- boot --------------------------------------------------------------------
-(async()=>{ await loadMeta(); await pull(true); pullFormat(); setInterval(()=>pull(false),12000); })();
+// ---- events + boot -----------------------------------------------------------
+$('#tab').onchange=e=>{GID=+e.target.value;LS.set('gid',GID);loadTab()};
+$$('#viewseg button').forEach(b=>b.onclick=()=>{VIEW=b.dataset.v;LS.set('view',VIEW);render()});
+$('#railtog').onclick=()=>{$('#rail').classList.toggle('hide');if(!$('#rail').classList.contains('hide'))buildRail()};
+$('#refresh').onclick=async()=>{setSync('load');$('#refresh').textContent='↻ refreshing…';try{await fetch('/api/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});await loadMeta();$('#tab').value=GID;await loadTab()}catch(e){setSync('err')}$('#refresh').textContent='↻ Refresh'};
+$('#dclose').onclick=closeDraft;$('#scrim').onclick=closeDraft;
+$$('#chanseg button').forEach(b=>b.onclick=()=>{if(b.classList.contains('chdis'))return;curChan=b.dataset.c;syncChan();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()});
+(async()=>{await loadMeta();await loadTab()})();
 </script>
 </body>
 </html>
diff --git a/snapshot.py b/snapshot.py
new file mode 100644
index 0000000..9ca09ad
--- /dev/null
+++ b/snapshot.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""
+snapshot.py — pull the ENTIRE workbook into OUR OWN local copy (data/snapshot.json) in
+just a few batch reads, so the viewer never live-reads the Google Sheet per tab-switch
+(that was the 60-reads/min quota that blanked tabs). One values.batchGet for all tab
+values + one includeGridData get for all green (enriched) cell maps. Re-run to refresh.
+"""
+import lib, json, urllib.request, urllib.parse, time, os
+
+def api(url, tok):
+    r = urllib.request.Request(url, headers={"Authorization": f"Bearer {tok}"})
+    return json.load(urllib.request.urlopen(r))
+
+def rng(title):
+    return "ranges=" + urllib.parse.quote("'" + title.replace("'", "''") + "'", safe="")
+
+def build(tok=None):
+    tok = tok or lib.access_token()
+    sheets = [(s["properties"]["sheetId"], s["properties"]["title"])
+              for s in lib.get_meta(tok)["sheets"]]
+    # 1) all values in ONE call
+    qs = "&".join(rng(t) for _, t in sheets)
+    vals = api(f"{lib.API}/{lib.SID}/values:batchGet?{qs}&majorDimension=ROWS", tok)
+    value_ranges = vals.get("valueRanges", [])
+    # 2) all green (enriched) cell backgrounds in ONE call
+    G = lib.GREEN
+    def is_green(bg):
+        return bool(bg) and abs(bg.get("red", 1) - G["red"]) < 0.06 and \
+            abs(bg.get("green", 1) - G["green"]) < 0.06 and abs(bg.get("blue", 1) - G["blue"]) < 0.06
+    fmt = api(f"{lib.API}/{lib.SID}?{qs}&includeGridData=true"
+              "&fields=sheets(properties(sheetId),data.rowData.values.effectiveFormat.backgroundColor)", tok)
+    green_rows = {sh["properties"]["sheetId"]: (sh.get("data") or [{}])[0].get("rowData", [])
+                  for sh in fmt.get("sheets", [])}
+    def header_row(rows):
+        """The real header is the row (of the first 4) with the most non-empty cells —
+        so a merged TITLE row above the header (panels, ARIZONA BROKERAGE) is skipped."""
+        best, bi = -1, 0
+        for i in range(min(4, len(rows))):
+            n = sum(1 for c in rows[i] if str(c).strip())
+            if n > best:
+                best, bi = n, i
+        return bi
+    tabs = []
+    for (gid, title), vr in zip(sheets, value_ranges):
+        rows = vr.get("values", [])
+        if not rows:
+            tabs.append({"gid": gid, "title": title, "headers": [], "rows": [], "green": []})
+            continue
+        hr = header_row(rows)
+        width = max((len(r) for r in rows), default=0)
+        headers = [(rows[hr][i].strip() if i < len(rows[hr]) else "") for i in range(width)]
+        body = [[(r[i] if i < len(r) else "") for i in range(width)] for r in rows[hr + 1:]]
+        green = []
+        for ri, row in enumerate(green_rows.get(gid, [])):
+            if ri <= hr:  # header + any title rows above it
+                continue
+            for ci, cell in enumerate(row.get("values", []) or []):
+                if is_green((cell.get("effectiveFormat") or {}).get("backgroundColor")):
+                    green.append(f"{ri-hr-1},{ci}")
+        tabs.append({"gid": gid, "title": title, "headers": headers, "rows": body, "green": green})
+    return {"generated": int(time.time()), "tabs": tabs}
+
+def main():
+    snap = build()
+    os.makedirs("data", exist_ok=True)
+    json.dump(snap, open("data/snapshot.json", "w"))
+    print(f"snapshot: {len(snap['tabs'])} tabs · "
+          f"{sum(len(t['rows']) for t in snap['tabs'])} rows · "
+          f"{sum(len(t['green']) for t in snap['tabs'])} green cells")
+
+if __name__ == "__main__":
+    main()

← 48eb172 Split Contact Name -> First Name + Last Name across ALL 19 n  ·  back to Rentv Sheet Enrich Refine  ·  5x sweep: default landing tab = Unique Contacts (was tab[0] 4ad282d →