← back to Commercialrealestate

public/index.html

2560 lines

<!doctype html>
<html lang="en">
<head>
<script>/* credential-safe fetch (2026-08-18): resolve EVERY relative fetch (with or without a leading slash) against a credential-free base so creds embedded in the page URL (http://user:pass@host/...) don't poison document.baseURI and throw "Request cannot be constructed from a URL that includes credentials". */(function(){var of=window.fetch,O=location.origin;window.fetch=function(u,o){try{if(typeof u==='string'){if(/^[a-z][a-z0-9+.-]*:/i.test(u)){var x=new URL(u);if(x.username||x.password){x.username='';x.password='';u=x.href;}}else{u=new URL(u,O+location.pathname+location.search).href;}}else if(u&&typeof u.url==='string'){var y=new URL(u.url,O+location.pathname+location.search);if(y.username||y.password){y.username='';y.password='';u=new Request(y.href,u);}}}catch(e){}return of.call(this,u,o);};try{if(location.href.indexOf('@')>=0){history.replaceState(null,'',location.pathname+location.search+location.hash);}}catch(e){}})();</script>
<script>(function(){try{var t=localStorage.getItem('crcp-theme');document.documentElement.setAttribute('data-theme',t==='dark'?'dark':'light');}catch(e){document.documentElement.setAttribute('data-theme','light');}})();</script>
<!-- Self-heal: nav-agent grid controls can persist a "removed"/"collapsed" state that hides the
     whole grid with no obvious way back. Clear it before nav-agent runs so a grid can never stay hidden. -->
<script>(function(){try{for(var i=localStorage.length-1;i>=0;i--){var k=localStorage.key(i);if(k&&k.indexOf('navagent:')===0){var v=localStorage.getItem(k);if(v&&v.indexOf('"removed":true')>=0){localStorage.removeItem(k);}}}}catch(e){}})();</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230e1116'/%3E%3Ctext x='8' y='12' font-size='11' text-anchor='middle' fill='%233fb950'%3E%24%3C/text%3E%3C/svg%3E">
<title>LA County CRE — Investment Explorer</title>
<style>
  :root { --cols:3; --drawerW:316px; }
  * { box-sizing:border-box; }
  :focus-visible { outline:2px solid var(--blue); outline-offset:2px; border-radius:4px; }
  @media (prefers-reduced-motion: reduce){ *,*::before,*::after { animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; scroll-behavior:auto !important; } }
  body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }

  /* ---- top bar ---- */
  header { display:flex; align-items:center; gap:14px; padding:12px 20px; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:30; background:var(--headbg); backdrop-filter:blur(8px); }
  .burger { display:inline-flex; align-items:center; gap:8px; background:var(--card); color:var(--ink); border:1px solid var(--line); border-radius:10px; padding:9px 13px; font-size:15px; cursor:pointer; font-weight:600; }
  .burger:hover { border-color:var(--blue); }
  .fcount { display:none; background:var(--blue); color:#04122b; font-size:11px; font-weight:700; border-radius:20px; padding:1px 7px; }
  .fcount.on { display:inline-block; }
  /* TK-10883 — display-scale two-line title lockup. Line 1 = brand identity at true display
     weight/scale; line 2 = qualifier in muted slate, smaller, tight leading. Two lines also fix
     the mobile "LA County…" truncation of the old single 36-char line. */
  .htitle h1 { margin:0; font-size:34px; font-weight:800; letter-spacing:-.2px; line-height:1.06; display:flex; flex-direction:column; }
  .htitle h1 .tl1 { color:var(--ink); }
  .htitle h1 .tl2 { font-size:.56em; font-weight:600; color:var(--mut,#64748b); letter-spacing:.1px; margin-top:1px; }
  @media (max-width:1000px){ .htitle h1 { font-size:28px; } }
  @media (max-width:640px){  .htitle h1 { font-size:22px; } }
  .htitle .sub { color:var(--mut); font-size:12px; margin-top:4px; }
  header .spacer { margin-left:auto; }
  /* nav-link cluster; on wide screens it sits inline right-aligned, on tablets/phones it becomes a swipeable strip (see @media 1024) */
  .topnav { display:flex; align-items:center; flex-wrap:nowrap; }
  .seg { display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
  .seg button { background:var(--card); color:var(--ink); border:0; border-right:1px solid var(--line); padding:8px 11px; font-size:12px; cursor:pointer; }
  .seg button:last-child { border-right:0; }
  .seg button.active { background:#1f6feb; color:#fff; }
  #drawer .seg { display:flex; width:100%; }
  #drawer .seg button { flex:1; text-align:center; padding:9px 6px; }

  /* ---- left drawer ---- */
  #scrim { position:fixed; inset:0; background:rgba(0,0,0,.5); opacity:0; pointer-events:none; transition:opacity .2s; z-index:38; }
  #scrim.on { opacity:1; pointer-events:auto; }
  #drawer { position:fixed; top:0; left:0; height:100vh; width:var(--drawerW); background:var(--bg2); border-right:1px solid var(--line); transform:translateX(-100%); transition:transform .22s ease; z-index:40; overflow-y:auto; padding:0 0 40px; }
  #drawer.open { transform:translateX(0); box-shadow:6px 0 30px rgba(0,0,0,.4); }
  .dhead { display:flex; align-items:center; justify-content:space-between; padding:16px 18px 10px; position:sticky; top:0; background:var(--bg2); border-bottom:1px solid var(--line); z-index:2; }
  .dhead b { font-size:15px; } .dhead .x { background:none; border:0; color:var(--mut); font-size:18px; cursor:pointer; }
  .fsec { padding:14px 18px; border-bottom:1px solid var(--line); }
  .fsec h4 { margin:0 0 9px; font-size:11px; text-transform:uppercase; letter-spacing:.6px; color:var(--mut); }
  .chips { display:flex; flex-wrap:wrap; gap:6px; }
  .chip { background:var(--card); color:var(--mut); border:1px solid var(--line); border-radius:18px; padding:5px 11px; font-size:12px; cursor:pointer; user-select:none; }
  .chip:hover { color:var(--ink); }
  .chip.active { border-color:var(--blue); color:var(--blue); background:rgba(88,166,255,.08); }
  .chip .ct { color:var(--mut); font-size:10px; margin-left:4px; }
  .chip.zero { opacity:.4; }
  .row2 { display:flex; gap:8px; }
  .fld { flex:1; }
  .fld label { display:block; font-size:10px; color:var(--mut); margin-bottom:3px; }
  input[type=text], input[type=number], select { width:100%; background:var(--card); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font-size:13px; }
  input[type=range] { width:100%; accent-color:var(--blue); }
  .rangeval { font-size:11px; color:var(--blue); float:right; }
  .toggle { display:flex; align-items:center; gap:8px; font-size:13px; cursor:pointer; }
  .resetbtn { width:calc(100% - 36px); margin:14px 18px 0; background:transparent; color:var(--bad); border:1px solid #7d2b28; border-radius:9px; padding:9px; font-size:13px; cursor:pointer; }

  /* ---- results bar + grid ---- */
  .bar { display:flex; flex-wrap:wrap; align-items:center; gap:14px; padding:11px 20px; border-bottom:1px solid var(--line); position:sticky; top:57px; background:var(--headbg); backdrop-filter:blur(6px); z-index:20; }
  .bar .count { font-size:13px; color:var(--mut); } .bar .count b { color:var(--ink); }
  .bar label { font-size:11px; color:var(--mut); text-transform:uppercase; letter-spacing:.5px; }
  .bar .right { margin-left:auto; display:flex; align-items:center; gap:12px; flex-wrap:wrap; justify-content:flex-end; min-width:0; }  /* wrap so the search/heat/sort controls can't overflow under the right-hand #ctrlrail when main is narrow */
  .grid { display:grid; grid-template-columns:repeat(var(--cols),minmax(0,1fr)); gap:22px; padding:30px 32px 44px; }
  @media (max-width:900px){ .grid{ grid-template-columns:repeat(2,minmax(0,1fr));} }
  /* ---- iPad / tablet / narrow-desktop band (≤1024): keep header ONE fixed row so sticky offsets hold,
         turn the nav-link cluster into a swipeable horizontal strip instead of clipping off-screen. ---- */
  @media (max-width:1024px){
    .grid{ grid-template-columns:repeat(2,minmax(0,1fr)); }  /* docked-drawer band: 2 cols so cards aren't crushed (iPad landscape) */
    header{ flex-wrap:nowrap; gap:10px; }
    header .spacer{ display:none; }                    /* nav no longer right-pushed; topnav flexes to fill */
    .htitle{ flex:0 0 auto; }                          /* TK-10883: title holds its intrinsic width; the topnav strip (overflow-x:auto) absorbs the squeeze by scrolling, so the two-line lockup never gets crushed to an ellipsis */
    /* TK-10883 — the title is now a two-line lockup of short lines; DON'T nowrap/ellipsis the h1
       (that caused the old "LA County…" mobile truncation). Ellipsis stays on the sub only. */
    .htitle h1{ white-space:normal; overflow:visible; }
    .htitle h1 .tl1, .htitle h1 .tl2{ white-space:nowrap; overflow:visible; text-overflow:clip; }  /* TK-10883: each line is a short phrase — keep it on one line but NEVER clip/ellipsis (that was the mobile "LA County…" truncation) */
    .htitle .sub{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
    .topnav{ flex:1 1 auto; min-width:0; overflow-x:auto; overflow-y:hidden;
             -webkit-overflow-scrolling:touch; scrollbar-width:none; }  /* iOS momentum-scroll, hidden scrollbar */
    .topnav::-webkit-scrollbar{ display:none; }
    .topnav > a, .topnav > .seg{ flex:0 0 auto; }      /* items keep intrinsic width; strip scrolls, nothing clips */
    .topnav > a{ padding:8px 0; }                      /* taller touch target for finger taps */
    html, body{ overflow-x:hidden; max-width:100%; }   /* no horizontal page scroll on iPad (html is the scroller) */
    .bar{ max-width:100%; top:auto; }                   /* sort/density strip: no hairline overflow; un-stick so it can't tuck under the taller (burger+title) iPad header */
  }
  @media (max-width:620px){ .grid{ grid-template-columns:1fr;} }
  .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:24px; display:flex; flex-direction:column; gap:10px; position:relative; transition:box-shadow .2s,border-color .2s; }
  .card.uc { opacity:.62; }
  .card.flash { border-color:var(--blue); box-shadow:0 0 0 3px rgba(88,166,255,.35); }
  .top { display:flex; justify-content:space-between; align-items:flex-start; gap:10px; }
  .rank { font-size:12px; color:var(--mut); }
  .score { font-size:30px; font-weight:700; line-height:1; }
  .score small { font-size:11px; color:var(--mut); font-weight:400; display:block; text-align:right; }
  .addr { font-weight:600; font-size:15px; }
  .city { color:var(--mut); font-size:13px; }
  .badges { display:flex; flex-wrap:wrap; gap:6px; }
  .b { font-size:11px; padding:3px 8px; border-radius:20px; border:1px solid var(--line); color:var(--mut); }
  .b.type { color:var(--blue); border-color:#284b7a; }
  .b.deal-badge { font-weight:700; cursor:help; }
  .b.ok { color:var(--acc); border-color:#1f6f37; }
  .b.warn { color:var(--warn); border-color:#6b5320; }
  .b.bad { color:var(--bad); border-color:#7d2b28; }
  .b.rec-Buy { color:var(--acc); border-color:#1f6f37; font-weight:600; }
  .b.rec-Watch { color:var(--warn); border-color:#6b5320; font-weight:600; }
  .b.rec-Pass { color:var(--bad); border-color:#7d2b28; font-weight:600; }
  .metrics { display:grid; grid-template-columns:1fr 1fr; gap:6px 14px; font-size:13px; border-top:1px solid var(--line); border-bottom:1px solid var(--line); padding:10px 0; }
  .metrics div { display:flex; justify-content:space-between; }
  .metrics .k { color:var(--mut); }
  .metrics .v { font-variant-numeric:tabular-nums; font-weight:600; }
  .pos { color:var(--acc); } .neg { color:var(--bad); } .na { color:var(--mut); }
  .thesis { font-size:13px; color:var(--ink); }
  .lst { font-size:12px; color:var(--mut); margin:2px 0 0; padding-left:16px; }
  .src { font-size:12px; } .src a { color:var(--blue); text-decoration:none; }
  .lcbtn,.histbtn { background:transparent; color:var(--warn); border:1px dashed #6b5320; border-radius:14px; padding:2px 8px; font-size:11px; cursor:pointer; }
  .histbtn { color:var(--blue); border-color:#284b7a; }
  .lcout,.histout { font-size:11px; color:var(--mut); margin-top:6px; }
  .lcout a { color:var(--blue); text-decoration:none; }
  .lcout .lc-item { padding:4px 0; border-top:1px solid var(--line); }
  .small { font-size:11px; color:var(--mut); }
  .statusline { font-size:12px; font-weight:600; font-variant-numeric:tabular-nums; padding:5px 9px; border-radius:8px; border:1px solid var(--line); background:rgba(255,255,255,.02); }
  .statusline.ok { color:var(--acc); border-color:#1f6f37; }
  .statusline.warn { color:var(--warn); border-color:#6b5320; }
  .statusline.bad { color:var(--bad); border-color:#7d2b28; }
  .statusline.mut { color:var(--mut); }
  .statusline.none { color:var(--warn); border-color:#6b5320; border-style:dashed; }
  .foot { color:var(--mut); font-size:12px; padding:0 20px 8px; }
  .empty { padding:60px 20px; text-align:center; color:var(--mut); }

  /* ---- Broker-history + prior-broker flag ---- */
  .brokerflag { display:inline-flex; align-items:center; gap:5px; font-size:11px; padding:3px 9px; border-radius:20px; background:rgba(255,159,9,.10); color:#ff9f09; border:1px solid rgba(255,159,9,.35); font-weight:700; cursor:help; margin-top:2px; }
  .txhist { font-size:11px; color:var(--mut); margin-top:4px; line-height:1.5; }
  .txhist .tx-row { display:flex; gap:8px; align-items:baseline; padding:2px 0; border-bottom:1px dashed rgba(255,255,255,.05); }
  .txhist .tx-row:last-child { border-bottom:0; }
  .txhist .tx-date { color:var(--blue); font-variant-numeric:tabular-nums; min-width:72px; }
  .txhist .tx-ev { flex:1; }
  .txhist .tx-price { color:var(--acc); font-weight:600; font-variant-numeric:tabular-nums; }
  .brokerline { font-size:11px; padding:4px 9px; background:rgba(88,166,255,.06); border-radius:7px; border:1px solid rgba(88,166,255,.15); color:#a5c8ff; margin-top:3px; }
  .brokerline strong { color:var(--blue); }

  /* ---- docked chat ---- */
  #chatwrap { border-top:1px solid var(--line); background:var(--bg2); padding:16px 20px 40px; }
  #chatwrap h2 { font-size:15px; margin:0 0 2px; }
  #chatwrap .hint { color:var(--mut); font-size:12px; margin-bottom:10px; }
  #log { display:flex; flex-direction:column; gap:10px; max-height:42vh; overflow-y:auto; padding:4px 2px; }
  .msg { max-width:80%; padding:9px 13px; border-radius:12px; font-size:14px; line-height:1.45; white-space:pre-wrap; word-wrap:break-word; }
  .msg.u { align-self:flex-end; background:#1f6feb; color:#fff; border-bottom-right-radius:3px; }
  .msg.a { align-self:flex-start; background:var(--card); border:1px solid var(--line); border-bottom-left-radius:3px; }
  .msg.a code { background:var(--bg); padding:1px 5px; border-radius:4px; }
  .msg.sys { align-self:center; color:var(--mut); font-size:12px; background:transparent; }
  .actiontag { display:inline-block; margin-top:6px; font-size:11px; color:var(--acc); border:1px solid #1f6f37; border-radius:6px; padding:2px 7px; }
  #chatform { display:flex; gap:10px; margin-top:12px; }
  #q { flex:1; background:var(--card); color:var(--ink); border:1px solid var(--line); border-radius:10px; padding:11px 13px; font-size:14px; }
  #send { background:#1f6feb; color:#fff; border:0; border-radius:10px; padding:0 20px; font-size:14px; font-weight:600; cursor:pointer; }
  #send:disabled { opacity:.5; cursor:default; }
  .examples { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
  .ex { font-size:12px; color:var(--blue); border:1px dashed #284b7a; border-radius:16px; padding:4px 10px; cursor:pointer; background:transparent; }

  /* ---- view toggle (grid/list) ---- */
  .viewseg { display:inline-flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
  .viewseg button { background:var(--card); color:var(--mut); border:0; border-right:1px solid var(--line); padding:7px 12px; font-size:12px; cursor:pointer; }
  .viewseg button:last-child { border-right:0; }
  .viewseg button.active { background:#1f6feb; color:#fff; }

  /* ===== nav-agent inline views: compact List / KV / Table group headers ===== */
  .compacthost, .kvhost { padding:20px 32px 44px; }
  .compactlist { display:flex; flex-direction:column; gap:6px; }
  .crow { display:flex; align-items:center; gap:12px; background:var(--card); border:1px solid var(--line); border-radius:9px; padding:8px 12px; }
  .crow .starbtn { flex:0 0 auto; }
  .crow .cid { flex:0 0 220px; min-width:180px; }
  .crow .cla { display:block; font-weight:600; color:var(--ink); font-size:13.5px; }
  .crow .clc { display:block; color:var(--mut); font-size:11px; }
  .crow .cfields { flex:1; display:flex; flex-wrap:wrap; gap:6px 14px; align-items:center; min-width:0; }
  .cchip { font-size:12px; color:var(--ink); white-space:nowrap; }
  .cchip .cck { color:var(--mut); font-size:10px; text-transform:uppercase; letter-spacing:.3px; margin-right:3px; }
  .kvgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(320px,1fr)); gap:16px; }
  .kvcard { background:var(--card); border:1px solid var(--line); border-radius:11px; padding:12px 14px; }
  .kvhead { display:flex; align-items:center; gap:8px; margin-bottom:8px; padding-bottom:8px; border-bottom:1px solid var(--line); }
  .kvaddr { font-weight:600; font-size:14px; color:var(--ink); }
  .kvrow { display:flex; gap:10px; padding:3px 0; font-size:12.5px; border-bottom:1px solid rgba(127,127,127,.08); }
  .kvrow:last-child { border-bottom:0; }
  .kvk { flex:0 0 42%; color:var(--mut); text-transform:uppercase; font-size:10px; letter-spacing:.3px; padding-top:2px; }
  .kvv { flex:1; color:var(--ink); min-width:0; word-break:break-word; }
  .grouphead { padding:8px 12px; margin:10px 0 4px; background:rgba(88,166,255,.08); border-left:3px solid var(--blue); border-radius:4px; font-size:12px; color:var(--mut); text-transform:uppercase; letter-spacing:.4px; }
  .grouphead b { color:var(--ink); }
  .kvgrid .grouphead { grid-column:1/-1; }
  tr.grouprow td.groupcell { background:rgba(88,166,255,.10); font-size:11px; text-transform:uppercase; letter-spacing:.4px; color:var(--mut); padding:7px 10px; }
  tr.grouprow td.groupcell b { color:var(--ink); }

  /* ===== right-hand control rail (in-flow, NOT floating) — nav-agent skill surface ===== */
  #ctrlrail { flex:0 0 var(--railW,272px); width:var(--railW,272px); border-left:1px solid var(--line); background:var(--headbg); align-self:stretch; padding:12px 12px 40px; position:sticky; top:57px; max-height:calc(100vh - 57px); overflow-y:auto; }
  #ctrlrail .railtitle { font-size:11px; text-transform:uppercase; letter-spacing:.6px; color:var(--mut); margin:2px 4px 12px; display:flex; align-items:center; gap:6px; }
  .rpanel { border:1px solid var(--line); border-radius:9px; margin-bottom:8px; overflow:hidden; background:var(--card); }
  .rpanel > .rphead { width:100%; display:flex; align-items:center; justify-content:space-between; gap:8px; background:transparent; color:var(--ink); border:0; padding:10px 12px; font-size:12.5px; font-weight:600; cursor:pointer; text-align:left; }
  .rpanel > .rphead:hover { color:var(--blue); }
  .rpanel > .rphead .caret { color:var(--mut); font-size:11px; transition:transform .15s; }
  .rpanel.open > .rphead .caret { transform:rotate(90deg); color:var(--blue); }
  .rpanel > .rpbody { display:none; padding:4px 12px 12px; }
  .rpanel.open > .rpbody { display:block; }
  .rpbody select, .rpbody input[type=text] { width:100%; background:var(--bg); color:var(--ink); border:1px solid var(--line); border-radius:7px; padding:7px 9px; font-size:12px; margin-top:6px; }
  .rpbody .rlbl { font-size:11px; color:var(--mut); margin-top:8px; display:block; }
  .rpbody .ftog { display:flex; width:100%; margin:0 0 4px; }
  @media (max-width:1100px){ #ctrlrail { display:none; } }   /* on narrow screens the left drawer already carries these controls */
  /* ---- field show/hide toggles: one CARD per field, click to toggle on/off ---- */
  .fieldtoggles { display:flex; flex-wrap:wrap; gap:6px; max-height:360px; overflow-y:auto; padding:2px 4px 2px 0; }
  .ftog { display:inline-flex; align-items:center; gap:5px; font-size:12px; color:var(--mut); cursor:pointer; padding:5px 9px;
          border:1px solid var(--line); border-radius:9px; background:var(--card); user-select:none; transition:border-color .12s,color .12s,background .12s; }
  .ftog input { display:none; }                                   /* card IS the toggle; checkbox drives state via :has() */
  .ftog::before { content:'○'; font-size:11px; color:var(--mut); }
  .ftog:hover { border-color:var(--blue); color:var(--ink); }
  .ftog:has(input:checked) { border-color:var(--blue); color:var(--blue); background:rgba(88,166,255,.10); }
  .ftog:has(input:checked)::before { content:'●'; color:var(--blue); }
  .ftog .colbadge { color:var(--mut); font-size:10px; }
  /* panel drag-to-reorder (Fix 3) */
  .ftog .flbl { flex:1; }
  .ftog .fdrag { cursor:grab; color:var(--mut); font-size:12px; opacity:.5; user-select:none; line-height:1; margin-right:2px; }
  .ftog:hover .fdrag { opacity:1; }
  .ftog.fdragging { opacity:.45; }
  .ftog.fdropT { box-shadow:inset 0 3px 0 var(--gold); }
  .ftog.fdropB { box-shadow:inset 0 -3px 0 var(--gold); }
  .minibtn { background:var(--card); color:var(--mut); border:1px solid var(--line); border-radius:7px; padding:4px 9px; font-size:11px; cursor:pointer; margin-right:5px; }
  .minibtn:hover { border-color:var(--blue); color:var(--ink); }
  /* ---- list view ---- */
  .listhost { display:block; padding:20px 20px 30px; }
  .listwrap { overflow:auto; border:1px solid var(--line); border-radius:12px; }
  /* col-resize.js wraps the table in .cr-scroll (overflow:auto) — THAT is the th's nearest scroll
     container, so bounding ITS height (not .listwrap's) is what makes the sticky header (thead
     top:0) AND sticky first column (td.stick left:0) actually freeze while scrolling both axes.
     --tbl-top = the sticky app-chrome height above the table (tune per layout). */
  .cr-scroll { max-height:calc(100vh - var(--tbl-top, 178px)); }
  /* max-content so columns size to their content and expand left/right (no artificial squish);
     min-width:100% keeps it filling the pane when there are few columns; .listwrap scrolls-x. */
  .listtbl { width:max-content; min-width:100%; border-collapse:collapse; font-size:12.5px; white-space:nowrap; }
  .listtbl th, .listtbl td { text-align:right; padding:9px 13px; border-top:1px solid var(--line); }
  .listtbl th { position:sticky; top:0; background:var(--bg2); color:var(--mut); font-size:10px; text-transform:uppercase; letter-spacing:.5px; border-top:0; z-index:2; }
  .listtbl td.stick, .listtbl th.stick { text-align:left; position:sticky; left:0; background:var(--card); z-index:1; }
  .listtbl th.stick { background:var(--bg2); z-index:3; }  /* corner = header row + first col: highest */
  .listtbl th.sortable { cursor:pointer; } .listtbl th.sortable:hover { color:var(--ink); }
  /* sort arrow via CSS ::after (not literal text) so the header TEXT stays stable and col-resize's tableSig-keyed saved widths survive re-render (TK-10089) */
  .listtbl th.asc::after { content:' ▲'; color:var(--gold); } .listtbl th.desc::after { content:' ▼'; color:var(--gold); }
  /* draggable column reorder on the PRIMARY list table (Steve list-build rule) */
  .listtbl th.dragcol { cursor:grab; }
  .listtbl th.dragging { opacity:.45; }
  .listtbl th.dropL { box-shadow:inset 3px 0 0 var(--blue); }
  .listtbl th.dropR { box-shadow:inset -3px 0 0 var(--blue); }
  .listtbl tr.lrow:hover td { background:rgba(88,166,255,.06); }
  .listtbl tr.lrow:hover td.stick { background:var(--card); }
  .listtbl .la { font-weight:600; color:var(--ink); text-decoration:none; }
  .listtbl .la:hover { color:var(--blue); }
  .listtbl .lc { color:var(--mut); font-size:11px; }
  .listtbl .lsrc { margin-top:2px; }
  .srclink { color:var(--blue); text-decoration:none; font-size:11px; font-weight:600; } .srclink:hover { text-decoration:underline; }
  .listtbl .flink { color:var(--ink); text-decoration:none; border-bottom:1px dotted #3a4453; }
  .listtbl .flink:hover { color:var(--blue); border-bottom-color:var(--blue); }
  .listtbl tr.lrow { cursor:pointer; }
  .listtbl .exptog { display:inline-block; width:11px; color:var(--mut); font-size:10px; transition:transform .15s; }
  .listtbl tr.lrow.open td.stick { background:var(--card); }
  .listtbl tr.lexp > td { padding:0; border-top:0; }
  .listtbl tr.lexp .lexpbody { max-height:0; overflow:hidden; transition:max-height .25s ease; background:var(--bg); }
  .listtbl tr.lexp.open .lexpbody { max-height:1200px; padding:12px 16px; border-top:1px solid var(--line); }
  .lexpbody .src { text-align:left; white-space:normal; font-size:12px; }
  .lexpbody .deep { text-align:left; white-space:normal; margin-top:8px; display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
  .lexpbody .deeplbl { color:var(--mut); font-size:10px; text-transform:uppercase; letter-spacing:.5px; margin-right:2px; }
  .lexpbody .deep a, .lexpbody .deep button { font-size:11px; padding:3px 9px; border-radius:14px; border:1px solid var(--line); background:var(--card); color:var(--ink); text-decoration:none; cursor:pointer; }
  .lexpbody .deep a:hover, .lexpbody .deep button:hover { border-color:var(--blue); color:var(--blue); }
  .lexpbody .lcout, .lexpbody .histout { text-align:left; white-space:normal; }
  /* Agent · contact column cell */
  .listtbl td .agentcell { text-align:left; white-space:normal; min-width:170px; display:flex; flex-direction:column; gap:3px; }
  .agentcell .amut { color:var(--mut); font-style:italic; font-size:11px; }
  .agentcell .ainrow { display:flex; flex-wrap:wrap; gap:8px; }
  .agentcell .ain { color:var(--mut); text-decoration:none; font-size:11px; }
  .agentcell .ain:hover { color:var(--blue); }
  .ain a { color:var(--blue); text-decoration:none; } .ain a:hover { text-decoration:underline; }
  .tierb { font-size:9px; text-transform:uppercase; letter-spacing:.4px; color:var(--mut); border:1px solid var(--line); border-radius:4px; padding:0 4px; vertical-align:middle; }
  .pocketbadge { font-size:11px; font-weight:600; color:var(--gold,#d29922); background:rgba(210,153,34,.12); border:1px solid rgba(210,153,34,.4); border-radius:5px; padding:1px 7px; white-space:nowrap; }
  .agentcell .chiprow:empty { display:none; }
  .agentcell .achip { display:inline-block; font-size:10px; color:var(--mut); background:var(--card); border:1px solid var(--line); border-radius:10px; padding:1px 6px; margin-right:4px; }
  .agentcell .achip.done { color:#3fb950; border-color:#264f2e; }
  .agentcell .acts { display:flex; flex-wrap:wrap; gap:5px; margin-top:2px; }
  .abtn { font-size:10.5px; padding:2px 8px; border-radius:12px; border:1px solid var(--line); background:var(--card); color:var(--ink); cursor:pointer; }
  .abtn:hover { border-color:var(--blue); color:var(--blue); }
  .abtn.contactbtn { border-color:#2f5233; color:#8fdfa0; }
  .abtn.markbtn { border-color:#284b7a; color:#8ec0ff; }
  /* CRM block inside the inline panel */
  .lexpbody .crm { margin-top:10px; border-top:1px solid var(--line); padding-top:10px; text-align:left; }
  .crm-info { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
  .crm-info .crmf { background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--ink); font-size:12px; padding:5px 8px; min-width:150px; }
  .crm-info .crmf:focus { outline:none; border-color:var(--blue); }
  .crm-acts { display:flex; flex-wrap:wrap; gap:6px; align-items:center; margin-top:8px; }
  .crm-letters { margin-top:10px; }
  .crm-letters .lett { border:1px solid var(--line); border-radius:8px; padding:7px 9px; margin-top:6px; background:var(--bg); }
  .crm-letters .letth { display:flex; align-items:center; gap:8px; font-size:12px; }
  .crm-letters .letts { color:var(--mut); font-size:10px; }
  .crm-letters .letdel { margin-left:auto; background:transparent; border:0; color:var(--mut); cursor:pointer; font-size:12px; }
  .crm-letters .letdel:hover { color:var(--bad,#f85149); }
  .crm-letters .lettb { white-space:pre-wrap; font-size:11.5px; color:var(--mut); margin-top:4px; }
  /* Compose-letter modal */
  .composeM { position:fixed; inset:0; background:rgba(0,0,0,.55); display:none; align-items:center; justify-content:center; z-index:9000; }
  .composeM.on { display:flex; }
  .composeCard { background:var(--card); border:1px solid var(--line); border-radius:14px; width:min(620px,92vw); max-height:88vh; overflow:auto; padding:16px; box-shadow:0 12px 40px rgba(0,0,0,.5); }
  .composeH { display:flex; align-items:center; font-size:14px; margin-bottom:8px; }
  .composeH .composeX { margin-left:auto; background:transparent; border:0; color:var(--mut); font-size:16px; cursor:pointer; }
  .composeGate { font-size:11px; color:#e3b341; background:#241d0e; border:1px solid #5a4a1e; border-radius:8px; padding:6px 9px; margin-bottom:10px; }
  .composeCard input, .composeCard textarea { width:100%; box-sizing:border-box; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--ink); font-size:13px; padding:8px 10px; margin-bottom:8px; font-family:inherit; }
  .composeCard textarea { resize:vertical; line-height:1.5; }
  .composeActs { display:flex; gap:8px; justify-content:flex-end; }
  .composeActs button { font-size:12px; padding:6px 14px; border-radius:10px; border:1px solid var(--line); background:var(--card); color:var(--ink); cursor:pointer; }
  .composeActs button.primary { border-color:#2f5233; color:#8fdfa0; }
  .composeActs button.gated { border-color:#5a4a1e; color:#e3b341; }
  .composeActs button.gated:hover:not([disabled]) { border-color:#e3b341; }
  .composeActs button[disabled] { opacity:.4; cursor:not-allowed; }
  /* Tabbed agent window */
  .composeCard.tabwin { width:min(680px,94vw); }
  .composeH .winmeta { color:var(--mut); font-size:11px; font-weight:400; margin-left:10px; }
  .tabbar { display:flex; gap:2px; border-bottom:1px solid var(--line); margin:4px 0 12px; flex-wrap:wrap; }
  .tabt { background:transparent; border:0; border-bottom:2px solid transparent; color:var(--mut); font-size:12.5px; padding:7px 12px; cursor:pointer; }
  .tabt:hover { color:var(--ink); }
  .tabt.active { color:var(--ink); border-bottom-color:var(--blue); font-weight:600; }
  .tabpane { display:none; }
  .tabpane.active { display:block; }
  .tabmut { color:var(--mut); font-size:12px; font-style:italic; padding:8px 2px; }
  .cm-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-top:10px; }
  .tabpane .ainrow { display:flex; flex-wrap:wrap; gap:14px; margin:8px 0; }
  .tabpane .ain { color:var(--mut); text-decoration:none; font-size:12px; }
  .tabpane .ain:hover { color:var(--blue); }
  .findgrid { display:flex; flex-direction:column; gap:7px; }
  .findlink { display:inline-block; background:var(--card); border:1px solid var(--line); border-radius:8px; color:var(--ink); text-decoration:none; font-size:12.5px; padding:8px 11px; cursor:pointer; text-align:left; }
  .findlink:hover { border-color:var(--blue); color:var(--blue); }
  .findlink.findloop { padding:8px 11px; }
  .findlink.findloop a { color:inherit; text-decoration:none; }
  .crmToast { position:fixed; bottom:22px; left:50%; transform:translateX(-50%) translateY(12px); background:var(--bg2); border:1px solid var(--line); color:var(--ink); font-size:12.5px; padding:9px 16px; border-radius:20px; opacity:0; transition:opacity .2s,transform .2s; z-index:9500; pointer-events:none; }
  .crmToast.on { opacity:1; transform:translateX(-50%) translateY(0); }
  /* Card-view agent-contact block (every card, every asset type) */
  .card .cardagent { margin-top:8px; border-top:1px solid var(--line); padding-top:8px; font-size:12px; }
  .card .cardagent .ca-top { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
  .card .cardagent .ainrow { display:flex; flex-wrap:wrap; gap:10px; margin-top:3px; }
  .card .cardagent .ain { color:var(--mut); text-decoration:none; font-size:11px; }
  .card .cardagent .ain:hover { color:var(--blue); }
  .card .cardagent .acts { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
  /* Full contact modal info row */
  .composeCard .cm-info { display:flex; flex-wrap:wrap; gap:6px; align-items:center; margin-bottom:10px; }
  .composeCard .cm-info .crmf { background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--ink); font-size:12px; padding:6px 8px; min-width:140px; flex:1 1 140px; margin:0; }
  .composeCard .cm-info .crmf:focus { outline:none; border-color:var(--blue); }
  .composeH .flink { font-size:11px; font-weight:400; margin-left:8px; }
  /* Inline (no-outside-link) agent bits */
  .aname { font-weight:600; color:var(--ink); }
  a.aname { text-decoration:none; cursor:pointer; } a.aname:hover { color:var(--blue); text-decoration:underline; }
  .flink { cursor:pointer; }
  .listtbl td .flink { border-bottom:1px dotted #3a4453; }
  .listtbl td .flink:hover { color:var(--blue); border-bottom-color:var(--blue); }
  /* ── drill atom: every displayed data point filters to its deeper view ── */
  .drill { cursor:pointer; border-bottom:1px dotted transparent; }
  .drill:hover { border-bottom-color:currentColor; filter:brightness(1.15); }
  .badges .b.drill:hover { border-bottom-color:currentColor; }
  .city .drill { color:inherit; }
  .city .drill:hover { color:var(--blue); border-bottom-color:var(--blue); }
  .copyb { background:transparent; border:0; color:var(--mut); cursor:pointer; font-size:11px; padding:0 3px; border-radius:4px; }
  .copyb:hover { color:var(--blue); }
  .crm-note, .lexpbody .crm-note { margin-top:6px; font-size:12px; color:var(--mut); white-space:pre-wrap; background:var(--bg); border:1px solid var(--line); border-radius:8px; padding:6px 9px; text-align:left; }
  .composeCard .cm-notes { margin-bottom:10px; }
  .composeCard .cm-notes .deeplbl { margin-bottom:4px; }
  .composeCard .cm-notes textarea { width:100%; box-sizing:border-box; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--ink); font-size:12.5px; padding:7px 9px; font-family:inherit; resize:vertical; line-height:1.5; }
  .composeCard .cm-notes textarea:focus { outline:none; border-color:var(--blue); }
  /* ---- Amazon-style docked left filter rail (desktop) ---- */
  .shell { display:flex; align-items:flex-start; }
  main { flex:1 1 auto; min-width:0; }
  @media (min-width:901px){
    #burger { display:none; }
    #scrim { display:none !important; }
    #drawer { position:sticky; top:57px; height:calc(100vh - 57px); transform:none; flex:0 0 var(--drawerW); box-shadow:none; }
    #drawer .dhead .x { display:none; }
  }
  /* collapsible filter tabs */
  .fsec > h4 { cursor:pointer; display:flex; align-items:center; justify-content:space-between; user-select:none; }
  .fsec > h4::after { content:'▾'; color:var(--mut); font-size:11px; transition:transform .15s; margin-left:8px; }
  .fsec.collapsed > h4::after { transform:rotate(-90deg); }
  .fsec.collapsed > *:not(h4) { display:none !important; }
  .assessline { border-top:1px dashed rgba(255,255,255,.06); padding-top:6px; margin-top:2px; }
  /* active-filter summary bar */
  .activebar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:10px 20px; border-bottom:1px solid var(--line); background:rgba(88,166,255,.03); }
  .activebar:empty { display:none; }
  .activebar .lbl { font-size:11px; color:var(--mut); text-transform:uppercase; letter-spacing:.5px; }
  .afchip { display:inline-flex; align-items:center; gap:6px; background:rgba(88,166,255,.10); border:1px solid rgba(88,166,255,.4); color:var(--blue); border-radius:16px; padding:4px 10px; font-size:12px; cursor:pointer; }
  .afchip:hover { background:rgba(88,166,255,.2); } .afchip .xx { opacity:.65; font-weight:700; }
  .afreset { background:transparent; border:1px solid #7d2b28; color:var(--bad); border-radius:14px; padding:4px 11px; font-size:12px; cursor:pointer; margin-left:auto; }
  .afreset:hover { background:rgba(248,81,73,.1); }
  /* shortlist stars */
  .starbtn { background:none; border:0; color:var(--mut); font-size:18px; line-height:1; cursor:pointer; padding:0; }
  .starbtn:hover, .starbtn.on { color:var(--warn); }
  .copybtn { background:none; border:0; color:var(--mut); font-size:14px; line-height:1; cursor:pointer; padding:0; }
  .copybtn:hover { color:var(--blue); }
  .listtbl .starbtn { font-size:14px; margin-right:5px; vertical-align:middle; }
  #shortbtn.shorton { background:rgba(210,153,34,.14); border-color:var(--warn); color:var(--warn); }
  /* aggregate stats strip */
  .statsbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding:9px 20px; border-bottom:1px solid var(--line); }
  .statsbar:empty { display:none; }
  .statpill { font-size:12px; color:var(--ink); background:var(--card); border:1px solid var(--line); border-radius:8px; padding:4px 10px; }
  .statpill .sl { color:var(--mut); } .statpill b { font-variant-numeric:tabular-nums; }
  .heatlegend { display:none; align-items:center; gap:5px; font-size:11px; color:var(--mut); }
  .heatlegend.on { display:inline-flex; }
  .heatlegend .bar { width:56px; height:8px; border-radius:4px; background:linear-gradient(90deg,hsl(0,70%,45%),hsl(60,70%,45%),hsl(120,70%,45%)); }
  .showmore-wrap { grid-column:1/-1; text-align:center; padding:20px 0 6px; }
  .showmore { background:var(--card); border:1px solid var(--line); color:var(--blue); border-radius:10px; padding:9px 20px; font-size:13px; cursor:pointer; }
  .showmore:hover { border-color:var(--blue); }
  .quickstart { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin:16px 0; }
  .qchip { background:var(--card); border:1px solid var(--line); color:var(--ink); border-radius:18px; padding:7px 14px; font-size:13px; cursor:pointer; }
  .qchip:hover { border-color:var(--blue); color:var(--blue); }
  .savedrow { display:flex; gap:6px; align-items:center; margin-bottom:5px; }
  .savedname { flex:1; text-align:left; background:var(--card); border:1px solid var(--line); color:var(--ink); border-radius:8px; padding:6px 9px; font-size:12px; cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
  .savedname:hover { border-color:var(--blue); color:var(--blue); }
  .saveddel { background:none; border:0; color:var(--mut); cursor:pointer; font-size:13px; }
  .saveddel:hover { color:var(--bad); }
  #savebtn { width:100%; background:transparent; border:1px dashed #284b7a; color:var(--blue); border-radius:8px; padding:7px; font-size:12px; cursor:pointer; margin-top:4px; }
  .savedempty { color:var(--mut); font-size:11px; }
  /* compare overlay */
  #cmpov { display:none; position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:60; align-items:flex-start; justify-content:center; padding:40px 14px; overflow:auto; }
  #cmpov.on { display:flex; }
  #cmpmodal { width:min(1100px,97vw); background:var(--card); border:1px solid var(--line); border-radius:15px; padding:20px 22px; position:relative; }
  .cmpx { position:absolute; top:12px; right:14px; background:none; border:0; color:var(--mut); font-size:22px; cursor:pointer; }
  .cmptbl { width:100%; border-collapse:collapse; font-size:13px; }
  .cmptbl th, .cmptbl td { padding:8px 11px; border-top:1px solid var(--line); text-align:right; white-space:nowrap; }
  .cmptbl thead th { position:sticky; top:0; background:var(--card); text-align:right; vertical-align:bottom; color:var(--ink); }
  .cmptbl .cmpk, .cmptbl thead th.cmpk { text-align:left; color:var(--mut); position:sticky; left:0; background:var(--card); }
  .cmptbl thead th .cmpsub { color:var(--mut); font-size:11px; font-weight:400; }
  .cmphint { color:var(--mut); padding:24px 4px; }
  .cmptools { display:flex; gap:8px; justify-content:flex-end; margin-bottom:10px; }
  .cmptools button { background:var(--card); border:1px solid var(--line); color:var(--blue); border-radius:8px; padding:6px 12px; font-size:12px; cursor:pointer; }
  .cmptools button:hover { border-color:var(--blue); }
  @media print {
    body.printing-compare * { visibility:hidden; }
    body.printing-compare #cmpov, body.printing-compare #cmpov * { visibility:visible; }
    body.printing-compare #cmpov { position:absolute; inset:0; background:#fff; display:block; padding:0; overflow:visible; }
    body.printing-compare #cmpmodal { width:100%; box-shadow:none; border:0; background:#fff; color:#000; }
    body.printing-compare #cmpmodal h3 { color:#000; }
    body.printing-compare .cmpx, body.printing-compare .no-print { display:none !important; }
    body.printing-compare .cmptbl th, body.printing-compare .cmptbl td { color:#000; border-color:#999; }
    body.printing-compare .cmptbl thead th, body.printing-compare .cmptbl .cmpk { background:#fff; color:#000; }
    body.printing-compare .cmpsub { color:#444; }
  }
</style>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-7R2X3B2V18"></script><script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','G-7R2X3B2V18');</script>
<link rel="stylesheet" href="/crcp-theme.css">
  <!-- <link rel="stylesheet" href="/nav-agent/nav-agent.css"> nav-agent removed (no floating pop-out) -->
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
  <button class="burger" id="burger" aria-label="Open filters">☰ Filters <span class="fcount" id="fcount">0</span></button>
  <div class="htitle">
    <h1><span class="tl1">LA County CRE</span><span class="tl2">Investment Explorer</span></h1>
    <div class="sub" id="sub">loading…</div>
  </div>
  <div class="spacer"></div>
  <div class="topnav">
  <a href="/crcp.html" style="color:var(--warn);text-decoration:none;font-size:13px;margin-right:12px" title="Commercial Real Estate Control Panel (live)">🛰️ CRCP</a>
  <a href="/map.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="All properties on a map, colored by type, with overhead aerial photos">🗺️ Map</a>
  <a href="/mls.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Spreadsheet-style listings + grid — all property data, sortable & searchable">📋 Listings</a>
  <a href="/valley-pools.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="San Fernando Valley single-family pool homes under $1.2M — deal-colored map + $/sqft + price-cut signals + payment estimate">🏊 Valley Pools</a>
  <a href="/deals.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Liquid-glass deal-intelligence charts">◆ Deals</a>
  <a href="/deals-flow.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Closed deal flow — public-record sales + FHA + SEC REIT, source-badged">💰 Deal Flow</a>
  <a href="/rent-rolls.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Rent-roll → DSCR underwriting">📑 Rent Rolls</a>
  <a href="/broker-grid.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="All brokers/agents — grid + list, sortable by every field">👥 Broker grid</a>
  <a href="/brokers.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Broker / agent relationship mind-map">🕸️ Broker map</a>
  <a href="/graphics.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Broker / firm / warrantability charts">📊 Graphics</a>
  <a href="/condos.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="LA condos — FHA warrantability">🏢 Condos</a>
  <a href="/fha-loans.html" style="color:var(--gold,#e9c46a);text-decoration:none;font-size:13px;margin-right:12px" title="HUD/FHA multifamily loans originally ≤3.5% — historic-low-rate flagged">◆ FHA Loans</a>
  <a href="/broker-grid.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Broker / agent contacts — name, company, phone, email">👤 Brokers</a>
  <a href="/linkedin.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="LinkedIn follow-list">🔗 LinkedIn</a>
  <a href="/licensed.html" style="color:var(--blue);text-decoration:none;font-size:13px;margin-right:12px" title="Licensed directory — RE agents, firms, title & escrow + firm IG">📋 Licensed</a>
  </div><!-- /.topnav -->
</header>

<!-- Real natural-language chat bar at the TOP — "4 units in los angeles" → drives filters + re-renders (client-side, no server) -->
<div id="chatbar" style="padding:9px 18px 10px;border-bottom:1px solid var(--line);background:var(--bg2,var(--card));position:sticky;top:57px;z-index:20;">
  <div style="display:flex;align-items:center;gap:9px;max-width:860px;margin:0 auto;width:100%;">
    <span style="font-size:16px" title="Ask for properties in plain English">💬</span>
    <input id="chatq" type="text" autocomplete="off" spellcheck="false"
      placeholder="Ask for what you want — e.g.  “4 units in Los Angeles”  ·  “multifamily under $2M”  ·  “retail 6% cap”  ·  “all cash”  (Enter)"
      style="flex:1;min-width:0;background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:9px 13px;font-size:14px;">
    <button id="chatgo" class="ex" title="Apply this request">Ask ▸</button>
    <button id="chatclear" class="ex" title="Clear the chat filters">Clear</button>
    <span id="chatmsg" style="font-size:12px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:22%"></span>
  </div>
  <div id="chatex" style="display:flex;flex-wrap:wrap;gap:6px;margin:7px auto 0;max-width:860px;justify-content:center;font-size:11px;color:var(--mut);align-items:center;">
    <span style="opacity:.7">Try:</span>
    <button class="chatchip" data-q="4 units in Los Angeles">4 units in Los Angeles</button>
    <button class="chatchip" data-q="unwarrantable condos">unwarrantable condos</button>
    <button class="chatchip" data-q="multifamily under $2M">multifamily under $2M</button>
    <button class="chatchip" data-q="retail 6% cap">retail 6% cap</button>
    <button class="chatchip" data-q="office in Pasadena">office in Pasadena</button>
    <button class="chatchip" data-q="non-QM condos">non-QM condos</button>
    <button class="chatchip" data-q="built after 2000">built after 2000</button>
    <button class="chatchip" data-q="all cash">all cash</button>
  </div>
</div>
<style>.chatchip{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:20px;padding:3px 11px;font-size:11px;cursor:pointer;transition:all .12s;} .chatchip:hover{border-color:var(--blue,#3b82f6);color:var(--blue,#3b82f6);}</style>

<div id="scrim"></div>
<div class="shell">
<aside id="drawer">
  <div class="dhead"><b>Filters</b><button class="x" id="dclose" aria-label="Close">✕</button></div>

  <div class="fsec"><h4>Financing scenario</h4>
    <span class="seg" id="scenario">
      <button data-s="25" class="active">25% down</button>
      <button data-s="30">30% down</button>
      <button data-s="cash">All cash</button>
    </span>
    <div class="small" style="margin-top:6px">Underwriting lens — drives cash-on-cash, DSCR, cash-needed &amp; Deal Score across the view.</div>
  </div>
  <div class="fsec" id="fieldsSec"><h4>Fields — show / hide · reorder</h4>
    <div class="fieldtoggles" id="fFields"></div>
    <div style="margin-top:8px"><button class="minibtn" id="fAll">All</button><button class="minibtn" id="fNone">None</button><button class="minibtn" id="fDef">Default</button><button class="minibtn" id="idxReset" title="Reset List columns to the tight default — order, widths &amp; visibility">↺ Reset layout</button></div>
    <div class="small" style="margin-top:6px">Drag a row by its ⠿ handle to reorder columns. ▤ = also a column in List view · drag a List header to reorder · drag its right edge to resize</div>
  </div>
  <div class="fsec"><h4>Saved searches</h4><div id="savedList"></div><button id="savebtn">💾 Save current view</button></div>
  <div class="fsec"><h4>Asset type</h4><div class="chips" id="fType"></div></div>
  <div class="fsec"><h4>Listing firm</h4><div class="chips" id="fFirm"></div></div>
  <div class="fsec"><h4>Submarket / city</h4>
    <input type="text" id="fCity" placeholder="search city or street… e.g. Encino, Ventura Blvd">
    <div class="chips" id="fCityChips" style="margin-top:8px"></div>
  </div>
  <div class="fsec"><h4>Price</h4>
    <div class="row2"><div class="fld"><label>Min $</label><input type="number" id="fPriceMin" placeholder="any" step="50000"></div>
    <div class="fld"><label>Max $</label><input type="number" id="fPriceMax" placeholder="any" step="50000"></div></div>
  </div>
  <div class="fsec"><h4>Cap rate <span class="rangeval" id="capVal">any</span></h4>
    <input type="range" id="fCap" min="0" max="10" step="0.25" value="0">
    <div class="small">minimum broker cap rate (0 = any)</div>
  </div>
  <div class="fsec"><h4>Units / size</h4>
    <div class="row2"><div class="fld"><label>Min units</label><input type="number" id="fUnits" placeholder="any" min="0" step="1"></div>
    <div class="fld"><label>Max units</label><input type="number" id="fUnitsMax" placeholder="any" min="0" step="1"></div></div>
    <div class="row2" style="margin-top:8px"><div class="fld"><label>Built after</label><input type="number" id="fYear" placeholder="any" min="1900" max="2026" step="1"></div>
    <div class="fld"><label>Built before</label><input type="number" id="fYearMax" placeholder="any" min="1900" max="2026" step="1"></div></div>
  </div>
  <div class="fsec"><h4>Status &amp; budget</h4>
    <div class="chips" id="fStatus">
      <span class="chip" data-st="all">All</span>
      <span class="chip" data-st="active">Active only</span>
    </div>
    <label class="toggle" style="margin-top:10px"><input type="checkbox" id="fAfford"> In budget ($400k down) only</label>
    <!-- Broker/firm contactability filters (Steve 2026-08-18): narrow to listings whose resolved broker/firm contact has a phone / email — so a reachable contact exists before working the lead. -->
    <label class="toggle" style="margin-top:10px"><input type="checkbox" id="fHasPhone"> Has broker phone</label>
    <label class="toggle" style="margin-top:6px"><input type="checkbox" id="fHasEmail"> Has broker email</label>
  </div>
  <div class="fsec"><h4>Sort &amp; density</h4>
    <select id="sort">
      <option value="deal">Deal Score ↓</option>
      <option value="composite">Best Investment (blended)</option>
      <option value="coc">Cash-on-Cash ↓</option>
      <option value="yield">Rent Yield (rent÷price) ↓</option>
      <option value="cap">Cap Rate ↓</option>
      <option value="dscr">DSCR ↓</option>
      <option value="priceAsc">Price ↑</option>
      <option value="priceDesc">Price ↓</option>
      <option value="cashNeeded">Cash Needed ↑</option>
      <option value="unitsDesc">Units ↓</option>
      <option value="rentRoi">Pro-forma Rent ROI ↓</option>
    </select>
    <div style="margin-top:10px"><label class="small">Cards per row (grid) <span class="rangeval" id="densVal">3</span></label>
      <input type="range" id="density" min="1" max="10" step="1" value="3"></div>
  </div>
  <button class="resetbtn" id="reset">Reset all filters</button>
</aside>

<main>
  <!-- ===== Brokers & agents — front-page contacts (name / company / phone / email) ===== -->
  <style>
    #brokers{background:var(--card);border:1px solid var(--line);border-radius:13px;padding:14px 16px;margin-bottom:18px;}
    #brokers h2{margin:0 0 2px;font-size:14px;text-transform:uppercase;letter-spacing:.6px;color:var(--warn);}
    #brokers .bksub{color:var(--mut);font-size:12px;margin-bottom:12px;}
    #bktbl{width:100%;border-collapse:collapse;font-size:13px;}
    #bktbl td,#bktbl th{text-align:left;padding:7px 8px;border-top:1px solid var(--line);}
    #bktbl th{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.4px;border-top:0;}
    #bktbl tr.bkrow{cursor:pointer;} #bktbl tr.bkrow:hover td{background:rgba(88,166,255,.06);}
    #bktbl a{color:var(--blue);text-decoration:none;} #bktbl .miss{color:#6e7681;}
    .bkatype{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;padding:1px 6px;border-radius:9px;margin-left:6px;}
    .bkatype.res{background:rgba(88,166,255,.16);color:var(--blue);} .bkatype.com{background:rgba(210,153,34,.16);color:var(--warn);}
    #bkov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:60;align-items:flex-start;justify-content:center;padding:40px 14px;overflow-y:auto;}
    #bkov.on{display:flex;}
    #bkmodal{width:min(720px,96vw);background:var(--card);border:1px solid var(--line);border-radius:15px;padding:20px 22px;position:relative;}
    #bkmodal h3{margin:0 0 2px;font-size:20px;} #bkmodal .mfirm{color:var(--warn);font-size:13px;}
    #bkmodal .x{position:absolute;top:12px;right:14px;background:none;border:0;color:var(--mut);font-size:22px;cursor:pointer;}
    #bkmodal .ci{display:flex;gap:16px;flex-wrap:wrap;margin:10px 0;font-size:14px;} #bkmodal .ci a{color:var(--blue);text-decoration:none;}
    #bkmodal .sec{margin-top:14px;} #bkmodal .sec h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);}
    #bkmodal .litem{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid var(--line);font-size:13px;} #bkmodal .litem .m{color:var(--mut);}
    .bkbtn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 12px;font-size:13px;cursor:pointer;margin-right:8px;} .bkbtn:hover{border-color:var(--blue);}
    .gate{font-size:12px;color:var(--mut);margin-top:8px;}
    .lead{border:1px solid var(--line);border-radius:11px;margin-bottom:8px;overflow:hidden;background:rgba(255,255,255,.012);}
    .lead .lhead{display:flex;align-items:center;gap:12px;padding:11px 14px;cursor:pointer;}
    .lead .lhead:hover{background:rgba(88,166,255,.06);}
    .lead .rk{font-weight:700;color:var(--warn);width:24px;text-align:center;font-variant-numeric:tabular-nums;}
    .lead .who{flex:1;min-width:0;} .lead .who .nm{font-weight:600;} .lead .who .fm{color:var(--mut);font-size:12px;}
    .lead .fit{font-size:11px;font-weight:700;color:var(--acc);background:rgba(63,185,80,.13);border:1px solid rgba(63,185,80,.35);border-radius:8px;padding:2px 8px;white-space:nowrap;}
    .lead .meta{color:var(--mut);font-size:12px;white-space:nowrap;}
    .lead .car{color:var(--mut);transition:transform .15s;} .lead.open .car{transform:rotate(90deg);}
    .lead .body{display:none;padding:4px 14px 12px;border-top:1px solid var(--line);}
    .lead.open .body{display:block;}
    .lead .acts{display:flex;gap:8px;margin:10px 0 6px;flex-wrap:wrap;}
    .figwrap{margin:8px 0 4px;display:flex;flex-direction:column;gap:4px;}
    .fighd{font-size:11px;color:var(--mut);letter-spacing:.02em;margin-bottom:1px;}
    .figli{display:flex;gap:9px;align-items:center;padding:5px 7px;border:1px solid var(--line);border-radius:8px;background:rgba(255,255,255,.02);text-decoration:none;color:inherit;}
    .figli:hover{border-color:var(--blue);}
    .figli-static{cursor:default;} .figli-static:hover{border-color:var(--line);}
    .figt{width:38px;height:38px;flex:0 0 38px;border-radius:6px;object-fit:cover;background:var(--line);display:inline-block;}
    .figb{display:flex;flex-direction:column;min-width:0;gap:1px;}
    .figc{font-size:12px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:min(70vw,560px);}
    .figm{font-size:10.5px;color:var(--mut);font-variant-numeric:tabular-nums;}
    .mls{width:100%;border-collapse:collapse;font-size:12.5px;}
    .mls th,.mls td{text-align:left;padding:5px 7px;border-top:1px solid var(--line);} .mls th{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.4px;border-top:0;}
    .mls td.n{text-align:right;font-variant-numeric:tabular-nums;} .mls tr.mrow{cursor:pointer;} .mls tr.mrow:hover td{background:rgba(210,153,34,.07);}
    .mls .hist{color:var(--blue);font-size:11px;}
    .phitem{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid var(--line);font-size:13px;} .phitem .m{color:var(--mut);}
  </style>
  <!-- "Top brokers" people panel removed from the root onload (Steve 2026-08-18: show properties, not people). Broker directory lives at /broker-grid.html. The #bkov modal below is retained — it's shared by the property-grid ✉ contact popup. -->
  <div id="bkov"><div id="bkmodal"><button class="x" aria-label="Close" onclick="document.getElementById('bkov').classList.remove('on')">✕</button><div id="bkmbody"></div></div></div>
  <script>
  (function(){
    if(!document.getElementById('bkleads')) return;   // "Top brokers" panel removed from root onload — this populate script no-ops (property-grid ✉ uses the global openContact, not this closure).
    const bq=s=>document.querySelector(s);
    const money=n=>n?'$'+Number(n).toLocaleString():'—';
    const esc=s=>(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
    const atype=t=>t==='residential'?'<span class="bkatype res">Residential</span>':'<span class="bkatype com">Commercial</span>';
    const fdate=s=>{ if(!s) return ''; try{ return new Date(s).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); }catch(e){ return ''; } };
    async function openContact(name,id){
      const ov=bq('#bkov'), mb=bq('#bkmbody'); ov.classList.add('on'); mb.innerHTML='<div class="gate">loading '+esc(name)+'…</div>';
      const qs=id?'id='+encodeURIComponent(id):'name='+encodeURIComponent(name);
      let d; try{ d=await (await fetch('/api/broker?'+qs)).json(); }catch(e){ mb.innerHTML='<div class="gate">error</div>'; return; }
      if(d.error){ mb.innerHTML='<div class="gate">'+esc(d.error)+'</div>'; return; }
      const b=d.broker, w=b.website?(/^https?:/.test(b.website)?b.website:'https://'+b.website):null;
      mb.innerHTML='<h3>'+esc(b.name)+atype(b.agent_type)+'</h3><div class="mfirm">'+esc(b.firm||'—')+(b.title?' · '+esc(b.title):'')+'</div>'
        +'<div class="ci">'
        +(b.phone?'<a href="tel:'+esc(b.phone)+'">☎ '+esc(b.phone)+'</a>':'<span class="gate">☎ —</span>')
        +(b.email?'<a href="mailto:'+esc(b.email)+'">✉ '+esc(b.email)+'</a>':'<span class="gate">✉ —</span>')
        +(w?'<a href="'+esc(w)+'" target="_blank" rel="noopener noreferrer">🌐 website</a>':'')+'</div>'
        +'<div class="sec"><h4>Current listings ('+d.current.length+')</h4>'+(d.current.length?d.current.map(l=>'<div class="litem"><span>'+esc(l.address)+', '+esc(l.city)+(l.zip?' '+esc(l.zip):'')+'</span><span class="m">'+esc(l.type)+' · '+money(l.price)+(l.units?' · '+l.units+'u':'')+(l.cap_rate?' · '+l.cap_rate+'% cap':'')+(l.listed_at?' · listed '+fdate(l.listed_at):'')+'</span></div>').join(''):'<div class="gate">none in our set</div>')+'</div>'
        +'<div class="sec"><h4>Expired / off-market ('+((d.expired&&d.expired.length)||0)+')</h4>'+((d.expired&&d.expired.length)?d.expired.map(x=>'<div class="litem"><span>'+esc(x.address)+', '+esc(x.city)+'</span><span class="m">'+money(x.list_price)+' → sold '+money(x.sold_price)+' · '+fdate(x.sold_date)+(x.mls?' · MLS '+esc(x.mls):'')+((x.url&&!_isAgg(x.url))?' · <a href="'+esc(x.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join(''):'<div class="gate">'+esc(d.expiredNote||'none')+'</div>')+'</div>'
        +'<div class="sec"><h4>Sales — area sold ('+((d.comps&&d.comps.length)||0)+')</h4>'+((d.comps&&d.comps.length)?d.comps.map(c=>'<div class="litem"><span>'+esc(c.address)+', '+esc(c.city)+(c.beds?' · '+c.beds+'bd':'')+(c.baths?'/'+c.baths+'ba':'')+(c.sqft?' · '+Number(c.sqft).toLocaleString()+'sf':'')+'</span><span class="m">'+money(c.sold_price)+' · '+fdate(c.sold_date)+(c.mls?' · MLS '+esc(c.mls):'')+((c.url&&!_isAgg(c.url))?' · <a href="'+esc(c.url)+'" target="_blank" rel="noopener noreferrer">↗</a>':'')+'</span></div>').join(''):'<div class="gate">no area sales</div>')+(d.compsNote?'<div class="gate">'+esc(d.compsNote)+'</div>':'')+'</div>';
    }
    bq('#bkov').addEventListener('click',e=>{ if(e.target.id==='bkov') e.currentTarget.classList.remove('on'); });
    const moneyK=n=>{ if(n==null) return '—'; n=+n; return n>=1e6?'$'+(n/1e6).toFixed(2)+'M':'$'+Math.round(n/1e3)+'k'; };
    // 100-year property history popup — address-only, NO owner/resident names (by design).
    async function propHistory(address,city){
      const ov=bq('#bkov'), mb=bq('#bkmbody'); ov.classList.add('on'); mb.innerHTML='<div class="gate">loading history for '+esc(address)+'…</div>';
      let d; try{ d=await (await fetch('/api/property-history?address='+encodeURIComponent(address)+'&city='+encodeURIComponent(city||''))).json(); }catch(e){ mb.innerHTML='<div class="gate">error</div>'; return; }
      const a=d.assessor||d.assessorArchive;
      let h='<h3>'+esc(address)+'</h3><div class="mfirm">'+esc(city||'')+' · property history (address only — no names)</div>';
      if(a){ h+='<div class="sec"><h4>Assessor parcel'+(a.roll_year?' ('+esc(a.roll_year)+' roll)':'')+'</h4><div class="ci">'
        +(a.year_built?'<span>🏗 Built '+a.year_built+'</span>':'')
        +(a.bedrooms!=null?'<span>🛏 '+a.bedrooms+' bd</span>':'')+(a.bathrooms!=null?'<span>🛁 '+a.bathrooms+' ba</span>':'')
        +(a.sqft_main?'<span>📐 '+Number(a.sqft_main).toLocaleString()+' sqft</span>':'')+(a.units?'<span>🏠 '+a.units+' units</span>':'')
        +((a.use_desc2||a.use_desc1||a.use_description)?'<span>🏷 '+esc(a.use_desc2||a.use_desc1||a.use_description)+'</span>':'')
        +((a.roll_total_value||a.total_value)?'<span>💰 assessed '+money(a.roll_total_value||a.total_value)+'</span>':'')+'</div></div>'; }
      h+='<div class="sec"><h4>Sold history ('+d.sales.length+')</h4>'+(d.sales.length?d.sales.map(x=>'<div class="phitem"><span>'+esc(x.address)+(x.city?', '+esc(x.city):'')+'</span><span class="m">'+money(x.sold_price)+' · '+String(x.sold_date||'').slice(0,10)+'</span></div>').join(''):'<div class="gate">no sold records on file for this address</div>')+'</div>';
      if(d.permits&&d.permits.length) h+='<div class="sec"><h4>Permits ('+d.permits.length+')</h4>'+d.permits.map(p=>'<div class="phitem"><span>'+esc(p.permit_type||p.use_description||'permit')+'</span><span class="m">'+esc(String(p.issue_date||'').slice(0,10))+(p.valuation?' · '+money(p.valuation):'')+'</span></div>').join('')+'</div>';
      if(d.events&&d.events.length) h+='<div class="sec"><h4>Place history</h4>'+d.events.map(ev=>'<div class="phitem"><span>'+esc(ev.summary||ev.kind)+'</span><span class="m">'+esc(String(ev.valid_from||'').slice(0,10))+'</span></div>').join('')+'</div>';
      if(!a && !d.sales.length && !(d.permits||[]).length) h+='<div class="gate">No assessor record matched this address in the LA County parcel roll (2.4M parcels), and no sold or permit history is on file. Try the street number plus street name exactly as it appears on the deed.</div>';
      mb.innerHTML=h;
    }
    // Sold / area history for a lead (per-broker closings aren't in our data; show area sold comps).
    async function brokerSold(id,btn){
      const box=btn.closest('.body').querySelector('.soldbox'); box.innerHTML='<div class="gate">loading sold history…</div>';
      try{ const d=await (await fetch('/api/broker?id='+id)).json(); const sold=d.comps||[];
        box.innerHTML='<div class="gate">Per-broker closings need a gated detail-scrape; showing recent <b>area sold comps</b> in this broker’s cities ('+sold.length+'):</div>'
          +(sold.length?sold.map(c=>'<div class="phitem"><span>'+esc(c.address)+', '+esc(c.city)+'</span><span class="m">'+money(c.sold_price)+' · '+String(c.sold_date||'').slice(0,10)+'</span></div>').join(''):'<div class="gate">none</div>');
      }catch(e){ box.innerHTML='<div class="gate">'+esc(e.message)+'</div>'; }
    }
    // Firm Instagram (Business Discovery — public firm business accounts only).
    // Loads the last-3-posts cache + firm->handle map, builds a normalized index.
    window.FIRM_IG={}; window.FIRM_IG_IDX={};
    const firmIgReady=Promise.all([
      fetch('data/firm-ig-cache.json').then(r=>r.json()).catch(()=>({})),
      fetch('data/firm-ig-map.json').then(r=>r.json()).catch(()=>({handles:{}}))
    ]).then(([cache,map])=>{ window.FIRM_IG=cache||{}; const idx={};
      for(const [h,al] of Object.entries((map.handles)||{})) for(const a of al) idx[a]=h; window.FIRM_IG_IDX=idx; }).catch(()=>{});
    const normFirm=s=>String(s||'').toLowerCase().replace(/[^a-z0-9]/g,'');
    function firmHandle(firm){ const n=normFirm(firm); if(!n) return null;
      if(window.FIRM_IG_IDX[n]) return window.FIRM_IG_IDX[n];
      for(const [a,h] of Object.entries(window.FIRM_IG_IDX)){ if(n===a || (a.length>=5 && n.includes(a)) || (a.length>=3 && n.startsWith(a))) return h; } return null; }
    function firmIgStrip(firm){ const h=firmHandle(firm); if(!h) return '';
      const c=window.FIRM_IG[h]; if(!c||!c.posts||!c.posts.length) return '';
      const items=c.posts.slice(0,3).map(p=>{ const cap=esc((p.caption||'').replace(/\s+/g,' ').slice(0,90))||'(no caption)';
        const when=p.timestamp?String(p.timestamp).slice(0,10):''; const vid=p.media_type==='VIDEO'?'▶ ':'';
        const thumb=p.image?'<img class="figt" loading="lazy" src="'+esc(p.image)+'" alt="">':'<span class="figt"></span>';
        return '<a class="figli" href="'+esc(p.permalink)+'" target="_blank" rel="noopener noreferrer">'+thumb+'<span class="figb"><span class="figc">'+vid+cap+' ↗</span><span class="figm">'+(when?when+' · ':'')+'♥ '+(p.likes||0)+' · 💬 '+(p.comments||0)+'</span></span></a>'; }).join('');
      return '<div class="figwrap"><div class="fighd">📷 @'+esc(h)+' · firm’s latest 3</div>'+items+'</div>'; }
    // Ranked top brokers — expandable to spreadsheet-style current listings + Sold + per-address history.
    (function loadBkLeads(_retry){ fetch('/api/leads/top?limit=40').then(r=>{ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); }).then(async d=>{
      await firmIgReady;
      const leads=d.leads||[];
      if(!leads.length){ bq('#bkleads').innerHTML='<div class="gate">No brokers to show yet.</div>'; return; }
      bq('#bkleads').innerHTML=leads.map((l,i)=>{
        const rows=(l.listings_json||[]).map(p=>'<tr class="mrow" data-addr="'+esc(p.address)+'" data-city="'+esc(p.city||'')+'">'
          +'<td>'+esc(p.address)+'<div style="color:var(--mut);font-size:11px">'+esc(p.city||'')+(p.zip?' '+esc(p.zip):'')+'</div>'+(p.created_at?'<div style="color:var(--mut);font-size:10px" title="captured '+esc(String(p.created_at))+'">🕓 '+new Date(p.created_at).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})+'</div>':'')+'</td>'
          +'<td>'+esc(p.type||'—')+'</td><td class="n">'+money(p.price)+'</td><td class="n">'+(p.units||'—')+'</td>'
          +'<td class="n">'+(p.cap_rate!=null?p.cap_rate+'%':'—')+'</td><td class="hist">📜 history</td></tr>').join('');
        return '<div class="lead" data-id="'+l.broker_id+'" data-name="'+esc(l.name)+'">'
          +'<div class="lhead"><span class="rk">'+(i+1)+'</span>'
          +'<div class="who"><span class="nm">'+esc(l.name)+'</span>'+atype(l.agent_type)+'<div class="fm">'+esc(l.firm||'—')+'</div></div>'
          +'<span class="fit">fit '+l.fit_score+'</span><span class="meta">'+l.listings+' listings · '+moneyK(l.total_value)+'</span><span class="car">▸</span></div>'
          +'<div class="body"><div class="acts"><button class="bkbtn" data-act="contact">✉ Contact</button>'
          +'<button class="bkbtn" data-act="sold">🔴 Sold / area history</button></div>'
          +window.firmIgInline(l.firm)
          +'<table class="mls"><thead><tr><th>Address</th><th>Type</th><th>Price</th><th>Units</th><th>Cap</th><th></th></tr></thead><tbody>'+rows+'</tbody></table>'
          +'<div class="soldbox"></div></div></div>';
      }).join('');
      bq('#bkleads').onclick=e=>{
        const lead=e.target.closest('.lead'); if(!lead) return;
        const mrow=e.target.closest('.mrow'); if(mrow){ propHistory(mrow.dataset.addr, mrow.dataset.city); return; }
        const act=e.target.closest('[data-act]'); if(act){ if(act.dataset.act==='contact') openContact(lead.dataset.name, lead.dataset.id); else brokerSold(lead.dataset.id, act); return; }
        if(e.target.closest('.lhead')) lead.classList.toggle('open');
      };
    }).catch(e=>{ if(_retry>0){ setTimeout(()=>loadBkLeads(_retry-1),1500); return; } bq('#bkleads').innerHTML='<div class="gate">Broker data is temporarily unavailable — please refresh.</div>'; console.warn('leads/top load failed:',e); }); })(1);
  })();
  </script>
  <div class="bar">
    <span class="count" id="count">…</span>
    <span class="viewseg" id="viewseg">
      <button data-view="grid">▦ Grid</button>
      <button data-view="list">☰ List</button>
      <button data-view="table">▤ Table</button>
      <button data-view="kv">🗂 KV</button>
    </span>
    <div class="right">
      <input id="idxSearch" type="text" placeholder="🔎 search all fields — address, firm, agent, type… (space = AND)" style="background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 11px;font-size:12.5px;min-width:230px" title="Filters across EVERY field on each listing (nested-safe). Space = AND.">
      <button id="shortbtn" class="ex" title="Show only your shortlisted (starred) listings">⭐ Shortlist (0)</button>
      <button id="cmpbtn" class="ex" title="Compare your shortlisted listings side by side">⚖ Compare</button>
      <button id="csvbtn" class="ex" title="Download the current filtered + sorted view (visible columns) as CSV">⬇ CSV</button>
      <button id="linkbtn" class="ex" title="Copy a shareable link to this exact filtered view">🔗 Copy link</button>
      <select id="heat" title="Color cards/rows by a metric — red (low) → green (high) over the filtered set" style="width:auto"><option value="none">Heat: off</option><option value="deal">Heat: Deal score</option><option value="cap">Heat: Cap rate</option><option value="coc">Heat: Cash-on-cash</option></select>
      <span id="heatlegend" class="heatlegend">low <span class="bar"></span> high · <span class="hl"></span></span>
      <label>Sort</label>
      <select id="sortTop" style="width:auto;min-width:180px"></select>
    </div>
  </div>
  <div class="activebar" id="activebar"></div>
  <div class="statsbar" id="statsbar"></div>
  <div class="grid" id="grid"></div>
  <div class="foot" id="foot"></div>
</main>
<aside id="ctrlrail" aria-label="View controls">
  <div class="railtitle">🧭 Controls</div>
  <div class="rpanel" data-rp="fields"><button class="rphead" type="button">Fields <span class="caret">▸</span></button><div class="rpbody"><div id="railFields"></div></div></div>
  <div class="rpanel" data-rp="sort"><button class="rphead" type="button">Sort <span class="caret">▸</span></button><div class="rpbody">
    <select id="railSort"></select>
    <label class="rlbl">Direction (any field)</label>
    <select id="railColSort"><option value="">— sort by column instead —</option></select>
    <select id="railColDir"><option value="1">Ascending ↑</option><option value="-1">Descending ↓</option></select>
  </div></div>
  <div class="rpanel" data-rp="group"><button class="rphead" type="button">Group <span class="caret">▸</span></button><div class="rpbody">
    <label class="rlbl">Group by field</label>
    <select id="railGroup"></select>
  </div></div>
  <div class="rpanel" data-rp="density"><button class="rphead" type="button">Density <span class="caret">▸</span></button><div class="rpbody">
    <label class="rlbl">Cards per row (grid) <span class="rangeval" id="railDensVal">3</span></label>
    <input type="range" id="railDensity" min="1" max="10" step="1" value="3" style="width:100%">
  </div></div>
  <div class="rpanel" data-rp="search"><button class="rphead" type="button">Search <span class="caret">▸</span></button><div class="rpbody">
    <input id="railSearch" type="text" placeholder="🔎 search all fields…">
  </div></div>
</aside>
</div><!-- /shell -->

<div id="chatwrap">
  <h2>💬 Ask the analyst &nbsp;<span class="small">— grounded in this dataset · free (Claude CLI)</span></h2>
  <div class="hint">Ask questions <b>or tell it to drive the view</b>: “show only multifamily under $1M at 30% down sorted by cash-on-cash”, “take me to the Reseda mixed-use”.</div>
  <div id="log"></div>
  <div class="examples" id="examples"></div>
  <form id="chatform" autocomplete="off">
    <input id="q" placeholder="Ask about the deals, or tell me to filter / sort / change financing…">
    <button id="send" type="submit">Send</button>
  </form>
</div>

<div id="cmpov"><div id="cmpmodal"><button class="cmpx" aria-label="Close" onclick="document.getElementById('cmpov').classList.remove('on')">✕</button><h3 style="margin:0 0 10px">⚖ Compare shortlist</h3><div id="cmpbody"></div></div></div>
<script src="finance.js"></script>
<script src="deal-score.js"></script>
<!-- href-to-deeper-data primitives (shared atoms). CRCP drives drilling through its
     own native Set-based facet model (data-firmfilter / data-typefilter / data-cityfilter
     → F.firms/F.types/F.cityQuery → render()→stateToURL()); this shared module is loaded
     so the atom contract + URL-addressable filter state are the single source of truth. -->
<script src="/drill.js"></script>
<!-- click a list row's open space → expand consolidated detail + PRIVATE per-user notes (localStorage, not shared) -->
<script src="/row-notes.js" defer></script>
<script>
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
const fmt = n => n==null ? '—' : '$'+Math.round(n).toLocaleString();
const pct = n => n==null ? '—' : n+'%';
const cls = (v, good, ok) => v==null?'na':(v>=good?'pos':(v>=ok?'':'neg'));

// ── Dated status line (HARD RULE: every property shows EITHER a current listing
// with its date OR historical data with dates — never a blank/undated status) ──
const MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtMonYear(d){ return d ? MON[d.getMonth()]+' '+d.getFullYear() : null; }
function fmtFullDate(d){ return d ? MON[d.getMonth()]+' '+d.getDate()+', '+d.getFullYear() : null; }
// Pull the most reliable known date for a listing: the refresh/source stamp in the
// note (ISO YYYY-MM-DD or "Mon D, YYYY" or MM/DD/YYYY), else the dataset's sourced date.
function listingDate(p){
  const t = p.upside_note || '';
  let m = t.match(/(20\d\d)-(\d\d)-(\d\d)/);                                 // 2026-06-27
  if (m) return { d:new Date(+m[1], +m[2]-1, +m[3]), exact:true };
  m = t.match(/\b([A-Z][a-z]{2,8})\s+(\d{1,2}),?\s+(20\d\d)/);              // June 21, 2026
  if (m){ const mi=MON.findIndex(x=>m[1].slice(0,3)===x); if(mi>=0) return { d:new Date(+m[3], mi, +m[2]), exact:true }; }
  m = t.match(/\b(\d{1,2})\/(\d{1,2})\/(20\d\d)/);                          // 06/27/2026
  if (m) return { d:new Date(+m[3], +m[1]-1, +m[2]), exact:true };
  // Fallback: the whole dataset's sourcing date (meta.sourced begins YYYY-MM-DD).
  const src = (DATA && DATA.meta && DATA.meta.sourced || '').match(/(20\d\d)-(\d\d)-(\d\d)/);
  if (src) return { d:new Date(+src[1], +src[2]-1, +src[3]), exact:false };
  return null;
}
// The guaranteed status line. Current listing → "Listed <date>". Off-market states
// (Under Contract / Pending / Sold) → labeled with date as historical. No date at
// all → an explicit "no recorded history" label, never a blank.
function statusLine(p){
  const ld = listingDate(p);
  const dateTxt = ld ? (ld.exact ? fmtFullDate(ld.d) : fmtMonYear(ld.d)+' (dataset)') : null;
  const title = ld ? ' title="'+(ld.exact?'Listing data as of '+ld.d.toISOString().slice(0,10):'No per-listing date; using dataset source date '+ld.d.toISOString().slice(0,10))+'"' : '';
  let verb, klass;
  if (/Under Contract|Pending|Escrow/i.test(p.status)) { verb='In escrow / '+p.status; klass='warn'; }
  else if (/Sold|Closed/i.test(p.status))             { verb='Last sold'; klass='mut'; }
  else if (/OUT OF BUDGET/i.test(p.status))           { verb='Listed (over budget)'; klass='bad'; }
  else                                                { verb='Listed'; klass='ok'; }
  if (!dateTxt) return `<div class="statusline none" title="No current listing date and no recorded sale history in this dataset">🕓 ${verb} · no recorded date · no sale history on file</div>`;
  const priced = p.price ? ' '+fmt(p.price) : '';
  return `<div class="statusline ${klass}"${title}>🕓 ${verb}${priced} · as of ${dateTxt}</div>`;
}

const SCEN = { '25':{budget:400000,downPct:25,ratePct:6.75,amortYears:30,closingPct:2.5},
               '30':{budget:400000,downPct:30,ratePct:6.75,amortYears:30,closingPct:2.5},
               'cash':{budget:400000,downPct:100,ratePct:0,amortYears:30,closingPct:2.5} };

let DATA=null, scenario='25', history=[];
window.DEMO = {};  // US Census ACS demographics by lowercased city (data/demographics.json)
function demoLine(p){
  const d = window.DEMO[(p.city||'').toLowerCase()]; if(!d) return '';
  const bits=[];
  if(d.renter_pct!=null) bits.push(`${d.renter_pct}% renters`);
  if(d.median_gross_rent) bits.push(`$${d.median_gross_rent.toLocaleString()} med rent`);
  if(d.median_income) bits.push(`$${Math.round(d.median_income/1000)}k income`);
  if(d.population) bits.push(`${Math.round(d.population/1000)}k pop`);
  return bits.length ? `<div class="small" title="US Census ACS 2022 — ${p.city} (ZCTA ${d.zcta})">📊 ${bits.join(' · ')}</div>` : '';
}
// Neighborhood-based rent estimate from Census median gross rent. std = market median × units
// (conservative in-place proxy); proforma = +12% market upside. Honest estimate, NOT an appraisal/1007.
function rentEstimate(p){
  const d = window.DEMO[(p.city||'').toLowerCase()]; if(!d || !d.median_gross_rent) return null;
  const base = d.median_gross_rent, units = p.units || 1;
  const std = Math.round(base*units), pro = Math.round(std*1.12);
  const annualPro = pro*12;
  return { std, pro, base, units, zcta:d.zcta, grossYieldPro: p.price? +(100*annualPro/p.price).toFixed(2):null };
}
function rentLine(p){
  const r = rentEstimate(p); if(!r) return '';
  // Plausibility guard: LA gross yields run ~5-10%. >18% almost always means a bad units count, not a deal.
  const fishy = r.grossYieldPro && r.grossYieldPro > 18;
  const yld = r.grossYieldPro ? ` · ${r.grossYieldPro}% gross yld (pro)${fishy?` <span class="b warn" title="Implausibly high — likely a bad unit count on the listing; verify.">⚠ verify units</span>`:''}` : '';
  return `<div class="small" title="Estimate from Census median gross rent ($${r.base.toLocaleString()}/unit, ${p.city} ZCTA ${r.zcta}) × ${r.units} unit${r.units>1?'s':''}. Not an appraisal — DSCR screen only.">💵 Est. rent: <b style="color:var(--ink)">$${r.std.toLocaleString()}/mo</b> std · <b style="color:var(--acc)">$${r.pro.toLocaleString()}/mo</b> proforma${yld}</div>`;
}
// For ranking, treat implausible (>18%) pro-forma yields as low-confidence so bad-units outliers don't top the list.
function rentRoiScore(p){ const r=rentEstimate(p); if(!r||!r.grossYieldPro) return -1e9; return r.grossYieldPro>18 ? -1 : r.grossYieldPro; }

// ===== Field show/hide + list/grid view + extra data points =====
const esc = s => String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
// Every data field the UI can render. card=shows on the grid card · col=available as a List column · def=default-on.
const FIELDS = [
  {k:'score',      label:'Investment score',  card:1, col:1, def:1},
  {k:'deal',       label:'Deal score',        card:1, col:1, def:1},
  {k:'agent',      label:'Agent · contact',   card:1, col:1, def:1},
  {k:'phone',      label:'Agent phone',       card:1, col:1, def:1},
  {k:'email',      label:'Email',             card:1, col:1, def:0},
  {k:'firmweb',    label:'Firm website',      card:1, col:1, def:0},
  {k:'firm',       label:'Listing firm',      card:1, col:1, def:1},
  {k:'type',       label:'Asset type',        card:1, col:1, def:1},
  {k:'units',      label:'Units',             card:1, col:1, def:1},
  {k:'price',      label:'Price',             card:1, col:1, def:1},
  {k:'ppu',        label:'Price / unit',      card:1, col:1, def:1},
  {k:'pricePerSf', label:'Price / SF',        card:1, col:1, def:0},
  {k:'cap',        label:'Cap rate',          card:1, col:1, def:1},
  {k:'verified',   label:'Cap-verified flag', card:1, col:0, def:1},
  {k:'yield',      label:'Rent yield',        card:1, col:1, def:1},
  {k:'grm',        label:'GRM (rent mult.)',  card:1, col:1, def:0},
  {k:'yearBuilt',  label:'Year built',        card:1, col:1, def:0},
  {k:'dom',        label:'Days on market',    card:1, col:1, def:0},
  {k:'zip',        label:'ZIP',               card:0, col:1, def:0},
  {k:'status',     label:'Status badge',      card:1, col:1, def:1},
  {k:'afford',     label:'Affordability',     card:1, col:1, def:1},
  {k:'rec',        label:'AI recommendation', card:1, col:1, def:0},
  {k:'statusline', label:'Listing-date line', card:1, col:0, def:1},
  {k:'brokerflag', label:'Prior-broker flag', card:1, col:0, def:1},
  {k:'txhist',     label:'Transaction history',card:1,col:0, def:0},
  {k:'brokerline', label:'Current broker',    card:1, col:1, def:1},
  {k:'brokerPhone',label:'Broker phone',      card:0, col:1, def:1},
  {k:'coc',        label:'Cash-on-cash',      card:1, col:1, def:1},
  {k:'dscr',       label:'DSCR',              card:1, col:1, def:1},
  {k:'noi',        label:'Est. NOI',          card:1, col:1, def:1},
  {k:'debt',       label:'Debt service /yr',  card:1, col:1, def:1},
  {k:'cashNeeded', label:'Cash needed',       card:1, col:1, def:1},
  {k:'cashFlow',   label:'Cash flow /yr',     card:1, col:1, def:1},
  {k:'thesis',     label:'AI thesis',         card:1, col:0, def:1},
  {k:'risks',      label:'Risks',             card:1, col:0, def:0},
  {k:'upside',     label:'Upside note',       card:1, col:0, def:1},
  {k:'demo',       label:'Demographics',      card:1, col:0, def:1},
  {k:'rent',       label:'Rent estimate',     card:1, col:1, def:1},
  {k:'links',      label:'Listing links',     card:1, col:0, def:1},
  {k:'beds',       label:'Beds (condo)',       card:1, col:1, def:0},
  {k:'baths',      label:'Baths (condo)',      card:1, col:1, def:0},
  {k:'sqft',       label:'Sq Ft',              card:1, col:1, def:0},
  {k:'hoa',        label:'HOA / mo (condo)',   card:1, col:1, def:0},
  {k:'warr',       label:'Warrantability',     card:1, col:1, def:0},
  {k:'broker',     label:'Broker (condo)',     card:1, col:1, def:1},
  {k:'assessed',   label:'Assessed value ✦',   card:1, col:0, def:0, assessor:1},
  {k:'landimp',    label:'Land / Improvement ✦',card:1, col:0, def:0, assessor:1},
  {k:'usecode',    label:'Assessor use code ✦', card:1, col:0, def:0, assessor:1},
  {k:'priorsale',  label:'Prior sale ✦',        card:1, col:0, def:0, assessor:1},
];
// Steve (2026-08-20): ALWAYS show agent · contact, phone, listing firm, current broker — never hideable,
// override any persisted localStorage off-state (a prior toggle-off used to hide them forever despite def:1).
const ALWAYS_ON = ['agent','phone','brokerPhone','firm','brokerline','broker'];
const VIS = (function(){ let s={}; try{ s=JSON.parse(localStorage.getItem('cre_fields')||'{}'); }catch(e){} const o={}; FIELDS.forEach(f=>o[f.k]=ALWAYS_ON.includes(f.k)?true:((f.k in s)?!!s[f.k]:!!f.def)); return o; })();
function vis(k){ return !!VIS[k]; }
function saveVis(){ ALWAYS_ON.forEach(k=>VIS[k]=true); try{ localStorage.setItem('cre_fields', JSON.stringify(VIS)); }catch(e){} }
let view = localStorage.getItem('cre_view') || 'list';
let groupBy = localStorage.getItem('cre_group') || '';   // group-by-any-field; '' = off
let colSort = null;  // {key, dir} — click-to-sort on a List column; overrides the sort dropdown when set
const SHORT = new Set((function(){ try{ return JSON.parse(localStorage.getItem('cre_shortlist')||'[]'); }catch(e){ return []; } })());
function saveShort(){ try{ localStorage.setItem('cre_shortlist', JSON.stringify([...SHORT])); }catch(e){} }
function toggleStar(id){ SHORT.has(id)?SHORT.delete(id):SHORT.add(id); saveShort(); }
let heat = localStorage.getItem('cre_heat') || 'none';
function heatVal(p){ const f=p.finance||{}; return heat==='deal'?(p.deal&&p.deal.score):heat==='cap'?p.cap_rate:heat==='coc'?f.coc:null; }
function heatColor(t){ t=Math.max(0,Math.min(1,t)); return `hsl(${Math.round(t*120)},70%,45%)`; }
function applyHeat(rows){
  const lg=$('#heatlegend'); if(lg){ lg.classList.toggle('on',heat!=='none'); if(heat!=='none'){ lg.querySelector('.hl').textContent={deal:'Deal score',cap:'Cap rate',coc:'Cash-on-cash'}[heat]; } }
  if(heat==='none') return;
  const vals=rows.map(heatVal).filter(v=>v!=null&&isFinite(v)); if(!vals.length) return;
  const min=Math.min(...vals), max=Math.max(...vals), span=(max-min)||1;
  const gc=$('#grid').classList;
  // Per-view heat target: Table stripes the sticky cell; List/KV stripe the row/card via a left inset;
  // Grid tints the card left border. (Regression fix TK-10747: List + KV were previously un-heated.)
  const mode = gc.contains('listhost') ? 'table' : gc.contains('compacthost') ? 'compact' : gc.contains('kvhost') ? 'kv' : 'grid';
  const targetFor=id => mode==='table' ? document.querySelector('.listtbl tr[data-id="'+id+'"] td.stick')
    : mode==='compact' ? document.querySelector('#grid .crow[data-id="'+id+'"]')
    : mode==='kv' ? document.querySelector('#grid .kvcard[data-id="'+id+'"]')
    : document.getElementById('card-'+id);
  const usesBoxShadow = mode==='table'||mode==='compact'||mode==='kv';
  rows.forEach(p=>{ const v=heatVal(p);
    const el=targetFor(p.id); if(!el) return;
    if(v==null||!isFinite(v)){ if(usesBoxShadow) el.style.boxShadow=''; else el.style.borderLeft=''; return; }
    const col=heatColor((v-min)/span);
    if(usesBoxShadow) el.style.boxShadow='inset 5px 0 0 '+col; else el.style.borderLeft='6px solid '+col;
  });
}
// ===== right-hand control rail: disclosure panels (collapsed on load) + nav-agent controls =====
function wireCtrlRail(densVal){
  const rail=$('#ctrlrail'); if(!rail) return;
  // Disclosure panels — collapsed by default; open only if a saved cre_rail_<name>==='1'.
  rail.querySelectorAll('.rpanel').forEach(p=>{
    const name=p.dataset.rp; const saved=localStorage.getItem('cre_rail_'+name);
    if(saved==='1') p.classList.add('open');   // default (null) = collapsed
    const head=p.querySelector('.rphead');
    head.setAttribute('aria-expanded', p.classList.contains('open')?'true':'false');
    head.onclick=()=>{ const open=p.classList.toggle('open'); head.setAttribute('aria-expanded',open?'true':'false'); localStorage.setItem('cre_rail_'+name, open?'1':'0'); };
  });
  // Sort — mirror the existing named SORTS (#sort/#sortTop).
  const rs=$('#railSort'); if(rs){ rs.innerHTML=$('#sort').innerHTML; rs.value=$('#sort').value;
    rs.onchange=()=>{ colSort=null; $('#sort').value=rs.value; $('#sortTop').value=rs.value; if($('#railColSort'))$('#railColSort').value=''; render(); }; }
  // Sort-by-ANY-field asc/desc via colSort (uses fieldVal).
  const groupable=[{k:'addr',label:'Property'}].concat(FIELDS.filter(f=>f.col||['type','firm','status','city'].includes(f.k)));
  const rcs=$('#railColSort'); if(rcs){ rcs.innerHTML='<option value="">— sort by column instead —</option>'+groupable.map(f=>`<option value="${f.k}">${esc(f.label)}</option>`).join('');
    if(colSort) rcs.value=colSort.key;
    const applyColSort=()=>{ const k=rcs.value; if(!k){ colSort=null; } else { colSort={key:k, dir:+($('#railColDir').value)||1}; } render(); };
    rcs.onchange=applyColSort; $('#railColDir').onchange=()=>{ if(rcs.value) applyColSort(); };
    if(colSort&&$('#railColDir')) $('#railColDir').value=String(colSort.dir); }
  // Group-by-ANY-field.
  const rg=$('#railGroup'); if(rg){ rg.innerHTML='<option value="">— no grouping —</option>'+groupable.map(f=>`<option value="${f.k}">${esc(f.label)}</option>`).join('');
    rg.value=groupBy; rg.onchange=()=>{ groupBy=rg.value; localStorage.setItem('cre_group',groupBy); render(); }; }
  // Density — mirror #density.
  const rd=$('#railDensity'); if(rd){ rd.value=densVal; $('#railDensVal').textContent=densVal;
    rd.oninput=()=>{ document.documentElement.style.setProperty('--cols',rd.value); $('#railDensVal').textContent=rd.value; if($('#density')){$('#density').value=rd.value; $('#densVal').textContent=rd.value;} localStorage.setItem('cre_density',rd.value); }; }
  // Search — synced to the top #idxSearch.
  const rse=$('#railSearch'); if(rse){ const top=$('#idxSearch'); if(top) rse.value=top.value;
    rse.oninput=()=>{ idxQuery=rse.value.trim().toLowerCase(); if(top) top.value=rse.value; render(); }; }
}
function buildFieldToggles(){
  const host=$('#fFields'); if(!host) return;
  // emit rows in COLORDER so panel order matches list column order; non-col fields appended after
  const ordered=(function(){
    syncColOrder();
    const colFs=COLORDER.map(k=>FIELDS.find(f=>f.k===k)).filter(Boolean);
    const rest=FIELDS.filter(f=>!f.col);
    return colFs.concat(rest);
  })();
  host.innerHTML = ordered.map(f=>`<label class="ftog" draggable="true" data-fk="${f.k}"><span class="fdrag" title="Drag to reorder column">⠿</span><input type="checkbox" data-fk="${f.k}" ${vis(f.k)?'checked':''}><span class="flbl">${f.label}</span>${f.col?'<span class="colbadge" title="Also shows as a column in List view">▤</span>':''}</label>`).join('');
  // Mirror into the right-rail Fields panel (checkbox-only, shares the same VIS state; no drag here).
  const rail=$('#railFields'); if(rail){ rail.innerHTML=ordered.map(f=>`<label class="ftog" data-fk="${f.k}"><input type="checkbox" data-fk="${f.k}" ${vis(f.k)?'checked':''}><span class="flbl">${f.label}</span></label>`).join(''); }
}
// ---- extra computed data points ----
function ppu(p){ return (p.price&&p.units)?Math.round(p.price/p.units):null; }
function ppsf(p){ return (p.price&&p.sqft)?Math.round(p.price/p.sqft):null; }
function grm(p){ const r=rentEstimate(p); if(!r||!r.std) return null; const annual=r.std*12; return annual?+(p.price/annual).toFixed(1):null; }
function daysOnMarket(p){ const ld=listingDate(p); if(!ld||!ld.exact) return null; const d=Math.round((Date.now()-ld.d.getTime())/86400000); return d>=0?d:null; }
function shortStatus(p){ if(/Under Contract|Pending|Escrow/i.test(p.status)) return p.status; if(/Sold|Closed/i.test(p.status)) return 'Sold'; if(/OUT OF BUDGET/i.test(p.status)) return 'Over budget'; return 'Active'; }
// ---- list-view renderer ----
// ── PRIMARY-TABLE column ORDER (Steve list-build rule: draggable + persisted per-browser) ──
// COLORDER is the display order over the col-eligible FIELDS keys; new/removed keys are
// reconciled on every read. Persisted as idxColOrder. The always-first "Property" (addr)
// stick column is NOT part of COLORDER — it's a fixed anchor, reordering happens among the rest.
let COLORDER=(function(){ let a=[]; try{ a=JSON.parse(localStorage.getItem('idxColOrder')||'[]'); }catch(e){} return Array.isArray(a)?a:[]; })();
function colKeys(){ return FIELDS.filter(f=>f.col).map(f=>f.k); }               // every col-eligible field key
function syncColOrder(){ const keys=colKeys();
  COLORDER=COLORDER.filter(k=>keys.includes(k));                                // prune removed
  // Insert any NEW col key next to its canonical FIELDS neighbor (its preceding col-field), NOT at the
  // end — so newly-added fields (Phone/Email/Firm website) surface beside Agent·contact where they
  // belong, instead of stranded at the bottom of a persisted column order (Steve 2026-08-18).
  keys.forEach(k=>{ if(COLORDER.includes(k)) return;
    const ci=keys.indexOf(k); let anchor=null;
    for(let j=ci-1;j>=0;j--){ if(COLORDER.includes(keys[j])){ anchor=keys[j]; break; } }
    if(anchor==null){ COLORDER.unshift(k); }
    else { COLORDER.splice(COLORDER.indexOf(anchor)+1,0,k); }
  });
}
function saveColOrder(){ try{ localStorage.setItem('idxColOrder',JSON.stringify(COLORDER)); }catch(e){} }
// Ordered, col-eligible FIELDS in the user's chosen order.
function orderedCols(){ syncColOrder(); return COLORDER.map(k=>FIELDS.find(f=>f.k===k)).filter(Boolean); }
// The visible List columns, in order — drives the header + body + resize signature.
function listCols(){ return orderedCols().filter(f=>vis(f.k)); }
// Column-set/order signature guard: when it changes, hand the table back to col-resize.js so
// it rebuilds its <colgroup> + drag handles for the current columns (Steve list-build rule 1).
let lastColSig='';
function reinitResize(){
  const tbl=document.querySelector('#grid .listtbl'); if(!tbl) return;
  const cg=tbl.querySelector('colgroup[data-cr]'); if(cg) cg.remove();
  delete tbl.dataset.crDone; tbl.style.tableLayout=''; tbl.style.width='';
  if(window.ColResize && window.ColResize.refresh) window.ColResize.refresh();
}
function colCell(k,p){ const f=p.finance||{};
  switch(k){
    case 'score': return p.composite??'—';
    case 'deal': return (p.deal&&p.deal.score!=null)?p.deal.score:'—';
    case 'agent': return agentContactCell(p);
    case 'phone': { const a=agentInfo(p); if(!a.phone) return '—';
      const tier=a.phoneTier==='firm'?' <span class="tierb" title="Firm branch line'+(a.phoneCity?' — '+esc(a.phoneCity)+' office':'')+'">firm</span>':'';
      return `<span class="ain"><a href="${telHref(a.phone)}" title="Call ${esc(a.phone)}">📞 ${esc(a.phone)}</a>${tier} <button class="copyb" data-copyval="${esc(a.phone)}" title="Copy phone">⧉</button></span>`; }
    case 'email': { const a=agentInfo(p); return a.email?`<span class="ain"><a href="mailto:${esc(a.email)}" title="Email ${esc(a.email)}">✉ ${esc(a.email)}</a> <button class="copyb" data-copyval="${esc(a.email)}" title="Copy email">⧉</button></span>`:'—'; }
    case 'brokerPhone': { const bp=brokerPhoneOf(p); if(!bp) return '—';
      return `<span class="ain"><a href="${telHref(bp)}" title="Call broker ${esc(bp)}">📞 ${esc(bp)}</a> <button class="copyb" data-copyval="${esc(bp)}" title="Copy broker phone">⧉</button></span>`; }
    case 'firmweb': { const a=agentInfo(p); const w=a.firmWebsite; if(!w) return '—';
      const disp=String(w).replace(/^https?:\/\//,'').replace(/\/$/,''); const href=/^https?:\/\//.test(w)?w:'https://'+w;
      return `<a class="ain" href="${esc(href)}" target="_blank" rel="noopener noreferrer" title="Open the listing firm’s website">🔗 ${esc(disp)}</a>`; }
    case 'firm': return firmCell(p);
    case 'type': return esc(p.type||'—');
    case 'units': return p.units||'—';
    case 'price': return fmt(p.price);
    case 'ppu': { const v=ppu(p); return v?'$'+v.toLocaleString():'—'; }
    case 'pricePerSf': { const v=ppsf(p); return v?'$'+v.toLocaleString():'—'; }
    case 'cap': return p.cap_rate!=null?p.cap_rate+'%':'—';
    case 'yield': return f.grossYield?f.grossYield+'%':'—';
    case 'grm': { const v=grm(p); return v!=null?v:'—'; }
    case 'yearBuilt': return p.year_built||'—';
    case 'dom': { const v=daysOnMarket(p); return v!=null?v+'d':'—'; }
    case 'zip': return p.zip||'—';
    case 'status': return esc(shortStatus(p));
    case 'afford': return f.affordable?'✓ in budget':'✗ over';
    case 'rec': return (p.qwen&&p.qwen.recommendation)||'—';
    case 'brokerline': { const bh=window.BROKER_HIST[p.id]||window.BROKER_HIST_ADDR[(p.address||'').toLowerCase().trim()]; const nm=(bh&&bh.current_broker)?(bh.current_broker.name||bh.current_broker.firm):null; return nm?agentCell(nm,(bh.current_broker&&bh.current_broker.firm)||firmLabel(p)):'—'; }
    case 'coc': return f.coc!=null?f.coc+'%':'—';
    case 'dscr': return f.dscr??'—';
    case 'noi': return fmt(f.noi);
    case 'debt': return fmt(f.annualDebtService);
    case 'cashNeeded': return fmt(f.cashNeeded);
    case 'cashFlow': return fmt(f.cashFlow);
    case 'rent': { const r=rentEstimate(p); return r?'$'+r.std.toLocaleString():'—'; }
    case 'beds': return p.beds??'—';
    case 'baths': return p.baths??'—';
    case 'sqft': return p.sqft?(+p.sqft).toLocaleString():'—';
    case 'hoa': return p.hoa?'$'+(+p.hoa).toLocaleString():'—';
    case 'warr': return esc(p.warrantable_status||'—');
    case 'broker': return p.broker_name?agentCell(p.broker_name,firmLabel(p)):'—';
    default: return '—';
  }
}
// arrow as a CSS CLASS (asc/desc), NOT literal text — keeps the <th> text stable so col-resize's
// tableSig doesn't change on sort and saved column widths survive the re-render (TK-10089).
// "Property" (addr) stays a fixed anchor; the rest are draggable to reorder + click to sort.
// renderListShell (defined above) builds the identical thead for both renderList and renderTableGrouped.
function renderList(rows){ return renderListShell(rows.map(renderListBodyRow).join('')); }
// The inline panel that opens under a list row: deep property/agent searches + the agent CRM
// (editable contact info, mark-contacted, saved-letter history, compose). Reuses the same
// #lc-/#hist- containers the card view uses so fetchLiveComps/fetchHistory work unchanged.
function expandBody(p){
  const a=agentInfo(p), firm=a.firm, id=p.id;
  // In-app actions ONLY — no external links. Live comps + Property history render inline in the
  // panel below; "More from this firm" filters the grid in place.
  const deep=[];
  deep.push(`<button class="lcbtn" data-id="${id}" title="Pull fresh comps inline (~$0.03)">🔄 Live comps</button>`);
  deep.push(`<button class="histbtn" data-id="${id}" data-address="${(p.address||'').replace(/"/g,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}" data-zip="${p.zip||''}" title="LA County assessor + permit + film history (inline, free)">📜 Property history</button>`);
  if(firm&&firm!=='Unknown') deep.push(`<button data-firmfilter="${esc(firm).replace(/"/g,'&quot;')}" title="Show only ${esc(firm)} listings">▦ More from ${esc(firm)}</button>`);
  // Agent summary + notes preview. Full editing (info, notes, letters, draft) lives in the inline
  // agent window that ✎ Open launches — no external search, no new tabs.
  const summary=`<div class="crm-summary">${a.name?`<a class="aname" href="/agent.html?name=${encodeURIComponent(a.name)}" target="_blank" rel="noopener noreferrer" title="Open this agent's own page — current & past listings">${esc(a.name)} ↗</a> `:(isPocket(a)?pocketBadge+' ':'<span class="amut">no agent on file — add via ✎ Open</span> ')}${contactBits(a)}`
    +`${a.contacted_at?`<span class="achip done">contacted ${fmtWhen(a.contacted_at)}</span>`:''}${a.letters.length?`<span class="achip">✎ ${a.letters.length} saved</span>`:''}</div>`
    +(a.notes?`<div class="crm-note">🗒 ${esc(a.notes)}</div>`:'');
  return `<div class="src">${deep.join(' ')}</div>`
    +`<div class="crm">${summary}
        <div class="crm-acts">
          <button class="abtn contactbtn" data-contact="${id}">✎ Open agent window</button>
          <button class="abtn markbtn" data-mark="${id}">${a.contacted_at?'↻ Update contacted date':'✓ Mark contacted'}</button>
        </div>
      </div>`
    +`<div class="lcout" id="lc-${id}"></div><div class="histout" id="hist-${id}"></div>`;
}
// raw (unformatted) value for a field, for generic column sorting
function fieldVal(p,k){ const f=p.finance||{};
  switch(k){
    case 'addr': return p.address; case 'score': return p.composite; case 'deal': return p.deal&&p.deal.score;
    case 'agent': { const a=agentInfo(p); return a.name||('~'); } // contactless rows sort last (~ > letters)
    case 'phone': { const a=agentInfo(p); return a.phone||'~'; }   // rows without a resolved phone sort last
    case 'brokerPhone': { const bp=brokerPhoneOf(p); return bp||'~'; }
    case 'email': { const a=agentInfo(p); return a.email||'~'; }
    case 'firmweb': { const a=agentInfo(p); return a.firmWebsite||'~'; }
    case 'firm': return firmLabel(p); case 'type': return p.type; case 'units': return p.units; case 'price': return p.price;
    case 'ppu': return ppu(p); case 'pricePerSf': return ppsf(p); case 'cap': return p.cap_rate; case 'yield': return f.grossYield;
    case 'grm': return grm(p); case 'yearBuilt': return p.year_built; case 'dom': return daysOnMarket(p); case 'zip': return p.zip;
    case 'status': return shortStatus(p); case 'afford': return f.affordable?1:0; case 'rec': return p.qwen&&p.qwen.recommendation;
    case 'coc': return f.coc; case 'dscr': return f.dscr; case 'noi': return f.noi; case 'debt': return f.annualDebtService;
    case 'cashNeeded': return f.cashNeeded; case 'cashFlow': return f.cashFlow;
    case 'rent': { const r=rentEstimate(p); return r?r.std:null; }
    case 'beds': return p.beds; case 'baths': return p.baths; case 'sqft': return p.sqft; case 'hoa': return p.hoa;
    case 'warr': return p.warrantable_status; case 'broker': return p.broker_name;
    default: return null;
  }
}
function sortRows(r){
  if(colSort){ const k=colSort.key, d=colSort.dir;
    r.sort((a,b)=>{ let x=fieldVal(a,k), y=fieldVal(b,k);
      const xn=(x!=null&&x!==''&&!isNaN(+x)), yn=(y!=null&&y!==''&&!isNaN(+y));
      if(xn&&yn){ x=+x; y=+y; } else { x=String(x==null?'':x).toLowerCase(); y=String(y==null?'':y).toLowerCase(); }
      if(x<y) return -d; if(x>y) return d; return 0; });
  } else {
    r.sort(SORTS[$('#sort').value]||SORTS.composite);
  }
  // group-by: keep the within-group order, but bucket rows so each group is contiguous (stable partition).
  if(groupBy){
    const buckets=new Map();
    r.forEach(p=>{ const gk=groupKeyLabel(p); if(!buckets.has(gk)) buckets.set(gk,[]); buckets.get(gk).push(p); });
    const keys=[...buckets.keys()].sort((a,b)=>{ const an=!isNaN(+a),bn=!isNaN(+b); if(an&&bn) return +a-+b; return a.localeCompare(b); });
    const out=[]; keys.forEach(k=>out.push(...buckets.get(k)));
    r.length=0; r.push(...out);
  }
  return r;
}

// ---- Unified Deal Score (0-100) — DealScore.compute() over the signals we already have. ----
// Gathers: pro-forma gross yield (Census rent estimate), cap rate, in-place DSCR (finance.js),
// a PROXY DSCR when no cap is disclosed (est NOI from pro-forma rent vs the active-scenario debt
// service), and the assumable-FHA/VA bonus. Transparent + labeled; never NaN. Cached on the row.
function dealScore(p){
  const r = rentEstimate(p);
  const grossYieldPro = (r && r.grossYieldPro != null && isFinite(r.grossYieldPro)) ? r.grossYieldPro : null;
  // Proxy DSCR only when there is NO in-place cap (else the real DSCR carries it). Estimate in-place
  // NOI as pro-forma annual gross × 0.6 (a 40% commercial expense ratio) and compare to debt service.
  let proxyDscr = null;
  const f = p.finance;
  if (p.cap_rate == null && r && r.pro && f && f.annualDebtService > 0){
    const estNoi = r.pro * 12 * 0.6;
    proxyDscr = +(estNoi / f.annualDebtService).toFixed(2);
  }
  return DealScore.compute({
    grossYieldPro,
    cap_rate: p.cap_rate,
    verified: p.verified,
    dscr: f ? f.dscr : null,
    proxyDscr,
    assum: p.assum,           // present only if the geocode pipeline tagged this listing (heuristic)
    fhaApproved: p.fhaApproved
  });
}
// LoopNet cross-reference for EVERY listing — free deep-links ($0, no scrape). Mirrors scripts/sources/loopnet.js.
const LN_TYPE={'Multifamily':'multifamily','Office':'office','Retail':'retail','Industrial':'industrial','Mixed-use':'mixed-use','Mixed Use':'mixed-use','Land':'land'};
function loopnetLink(p){
  const sl=s=>String(s||'').toLowerCase().trim().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
  const city=p.city?`${sl(p.city)}-ca`:'los-angeles-ca';
  const ptype=LN_TYPE[p.type]||'';
  // exact: site-search resolves the specific LoopNet listing for this address if it exists.
  const exact=`https://www.google.com/search?q=${encodeURIComponent(`site:loopnet.com "${[p.address,p.city].filter(Boolean).join(' ')}"`)}`;
  const compsQ=`site:loopnet.com ${ptype||'commercial'} for sale ${p.city||'Los Angeles'} CA`;
  const comps=`https://www.google.com/search?q=${encodeURIComponent(compsQ)}`;
  return `<a href="${exact}" target="_blank" rel="noopener noreferrer" title="Find this exact property on LoopNet (CoStar) — free search, opens in a new tab">LoopNet ↗</a> <a href="${comps}" target="_blank" rel="noopener noreferrer" class="small" title="Browse comparable ${p.type||'commercial'} for-sale on LoopNet in ${p.city||'LA'}" style="opacity:.7">(comps)</a>`;
}
// Rich filter state
const F = { types:new Set(), firms:new Set(), cityQuery:'', priceMin:null, priceMax:null,
            capMin:0, unitsMin:null, unitsMax:null, yearMin:null, yearMax:null, status:'all', afford:false, unwarrantable:false, nonqm:false, shortlist:false, hasPhone:false, hasEmail:false };

// ── SEARCH ALL DATA (Steve list-build rule 7) ──────────────────────────────────
// Free-text search across EVERY field on a listing — not just the visible/defined columns,
// nested objects included (finance/qwen/deal…) — via JSON.stringify. Space = AND.
// A per-row haystack is memoized on `p.__idxHay` and invalidated when the row identity changes.
let idxQuery='';
function idxHay(p){
  if(p.__idxHay!=null) return p.__idxHay;
  let s;
  try{ s=JSON.stringify(p, (k,v)=> k==='__idxHay'?undefined:v); }
  catch(e){ s=Object.keys(p).map(k=>{ const v=p[k]; return v==null?'':(typeof v==='object'?'':String(v)); }).join(' '); }
  // fold in the CRM-saved contact (agent name/phone/email) which lives outside the row object
  const c=window.CONTACTS&&window.CONTACTS[p.id]; if(c) s+=' '+JSON.stringify(c);
  s=s.toLowerCase();
  try{ Object.defineProperty(p,'__idxHay',{value:s,enumerable:false,configurable:true,writable:true}); }catch(_){ p.__idxHay=s; }
  return s;
}
function idxSearchPass(p){
  if(!idxQuery) return true;
  const hay=idxHay(p);
  return idxQuery.split(/\s+/).filter(Boolean).every(t=>hay.includes(t));
}

function recompute(){
  const A = SCEN[scenario];
  DATA.ranked.forEach(p=>{
    try{ delete p.__idxHay; }catch(_){}   // finance/deal recomputed → refresh the search haystack
    p.finance = CREFin.finance(p, A);
    const fsc = CREFin.financeScore(p, p.finance);
    p.financeScore = fsc.score; p.financeConf = fsc.conf;
    p.composite = CREFin.composite(p, p.finance, fsc.score, p.qwenScore);
    p.deal = dealScore(p);   // {score, parts, confidence, coverage} — cached for badge + sort + tooltip
  });
  DATA.ranked.sort((a,b)=>b.composite-a.composite);
  DATA.ranked.forEach((p,i)=>p.rank=i+1);
}

function firmLabel(p){
  if(p.firm) return p.firm;
  const d=(p.source||'').replace(/^https?:\/\/(www\.)?/,'').split('/')[0]||'';
  const map={'crexi.com':'Crexi','loopnet.com':'LoopNet','compass.com':'Compass','remax.com':'RE/MAX',
    'coldwellbankerhomes.com':'Coldwell Banker','marcusmillichap.com':'Marcus & Millichap',
    'lee-associates.com':'Lee & Associates','colliers.com':'Colliers','cbre.com':'CBRE','jll.com':'JLL'};
  if(map[d]) return map[d];
  return d ? d.split('.')[0].replace(/^./,c=>c.toUpperCase()) : 'Unknown';
}

// ── Agent-contact CRM (front-end) ──────────────────────────────────────────────────
// CONTACTS[listingId] = {name,phone,email,firm,contacted_at,letters:[]}. Loaded once at
// boot from /api/contacts; every save re-writes the record here so the grid re-renders
// without a reload. Sending a letter is NOT done here — letters are SAVED only.
window.CONTACTS = window.CONTACTS || {};
fetch('/api/contacts').then(r=>r.json()).then(j=>{ window.CONTACTS=j.contacts||{}; if(window.DATA_READY) render(); }).catch(()=>{});

// ── Real phone reveal (TK-10687) ── each listing carries a broker/agent NAME but no phone; the real
// numbers live in the usre DRE registry. resolveContacts() batches the visible page's agents to the
// /api/broker-contacts proxy, which returns a REAL city-scoped contact (see serve.js). National
// toll-free numbers arrive flagged phone_national and are SUPPRESSED below (Steve: "not a national
// number — closest to the listing"); the firm website link stands in so the local branch is reachable.
window.BROKER_RESOLVED = window.BROKER_RESOLVED || {};
const _TOLLFREE=new Set(['800','833','844','855','866','877','888','822','880','887','889']);
// CRCP is LA-County-only, so the "closest to the listing" phone (Steve's rule) must be a CALIFORNIA
// number. Every CA area code — a firm phone outside this set (e.g. a Compass 212 NY HQ line) is a
// national/HQ leak and is SUPPRESSED (the firm website stands in) exactly like a toll-free number.
const _CA_AREA=new Set(['209','213','279','310','323','341','350','408','415','424','442','510','530','559','562','619','626','628','650','657','661','669','707','714','747','760','805','818','820','831','858','909','916','925','935','949','951']);
const _area=ph=>{ const d=String(ph||'').replace(/\D/g,'').replace(/^1(?=\d{10}$)/,''); return d.slice(0,3); };
const isNat=ph=>{ const a=_area(ph); return _TOLLFREE.has(a) || (a.length===3 && !_CA_AREA.has(a)); };
const _resolveDone=new Set();
async function resolveContacts(rows){
  const items=[];
  for(const p of (rows||[])){ if(p&&p.id!=null&&!_resolveDone.has(p.id)){ _resolveDone.add(p.id);
    const a=agentInfo(p); if(a.name) items.push({id:p.id,name:a.name,firm:a.firm||'',city:p.city||''}); } }
  if(!items.length) return;
  try{ const j=await postJSON('/api/broker-contacts',{items});
    if(j&&j.contacts&&Object.keys(j.contacts).length){ Object.assign(window.BROKER_RESOLVED,j.contacts); paintPage(); } }catch(_){}
}

// ── Firm Instagram (global, link-free) ── each brokerage firm's last 3 IG posts,
// rendered INLINE under the agent-contact card (no outside links — Business
// Discovery, thumbnails cached same-origin). Loads once at boot, re-renders on ready.
window.FIRM_IG = window.FIRM_IG || {}; window.FIRM_IG_IDX = window.FIRM_IG_IDX || {};
Promise.all([
  fetch('data/firm-ig-cache.json').then(r=>r.json()).catch(()=>({})),
  fetch('data/firm-ig-map.json').then(r=>r.json()).catch(()=>({handles:{}}))
]).then(([cache,map])=>{ window.FIRM_IG=cache||{}; const idx={};
  for(const [h,al] of Object.entries((map.handles)||{})) for(const a of al) idx[a]=h; window.FIRM_IG_IDX=idx;
  if(window.DATA_READY) render(); }).catch(()=>{});
window.firmHandleOf=function(firm){ const n=String(firm||'').toLowerCase().replace(/[^a-z0-9]/g,''); if(!n) return null;
  if(window.FIRM_IG_IDX[n]) return window.FIRM_IG_IDX[n];
  for(const [a,h] of Object.entries(window.FIRM_IG_IDX)){ if(n===a||(a.length>=5&&n.includes(a))||(a.length>=3&&n.startsWith(a))) return h; } return null; };
window.firmIgInline=function(firm){ const h=window.firmHandleOf(firm); if(!h) return '';
  const c=window.FIRM_IG[h]; if(!c||!c.posts||!c.posts.length) return '';
  const e=s=>String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]));
  const items=c.posts.slice(0,3).map(p=>{ const cap=e((p.caption||'').replace(/\s+/g,' ').slice(0,90))||'(no caption)';
    const when=p.timestamp?String(p.timestamp).slice(0,10):''; const vid=p.media_type==='VIDEO'?'▶ ':'';
    const thumb=p.image?'<img class="figt" loading="lazy" src="'+e(p.image)+'" alt="">':'<span class="figt"></span>';
    return '<div class="figli figli-static">'+thumb+'<span class="figb"><span class="figc">'+vid+cap+'</span><span class="figm">'+(when?when+' · ':'')+'♥ '+(p.likes||0)+' · 💬 '+(p.comments||0)+'</span></span></div>'; }).join('');
  return '<div class="figwrap"><div class="fighd">📷 @'+e(h)+' · firm’s latest 3</div>'+items+'</div>'; };
// Best-known contact info for a listing: the saved CRM record wins, else what the scrape gave us.
function agentInfo(p){
  const c=window.CONTACTS[p.id]||{};
  const rv=window.BROKER_RESOLVED[p.id]||{};                       // real contact resolved from usre (TK-10687)
  const bh=window.BROKER_HIST[p.id]||window.BROKER_HIST_ADDR[(p.address||'').toLowerCase().trim()];
  const dName=p.broker_name||p.broker_agent||(Array.isArray(p.broker_agents)&&p.broker_agents[0])||(bh&&bh.current_broker&&(bh.current_broker.name||bh.current_broker.firm))||'';
  // Phone precedence: hand-saved CRM > scraped row > resolved usre line — but a NATIONAL/toll-free
  // number is never shown as the phone (Steve: "not a national number"); the firm website stands in.
  const rvPhone=(rv.phone && !rv.phone_national && !isNat(rv.phone))?rv.phone:'';   // isNat now also drops non-CA area codes (out-of-region HQ lines)
  return { name:c.name||dName||'', phone:c.phone||p.broker_phone||rvPhone||'', email:c.email||p.broker_email||rv.email||'',
           firm:c.firm||(rv.firm)||firmLabel(p),
           phoneTier:rv.phone_tier||null, phoneCity:rv.phone_city||null, phoneNational:!!rv.phone_national,
           firmWebsite:rv.firm_website||'', firmPhone:(rv.firm_phone && !isNat(rv.firm_phone))?rv.firm_phone:'', firmAddress:rv.firm_address||'',
           contacted_at:c.contacted_at||null, notes:c.notes||'', letters:(c.letters||[]) };
}
// Broker (listing broker of record) phone — the enriched current-broker line, distinct from the
// agent/contact phone in agentInfo (which folds in the scraped row's broker_phone). Steve 2026-08-25:
// "add broker and agent phone numbers to the fields."
function brokerPhoneOf(p){
  const a=agentInfo(p);
  const bh=window.BROKER_HIST[p.id]||window.BROKER_HIST_ADDR[(p.address||'').toLowerCase().trim()];
  const chist=(bh&&bh.current_broker&&bh.current_broker.phone)||'';
  // Prefer a DISTINCT firm/broker-of-record line (resolved usre firm_phone or broker-history), then the
  // raw row, then — if the agent's own resolved line IS a firm/branch line — use that (it's the broker
  // office number). Keeps "Broker phone" the firm-of-record line, distinct from the agent-direct phone.
  return a.firmPhone || chist || p.broker_phone || (a.phoneTier==='firm' ? a.phone : '') || '';
}
function gsearch(q){ return 'https://www.google.com/search?q='+encodeURIComponent(q); } // retained; no longer linked out
function safeUrl(u){ u=String(u==null?'':u); return /^https?:\/\//i.test(u)?u:'#'; }
// "↗ View property" — a link OUT to the real listing on whichever service it's on (Crexi / LoopNet /
// the broker's own site). Uses p.source, the actual listing URL. Steve 2026-08-18: "need to see the property."
function sourceLink(p){ const u=p.source; if(!u||!/^https?:\/\//i.test(u)) return '';
  const host=u.replace(/^https?:\/\/(www\.)?/,'').split('/')[0];
  const map={'crexi.com':'Crexi','loopnet.com':'LoopNet','compass.com':'Compass','remax.com':'RE/MAX','lyonstahl.com':'Lyon Stahl','coldwellbankerhomes.com':'Coldwell Banker'};
  const name=map[host]||host.replace(/\.(com|net|org)$/,'').replace(/^./,c=>c.toUpperCase());
  return `<a class="srclink" href="${safeUrl(u)}" target="_blank" rel="noopener noreferrer" title="See this property on ${esc(name)}">↗ View on ${esc(name)}</a>`;
}
// Aggregator doctrine: never surface a clickable href to crexi/redfin/zillow/costar/loopnet/realtor/myelisting.
// Mirror of mls.html brokerListing() — resolve canonical click-through from listing data.
const _AGG=['crexi','redfin','zillow','costar','loopnet','realtor','myelisting'];
const _host=u=>{try{return new URL(u).hostname.replace(/^www\./,'');}catch{return null;}};
const _isAgg=u=>{const h=_host(u);return !!(h&&_AGG.some(a=>h.includes(a)));};
// Refined listing-link doctrine (2026-07-31):
// 1. Non-agg source → firm-direct listing link.
// 2. Agg/absent source + non-agg broker_url → honest "firm site" label (NOT "the listing").
// 3. Always append a Google ground-truth finder (never crexi/aggregator).
// Returns array of HTML strings to push() into the find[] block.
function listingLinkBits(p, firm){
  const bits=[];
  const listingQ='"'+(p.address||'')+'" '+(p.city||'')+' '+(firm&&firm!=='Unknown'?firm:'')+' commercial listing';
  if(p.firm_direct_url&&!_isAgg(p.firm_direct_url)){   // harvested broker's-own-page deep link — highest confidence
    bits.push(`<a class="findlink" href="${safeUrl(p.firm_direct_url)}" target="_blank" rel="noopener noreferrer">↗ View firm listing</a>`);
  } else if(p.source&&!_isAgg(p.source)){
    bits.push(`<a class="findlink" href="${safeUrl(p.source)}" target="_blank" rel="noopener noreferrer">↗ View firm listing</a>`);
  } else {
    if(p.broker_url&&!_isAgg(p.broker_url)){
      bits.push(`<a class="findlink" href="${safeUrl(p.broker_url)}" target="_blank" rel="noopener noreferrer">🏢 ${esc(firm&&firm!=='Unknown'?firm:'Broker')} site</a>`);
    }
    bits.push(`<a class="findlink" href="https://www.google.com/search?q=${encodeURIComponent(listingQ)}" target="_blank" rel="noopener noreferrer">🔎 Find this listing</a>`);
  }
  return bits;
}
// A listing with NO contact of any kind (no agent name, phone, email, or firm site) is an off-market /
// POCKET listing — the broker deliberately isn't published. Show that honestly instead of "no agent on
// file" (which reads like missing data). Steve 2026-08-19: "show the word pocket listing if appropriate."
function isPocket(a){ return !a.name && !a.phone && !a.email && !a.firmWebsite && !a.firm; }
const pocketBadge = `<span class="pocketbadge" title="Off-market / pocket listing — the broker isn't publicly listed for this property">🔒 Pocket listing</span>`;
// Firm cell — clicking filters the grid to that firm IN-APP (no external link).
function firmCell(p){ const n=firmLabel(p); if(!n||n==='Unknown') return esc(n||'—');
  return `<span class="flink" data-firmfilter="${esc(n).replace(/"/g,'&quot;')}" title="Show only ${esc(n)} listings">${esc(n)}</span>`; }
// Agent name — plain in-app text (no external search).
function agentCell(name){ return name?esc(name):'—'; }
// Inline phone/email. The phone is a REAL tel: link (Steve 2026-08-18 "make phone icon actually call
// out") so clicking 📞 or the number places the call; email is a mailto:. Copy buttons stay for both.
const telHref=ph=>'tel:'+String(ph||'').replace(/[^0-9+]/g,'');
function contactBits(a){
  const tier=a.phone&&a.phoneTier==='firm'?`<span class="tierb" title="Firm branch line${a.phoneCity?' — '+esc(a.phoneCity)+' office':''}">firm</span>`:'';
  const tel=a.phone?`<span class="ain"><a href="${telHref(a.phone)}" title="Call ${esc(a.phone)}">📞 ${esc(a.phone)}</a> ${tier} <button class="copyb" data-copyval="${esc(a.phone)}" title="Copy phone">⧉</button></span>`:'';
  const eml=a.email?`<span class="ain"><a href="mailto:${esc(a.email)}" title="Email ${esc(a.email)}">✉ ${esc(a.email)}</a> <button class="copyb" data-copyval="${esc(a.email)}" title="Copy email">⧉</button></span>`:'';
  // Firm website — "open listing firm website" (and the way to reach the LOCAL branch when we only
  // have a national/missing number). Opens in a new tab; noopener for safety.
  const w=a.firmWebsite?String(a.firmWebsite).replace(/^https?:\/\//,'').replace(/\/$/,''):'';
  const web=w?`<span class="ain">🔗 <a href="${esc(a.firmWebsite.match(/^https?:\/\//)?a.firmWebsite:'https://'+a.firmWebsite)}" target="_blank" rel="noopener noreferrer" title="Open the listing firm’s website — find the local office">${esc(w)}</a></span>`:'';
  const inner=[tel,eml,web].filter(Boolean).join(' ');
  return inner?`<div class="ainrow">${inner}</div>`:'';
}
// The Agent · contact column cell: name + copyable phone/email + Open/Mark buttons + chips.
function agentContactCell(p){
  const a=agentInfo(p);
  const nm=a.name?`<a class="aname" href="/agent.html?name=${encodeURIComponent(a.name)}" target="_blank" rel="noopener noreferrer" title="Open this agent's own page — current & past listings">${esc(a.name)} ↗</a>`:(isPocket(a)?pocketBadge:`<span class="amut">no agent on file</span>`);
  const contacted=a.contacted_at?`<span class="achip done" title="Marked contacted ${esc(a.contacted_at)}">✓ ${fmtWhen(a.contacted_at)}</span>`:'';
  const lc=a.letters.length?`<span class="achip" title="${a.letters.length} saved letter(s)">✎ ${a.letters.length}</span>`:'';
  const nt=a.notes?`<span class="achip" title="Has notes">🗒 notes</span>`:'';
  // Steve 2026-08-18 "phone on front page. no OPEN": the contact info (phone/email/firm website) shows
  // DIRECTLY in the cell — no "Open" button gating it. Only a compact Contacted mark remains (click the
  // ROW to open the full agent window if needed; the row click handler already does that).
  const btns=`<span class="acts">`
    +`<button class="abtn markbtn" data-mark="${p.id}" title="${a.contacted_at?'Update the contacted date':'Mark contacted (saves the date)'}">${a.contacted_at?'↻':'✓'} Contacted</button>`
    +`</span>`;
  return `<div class="agentcell">${nm}${contactBits(a)}<div class="chiprow">${contacted}${lc}${nt}</div>${btns}</div>`;
}
function fmtWhen(iso){ try{ return new Date(iso).toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); }catch(_){ return String(iso||'').slice(0,10); } }

// Deal-Score badge + plain-text breakdown tooltip (explains WHY the score is what it is).
function dealBadge(p){
  const d = p.deal; if(!d || d.score==null) return `<span class="b" title="Not enough signal to score this deal (no cap rate, rent estimate, or DSCR available).">⬡ Deal —</span>`;
  const col = DealScore.scoreColor(d.score);
  const lines = d.parts.map(pt=>`• ${pt.label}: ${Math.round(pt.score)}/100 (${pt.conf}, weight ${Math.round(pt.weight)})`).join('\n');
  const tip = `Deal Score ${d.score}/100 — weighted blend of the signals below, re-normalized over what's present (missing signals drop out, never NaN).\nConfidence: ${d.confidence}${d.coverage!=null?` (core coverage ${Math.round(d.coverage*100)}%)`:''}.\n\n${lines}\n\nA screen, not an appraisal. Verify NOI + units in DD.`;
  return `<span class="b deal-badge" style="color:${col};border-color:${col}" title="${tip.replace(/"/g,'&quot;')}">⬡ Deal ${d.score}${d.confidence==='low'||d.confidence==='thin'?' ?':''}</span>`;
}
function card(p){
  const f=p.finance, uc=/Under Contract|Pending/i.test(p.status);
  // firm badge is now a DRILL atom → filters the grid to this firm (reuses data-firmfilter handler)
  const _fn=firmLabel(p);
  const firmB = (_fn && _fn!=='Unknown')
    ? `<span class="b drill" data-firmfilter="${esc(_fn).replace(/"/g,'&quot;')}" data-filter="firm" data-v="${esc(_fn).replace(/"/g,'&quot;')}" title="Filter to ${esc(_fn)} listings">🏢 ${esc(_fn)}</span>`
    : `<span class="b" title="Listing source / brokerage firm">🏢 ${esc(_fn||'—')}</span>`;
  const aff = f.affordable ? `<span class="b ok">✓ $${(f.cashNeeded/1000).toFixed(0)}k in budget</span>`
                           : `<span class="b bad">✗ needs $${(f.cashNeeded/1000).toFixed(0)}k</span>`;
  const rec = p.qwen && p.qwen.recommendation ? `<span class="b rec-${p.qwen.recommendation}">${p.qwen.recommendation}</span>` : '';
  const statusB = uc ? `<span class="b warn">${p.status}</span>` : (/OUT OF BUDGET/i.test(p.status)?`<span class="b bad">over budget</span>`:`<span class="b ok">Active</span>`);
  const capB = p.cap_rate!=null
     ? `<span class="b">${p.cap_rate}% cap${p.cap_rate_projected?` →${p.cap_rate_projected}%*`:''}</span>`
     : `<span class="b warn">cap n/d</span>`;
  const verB = p.verified===true ? `<span class="b ok" title="In-place NOI confirmed against MLS">✓ cap verified</span>`
     : (p.cap_rate!=null ? `<span class="b" title="Broker-stated cap, not independently verified">broker cap</span>`
     : `<span class="b warn" title="No in-place income disclosed">⚠ no income data</span>`);
  // Broker-history enrichment — keyed by listing id then by address fallback
  const bh = window.BROKER_HIST[p.id] || window.BROKER_HIST_ADDR[(p.address||'').toLowerCase().trim()];
  const brokerFlagBadge = bh && bh.broker_flag
    ? `<span class="brokerflag" title="${esc(bh.flag_reason||'')}">⚑ Prior broker on file</span>`
    : '';
  function txHistHTML(bh){
    if(!bh || !bh.tx_history || !bh.tx_history.length) return '';
    const rows = bh.tx_history.map(t=>{
      const price = t.price ? `<span class="tx-price">$${Number(t.price).toLocaleString()}</span>` : '';
      const src = t.tier==='A' ? ' <span style="color:var(--acc);font-size:9px">[A]</span>' : ' <span style="color:var(--mut);font-size:9px">[B]</span>';
      return `<div class="tx-row"><span class="tx-date">${esc(String(t.date||'').slice(0,10))}</span><span class="tx-ev">${esc(t.event||'')}${src}</span>${price}</div>`;
    }).join('');
    // Also show the last deed recording from assessor if we have AIN
    const ain = bh.ain ? `<div style="font-size:10px;color:var(--mut);margin-top:2px">AIN ${bh.ain} · built ${bh.assessor_year_built||'—'} · ${bh.assessor_units||'?'} units (assessor)</div>` : '';
    return `<div class="txhist">${rows}${ain}</div>`;
  }
  function brokerLineHTML(bh){
    if(!bh || !bh.current_broker) return '';
    const b = bh.current_broker;
    if(!b.name && !b.firm) return '';
    const nameStr = b.name ? `<strong>${esc(b.name)}</strong>` : '';
    const firmStr = b.firm ? esc(b.firm) : '';
    const dreStr  = b.dre ? (function(){const L=String(b.dre).trim();return /^\d{8}$/.test(L)?` · DRE# <a href="https://www2.dre.ca.gov/PublicASP/pplinfo.asp?License_id=${encodeURIComponent(L)}" target="_blank" rel="noopener noreferrer" title="Full CA DRE license record ↗">${esc(L)} ↗</a>`:` · DRE ${esc(L)}`;})() : '';
    const phoneStr = b.phone ? ` · <a href="tel:${esc(String(b.phone).replace(/[^0-9+]/g,''))}">${esc(b.phone)}</a>` : '';
    const mlsStr  = b.mls  ? ` · MLS ${esc(b.mls)}` : '';
    const parts = [nameStr, firmStr].filter(Boolean).join(' · ');
    const tierBadge = b.tier==='A' ? ' <span style="color:var(--acc);font-size:9px">[A]</span>' : '';
    return `<div class="brokerline">🧑‍💼 ${parts}${dreStr}${phoneStr}${mlsStr}${tierBadge}</div>`;
  }
  return `<div class="card ${uc?'uc':''}" id="card-${p.id}">
    <div class="top">
      <div><div class="rank">#${p.rank} · score</div><div class="addr">${esc(p.address||'')}</div><div class="city">${p.city?`<span class="drill" data-cityfilter="${esc(p.city).replace(/"/g,'&quot;')}" data-filter="city" data-v="${esc(p.city).replace(/"/g,'&quot;')}" title="Filter to ${esc(p.city)}">${esc(p.city)}</span>`:''}, CA ${esc(p.zip||'')}</div></div>
      <div style="display:flex;align-items:flex-start;gap:8px">
        <button class="copybtn" data-copy="${p.id}" aria-label="Copy listing summary" title="Copy summary to clipboard">📋</button>
        <button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="${SHORT.has(p.id)?'Remove from':'Add to'} shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist this listing">★</button>
        ${vis('score')?`<div class="score">${p.composite}<small>fin ${p.financeScore}${p.qwenScore!=null?' · ai '+p.qwenScore:''}</small></div>`:''}
      </div>
    </div>
    <div class="badges">${vis('deal')?dealBadge(p):''}${vis('firm')?firmB:''}${vis('type')?(p.type?`<span class="b type drill" data-typefilter="${esc(p.type).replace(/"/g,'&quot;')}" data-filter="type" data-v="${esc(p.type).replace(/"/g,'&quot;')}" title="Filter to ${esc(p.type)}">${esc(p.type)}</span>`:`<span class="b type">—</span>`):''}${vis('units')?`<span class="b">${p.units} unit${p.units>1?'s':''}</span>`:''}${vis('price')?`<span class="b">${fmt(p.price)}</span>`:''}${vis('ppu')&&ppu(p)&&!p.isCondo?`<span class="b" title="Price per unit">$${ppu(p).toLocaleString()}/unit</span>`:''}${vis('pricePerSf')&&ppsf(p)?`<span class="b" title="Price per building SF">$${ppsf(p).toLocaleString()}/sf</span>`:''}${vis('cap')?capB:''}${vis('verified')?verB:''}${vis('yield')&&f.grossYield?`<span class="b ok" title="Annual gross rent ÷ price">${f.grossYield}% rent yld</span>`:''}${vis('grm')&&grm(p)!=null?`<span class="b" title="Gross rent multiplier — price ÷ annual gross rent (lower = cheaper vs rent)">GRM ${grm(p)}</span>`:''}${vis('yearBuilt')&&p.year_built?`<span class="b" title="Year built">🏗 ${p.year_built}</span>`:''}${vis('dom')&&daysOnMarket(p)!=null?`<span class="b" title="Days since listed">${daysOnMarket(p)}d on mkt</span>`:''}${vis('beds')&&p.beds!=null?`<span class="b">${p.beds} bd</span>`:''}${vis('baths')&&p.baths!=null?`<span class="b">${p.baths} ba</span>`:''}${vis('sqft')&&p.sqft?`<span class="b">${(+p.sqft).toLocaleString()} sf</span>`:''}${vis('hoa')&&p.hoa?`<span class="b" title="HOA dues">$${(+p.hoa).toLocaleString()}/mo</span>`:''}${vis('warr')&&p.warrantable_status?`<span class="b ${p.warrantable_status==='fha_approved'?'ok':'warn'}" title="Condo warrantability (FHA-approval proxy)">${p.warrantable_status==='fha_approved'?'✓ warrantable':'⚑ '+p.warrantable_status}</span>`:''}${vis('broker')&&p.broker_name?`<span class="b" title="Listing broker">🧑‍💼 ${esc(p.broker_name)}</span>`:''}${vis('status')?statusB:''} ${vis('afford')?aff:''} ${vis('rec')?rec:''}</div>
    ${vis('brokerflag')?brokerFlagBadge:''}
    ${vis('statusline')?statusLine(p):''}
    ${vis('txhist')?txHistHTML(bh):''}
    ${vis('brokerline')?brokerLineHTML(bh):''}
    ${(vis('coc')||vis('dscr')||vis('noi')||vis('debt')||vis('cashNeeded')||vis('cashFlow'))?`<div class="metrics">
      ${vis('coc')?`<div><span class="k">Cash-on-cash</span><span class="v ${cls(f.coc,6,3)}">${pct(f.coc)}</span></div>`:''}
      ${vis('dscr')?`<div><span class="k">DSCR</span><span class="v ${cls(f.dscr,1.25,1.0)}">${f.dscr??'—'}</span></div>`:''}
      ${vis('noi')?`<div><span class="k">Est. NOI</span><span class="v">${fmt(f.noi)}</span></div>`:''}
      ${vis('debt')?`<div><span class="k">${scenario==='cash'?'Debt svc':'Debt svc/yr'}</span><span class="v">${fmt(f.annualDebtService)}</span></div>`:''}
      ${vis('cashNeeded')?`<div><span class="k">Cash needed</span><span class="v ${f.affordable?'pos':'neg'}">${fmt(f.cashNeeded)}</span></div>`:''}
      ${vis('cashFlow')?`<div><span class="k">Cash flow/yr</span><span class="v ${cls(f.cashFlow,1,0)}">${fmt(f.cashFlow)}</span></div>`:''}
    </div>`:''}
    ${vis('thesis')&&p.qwen?.thesis?`<div class="thesis">${esc(p.qwen.thesis)}</div>`:''}
    ${vis('risks')&&p.qwen?.risks?.length?`<div><span class="small">Risks</span><ul class="lst">${p.qwen.risks.map(r=>`<li>${esc(r)}</li>`).join('')}</ul></div>`:''}
    ${vis('upside')&&p.upside_note?`<div class="small">💡 ${esc(p.upside_note)}</div>`:''}
    ${vis('demo')?demoLine(p):''}
    ${vis('rent')?rentLine(p):''}
    ${anyAssessorVisible()?`<div class="assessline small" data-addr="${(p.address||'').replace(/"/g,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}">📋 assessor…</div>`:''}
    ${cardAgentBlock(p)}
  </div>`;
}
// Per-listing agent-contact block for the CARD view — every card (any asset type) gets the same
// contact modal + mark-contacted as the list rows. Compact: name/phone/email + chips + buttons.
function cardAgentBlock(p){
  const a=agentInfo(p), id=p.id;
  const nm=a.name?`<a class="aname" href="/agent.html?name=${encodeURIComponent(a.name)}" target="_blank" rel="noopener noreferrer" title="Open this agent's own page — current & past listings">${esc(a.name)} ↗</a>`:(isPocket(a)?pocketBadge:`<span class="amut">no agent on file</span>`);
  const chips=`${a.contacted_at?`<span class="achip done" title="Contacted ${esc(a.contacted_at)}">✓ ${fmtWhen(a.contacted_at)}</span>`:''}${a.letters.length?`<span class="achip" title="${a.letters.length} saved letter(s)">✎ ${a.letters.length}</span>`:''}${a.notes?`<span class="achip" title="Has notes">🗒 notes</span>`:''}`;
  return `<div class="cardagent" id="cardagent-${id}">`
    +`<div class="ca-top">🧑‍💼 ${nm} ${chips}</div>`
    +contactBits(a)
    +window.firmIgInline(a.firm)
    +`<div class="acts"><button class="abtn contactbtn" data-contact="${id}" title="Open the inline agent window — notes, letters, draft">✎ Open</button>`
    +`<button class="abtn markbtn" data-mark="${id}" title="${a.contacted_at?'Update contacted date':'Mark contacted (saves the date)'}">${a.contacted_at?'↻':'✓'} Contacted</button></div>`
    +`</div>`;
}

// ---- Non-QM target set: unwarrantable condos + small multifamily (1–9u) + mixed-use ----
function isNonQM(p){
  const t=(p.type||'').toLowerCase();
  if(p.isCondo && p.warrantable_status && p.warrantable_status!=='fha_approved') return true;
  if(/multi/.test(t) && p.units>=1 && p.units<=9) return true;
  if(/mixed/.test(t)) return true;
  return false;
}
// ---- lazy assessor enrichment (LA County parcel roll via /api/property-history, free) ----
function anyAssessorVisible(){ return FIELDS.some(f=>f.assessor && vis(f.k)); }
window.ASSESS = window.ASSESS || {};
let assessObs = null;
function assessLine(d){
  const a=d&&(d.assessor||d.assessorArchive); const bits=[];
  if(vis('assessed')){ const v=a&&(a.roll_total_value||a.total_value); if(v) bits.push('💰 assessed $'+(+v).toLocaleString()); }
  if(vis('landimp')&&a){ const l=a.roll_land_value, im=a.roll_imp_value; if(l||im) bits.push('land $'+(+(l||0)).toLocaleString()+' / imp $'+(+(im||0)).toLocaleString()); }
  if(vis('usecode')&&a){ const u=a.use_desc2||a.use_desc1||a.use_description; if(u) bits.push('🏷 '+u); }
  if(vis('priorsale')&&d&&d.sales&&d.sales.length){ const s=d.sales[0]; bits.push('last sold $'+(+(s.sold_price||0)).toLocaleString()+' · '+String(s.sold_date||'').slice(0,10)); }
  return bits.length ? ('📋 '+bits.join(' · ')) : '<span class="na">📋 no assessor record on file</span>';
}
function enrichAssessor(){
  if(assessObs){ assessObs.disconnect(); assessObs=null; }
  if(!anyAssessorVisible()) return;
  const els=[...document.querySelectorAll('.assessline')];
  const queue=[]; let active=0; const MAX=6;
  function pump(){ while(active<MAX && queue.length){ fetchOne(queue.shift()); } }
  function fetchOne(el){
    const addr=el.dataset.addr, city=el.dataset.city, key=(addr+'|'+city).toLowerCase();
    if(window.ASSESS[key]!==undefined){ el.innerHTML=assessLine(window.ASSESS[key]); pump(); return; }
    active++;
    fetch('/api/property-history?address='+encodeURIComponent(addr)+'&city='+encodeURIComponent(city||''))
      .then(r=>r.json()).then(d=>{ window.ASSESS[key]=d; el.innerHTML=assessLine(d); })
      .catch(()=>{ el.innerHTML='<span class="na">📋 assessor lookup failed</span>'; })
      .finally(()=>{ active--; pump(); });
  }
  // As cards approach the viewport, queue them for enrichment (concurrency-capped).
  assessObs=new IntersectionObserver((ents,obs)=>{ ents.forEach(en=>{ if(en.isIntersecting){ obs.unobserve(en.target); queue.push(en.target); pump(); } }); },{rootMargin:'400px'});
  els.forEach(el=>assessObs.observe(el));
  // Eager first batch so the top of the grid always resolves (no dependence on a scroll event).
  els.slice(0,30).forEach(el=>{ assessObs.unobserve(el); queue.push(el); }); pump();
}

// ---- the rich filter pass ----
function applyFilters(list){
  return list.filter(p=>{
    if(!idxSearchPass(p)) return false;
    if(F.types.size && !F.types.has(p.type)) return false;
    if(F.firms.size && !F.firms.has(firmLabel(p))) return false;
    if(F.cityQuery){ const hay=((p.city||'')+' '+(p.address||'')).toLowerCase(); if(!hay.includes(F.cityQuery)) return false; }
    if(F.priceMin!=null && p.price<F.priceMin) return false;
    if(F.priceMax!=null && p.price>F.priceMax) return false;
    if(F.capMin>0 && !(p.cap_rate!=null && p.cap_rate>=F.capMin)) return false;
    if(F.unitsMin!=null && !(p.units>=F.unitsMin)) return false;
    if(F.unitsMax!=null && !(p.units!=null && p.units<=F.unitsMax)) return false;
    if(F.yearMin!=null && !(p.year_built && p.year_built>=F.yearMin)) return false;
    if(F.yearMax!=null && !(p.year_built && p.year_built<=F.yearMax)) return false;
    if(F.status==='active' && /Under Contract|Pending|OUT OF BUDGET/i.test(p.status)) return false;
    if(F.afford && !p.finance.affordable) return false;
    if(F.unwarrantable && !(p.isCondo && p.warrantable_status && p.warrantable_status!=='fha_approved')) return false;
    if(F.nonqm && !isNonQM(p)) return false;
    if(F.hasPhone||F.hasEmail){ const ci=agentInfo(p); if(F.hasPhone && !ci.phone) return false; if(F.hasEmail && !ci.email) return false; }
    if(F.shortlist && !SHORT.has(p.id)) return false;
    return true;
  });
}
// Match everything EXCEPT one facet dimension — powers live facet counts (Amazon-style narrowing).
function passExcept(p, skip){
  if(!idxSearchPass(p)) return false;
  if(skip!=='type' && F.types.size && !F.types.has(p.type)) return false;
  if(skip!=='firm' && F.firms.size && !F.firms.has(firmLabel(p))) return false;
  if(skip!=='city' && F.cityQuery){ const hay=((p.city||'')+' '+(p.address||'')).toLowerCase(); if(!hay.includes(F.cityQuery)) return false; }
  if(F.priceMin!=null && p.price<F.priceMin) return false;
  if(F.priceMax!=null && p.price>F.priceMax) return false;
  if(F.capMin>0 && !(p.cap_rate!=null && p.cap_rate>=F.capMin)) return false;
  if(F.unitsMin!=null && !(p.units>=F.unitsMin)) return false;
  if(F.unitsMax!=null && !(p.units!=null && p.units<=F.unitsMax)) return false;
  if(F.yearMin!=null && !(p.year_built && p.year_built>=F.yearMin)) return false;
  if(F.yearMax!=null && !(p.year_built && p.year_built<=F.yearMax)) return false;
  if(F.status==='active' && /Under Contract|Pending|OUT OF BUDGET/i.test(p.status)) return false;
  if(F.afford && !p.finance.affordable) return false;
  if(F.unwarrantable && !(p.isCondo && p.warrantable_status && p.warrantable_status!=='fha_approved')) return false;
  if(F.nonqm && !isNonQM(p)) return false;
  if(F.hasPhone||F.hasEmail){ const ci=agentInfo(p); if(F.hasPhone && !ci.phone) return false; if(F.hasEmail && !ci.email) return false; }
  if(F.shortlist && !SHORT.has(p.id)) return false;
  return true;
}
function updateFacetCounts(){
  const tc={}, fc={}, cc={};
  DATA.ranked.forEach(p=>{
    if(passExcept(p,'type')) tc[p.type]=(tc[p.type]||0)+1;
    if(passExcept(p,'firm')){ const fl=firmLabel(p); fc[fl]=(fc[fl]||0)+1; }
    if(p.city && passExcept(p,'city')) cc[p.city]=(cc[p.city]||0)+1;
  });
  const paint=(sel,attr,map)=>$$(sel).forEach(c=>{ const key=c.dataset[attr]; if(key==null) return; const ct=c.querySelector('.ct'); const n=map[key]||0; if(ct) ct.textContent=n; c.classList.toggle('zero', n===0 && !c.classList.contains('active')); });
  paint('#fType .chip','t',tc); paint('#fFirm .chip','fm',fc); paint('#fCityChips .chip','cy',cc);
}
function activeFilterCount(){
  let n=0;
  if(F.types.size) n++; if(F.firms.size) n++; if(F.cityQuery) n++;
  if(F.priceMin!=null||F.priceMax!=null) n++; if(F.capMin>0) n++;
  if(F.unitsMin!=null) n++; if(F.unitsMax!=null) n++; if(F.yearMin!=null) n++; if(F.yearMax!=null) n++;
  if(F.status!=='all') n++; if(F.afford) n++; if(F.unwarrantable) n++; if(F.nonqm) n++; if(F.shortlist) n++; if(F.hasPhone) n++; if(F.hasEmail) n++;
  return n;
}

const SORTS={ deal:(a,b)=>dealNum(b)-dealNum(a), composite:(a,b)=>b.composite-a.composite, coc:(a,b)=>num(b.finance.coc)-num(a.finance.coc),
  yield:(a,b)=>num(b.finance.grossYield)-num(a.finance.grossYield),
  cap:(a,b)=>num(b.cap_rate)-num(a.cap_rate), dscr:(a,b)=>num(b.finance.dscr)-num(a.finance.dscr),
  priceAsc:(a,b)=>a.price-b.price, priceDesc:(a,b)=>b.price-a.price,
  cashNeeded:(a,b)=>a.finance.cashNeeded-b.finance.cashNeeded, unitsDesc:(a,b)=>num(b.units)-num(a.units),
  rentRoi:(a,b)=>rentRoiScore(b)-rentRoiScore(a) };
const num=v=>v==null?-1e9:v;
const dealNum=p=>(p.deal && p.deal.score!=null) ? p.deal.score : -1e9;  // unscored sink to the bottom

// ---- active-filter summary bar (removable chips + reset) ----
function renderActiveBar(){
  const bar=$('#activebar'); if(!bar) return; const chips=[];
  F.types.forEach(t=>chips.push(['type:'+t, t]));
  F.firms.forEach(f=>chips.push(['firm:'+f, '🏢 '+f]));
  if(F.cityQuery) chips.push(['city', 'city ~ “'+F.cityQuery+'”']);
  if(F.priceMin!=null) chips.push(['priceMin', '≥ $'+(+F.priceMin).toLocaleString()]);
  if(F.priceMax!=null) chips.push(['priceMax', '≤ $'+(+F.priceMax).toLocaleString()]);
  if(F.capMin>0) chips.push(['capMin', 'cap ≥ '+F.capMin+'%']);
  if(F.unitsMin!=null) chips.push(['unitsMin', '≥ '+F.unitsMin+' units']);
  if(F.unitsMax!=null) chips.push(['unitsMax', '≤ '+F.unitsMax+' units']);
  if(F.yearMin!=null) chips.push(['yearMin', 'built ≥ '+F.yearMin]);
  if(F.yearMax!=null) chips.push(['yearMax', 'built ≤ '+F.yearMax]);
  if(F.status!=='all') chips.push(['status', 'active only']);
  if(F.afford) chips.push(['afford', 'in budget']);
  if(F.unwarrantable) chips.push(['unwarrantable', '⚑ unwarrantable']);
  if(F.nonqm) chips.push(['nonqm', '◆ non-QM']);
  if(F.hasPhone) chips.push(['hasPhone', '☎ has phone']);
  if(F.hasEmail) chips.push(['hasEmail', '✉ has email']);
  if(F.shortlist) chips.push(['shortlist', '⭐ shortlist']);
  bar.innerHTML = chips.length
    ? '<span class="lbl">Filtering</span>'+chips.map(([k,l])=>`<span class="afchip" data-clear="${k.replace(/"/g,'&quot;')}">${esc(l)} <span class="xx">✕</span></span>`).join('')+'<button class="afreset" id="afreset">Reset all ✕</button>'
    : '';
}
function clearFilter(spec){
  if(spec.startsWith('type:')){ F.types.delete(spec.slice(5)); if(!F.types.has('Condo')) F.unwarrantable=false; }
  else if(spec.startsWith('firm:')){ F.firms.delete(spec.slice(5)); }
  else if(spec==='city'){ F.cityQuery=''; if($('#fCity')) $('#fCity').value=''; }
  else if(spec==='priceMin'){ F.priceMin=null; if($('#fPriceMin')) $('#fPriceMin').value=''; }
  else if(spec==='priceMax'){ F.priceMax=null; if($('#fPriceMax')) $('#fPriceMax').value=''; }
  else if(spec==='capMin'){ F.capMin=0; if($('#fCap')){ $('#fCap').value=0; $('#capVal').textContent='any'; } }
  else if(spec==='unitsMin'){ F.unitsMin=null; if($('#fUnits')) $('#fUnits').value=''; }
  else if(spec==='unitsMax'){ F.unitsMax=null; if($('#fUnitsMax')) $('#fUnitsMax').value=''; }
  else if(spec==='yearMin'){ F.yearMin=null; if($('#fYear')) $('#fYear').value=''; }
  else if(spec==='yearMax'){ F.yearMax=null; if($('#fYearMax')) $('#fYearMax').value=''; }
  else if(spec==='status'){ F.status='all'; }
  else if(spec==='afford'){ F.afford=false; if($('#fAfford')) $('#fAfford').checked=false; }
  else if(spec==='unwarrantable'){ F.unwarrantable=false; }
  else if(spec==='nonqm'){ F.nonqm=false; }
  else if(spec==='hasPhone'){ F.hasPhone=false; if($('#fHasPhone')) $('#fHasPhone').checked=false; }
  else if(spec==='hasEmail'){ F.hasEmail=false; if($('#fHasEmail')) $('#fHasEmail').checked=false; }
  else if(spec==='shortlist'){ F.shortlist=false; }
  syncChips(); render();
}
// ---- shareable URL state (every filter/sort/view/scenario ↔ query string) ----
function stateToURL(){
  const p=new URLSearchParams();
  if(F.types.size) p.set('t',[...F.types].join(','));
  if(F.firms.size) p.set('fm',[...F.firms].join('~'));
  if(F.cityQuery) p.set('city',F.cityQuery);
  if(F.priceMin!=null) p.set('pmin',F.priceMin);
  if(F.priceMax!=null) p.set('pmax',F.priceMax);
  if(F.capMin>0) p.set('cap',F.capMin);
  if(F.unitsMin!=null) p.set('u',F.unitsMin);
  if(F.unitsMax!=null) p.set('ux',F.unitsMax);
  if(F.yearMin!=null) p.set('yr',F.yearMin);
  if(F.yearMax!=null) p.set('yrx',F.yearMax);
  if(F.status!=='all') p.set('st',F.status);
  if(F.afford) p.set('aff','1');
  if(F.unwarrantable) p.set('unw','1');
  if(F.nonqm) p.set('nq','1');
  if(F.hasPhone) p.set('hp','1');
  if(F.hasEmail) p.set('he','1');
  if(scenario!=='25') p.set('sc',scenario);
  const so=$('#sort')&&$('#sort').value; if(so && so!=='deal') p.set('sort',so);
  if(colSort) p.set('cs',colSort.key+':'+(colSort.dir>0?'a':'d'));
  if(heat!=='none') p.set('ht',heat);
  if(view!=='grid') p.set('v',view);
  const qs=p.toString();
  // NOTE: the app declares a local `history` (chat log) that shadows window.history — must qualify.
  try{ window.history.replaceState(null,'',location.pathname+(qs?('?'+qs):'')); }catch(e){}
}
// Snapshot the ORIGINAL query at first script-eval — an early render() can call stateToURL()
// and rewrite location.search (it drops ?v= whenever the current view isn't grid) BEFORE
// urlToState() runs, clobbering a shared ?v=/?t= link. Read shared state from this frozen copy.
const INITIAL_SEARCH = location.search;
function urlToState(){
  const p=new URLSearchParams(INITIAL_SEARCH); let any=false;
  if(p.get('t')){ F.types=new Set(p.get('t').split(',').filter(Boolean)); any=true; }
  if(p.get('fm')){ F.firms=new Set(p.get('fm').split('~').filter(Boolean)); any=true; }
  if(p.get('city')){ F.cityQuery=p.get('city'); if($('#fCity'))$('#fCity').value=F.cityQuery; any=true; }
  if(p.get('pmin')){ F.priceMin=+p.get('pmin'); if($('#fPriceMin'))$('#fPriceMin').value=F.priceMin; any=true; }
  if(p.get('pmax')){ F.priceMax=+p.get('pmax'); if($('#fPriceMax'))$('#fPriceMax').value=F.priceMax; any=true; }
  if(p.get('cap')){ F.capMin=+p.get('cap'); if($('#fCap')){$('#fCap').value=F.capMin; $('#capVal').textContent=F.capMin+'%+';} any=true; }
  if(p.get('u')){ F.unitsMin=+p.get('u'); if($('#fUnits'))$('#fUnits').value=F.unitsMin; any=true; }
  if(p.get('ux')){ F.unitsMax=+p.get('ux'); if($('#fUnitsMax'))$('#fUnitsMax').value=F.unitsMax; any=true; }
  if(p.get('yr')){ F.yearMin=+p.get('yr'); if($('#fYear'))$('#fYear').value=F.yearMin; any=true; }
  if(p.get('yrx')){ F.yearMax=+p.get('yrx'); if($('#fYearMax'))$('#fYearMax').value=F.yearMax; any=true; }
  if(p.get('st')){ F.status=p.get('st'); any=true; }
  if(p.get('aff')==='1'){ F.afford=true; if($('#fAfford'))$('#fAfford').checked=true; any=true; }
  if(p.get('unw')==='1'){ F.unwarrantable=true; any=true; }
  if(p.get('nq')==='1'){ F.nonqm=true; any=true; }
  if(p.get('hp')==='1'){ F.hasPhone=true; if($('#fHasPhone'))$('#fHasPhone').checked=true; any=true; }
  if(p.get('he')==='1'){ F.hasEmail=true; if($('#fHasEmail'))$('#fHasEmail').checked=true; any=true; }
  if(p.get('sc')){ scenario=p.get('sc'); }
  if(p.get('sort')&&$('#sort')){ $('#sort').value=p.get('sort'); if($('#sortTop'))$('#sortTop').value=p.get('sort'); }
  if(p.get('v')){ view=p.get('v'); }
  if(p.get('cs')){ const [k,d]=p.get('cs').split(':'); colSort={key:k,dir:d==='a'?1:-1}; }
  if(p.get('ht')){ heat=p.get('ht'); }
  return any;
}
function csvEsc(v){ v=(v==null?'':String(v)); return '"'+v.replace(/"/g,'""')+'"'; }
function exportCSV(){
  const cols=listCols();
  const rows=sortRows(applyFilters(DATA.ranked.slice()));
  const head=['Address','City','ZIP'].concat(cols.map(c=>c.label));
  const lines=[head.map(csvEsc).join(',')];
  rows.forEach(p=>{ const row=[p.address,p.city,p.zip].concat(cols.map(c=>fieldVal(p,c.k))); lines.push(row.map(csvEsc).join(',')); });
  const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([lines.join('\n')],{type:'text/csv'})); a.download='cre-explorer-'+rows.length+'.csv'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000);
}
function copyShareLink(){ const t=$('#linkbtn'); try{ navigator.clipboard.writeText(location.href); if(t){ const o=t.textContent; t.textContent='✓ Copied'; setTimeout(()=>t.textContent=o,1400); } }catch(e){ if(t) t.textContent='⌘C to copy'; } }
// ---- side-by-side compare of shortlisted listings ----
const CMP=[
  {l:'Type', f:p=>esc(p.type||'—')},
  {l:'Price', f:p=>fmt(p.price)},
  {l:'$/unit', f:p=>{const v=ppu(p);return v?'$'+v.toLocaleString():'—';}},
  {l:'$/SF', f:p=>{const v=ppsf(p);return v?'$'+v.toLocaleString():'—';}},
  {l:'Units', f:p=>p.units??'—'},
  {l:'Cap rate', f:p=>p.cap_rate!=null?p.cap_rate+'%':'—'},
  {l:'DSCR', f:p=>p.finance&&p.finance.dscr!=null?p.finance.dscr:'—'},
  {l:'Cash-on-cash', f:p=>p.finance&&p.finance.coc!=null?p.finance.coc+'%':'—'},
  {l:'Cash needed', f:p=>p.finance?fmt(p.finance.cashNeeded):'—'},
  {l:'Cash flow/yr', f:p=>p.finance?fmt(p.finance.cashFlow):'—'},
  {l:'Est. rent', f:p=>{const e=rentEstimate(p);return e?'$'+e.std.toLocaleString()+'/mo':'—';}},
  {l:'Beds / Baths', f:p=>(p.beds!=null||p.baths!=null)?((p.beds??'—')+' / '+(p.baths??'—')):'—'},
  {l:'SqFt', f:p=>p.sqft?(+p.sqft).toLocaleString():'—'},
  {l:'Year built', f:p=>p.year_built||'—'},
  {l:'Warrantability', f:p=>esc(p.warrantable_status||'—')},
  {l:'Deal score', f:p=>p.deal&&p.deal.score!=null?p.deal.score:'—'},
  {l:'Invest score', f:p=>p.composite??'—'},
];
function openCompare(){
  const ov=$('#cmpov'), body=$('#cmpbody');
  const items=DATA.ranked.filter(p=>SHORT.has(p.id));
  if(items.length<2){ body.innerHTML='<div class="cmphint">Star at least 2 listings (the ★ on any card) to compare them side by side.</div>'; ov.classList.add('on'); return; }
  const head='<tr><th class="cmpk" scope="col"></th>'+items.map(p=>`<th scope="col">${esc(p.address)}<div class="cmpsub">${esc(p.city)}</div></th>`).join('')+'</tr>';
  const rows=CMP.map(m=>`<tr><td class="cmpk">${m.l}</td>`+items.map(p=>`<td>${m.f(p)}</td>`).join('')+'</tr>').join('');
  body.innerHTML=`<div class="cmptools no-print"><button id="cmpcsv">⬇ CSV</button><button id="cmpprint">🖨 Print</button></div><table class="cmptbl"><thead>${head}</thead><tbody>${rows}</tbody></table>`;
  ov.classList.add('on');
}
function decodeEnt(s){ return String(s).replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'"').replace(/&#39;/g,"'"); }
function exportCompareCSV(){
  const items=DATA.ranked.filter(p=>SHORT.has(p.id)); if(items.length<2) return;
  const cols=['Address','City'].concat(CMP.map(m=>m.l));
  const lines=[cols.map(csvEsc).join(',')];
  items.forEach(p=>{ const row=[p.address,p.city].concat(CMP.map(m=>decodeEnt(m.f(p)))); lines.push(row.map(csvEsc).join(',')); });
  const a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([lines.join('\n')],{type:'text/csv'})); a.download='cre-compare-'+items.length+'.csv'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000);
}
function printCompare(){ document.body.classList.add('printing-compare'); window.print(); }
// ---- paginated DOM (perf): render in pages, "Show more" appends ----
let _rows=[], _shown=0;
const PAGE=()=> view==='grid'?60:200;
function moreBtn(){ return _shown<_rows.length ? `<div class="showmore-wrap"><button class="showmore" id="showmore">Show ${Math.min(PAGE(),_rows.length-_shown)} more · showing ${_shown} of ${_rows.length}</button></div>` : ''; }
// Infinite scroll: auto-trigger the "show more" grow when it scrolls near view (reuses the
// existing #showmore click handler → _shown+=PAGE → paintPage). Viewport-rooted so it works
// whether the page or an inner pane scrolls. Re-pointed at the fresh button after each repaint.
let _moreIO=null, _moreBusy=false;
function wireAutoMore(){
  const btn=document.getElementById('showmore'); if(!btn) return;
  if(typeof IntersectionObserver==='undefined') return;
  if(!_moreIO) _moreIO=new IntersectionObserver(es=>{ if(_moreBusy) return; if(es.some(e=>e.isIntersecting)){ const b=document.getElementById('showmore'); if(b){ _moreBusy=true; b.click(); setTimeout(()=>{_moreBusy=false;},60); } } },{rootMargin:'500px 0px'});
  _moreIO.disconnect(); _moreIO.observe(btn);
}
function paintPage(){
  const g=$('#grid'); const r=_rows; const slice=r.slice(0,_shown);
  if(!r.length){ g.className='grid'; g.innerHTML=`<div class="empty">No properties match these filters.<br><span class="small">Loosen a filter or hit “Reset all”.</span></div>`; return; }
  if(view==='table'){                                    // full columnar spreadsheet (sortable + drag-reorder + resizable cols)
    g.className='listhost'; g.innerHTML=renderTableGrouped(slice)+moreBtn();
    const csig=listCols().map(c=>c.k).join(',');
    if(csig!==lastColSig){ lastColSig=csig; reinitResize(); }
  }
  else if(view==='kv'){                                  // key→value block per record (deep inspection)
    g.className='kvhost'; g.innerHTML=renderKVGrouped(slice)+moreBtn();
  }
  else if(view==='list'){                                // compact one-row-per-record: identity + inline summary chips
    g.className='compacthost'; g.innerHTML=renderCompactGrouped(slice)+moreBtn();
  }
  else { g.className='grid'; g.innerHTML=groupWrap(slice,card,'grid')+moreBtn(); enrichAssessor(); }
  applyHeat(slice);
  resolveContacts(slice);   // fetch REAL phones for the visible agents, then repaint (TK-10687)
  wireAutoMore();           // convert the show-more button into infinite scroll
}
// ===== nav-agent inline view renderers: List (compact) / KV / Table + group-by-any-field =====
// The rail sort already ordered rows; sortRows() also front-sorts by the group key when groupBy is set,
// so groups arrive contiguous here — we just detect a key change and emit a header band.
function groupKeyLabel(p){ if(!groupBy) return ''; let v=fieldVal(p,groupBy); if(v==null||v==='') return '(none)'; return String(v); }
// Generic grouping wrapper for non-table views: emits a heading band before each group.
function groupWrap(rows, renderOne, kind){
  if(!groupBy){ return rows.map(renderOne).join(''); }
  let out='', last=null;
  rows.forEach(p=>{ const gk=groupKeyLabel(p); if(gk!==last){ last=gk;
    out+=`<div class="grouphead grouphead-${kind}">${esc(fieldLabel(groupBy))}: <b>${esc(gk)}</b></div>`; }
    out+=renderOne(p); });
  return out;
}
function fieldLabel(k){ if(k==='addr') return 'Property'; const f=FIELDS.find(x=>x.k===k); return f?f.label:k; }
// ---- compact List: address identity + inline summary chips from the visible columns ----
function compactChips(p){
  const cols=listCols().slice(0,7);   // a handful of the visible fields as inline chips (keeps the row light)
  return cols.map(c=>`<span class="cchip"><span class="cck">${esc(c.label)}</span> ${colCell(c.k,p)}</span>`).join('');
}
function renderCompactRow(p){
  return `<div class="crow" data-id="${p.id}">`
    +`<button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="${SHORT.has(p.id)?'Remove from':'Add to'} shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist">★</button>`
    +`<div class="cid"><span class="cla">${esc(p.address)}</span><span class="clc">${esc(p.city)}, CA ${p.zip||''}</span></div>`
    +`<div class="cfields">${compactChips(p)}</div></div>`;
}
function renderCompactGrouped(rows){ return `<div class="compactlist">${groupWrap(rows,renderCompactRow,'compact')}</div>`; }
// ---- KV: each record a labeled key→value block, every visible field vertical ----
function renderKVCard(p){
  const cols=listCols();
  const rowsHtml=[`<div class="kvrow"><span class="kvk">Property</span><span class="kvv">${esc(p.address)}, ${esc(p.city)} CA ${p.zip||''}</span></div>`]
    .concat(cols.map(c=>`<div class="kvrow"><span class="kvk">${esc(c.label)}</span><span class="kvv">${colCell(c.k,p)}</span></div>`)).join('');
  return `<div class="kvcard" data-id="${p.id}">`
    +`<div class="kvhead"><button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="Shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist">★</button><span class="kvaddr">${esc(p.address)}</span></div>`
    +rowsHtml+`</div>`;
}
function renderKVGrouped(rows){ return `<div class="kvgrid">${groupWrap(rows,renderKVCard,'kv')}</div>`; }
// ---- Table: reuse the full renderList() spreadsheet, injecting group header rows when grouped ----
function renderTableGrouped(rows){
  if(!groupBy) return renderList(rows);
  // Build the grouped body DIRECTLY (no regex-splice of renderList's output) so col-resize sees the
  // same thead/table shell and we never rebuild a body just to discard it.
  const ncols=listCols().length+1;
  let bodyRows='', last=null;
  rows.forEach(p=>{ const gk=groupKeyLabel(p);
    if(gk!==last){ last=gk; bodyRows+=`<tr class="grouprow"><td class="groupcell" colspan="${ncols}">${esc(fieldLabel(groupBy))}: <b>${esc(gk)}</b></td></tr>`; }
    bodyRows+=renderListBodyRow(p); });
  return renderListShell(bodyRows);
}
// The table shell (listwrap + thead), body injected. Shared by renderList() and the grouped table so
// the <thead>/sortable/drag headers are identical in both paths (col-resize signature stays stable).
function renderListShell(bodyHtml){
  const cols=listCols();
  const arrow=k=>colSort&&colSort.key===k?(colSort.dir>0?' asc':' desc'):'';
  const head='<th scope="col" class="stick sortable'+arrow('addr')+'" data-sortk="addr" title="Sort by address">Property</th>'+cols.map(c=>`<th scope="col" class="sortable dragcol${arrow(c.k)}" draggable="true" data-sortk="${c.k}" data-k="${c.k}" title="Drag to reorder · click to sort by ${esc(c.label)}">${esc(c.label)}</th>`).join('');
  return `<div class="listwrap"><table class="listtbl" data-no-sort><thead><tr>${head}</tr></thead><tbody>${bodyHtml}</tbody></table></div>`;
}
// One <tr> body row, factored out of renderList so the grouped table can interleave group headers.
function renderListBodyRow(p){
  const cols=listCols();
  return `<tr class="lrow" data-id="${p.id}">`
    +`<td class="stick"><button class="starbtn ${SHORT.has(p.id)?'on':''}" data-star="${p.id}" aria-label="${SHORT.has(p.id)?'Remove from':'Add to'} shortlist" aria-pressed="${SHORT.has(p.id)}" title="Shortlist">★</button> <span class="la">${esc(p.address)}</span><div class="lc">${esc(p.city)}, CA ${p.zip||''}</div>${sourceLink(p)?`<div class="lsrc">${sourceLink(p)}</div>`:''}</td>`
    +cols.map(c=>`<td>${colCell(c.k,p)}</td>`).join('')+`</tr>`;
}
// ---- saved searches (name + restore the full shareable-URL state) ----
const SAVED=(function(){ try{ return JSON.parse(localStorage.getItem('cre_saved')||'[]'); }catch(e){ return []; } })();
function saveSaved(){ try{ localStorage.setItem('cre_saved', JSON.stringify(SAVED)); }catch(e){} }
function buildSavedList(){ const el=$('#savedList'); if(!el) return;
  el.innerHTML = SAVED.length
    ? SAVED.map((s,i)=>`<div class="savedrow"><button class="savedname" data-qs="${(s.qs||'').replace(/"/g,'&quot;')}" title="Restore this saved view">📁 ${esc(s.name)}</button><button class="saveddel" data-del="${i}" aria-label="Delete saved search">✕</button></div>`).join('')
    : '<div class="savedempty">None yet — filter, then “Save current view”.</div>';
}
// ---- per-listing clipboard summary (honest fields only) ----
function cardSummary(p){ const f=p.finance||{}; const L=[];
  L.push(`${p.address}, ${p.city} CA ${p.zip||''}`.trim());
  const l2=[p.type, p.units?`${p.units} unit${p.units>1?'s':''}`:null, p.price?fmt(p.price):null].filter(Boolean).join(' · '); if(l2) L.push(l2);
  const m=[]; if(p.cap_rate!=null) m.push('Cap '+p.cap_rate+'%'); if(ppu(p)&&!p.isCondo) m.push('$'+ppu(p).toLocaleString()+'/unit'); if(ppsf(p)) m.push('$'+ppsf(p).toLocaleString()+'/SF'); if(m.length) L.push(m.join(' · '));
  const fin=[]; if(f.dscr!=null) fin.push('DSCR '+f.dscr); if(f.coc!=null) fin.push('CoC '+f.coc+'%'); if(f.cashNeeded!=null) fin.push('Cash needed '+fmt(f.cashNeeded)); if(f.cashFlow!=null) fin.push('Cash flow/yr '+fmt(f.cashFlow)); if(fin.length) L.push(fin.join(' · '));
  const r=rentEstimate(p); const ex=[]; if(r) ex.push('Est. rent $'+r.std.toLocaleString()+'/mo'); if(p.deal&&p.deal.score!=null) ex.push('Deal score '+p.deal.score+'/100'); if(p.warrantable_status) ex.push('Warrantability: '+p.warrantable_status); if(ex.length) L.push(ex.join(' · '));
  if(p.source) L.push(p.source);
  return L.join('\n');
}
function copySummary(id, btn){ const p=DATA.ranked.find(x=>x.id===id); if(!p) return;
  const done=()=>{ if(btn){ btn.textContent='✓'; setTimeout(()=>btn.textContent='📋',1200); } };
  try{ navigator.clipboard.writeText(cardSummary(p)).then(done).catch(done); }catch(e){ done(); }
}
function applyQuick(k){
  if(k==='condo'){ F.types=new Set(['Condo']); }
  else if(k==='mf'){ F.types=new Set(['Multifamily']); }
  else if(k==='nonqm'){ F.nonqm=true; }
  else if(k==='unw'){ F.unwarrantable=true; F.types=new Set(['Condo']); }
  else if(k==='afford'){ F.afford=true; if($('#fAfford')) $('#fAfford').checked=true; }
  else if(k==='short'){ F.shortlist=true; }
  syncChips(); render();
}
function median(a){ if(!a.length) return null; const s=a.slice().sort((x,y)=>x-y); const m=Math.floor(s.length/2); return s.length%2?s[m]:(s[m-1]+s[m])/2; }
function renderStats(r){
  const sb=$('#statsbar'); if(!sb) return;
  if(!r||!r.length){ sb.innerHTML=''; return; }
  const prices=r.map(p=>p.price).filter(v=>v>0);
  const ppus=r.map(p=>ppu(p)).filter(v=>v>0);
  const ppsfs=r.map(p=>ppsf(p)).filter(v=>v>0);
  const caps=r.map(p=>p.cap_rate).filter(v=>v!=null);
  const aff=r.filter(p=>p.finance&&p.finance.affordable).length;
  const rents=r.map(p=>{ const e=rentEstimate(p); return e?e.std:null; }).filter(v=>v);
  const pill=(l,v)=>v==null?'':`<span class="statpill"><span class="sl">${l}</span> <b>${v}</b></span>`;
  sb.innerHTML =
    pill('Median price', prices.length?'$'+Math.round(median(prices)).toLocaleString():null)
    +pill('Median $/unit', ppus.length?'$'+Math.round(median(ppus)).toLocaleString():null)
    +pill('Median $/SF', ppsfs.length?'$'+Math.round(median(ppsfs)).toLocaleString():null)
    +pill('Avg cap', caps.length?(caps.reduce((a,b)=>a+(+b),0)/caps.length).toFixed(2)+'% ('+caps.length+' disc.)':null)
    +pill('Median est. rent', rents.length?'$'+Math.round(median(rents)).toLocaleString()+'/mo':null)
    +pill('In budget', aff+' of '+r.length);
}
function render(){
  const g=$('#grid'); const fc=activeFilterCount();
  $('#fcount').textContent=fc; $('#fcount').classList.toggle('on',fc>0);
  { const sb=$('#shortbtn'); if(sb){ sb.textContent='⭐ Shortlist ('+SHORT.size+')'; sb.classList.toggle('shorton',F.shortlist); } }
  renderActiveBar(); stateToURL(); updateFacetCounts();
  // Wait-for-selection: on a clean load (no filter chosen) don't dump 2,134 cards — prompt for a pick.
  // A free-text search counts as a selection, so typing in the search box shows results immediately.
  if(fc===0 && !F.cityQuery && !idxQuery){
    g.className='grid';
    g.innerHTML=`<div class="empty">👈 Expand a filter tab on the left — or start with one of these:
      <div class="quickstart">
        <button class="qchip" data-quick="condo">🏢 Condos</button>
        <button class="qchip" data-quick="mf">🏠 Multifamily</button>
        <button class="qchip" data-quick="nonqm">◆ Non-QM</button>
        <button class="qchip" data-quick="unw">⚑ Unwarrantable</button>
        <button class="qchip" data-quick="afford">✓ In budget</button>
        ${SHORT.size?`<button class="qchip" data-quick="short">⭐ My shortlist (${SHORT.size})</button>`:''}
      </div>
      <span class="small">${(DATA.ranked.length).toLocaleString()} listings loaded · ${DATA.meta.market} — results appear instantly; your active filters show as removable chips up top.</span></div>`;
    $('#count').innerHTML=`<b>${(DATA.ranked.length).toLocaleString()}</b> loaded · choose a filter to view`;
    $('#statsbar').innerHTML='';
    saveState(); return;
  }
  let r = applyFilters(DATA.ranked.slice());
  sortRows(r);
  renderStats(r);
  _rows=r; _shown=Math.min(PAGE(), r.length);   // paginate the DOM; stats/count/facets still use the full set
  paintPage();
  $('#count').innerHTML = `<b>${r.length}</b> of ${DATA.ranked.length} properties${fc?` · ${fc} filter${fc>1?'s':''} active`:''}`;
  $('#foot').textContent = `${scenario==='cash'?'all-cash':SCEN[scenario].downPct+'% down @ '+SCEN[scenario].ratePct+'%'} · * →X% = projected cap after value-add (not in-place). Cap rates broker-stated — verify NOI in DD.`;
  saveState();
}

// ---- build the filter UI from the data ----
function buildFacets(){
  const tCount={}, fCount={}, cCount={};
  DATA.ranked.forEach(p=>{ tCount[p.type]=(tCount[p.type]||0)+1; const fl=firmLabel(p); fCount[fl]=(fCount[fl]||0)+1; if(p.city) cCount[p.city]=(cCount[p.city]||0)+1; });
  const chipHTML=(obj,key)=>Object.entries(obj).sort((a,b)=>b[1]-a[1])
    .map(([k,c])=>`<span class="chip" data-${key}="${k.replace(/"/g,'&quot;')}">${k}<span class="ct">${c}</span></span>`).join('');
  // Condos are merged into the grid as a real 'Condo' asset type (chip comes from the data below).
  // Prepend one special in-grid filter: ⚑ Unwarrantable = Condo AND not FHA-approved (non-QM target).
  const nonqmChip='<span class="chip" data-warr="nonqm" style="border-color:rgba(188,140,255,.55);color:var(--purp,#bc8cff)" title="Non-QM target set: unwarrantable condos + small multifamily (1–9 units) + mixed-use">◆ Non-QM</span>';
  const unwChip='<span class="chip" data-warr="unwarrantable" style="border-color:rgba(248,81,73,.55);color:var(--bad)" title="Non-FHA-approved condos — filters the grid to non-QM targets">⚑ Unwarrantable</span>';
  $('#fType').innerHTML=nonqmChip+unwChip+chipHTML(tCount,'t');
  $('#fFirm').innerHTML=chipHTML(fCount,'fm');
  // top 14 cities as quick chips; the search box covers the rest
  const topCities=Object.entries(cCount).sort((a,b)=>b[1]-a[1]).slice(0,14);
  $('#fCityChips').innerHTML=topCities.map(([k,c])=>`<span class="chip" data-cy="${k.replace(/"/g,'&quot;')}">${k}<span class="ct">${c}</span></span>`).join('');
  // sort options mirrored into the top bar
  $('#sortTop').innerHTML=$('#sort').innerHTML;
}

// ---- chat context ----
function getContext(){
  const lines=[];
  lines.push(`MARKET: ${DATA.meta.market}. Budget: $400,000 down. Active scenario: ${scenario==='cash'?'ALL CASH':SCEN[scenario].downPct+'% down, '+SCEN[scenario].ratePct+'%/'+SCEN[scenario].amortYears+'yr'}.`);
  lines.push(`Active filters: types=[${[...F.types]}], firms=[${[...F.firms]}], city~"${F.cityQuery}", price ${F.priceMin||'any'}-${F.priceMax||'any'}, capMin=${F.capMin}, unitsMin=${F.unitsMin||'any'}, unitsMax=${F.unitsMax||'any'}, builtAfter=${F.yearMin||'any'}, builtBefore=${F.yearMax||'any'}, status=${F.status}, inBudgetOnly=${F.afford}, sort=${$('#sort').value}.`);
  lines.push('RANKED PROPERTIES (current scenario):');
  applyFilters(DATA.ranked).slice(0,120).forEach(p=>{ const f=p.finance;
    lines.push(`#${p.rank} id=${p.id} | ${p.address}, ${p.city} | ${p.type} | $${p.price.toLocaleString()} | ${p.units}u | cap ${p.cap_rate!=null?p.cap_rate+'%':'n/d'}${p.verified===true?' VERIFIED':''} | firm ${firmLabel(p)} | CoC ${f.coc!=null?f.coc+'%':'n/a'} | DSCR ${f.dscr??'n/a'} | cash needed $${f.cashNeeded.toLocaleString()} ${f.affordable?'(in budget)':'(OVER)'} | composite ${p.composite} | status ${p.status}`);
  });
  if(DATA.comps && DATA.comps.submarket_cap_benchmarks){
    lines.push('\nSUBMARKET CAP BENCHMARKS:');
    Object.entries(DATA.comps.submarket_cap_benchmarks).forEach(([k,v])=>lines.push(`- ${k}: ${v}`));
  }
  const lc = Object.entries(window.liveComps||{}).filter(([,v])=>v&&v.length);
  if(lc.length){ lines.push('\nLIVE COMPS THIS SESSION:'); lc.forEach(([id,arr])=>{ const p=DATA.ranked.find(x=>x.id===id); lines.push(`for ${p?p.address:id}:`); arr.forEach(c=>lines.push(`  - ${c.title} | ${[c.price,c.cap,c.units,c.sf].filter(Boolean).join(' · ')}`)); }); }
  return lines.join('\n');
}

// ---- chat-issued actions (extended for the rich filters) ----
const TYPE_ALIASES={multifamily:'Multifamily',retail:'Retail',office:'Office',industrial:'Industrial','mixed':'Mixed-use','mixed-use':'Mixed-use',land:'Land',hospitality:'Hospitality'};
function applyAction(a){
  const done=[];
  if(a.scenario && SCEN[String(a.scenario)]){ setScenario(String(a.scenario)); done.push('scenario→'+a.scenario); }
  if(a.sort && SORTS[a.sort]){ $('#sort').value=a.sort; $('#sortTop').value=a.sort; done.push('sort→'+a.sort); }
  const setType=v=>{ const key=TYPE_ALIASES[String(v).toLowerCase()]; if(key){ F.types=new Set([key]); done.push('type→'+key); } };
  if(a.type) setType(a.type);
  if(a.filter && a.filter!=='all' && a.filter!=='active' && a.filter!=='affordable') setType(a.filter);
  if(a.filter==='affordable'){ F.afford=true; done.push('in-budget only'); }
  if(a.filter==='active'){ F.status='active'; done.push('active only'); }
  if(a.filter==='all'){ F.types.clear(); F.afford=false; F.status='all'; done.push('cleared'); }
  if(a.firm){ F.firms=new Set([a.firm]); done.push('firm→'+a.firm); }
  if(a.city){ F.cityQuery=String(a.city).toLowerCase(); $('#fCity').value=a.city; done.push('city→'+a.city); }
  if(a.maxPrice!=null && !isNaN(+a.maxPrice)){ F.priceMax=+a.maxPrice; $('#fPriceMax').value=+a.maxPrice; done.push('max $'+(+a.maxPrice).toLocaleString()); }
  if(a.minPrice!=null && !isNaN(+a.minPrice)){ F.priceMin=+a.minPrice; $('#fPriceMin').value=+a.minPrice; done.push('min $'+(+a.minPrice).toLocaleString()); }
  if(a.capMin!=null && !isNaN(+a.capMin)){ F.capMin=+a.capMin; $('#fCap').value=+a.capMin; $('#capVal').textContent=F.capMin?F.capMin+'%+':'any'; done.push('cap ≥'+a.capMin+'%'); }
  if(a.unitsMin!=null && !isNaN(+a.unitsMin)){ F.unitsMin=+a.unitsMin; $('#fUnits').value=+a.unitsMin; done.push('units ≥'+a.unitsMin); }
  if(a.unitsMax!=null && !isNaN(+a.unitsMax)){ F.unitsMax=+a.unitsMax; $('#fUnitsMax').value=+a.unitsMax; done.push('units ≤'+a.unitsMax); }
  if(a.yearMin!=null && !isNaN(+a.yearMin)){ F.yearMin=+a.yearMin; $('#fYear').value=+a.yearMin; done.push('built ≥'+a.yearMin); }
  if(a.yearMax!=null && !isNaN(+a.yearMax)){ F.yearMax=+a.yearMax; $('#fYearMax').value=+a.yearMax; done.push('built ≤'+a.yearMax); }
  syncChips(); render();
  if(a.highlightId){ const el=$('#card-'+a.highlightId); if(el){ el.scrollIntoView({behavior:'smooth',block:'center'}); el.classList.add('flash'); setTimeout(()=>el.classList.remove('flash'),2200); done.push('→'+a.highlightId); } }
  return done;
}

function setScenario(s){ scenario=s; localStorage.setItem('cre_scenario',s);
  $$('#scenario button').forEach(b=>b.classList.toggle('active',b.dataset.s===s)); recompute(); }

function syncChips(){
  $$('#fType .chip').forEach(c=>{ if(c.dataset.warr==='unwarrantable') c.classList.toggle('active',F.unwarrantable); else if(c.dataset.warr==='nonqm') c.classList.toggle('active',F.nonqm); else c.classList.toggle('active',F.types.has(c.dataset.t)); });
  $$('#fFirm .chip').forEach(c=>c.classList.toggle('active',F.firms.has(c.dataset.fm)));
  $$('#fCityChips .chip').forEach(c=>c.classList.toggle('active',F.cityQuery===c.dataset.cy.toLowerCase()));
  $$('#fStatus .chip').forEach(c=>c.classList.toggle('active',F.status===c.dataset.st));
}

// ---- Top-of-page natural-language chat bar (client-side; drives F via applyAction) ----
const CHAT_TYPES=[['multifamily','Multifamily'],['multi-family','Multifamily'],['multi family','Multifamily'],['apartment','Multifamily'],['office','Office'],['industrial','Industrial'],['retail','Retail'],['mixed-use','Mixed-use'],['mixed use','Mixed-use'],['net lease','Retail'],['net-lease','Retail'],['nnn','Retail'],['land','Land'],['hotel','Hospitality'],['hospitality','Hospitality']];
function chatMoney(s){ s=String(s).toLowerCase().replace(/[,\s$]/g,''); let n=parseFloat(s); if(isNaN(n))return null; if(/m/.test(s))n*=1e6; else if(/k/.test(s))n*=1e3; return Math.round(n); }
function chatCities(){ try{ return [...new Set((DATA||[]).map(r=>(r.city||'').trim()).filter(Boolean))].sort((a,b)=>b.length-a.length); }catch(e){ return []; } }
function chatClear(){
  F.types.clear(); F.cityQuery=''; F.priceMin=F.priceMax=F.unitsMin=F.unitsMax=F.yearMin=F.yearMax=null; F.capMin=0; F.unwarrantable=F.nonqm=false;
  ['#fCity','#fPriceMin','#fPriceMax','#fUnits','#fUnitsMax','#fYear','#fYearMax'].forEach(x=>{const e=$(x); if(e)e.value='';});
  const c=$('#fCap'); if(c){ c.value=0; const v=$('#capVal'); if(v)v.textContent='any'; }
  syncChips(); render();
}
function applyChat(raw){
  const msg=$('#chatmsg'); const s=' '+String(raw||'').toLowerCase().replace(/\s+/g,' ').trim()+' ';
  if(!s.trim())return;
  if(/^\s*(clear|reset|all|everything|start over|show all)\s*$/.test(s)){ chatClear(); if(msg)msg.textContent='Cleared'; return; }
  // fresh search — clear chat-controlled fields + their drawer inputs first
  F.types.clear(); F.cityQuery=''; F.priceMin=F.priceMax=F.unitsMin=F.unitsMax=F.yearMin=F.yearMax=null; F.capMin=0; F.unwarrantable=F.nonqm=false;
  ['#fCity','#fPriceMin','#fPriceMax','#fUnits','#fUnitsMax','#fYear','#fYearMax'].forEach(x=>{const e=$(x); if(e)e.value='';});
  const a={}; const got=[]; let m;
  // warrantability / condo
  if(/unwarrantable|non-?warrantable|not warrantable/.test(s)){ F.unwarrantable=true; got.push('unwarrantable condos'); }
  if(/non-?qm\b|non qm/.test(s)){ F.nonqm=true; got.push('non-QM'); }
  if(/\bcondos?\b/.test(s) && !F.unwarrantable){ F.types.add('Condo'); got.push('Condo'); }
  // financing scenario (the seg now lives in the left drawer)
  if(/\ball\s*cash\b/.test(s)){ a.scenario='cash'; got.push('all cash'); }
  else if(/\b30\s*%?\s*down\b/.test(s)){ a.scenario='30'; got.push('30% down'); }
  else if(/\b25\s*%?\s*down\b/.test(s)){ a.scenario='25'; got.push('25% down'); }
  // units
  const WORDU={duplex:2,triplex:3,fourplex:4,fiveplex:5};
  for(const w in WORDU){ if(s.includes(' '+w)){ a.unitsMin=a.unitsMax=WORDU[w]; got.push(WORDU[w]+' units'); } }
  if(m=s.match(/(\d+)\s*(?:-|to)\s*(\d+)\s*unit/)){ a.unitsMin=+m[1]; a.unitsMax=+m[2]; got.push(m[1]+'–'+m[2]+' units'); }
  else if(m=s.match(/(?:under|below|less than|up to|max(?:imum)?|<)\s*(\d+)\s*unit/)){ a.unitsMax=+m[1]; got.push('≤'+m[1]+' units'); }
  else if(m=s.match(/(?:over|above|more than|at least|min(?:imum)?|\+|>)\s*(\d+)\s*unit/)){ a.unitsMin=+m[1]; got.push(m[1]+'+ units'); }
  else if(m=s.match(/(\d+)\s*unit/)){ a.unitsMin=a.unitsMax=+m[1]; got.push(m[1]+' units'); }
  // type (set on F directly so Condo/multi don't get clobbered by applyAction's single-type set)
  const types=[]; for(const [k,v] of CHAT_TYPES){ if(s.includes(' '+k) && !types.includes(v)) types.push(v); }
  types.forEach(v=>F.types.add(v)); if(types.length) got.push(types.join('+'));
  // city (known city names from the data; longest-first to avoid substring collisions)
  for(const c of chatCities()){ const cl=c.toLowerCase(); if(s.includes(' '+cl+' ') || s.includes(' in '+cl)){ a.city=c; got.push(c); break; } }
  // price
  if((m=s.match(/\$?\s*([\d.,]+\s*[mk]?)\s*(?:-|to)\s*\$?\s*([\d.,]+\s*[mk]?)/)) && /\$|[mk]\b/.test(m[0])){ a.minPrice=chatMoney(m[1]); a.maxPrice=chatMoney(m[2]); got.push('$'+m[1].trim()+'–$'+m[2].trim()); }
  else if((m=s.match(/(?:under|below|less than|up to|<)\s*\$?\s*([\d.,]+\s*[mk]?)/)) && /\$|[mk]\b|\d{4,}/.test(m[0])){ a.maxPrice=chatMoney(m[1]); got.push('< $'+m[1].trim()); }
  else if((m=s.match(/(?:over|above|more than|at least|>)\s*\$?\s*([\d.,]+\s*[mk]?)/)) && /\$|[mk]\b|\d{4,}/.test(m[0])){ a.minPrice=chatMoney(m[1]); got.push('> $'+m[1].trim()); }
  // cap rate
  if(m=s.match(/(\d+(?:\.\d+)?)\s*%?\s*cap/) || (m=s.match(/cap\s*(?:rate)?\s*(?:over|above|min|>|of)?\s*(\d+(?:\.\d+)?)/))){ a.capMin=+m[1]; got.push(m[1]+'%+ cap'); }
  // year
  if(m=s.match(/(?:built\s*after|newer than|after|since)\s*(\d{4})/)){ a.yearMin=+m[1]; got.push('after '+m[1]); }
  if(m=s.match(/(?:built\s*before|older than|before)\s*(\d{4})/)){ a.yearMax=+m[1]; got.push('before '+m[1]); }
  applyAction(a);   // maps a.* → F + drawer inputs, then syncChips()+render()
  if(msg) msg.textContent = got.length ? ('Showing: '+got.join(' · ')) : ('Couldn’t parse — try “4 units in Los Angeles”');
}
(function(){ const q=$('#chatq'), go=$('#chatgo'), cl=$('#chatclear'), ex=$('#chatex');
  if(q) q.addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); applyChat(q.value); } });
  if(go) go.addEventListener('click',()=>applyChat(q?q.value:''));
  if(cl) cl.addEventListener('click',()=>{ if(q)q.value=''; chatClear(); const m=$('#chatmsg'); if(m)m.textContent='Cleared'; });
  if(ex) ex.addEventListener('click',e=>{ const c=e.target.closest('.chatchip'); if(!c)return; if(q)q.value=c.dataset.q; applyChat(c.dataset.q); });
})();

// ---- persistence ----
function saveState(){ try{ localStorage.setItem('cre_filters', JSON.stringify({
  types:[...F.types],firms:[...F.firms],cityQuery:F.cityQuery,priceMin:F.priceMin,priceMax:F.priceMax,
  capMin:F.capMin,unitsMin:F.unitsMin,unitsMax:F.unitsMax,yearMin:F.yearMin,yearMax:F.yearMax,status:F.status,afford:F.afford,hasPhone:F.hasPhone,hasEmail:F.hasEmail,sort:$('#sort').value })); }catch(e){} }
function loadState(){ try{ const s=JSON.parse(localStorage.getItem('cre_filters')||'{}');
  if(s.types) F.types=new Set(s.types); if(s.firms) F.firms=new Set(s.firms);
  F.cityQuery=s.cityQuery||''; F.priceMin=s.priceMin??null; F.priceMax=s.priceMax??null;
  F.capMin=s.capMin||0; F.unitsMin=s.unitsMin??null; F.unitsMax=s.unitsMax??null; F.yearMin=s.yearMin??null; F.yearMax=s.yearMax??null; F.status=s.status||'all'; F.afford=!!s.afford; F.hasPhone=!!s.hasPhone; F.hasEmail=!!s.hasEmail;
  if(s.cityQuery) $('#fCity').value=s.cityQuery; if(s.priceMin) $('#fPriceMin').value=s.priceMin; if(s.priceMax) $('#fPriceMax').value=s.priceMax;
  if(s.capMin){ $('#fCap').value=s.capMin; $('#capVal').textContent=s.capMin+'%+'; } if(s.unitsMin) $('#fUnits').value=s.unitsMin; if(s.unitsMax) $('#fUnitsMax').value=s.unitsMax; if(s.yearMin) $('#fYear').value=s.yearMin; if(s.yearMax) $('#fYearMax').value=s.yearMax;
  $('#fAfford').checked=F.afford; if($('#fHasPhone'))$('#fHasPhone').checked=F.hasPhone; if($('#fHasEmail'))$('#fHasEmail').checked=F.hasEmail; if(s.sort){ $('#sort').value=s.sort; }
}catch(e){} }
function resetAll(){ F.types.clear(); F.firms.clear(); F.cityQuery=''; F.priceMin=F.priceMax=F.unitsMin=F.unitsMax=F.yearMin=F.yearMax=null; F.capMin=0; F.status='all'; F.afford=false; F.unwarrantable=false; F.nonqm=false; F.hasPhone=false; F.hasEmail=false;
  ['fCity','fPriceMin','fPriceMax','fUnits','fUnitsMax','fYear','fYearMax'].forEach(id=>$('#'+id).value=''); $('#fCap').value=0; $('#capVal').textContent='any'; $('#fAfford').checked=false; if($('#fHasPhone'))$('#fHasPhone').checked=false; if($('#fHasEmail'))$('#fHasEmail').checked=false;
  syncChips(); render(); }

// ---- chat ----
function addMsg(role, text, actions){
  const d=document.createElement('div'); d.className='msg '+role;
  d.innerHTML = (text||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/```action[\s\S]*?```/g,'').trim()
    .replace(/\*\*(.+?)\*\*/g,'<b>$1</b>').replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\n/g,'<br>');
  if(actions&&actions.length) d.innerHTML += `<div class="actiontag">⚡ view updated: ${actions.join(', ')}</div>`;
  $('#log').appendChild(d); $('#log').scrollTop=$('#log').scrollHeight; return d;
}
function parseAction(reply){ const m=reply.match(/```action\s*([\s\S]*?)```/); if(!m) return null; try{ return JSON.parse(m[1].trim()); }catch{ return null; } }
async function ask(text){
  addMsg('u', text); history.push({role:'user',content:text});
  $('#send').disabled=true; const thinking=addMsg('sys','…thinking');
  try{
    const r = await fetch('/api/claude-chat',{ method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({ messages: history, context: getContext() }) });
    const j = await r.json(); thinking.remove();
    if(j.error){ addMsg('sys','⚠ '+j.error); }
    else { const act=parseAction(j.reply); const applied=act?applyAction(act):null; addMsg('a', j.reply, applied); history.push({role:'assistant',content:j.reply}); }
  }catch(e){ thinking.remove(); addMsg('sys','⚠ '+e.message); }
  $('#send').disabled=false; $('#q').focus();
}

// ---- property history + live comps ----
window.liveComps = {};
async function fetchHistory(btn){
  const id=btn.dataset.id, out=$('#hist-'+id); if(!out) return;
  if(out.dataset.loaded){ out.style.display = out.style.display==='none'?'block':'none'; return; }
  out.innerHTML='<span class="small">…checking WhoLivedThere archive (free)…</span>';
  try{
    const q=new URLSearchParams({address:btn.dataset.address||'',city:btn.dataset.city||'',zip:btn.dataset.zip||''});
    const j=await (await fetch('/api/history?'+q)).json();
    out.dataset.loaded='1';
    if(j.available===false){ out.innerHTML='<span class="b warn">⚠ archive offline</span>'; return; }
    if(!j.matched){ out.innerHTML='<span class="small">📜 No record in the WhoLivedThere archive for this address yet.</span>'; return; }
    const P=j.parcel, money=v=>v?'$'+Number(v).toLocaleString():'—';
    let h='<div class="small" style="margin-top:6px;border-top:1px solid #2a2a2a;padding-top:6px">📜 <b>WhoLivedThere archive</b>';
    if(P){ h+=`<div class="badges" style="margin:4px 0">${P.year_built?`<span class="b">built ${P.year_built}</span>`:''}${P.use_type?`<span class="b type">${P.use_type}</span>`:''}${P.units?`<span class="b">${P.units} unit${P.units>1?'s':''}</span>`:''}${P.total_value?`<span class="b ok" title="LA County assessor total assessed value">assessed ${money(P.total_value)}</span>`:''}${P.land_value?`<span class="b">land ${money(P.land_value)}</span>`:''}${P.improvement_value?`<span class="b">improv ${money(P.improvement_value)}</span>`:''}</div><div class="small">${P.use_description||''} · APN ${P.apn||'—'} · ${P.situs_address||''}</div>`; }
    if(j.permits&&j.permits.length){ h+=`<div class="small" style="margin-top:4px">🔨 Permits (${j.permits.length}): `+j.permits.slice(0,5).map(pm=>`${pm.permit_type||'permit'}${pm.valuation?' $'+Number(pm.valuation).toLocaleString():''}${pm.issue_date?' ('+String(pm.issue_date).slice(0,10)+')':''}`).join(' · ')+'</div>'; }
    if(j.filming&&j.filming.length){ h+=`<div class="small" style="margin-top:4px">🎬 Filmed here: `+j.filming.slice(0,5).map(f=>`${f.title||'production'}${f.year?' ('+f.year+')':''}`).join(' · ')+'</div>'; }
    if(j.events&&j.events.length){ h+=`<div class="small" style="margin-top:4px">📌 ${j.events.slice(0,4).map(ev=>ev.summary||ev.kind).join(' · ')}</div>`; }
    out.innerHTML=h+'</div>';
  }catch(e){ out.innerHTML='<span class="b warn">⚠ '+e.message+'</span>'; }
}
async function fetchLiveComps(id){
  const p = DATA.ranked.find(x=>x.id===id); if(!p) return;
  const out = $('#lc-'+id); if(!out) return;
  out.innerHTML = '<span class="small">…pulling fresh comps (~$0.03)…</span>';
  try{
    const r = await fetch('/api/comps',{method:'POST',headers:{'Content-Type':'application/json'},
      body:JSON.stringify({address:p.address,city:p.city,type:p.type})});
    const j = await r.json();
    if(j.error && !(j.comps&&j.comps.length)){ out.innerHTML = `<span class="b warn">⚠ ${j.error}</span>`; return; }
    window.liveComps[id] = j.comps||[];
    const none = (!j.comps||!j.comps.length)?' — none scraped (source may have blocked)':'';
    out.innerHTML = `<div class="small">Live comps via ${j.source||'web'} (cost ${j.cost})${none}:</div>` +
      (j.comps||[]).map(c=>`<div class="lc-item"><span>${esc(c.title||c.url||'comp')}</span>${c.url?` <button class="copyb" data-copyval="${esc(c.url)}" title="Copy comp URL">⧉</button>`:''} — ${esc([c.price,c.cap,c.units,c.sf].filter(Boolean).join(' · '))}</div>`).join('');
  }catch(e){ out.innerHTML = `<span class="b bad">⚠ ${e.message}</span>`; }
}

// ---- drawer open/close ----
function openDrawer(){ $('#drawer').classList.add('open'); $('#scrim').classList.add('on'); }
function closeDrawer(){ $('#drawer').classList.remove('open'); $('#scrim').classList.remove('on'); }

// ── Inline list-row expansion + Agent CRM actions ──────────────────────────────────
function postJSON(url,body){ return fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})}).then(r=>r.json()); }
function toast(msg){ let t=document.getElementById('crmToast'); if(!t){ t=document.createElement('div'); t.id='crmToast'; t.className='crmToast'; document.body.appendChild(t); } t.textContent=msg; t.classList.add('on'); clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('on'),1900); }
// In-place refresh after any CRM save — repaints EVERY visible surface for this listing (list
// agent cell + open list-row panel + card block) WITHOUT a full render() (which would collapse
// expanded rows). Works for every asset type in either view.
function refreshContactUI(id){
  const p=DATA.ranked.find(x=>x.id===id); if(!p) return;
  try{ delete p.__idxHay; }catch(_){}   // CRM changed → let the search haystack rebuild

  const row=[...document.querySelectorAll('.listtbl tr.lrow')].find(r=>r.dataset.id===id);
  if(row){ const cols=listCols(); const ai=cols.findIndex(c=>c.k==='agent');
    if(ai>=0 && row.children[ai+1]) row.children[ai+1].innerHTML=colCell('agent',p);
    const exp=row.nextElementSibling;
    if(exp&&exp.classList.contains('lexp')&&exp.classList.contains('open')){
      const body=exp.querySelector('.lexpbody'); if(body){ body.innerHTML=expandBody(p); body.dataset.loaded='1'; }
    }
  }
  const cb=document.getElementById('cardagent-'+id); if(cb) cb.outerHTML=cardAgentBlock(p);
}
async function markContacted(id){
  try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/contacted',{}); if(j.contact){ window.CONTACTS[id]=j.contact; refreshContactUI(id); repaintModal(id); toast('✓ marked contacted '+fmtWhen(j.contact.contacted_at)); } else toast('⚠ '+(j.error||'failed')); }
  catch(e){ toast('⚠ '+e.message); }
}
async function saveAgentInfo(id){
  const scope=document.getElementById('composeM')&&document.getElementById('composeM').classList.contains('on')?document.getElementById('composeM'):document;
  const inputs=[...scope.querySelectorAll('.crmf')].filter(i=>i.dataset.id===id);
  const body={}; inputs.forEach(i=>body[i.dataset.info]=i.value.trim());
  try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/info',body); if(j.contact){ window.CONTACTS[id]=j.contact; refreshContactUI(id); toast('💾 contact info saved'); } else toast('⚠ '+(j.error||'failed')); }
  catch(e){ toast('⚠ '+e.message); }
}
async function deleteLetter(id,idx){
  try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/letter/'+encodeURIComponent(idx)+'/delete',{}); if(j.contact){ window.CONTACTS[id]=j.contact; refreshContactUI(id); repaintModal(id); toast('letter deleted'); } }
  catch(e){ toast('⚠ '+e.message); }
}
function agentDraft(p,a){ return `Hi ${a.name||'there'},\n\nI'm interested in ${p.address||'your listing'}${p.city?' in '+p.city:''}${p.price?' (listed '+fmt(p.price)+')':''}. Could you share the setup — current rents / in-place NOI, any financing in place, and whether an offer near list would be entertained?\n\nThanks`; }
// The full per-listing contact modal body (editable info + mark-contacted + compose/save + letter log).
// The single per-listing "Agent window" — ONE modal with tabs: Agent · Letters · Find · Comps · History.
function contactModalBody(p){
  const a=agentInfo(p), id=p.id, firm=a.firm;
  const tel=a.phone?`<a class="ain" href="tel:${esc(a.phone).replace(/[^0-9+]/g,'')}">📞 ${esc(a.phone)}</a>`:'';
  const eml=a.email?`<a class="ain" href="mailto:${esc(a.email)}">✉ ${esc(a.email)}</a>`:'';
  const letters=a.letters.length?`<div class="crm-letters"><div class="deeplbl">Saved letters (${a.letters.length}) — saved, not sent</div>`
    +a.letters.map((l,i)=>`<div class="lett"><div class="letth"><b>${esc(l.subject||'(no subject)')}</b>${l.draft_id?`<span class="achip done" title="Gmail draft created${l.to?' to '+esc(l.to):''} — review & send in Gmail">📤 drafted</span>`:''}<span class="letts">${fmtWhen(l.ts)}</span><button class="letdel" data-letdel="${id}" data-idx="${l.id||i}" title="Delete this saved letter">✕</button></div><div class="lettb">${esc(l.body||'')}</div></div>`).join('')+`</div>`:'<div class="tabmut">No saved letters yet.</div>';
  const find=[];
  if(firm&&firm!=='Unknown'){
    find.push(`<a class="findlink" href="${gsearch('"'+firm+'" commercial real estate Los Angeles')}" target="_blank" rel="noopener noreferrer">🏢 ${esc(firm)} — Google</a>`);
    find.push(`<a class="findlink" href="https://www.linkedin.com/search/results/companies/?keywords=${encodeURIComponent(firm)}" target="_blank" rel="noopener noreferrer">🏢 ${esc(firm)} — LinkedIn</a>`);
    find.push(`<button class="findlink" data-firmfilter="${esc(firm).replace(/"/g,'&quot;')}" title="Show only ${esc(firm)} listings in the grid">▦ More from ${esc(firm)} (in-app)</button>`);
  }
  if(a.name){
    find.push(`<a class="findlink" href="${gsearch('"'+a.name+'"'+(firm&&firm!=='Unknown'?' "'+firm+'"':'')+' real estate agent')}" target="_blank" rel="noopener noreferrer">🧑‍💼 ${esc(a.name)} — Google</a>`);
    find.push(`<a class="findlink" href="https://www.linkedin.com/search/results/people/?keywords=${encodeURIComponent(a.name+(firm&&firm!=='Unknown'?' '+firm:''))}" target="_blank" rel="noopener noreferrer">🧑‍💼 ${esc(a.name)} — LinkedIn</a>`);
    find.push(`<a class="findlink" href="${gsearch('"'+a.name+'" DRE license California real estate')}" target="_blank" rel="noopener noreferrer">🪪 ${esc(a.name)} — DRE license</a>`);
  }
  listingLinkBits(p,firm).forEach(b=>find.push(b));
  find.push(`<span class="findlink findloop">${loopnetLink(p)}</span>`);
  const findHtml=find.length?`<div class="findgrid">${find.join('')}</div>`:'<div class="tabmut">Add an agent/firm in the Agent tab to enable lookups.</div>';
  return `<div class="composeCard tabwin">
    <div class="composeH">🧑‍💼 <b>${esc(p.address||'listing')}</b>${p.city?', '+esc(p.city):''}<span class="winmeta">${a.name?esc(a.name):'no agent yet'}${a.contacted_at?' · ✓ '+fmtWhen(a.contacted_at):''}</span><button class="composeX" title="Close">✕</button></div>
    <div class="tabbar">
      <button class="tabt active" data-tab="agent">Agent</button>
      <button class="tabt" data-tab="letters">Letters${a.letters.length?' ('+a.letters.length+')':''}</button>
      <button class="tabt" data-tab="find">Find</button>
      <button class="tabt" data-tab="comps">Comps</button>
      <button class="tabt" data-tab="history">History</button>
    </div>
    <div class="tabpane active" data-pane="agent">
      <div class="cm-info">
        <input class="crmf" data-info="name" data-id="${id}" placeholder="Agent name" value="${esc(a.name||'')}">
        <input class="crmf" data-info="phone" data-id="${id}" placeholder="Phone" value="${esc(a.phone||'')}">
        <input class="crmf" data-info="email" data-id="${id}" placeholder="Email" value="${esc(a.email||'')}">
      </div>
      ${(tel||eml)?`<div class="ainrow">${tel} ${eml}</div>`:''}
      <div class="cm-notes"><div class="deeplbl">Notes</div><textarea class="crmf" data-info="notes" data-id="${id}" rows="4" placeholder="Notes on this agent / listing">${esc(a.notes||'')}</textarea></div>
      <div class="cm-row">
        <button class="abtn saveinfobtn" data-saveinfo="${id}">💾 Save info + notes</button>
        <button class="abtn markbtn" data-mark="${id}">${a.contacted_at?'↻ Update contacted date':'✓ Mark contacted'}</button>
        ${a.contacted_at?`<span class="achip done">contacted ${fmtWhen(a.contacted_at)}</span>`:''}
      </div>
    </div>
    <div class="tabpane" data-pane="letters">
      <div class="composeGate">⚑ <b>Save letter</b> logs it. <b>Send via George</b> creates a Gmail <b>DRAFT</b> — NOT sent; you review &amp; hit send in Gmail.</div>
      <input id="composeSubj" placeholder="Subject" value="${esc('Interest in '+(p.address||'your listing')+(p.city?', '+p.city:''))}">
      <textarea id="composeBody" rows="7">${esc(agentDraft(p,a))}</textarea>
      <div class="composeActs">
        <button id="composeSave" class="primary" data-id="${id}">💾 Save letter</button>
        <button id="composeSend" class="gated" data-id="${id}" title="Create a Gmail DRAFT to the agent (fill Email in the Agent tab) — not sent">📤 Send via George</button>
        <button id="composeCopy">📋 Copy</button>
      </div>
      ${letters}
    </div>
    <div class="tabpane" data-pane="find">${findHtml}</div>
    <div class="tabpane" data-pane="comps">
      <button class="lcbtn" data-id="${id}" title="Pull fresh comps via a cloud browser (~$0.03)">🔄 Pull live comps (~$0.03)</button>
      <div class="lcout" id="lc-${id}"></div>
    </div>
    <div class="tabpane" data-pane="history">
      <button class="histbtn" data-id="${id}" data-address="${(p.address||'').replace(/"/g,'&quot;')}" data-city="${(p.city||'').replace(/"/g,'&quot;')}" data-zip="${p.zip||''}" title="LA County assessor + permit + film history (free)">📜 Load property history</button>
      <div class="histout" id="hist-${id}"></div>
    </div>
  </div>`;
}
// One modal, opens for EVERY listing from any surface. Its own click delegation (it lives on
// document.body, outside #grid) handles save-info / mark / save-letter / delete-letter.
function openContact(id){
  const p=DATA.ranked.find(x=>x.id===id); if(!p) return;
  let m=document.getElementById('composeM');
  if(!m){ m=document.createElement('div'); m.id='composeM'; m.className='composeM'; document.body.appendChild(m); }
  m.dataset.id=id; m.innerHTML=contactModalBody(p); m.classList.add('on');
  m.onclick=async(e)=>{
    if(e.target===m || e.target.closest('.composeX,.composeX2')){ closeCompose(); return; }
    const tb=e.target.closest('.tabt'); if(tb){ activateTab(m,tb.dataset.tab); return; }
    const cv=e.target.closest('[data-copyval]'); if(cv){ navigator.clipboard.writeText(cv.dataset.copyval).then(()=>toast('📋 copied')).catch(()=>{}); return; }
    const ff=e.target.closest('[data-firmfilter]'); if(ff){ closeCompose(); F.firms=new Set([ff.dataset.firmfilter]); syncChips(); render(); return; }
    const lc=e.target.closest('.lcbtn'); if(lc){ fetchLiveComps(lc.dataset.id); return; }
    const hi=e.target.closest('.histbtn'); if(hi){ fetchHistory(hi); return; }
    if(e.target.closest('#composeCopy')){ navigator.clipboard.writeText(m.querySelector('#composeBody').value); toast('📋 copied'); return; }
    if(e.target.closest('#composeSave')){
      const subject=m.querySelector('#composeSubj').value, body=m.querySelector('#composeBody').value;
      try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/letter',{subject,body,channel:'letter'});
        if(j.contact){ window.CONTACTS[id]=j.contact; refreshContactUI(id); repaintModal(id); toast('💾 letter saved to contact log'); }
        else toast('⚠ '+(j.error||'save failed')); }catch(err){ toast('⚠ '+err.message); }
      return;
    }
    if(e.target.closest('#composeSend')){
      const to=((m.querySelector('.crmf[data-info=email]')||{}).value||'').trim();
      if(!to){ toast('add the agent email first'); return; }
      const subject=m.querySelector('#composeSubj').value, body=m.querySelector('#composeBody').value;
      if(!confirm('Create a Gmail DRAFT to '+to+' via George?\n\nIt will NOT be sent — it lands in your Gmail drafts (steve@designerwallcoverings.com) for you to review and send.')) return;
      try{ const j=await postJSON('/api/contacts/'+encodeURIComponent(id)+'/send-draft',{to,subject,body});
        if(j.ok){ window.CONTACTS[id]=j.contact; refreshContactUI(id); repaintModal(id); toast('📤 Gmail draft created — review & send in Gmail'); }
        else if(j.error==='george_not_configured'){ toast('⚠ George drafting not enabled on this box'); alert('George drafting isn’t enabled on this server yet — the letter is not drafted.\n\nTo turn it on, GEORGE_AUTH must be set in the CRCP env (one-time). The letter is unaffected; use 💾 Save letter to keep it.'); }
        else toast('⚠ '+(j.detail||j.error||'draft failed')); }
      catch(err){ toast('⚠ '+err.message); }
      return;
    }
    if(e.target.closest('[data-saveinfo]')){ saveAgentInfo(id); return; }
    if(e.target.closest('[data-mark]')){ markContacted(id); return; }
    const ld=e.target.closest('[data-letdel]'); if(ld){ deleteLetter(id, ld.dataset.idx); return; }
  };
}
// Switch the active tab within the agent window.
function activateTab(m,tab){
  m.querySelectorAll('.tabt').forEach(t=>t.classList.toggle('active',t.dataset.tab===tab));
  m.querySelectorAll('.tabpane').forEach(pn=>pn.classList.toggle('active',pn.dataset.pane===tab));
}
// Repaint the open modal in place (after mark/letter changes) — preserves open state AND active tab.
function repaintModal(id){ const m=document.getElementById('composeM'); const p=DATA.ranked.find(x=>x.id===id);
  if(m && m.classList.contains('on') && m.dataset.id===id && p){
    const cur=(m.querySelector('.tabt.active')||{}).dataset; const tab=cur?cur.tab:'agent';
    m.innerHTML=contactModalBody(p); activateTab(m,tab||'agent');
  } }
function openCompose(id){ openContact(id); }   // back-compat alias
function closeCompose(){ const m=document.getElementById('composeM'); if(m) m.classList.remove('on'); }

// ---- Broker-history pilot index ----
// Keyed by both listing id (e.g. crx2477882) and lowercase address.
// Loaded once at boot; cards read from this map at render time.
window.BROKER_HIST = {};   // id → enrichment object
window.BROKER_HIST_ADDR = {};  // lc-address → enrichment object
fetch('/api/broker-history').then(r=>r.json()).then(j=>{
  (j.properties||[]).forEach(p=>{
    if(p.id) window.BROKER_HIST[p.id]=p;
    if(p.address) window.BROKER_HIST_ADDR[p.address.toLowerCase().trim()]=p;
  });
  // Re-render if DATA is already loaded (race: demographics loads fast too)
  if(window.DATA_READY) render();
}).catch(()=>{}); // graceful — broker pilot data is additive, not load-blocking

// ---- boot ----
fetch('data/comps.json').then(r=>r.json()).then(c=>{ if(DATA) DATA.comps=c; else window.__comps=c; }).catch(()=>{});
fetch('data/demographics.json').then(r=>r.json()).then(d=>{ window.DEMO=d.byCity||{}; if(DATA) render(); }).catch(()=>{});
// Map a condo (separate /api/condos schema) into the explorer's property shape so it flows through
// finance, scoring, filters, sort, and the card/list renderers as a first-class 'Condo' asset type.
function mapCondo(c){
  return { id:c.id, address:c.address, city:c.city, zip:c.zip, type:'Condo',
    price:+c.price||0, units:1, cap_rate:null, year_built:c.year_built||null,
    status:'Active', source:c.source, firm:c.firm_name||null, verified:false,
    isCondo:true, warrantable_status:c.warrantable_status,
    beds:c.beds, baths:c.baths, sqft:c.sqft, hoa:c.hoa, broker_name:c.broker_name,
    project_name:c.project_name, upside_note:null, qwen:null };
}
function loadRanked(_retry){ return fetch('data/ranked.json').then(r=>{ if(!r.ok) throw new Error('HTTP '+r.status); return r.json(); }).catch(e=>{ if(_retry>0){ return new Promise(res=>setTimeout(res,1500)).then(()=>loadRanked(_retry-1)); } throw e; }); } loadRanked(1).then(async d=>{
  DATA=d; if(window.__comps) DATA.comps=window.__comps;
  // Merge condo inventory in-grid (best-effort; explorer still works if the API is down).
  let condoN=0; try{ const cd=await (await fetch('/api/condos')).json(); const cs=(cd.condos||[]).map(mapCondo); condoN=cs.length; DATA.ranked=DATA.ranked.concat(cs); }catch(e){}
  const nf = n => Number(n).toLocaleString('en-US'); // TK-10883 — comma-format counts, drop bold on geography
  $('#sub').innerHTML = `${nf(d.ranked.length)} listings${condoN?` · ${nf(condoN)} condos`:''} · ${d.meta.market}${d.generated?' · sourced '+d.generated:''}`;

  scenario = localStorage.getItem('cre_scenario')||'25';
  $$('#scenario button').forEach(b=>b.classList.toggle('active',b.dataset.s===scenario));
  buildFacets();
  loadState();
  urlToState();  // a shared ?query overrides saved localStorage state and restores the exact view
  $$('#scenario button').forEach(b=>b.classList.toggle('active',b.dataset.s===scenario));

  // drawer wiring
  $('#burger').onclick=openDrawer; $('#dclose').onclick=closeDrawer; $('#scrim').onclick=closeDrawer;
  $('#reset').onclick=resetAll;
  // collapsible filter tabs — click a section header to expand/collapse; all collapsed on load
  $('#drawer').addEventListener('click',e=>{ const h=e.target.closest('h4'); if(h && h.parentElement.classList.contains('fsec')) h.parentElement.classList.toggle('collapsed'); });
  // Fields section starts EXPANDED and first — all other fsec sections collapse on load.
  $$('#drawer .fsec').forEach(s=>{ if(s.id!=='fieldsSec') s.classList.add('collapsed'); });
  // active-filter summary bar: remove one filter or reset all
  $('#activebar').addEventListener('click',e=>{ if(e.target.closest('#afreset')){ resetAll(); return; } const c=e.target.closest('.afchip'); if(c) clearFilter(c.dataset.clear); });
  // saved searches
  buildSavedList();
  $('#savedList').addEventListener('click',e=>{ const del=e.target.closest('[data-del]'); if(del){ SAVED.splice(+del.dataset.del,1); saveSaved(); buildSavedList(); return; } const n=e.target.closest('.savedname'); if(n){ location.href=location.pathname+(n.dataset.qs||''); } });
  $('#savebtn').onclick=()=>{ const name=(prompt('Name this saved search:')||'').trim(); if(!name) return; SAVED.push({name, qs:location.search||''}); saveSaved(); buildSavedList(); };
  $('#linkbtn').onclick=copyShareLink;
  $('#csvbtn').onclick=exportCSV;
  // search-all-fields box (Steve list-build rule 7) — filters across EVERY field, space = AND
  { const si=$('#idxSearch'); if(si) si.oninput=()=>{ idxQuery=si.value.trim().toLowerCase(); if($('#railSearch'))$('#railSearch').value=si.value; render(); }; }
  // Reset the PRIMARY List table layout back to the tight default: column order, resize widths,
  // and visibility. Clears idxColOrder + col-resize's cr:<pathname> widths + resets VIS to defaults.
  { const rb=$('#idxReset'); if(rb) rb.onclick=()=>{
      COLORDER=[]; saveColOrder();
      try{ Object.keys(localStorage).forEach(k=>{ if(k.indexOf('cr:'+location.pathname+':')===0) localStorage.removeItem(k); }); }catch(e){}
      FIELDS.forEach(f=>VIS[f.k]=!!f.def); saveVis();
      lastColSig=''; buildFieldToggles(); render();
    }; }
  // facet chip handlers (multi-select toggles)
  $('#fType').onclick=e=>{ const c=e.target.closest('.chip'); if(!c) return;
    if(c.dataset.warr==='nonqm'){ F.nonqm=!F.nonqm; syncChips(); render(); return; }
    if(c.dataset.warr==='unwarrantable'){ F.unwarrantable=!F.unwarrantable; if(F.unwarrantable) F.types=new Set(['Condo']); syncChips(); render(); return; }
    F.types.has(c.dataset.t)?F.types.delete(c.dataset.t):F.types.add(c.dataset.t);
    if(!F.types.has('Condo')) F.unwarrantable=false;
    syncChips(); render(); };
  $('#fFirm').onclick=e=>{ const c=e.target.closest('.chip'); if(!c) return; F.firms.has(c.dataset.fm)?F.firms.delete(c.dataset.fm):F.firms.add(c.dataset.fm); syncChips(); render(); };
  $('#fCityChips').onclick=e=>{ const c=e.target.closest('.chip'); if(!c) return; const cy=c.dataset.cy.toLowerCase(); F.cityQuery=(F.cityQuery===cy)?'':cy; $('#fCity').value=(F.cityQuery?c.dataset.cy:''); syncChips(); render(); };
  $('#fStatus').onclick=e=>{ const c=e.target.closest('.chip'); if(!c) return; F.status=c.dataset.st; syncChips(); render(); };
  $('#fCity').oninput=()=>{ F.cityQuery=$('#fCity').value.trim().toLowerCase(); syncChips(); render(); };
  $('#fPriceMin').oninput=()=>{ F.priceMin=$('#fPriceMin').value?+$('#fPriceMin').value:null; render(); };
  $('#fPriceMax').oninput=()=>{ F.priceMax=$('#fPriceMax').value?+$('#fPriceMax').value:null; render(); };
  $('#fCap').oninput=()=>{ F.capMin=+$('#fCap').value; $('#capVal').textContent=F.capMin?F.capMin+'%+':'any'; render(); };
  $('#fUnits').oninput=()=>{ F.unitsMin=$('#fUnits').value?+$('#fUnits').value:null; render(); };
  $('#fUnitsMax').oninput=()=>{ F.unitsMax=$('#fUnitsMax').value?+$('#fUnitsMax').value:null; render(); };
  $('#fYear').oninput=()=>{ F.yearMin=$('#fYear').value?+$('#fYear').value:null; render(); };
  $('#fYearMax').oninput=()=>{ F.yearMax=$('#fYearMax').value?+$('#fYearMax').value:null; render(); };
  $('#fAfford').onchange=()=>{ F.afford=$('#fAfford').checked; render(); };
  $('#fHasPhone').onchange=()=>{ F.hasPhone=$('#fHasPhone').checked; render(); };
  $('#fHasEmail').onchange=()=>{ F.hasEmail=$('#fHasEmail').checked; render(); };
  $('#scenario').onclick=e=>{ if(!e.target.dataset.s) return; setScenario(e.target.dataset.s); render(); };

  // sort (drawer + top bar mirror each other)
  $('#sort').onchange=()=>{ colSort=null; $('#sortTop').value=$('#sort').value; render(); };
  $('#sortTop').onchange=()=>{ colSort=null; $('#sort').value=$('#sortTop').value; render(); };
  $('#sortTop').value=$('#sort').value;
  // density
  const dv=String(Math.min(10,Math.max(1,+(localStorage.getItem('cre_density')||'3')||3))); $('#density').value=dv; $('#densVal').textContent=dv; document.documentElement.style.setProperty('--cols',dv); localStorage.setItem('cre_density',dv);
  $('#density').oninput=()=>{ document.documentElement.style.setProperty('--cols',$('#density').value); $('#densVal').textContent=$('#density').value; localStorage.setItem('cre_density',$('#density').value); if($('#railDensity')){$('#railDensity').value=$('#density').value; $('#railDensVal').textContent=$('#density').value;} };

  // ===== right-hand control rail (nav-agent skill surface) =====
  wireCtrlRail(dv);

  // field show/hide toggles + list/grid view
  buildFieldToggles();
  $('#fFields').onchange=e=>{ const cb=e.target.closest('input[data-fk]'); if(!cb) return; VIS[cb.dataset.fk]=cb.checked; saveVis(); buildFieldToggles(); render(); };
  { const rf=$('#railFields'); if(rf) rf.onchange=e=>{ const cb=e.target.closest('input[data-fk]'); if(!cb) return; VIS[cb.dataset.fk]=cb.checked; saveVis(); buildFieldToggles(); render(); }; }
  $('#fAll').onclick=()=>{ FIELDS.forEach(f=>VIS[f.k]=true); saveVis(); buildFieldToggles(); render(); };
  $('#fNone').onclick=()=>{ FIELDS.forEach(f=>VIS[f.k]=false); saveVis(); buildFieldToggles(); render(); };
  $('#fDef').onclick=()=>{ FIELDS.forEach(f=>VIS[f.k]=!!f.def); saveVis(); buildFieldToggles(); render(); };
  // ── panel drag-to-reorder columns (Fix 3) ──
  // Dragging the ⠿ handle on a .ftog row reorders COLORDER then rebuilds toggles + re-renders list.
  let pDragKey=null;
  $('#fFields').addEventListener('dragstart',e=>{ const row=e.target.closest('.ftog'); if(!row) return;
    if(!e.target.closest('.fdrag')){ e.preventDefault(); return; }  // only ⠿ handle starts a move
    pDragKey=row.dataset.fk; e.dataTransfer.effectAllowed='move'; try{e.dataTransfer.setData('text/plain',pDragKey);}catch(_){}
    row.classList.add('fdragging'); });
  $('#fFields').addEventListener('dragover',e=>{ if(!pDragKey) return; const row=e.target.closest('.ftog'); if(!row||row.dataset.fk===pDragKey) return;
    e.preventDefault(); e.dataTransfer.dropEffect='move';
    const r=row.getBoundingClientRect(), after=(e.clientY-r.top)>r.height/2;
    $('#fFields').querySelectorAll('.ftog').forEach(x=>x.classList.remove('fdropT','fdropB'));
    row.classList.toggle('fdropB',after); row.classList.toggle('fdropT',!after); });
  $('#fFields').addEventListener('dragleave',e=>{ const row=e.target.closest('.ftog'); if(row) row.classList.remove('fdropT','fdropB'); });
  $('#fFields').addEventListener('drop',e=>{ if(!pDragKey) return; const row=e.target.closest('.ftog'); if(!row){ pDragKey=null; return; } e.preventDefault();
    const tgt=row.dataset.fk;
    if(tgt!==pDragKey){
      const r=row.getBoundingClientRect(), after=(e.clientY-r.top)>r.height/2;
      syncColOrder(); const from=COLORDER.indexOf(pDragKey);
      if(from>=0){ COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,pDragKey);
        saveColOrder(); lastColSig=''; buildFieldToggles(); render(); } }
    pDragKey=null; });
  $('#fFields').addEventListener('dragend',()=>{ $('#fFields').querySelectorAll('.fdragging,.fdropT,.fdropB').forEach(x=>x.classList.remove('fdragging','fdropT','fdropB')); pDragKey=null; });
  $$('#viewseg button').forEach(b=>b.classList.toggle('active',b.dataset.view===view));
  $('#viewseg').onclick=e=>{ const b=e.target.closest('button[data-view]'); if(!b) return; view=b.dataset.view; localStorage.setItem('cre_view',view); $$('#viewseg button').forEach(x=>x.classList.toggle('active',x.dataset.view===view)); render(); };

  // chat
  const EX=['Show only multifamily under $1M, sorted by cash-on-cash','Which deals actually cash-flow at 30% down?','Office and industrial in Pasadena','Take me to the Reseda mixed-use','Best in-budget deals'];
  $('#examples').innerHTML = EX.map(x=>`<button class="ex">${x}</button>`).join('');
  $('#examples').onclick=e=>{ if(e.target.classList.contains('ex')){ $('#q').value=e.target.textContent; $('#q').focus(); } };
  $('#chatform').onsubmit=e=>{ e.preventDefault(); const t=$('#q').value.trim(); if(!t) return; $('#q').value=''; ask(t); };
  $('#grid').onclick=e=>{ const cp=e.target.closest('[data-copy]'); if(cp){ copySummary(cp.dataset.copy, cp); return; } const qk=e.target.closest('[data-quick]'); if(qk){ applyQuick(qk.dataset.quick); return; } const more=e.target.closest('#showmore'); if(more){ _shown=Math.min(_shown+PAGE(), _rows.length); paintPage(); return; } const st=e.target.closest('[data-star]'); if(st){ toggleStar(st.dataset.star); st.classList.toggle('on',SHORT.has(st.dataset.star)); const sb=$('#shortbtn'); if(sb) sb.textContent='⭐ Shortlist ('+SHORT.size+')'; if(F.shortlist) render(); return; } const th=e.target.closest('.listtbl th[data-sortk]'); if(th){ const k=th.dataset.sortk; if(colSort&&colSort.key===k) colSort.dir*=-1; else colSort={key:k,dir:['type','firm','warr','broker','status','zip','addr','rec','phone','email','firmweb'].includes(k)?1:-1}; render(); return; } const b=e.target.closest('.lcbtn'); if(b){ fetchLiveComps(b.dataset.id); return; } const h=e.target.closest('.histbtn'); if(h){ fetchHistory(h); return; }
    const cv=e.target.closest('[data-copyval]'); if(cv){ navigator.clipboard.writeText(cv.dataset.copyval).then(()=>toast('📋 copied')).catch(()=>toast('⚠ copy failed')); return; }
    const ff=e.target.closest('[data-firmfilter]'); if(ff){ if(e.metaKey||e.ctrlKey||e.shiftKey) F.firms.add(ff.dataset.firmfilter); else F.firms=new Set([ff.dataset.firmfilter]); syncChips(); render(); return; }
    // drill atoms: card asset-type + city → filter the grid to that value (URL updates via render→stateToURL)
    const tf=e.target.closest('[data-typefilter]'); if(tf){ if(e.metaKey||e.ctrlKey||e.shiftKey) F.types.add(tf.dataset.typefilter); else F.types=new Set([tf.dataset.typefilter]); syncChips(); render(); return; }
    const cf=e.target.closest('[data-cityfilter]'); if(cf){ const cy=cf.dataset.cityfilter.toLowerCase(); F.cityQuery=(F.cityQuery===cy)?'':cy; if($('#fCity'))$('#fCity').value=(F.cityQuery?cf.dataset.cityfilter:''); syncChips(); render(); return; }
    const ct=e.target.closest('[data-contact]'); if(ct){ openCompose(ct.dataset.contact); return; }
    const mk=e.target.closest('[data-mark]'); if(mk){ markContacted(mk.dataset.mark); return; }
    const si=e.target.closest('[data-saveinfo]'); if(si){ saveAgentInfo(si.dataset.saveinfo); return; }
    const ld=e.target.closest('[data-letdel]'); if(ld){ deleteLetter(ld.dataset.letdel, ld.dataset.idx); return; }
    // Row-click NO LONGER opens the agent pop-out (Steve 2026-08-18: "do not allow this to pop out, or
    // move round. fix in place"). Contact info renders directly in the row; explicit links/buttons still work.
  };
  // ── drag-to-reorder PRIMARY List columns (Steve list-build rule 2) ──
  // Delegated on #grid since the list table is re-rendered each render(). Property (addr) is a
  // fixed anchor (no data-k) so it never participates as a drag source or drop target.
  let idxDragKey=null;
  $('#grid').addEventListener('dragstart',e=>{ const th=e.target.closest('.listtbl th.dragcol'); if(!th||!th.dataset.k) return;
    if(e.target.closest && e.target.closest('.cr-handle')){ e.preventDefault(); return; }  // that grab is a resize, not a move
    idxDragKey=th.dataset.k; e.dataTransfer.effectAllowed='move'; try{ e.dataTransfer.setData('text/plain',idxDragKey); }catch(_){}
    th.classList.add('dragging'); });
  $('#grid').addEventListener('dragover',e=>{ if(!idxDragKey) return; const th=e.target.closest('.listtbl th.dragcol'); if(!th||!th.dataset.k||th.dataset.k===idxDragKey) return;
    e.preventDefault(); e.dataTransfer.dropEffect='move';
    const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
    th.classList.toggle('dropR',after); th.classList.toggle('dropL',!after); });
  $('#grid').addEventListener('dragleave',e=>{ const th=e.target.closest('.listtbl th.dragcol'); if(th) th.classList.remove('dropL','dropR'); });
  $('#grid').addEventListener('drop',e=>{ if(!idxDragKey) return; const th=e.target.closest('.listtbl th.dragcol'); if(!th||!th.dataset.k){ idxDragKey=null; return; }
    e.preventDefault(); const tgt=th.dataset.k;
    if(tgt!==idxDragKey){ const r=th.getBoundingClientRect(), after=(e.clientX-r.left)>r.width/2;
      syncColOrder(); const from=COLORDER.indexOf(idxDragKey);
      if(from>=0){ COLORDER.splice(from,1); let to=COLORDER.indexOf(tgt); if(after) to+=1; COLORDER.splice(to,0,idxDragKey);
        saveColOrder(); lastColSig=''; render(); } }
    idxDragKey=null; });
  $('#grid').addEventListener('dragend',()=>{ document.querySelectorAll('.listtbl th.dragging,.listtbl th.dropL,.listtbl th.dropR').forEach(x=>x.classList.remove('dragging','dropL','dropR')); idxDragKey=null; });
  $('#shortbtn').onclick=()=>{ F.shortlist=!F.shortlist; render(); };
  $('#cmpbtn').onclick=openCompare;
  $('#heat').value=heat; $('#heat').onchange=()=>{ heat=$('#heat').value; localStorage.setItem('cre_heat',heat); render(); };
  $('#cmpov').addEventListener('click',e=>{ if(e.target.id==='cmpov') e.currentTarget.classList.remove('on'); });
  $('#cmpmodal').addEventListener('click',e=>{ if(e.target.closest('#cmpcsv')) exportCompareCSV(); else if(e.target.closest('#cmpprint')) printCompare(); });
  window.addEventListener('afterprint',()=>document.body.classList.remove('printing-compare'));
  // a11y: Escape closes any open overlay (compare / broker / property-history modals)
  document.addEventListener('keydown',e=>{ if(e.key==='Escape'){ ['cmpov','bkov'].forEach(id=>{ const el=document.getElementById(id); if(el) el.classList.remove('on'); }); } });

  window.DATA_READY = true;
  recompute(); syncChips(); render();
}).catch(e=>{ $('#sub').textContent='Listings are still loading — refresh in a moment if this persists.'; console.warn('ranked.json load failed:',e); });
</script>
  <script src="/col-resize.js" defer></script>
  <script src="/global-search.js" defer></script>
<script src="/corner-nav.js" defer></script>
<script>
// Day/Night toggle — theme already applied before first paint by the head snippet.
// crcp-theme key; Day (light) is the default. Only an explicit click persists.
(function(){
  var root=document.documentElement;
  function label(t){
    var b=document.getElementById('crcpThemeToggle'); if(!b) return;
    b.innerHTML=(t==='light'?'☀️':'🌙')+' <span>'+(t==='light'?'Day':'Night')+'</span>';
  }
  function applyTheme(t){ root.setAttribute('data-theme',t); label(t); }
  function mount(){
    if(document.getElementById('crcpThemeToggle')){ label(root.getAttribute('data-theme')||'light'); return; }
    var btn=document.createElement('button');
    btn.id='crcpThemeToggle'; btn.type='button';
    btn.title='Toggle day / night'; btn.setAttribute('aria-label','Toggle day / night theme');
    btn.addEventListener('click',function(){
      var nt=root.getAttribute('data-theme')==='light'?'dark':'light';
      applyTheme(nt);
      try{ localStorage.setItem('crcp-theme',nt); }catch(e){}
    });
    var host=document.querySelector('header .nav')||document.querySelector('header');
    if(host){ host.appendChild(btn); } else { btn.style.cssText+=';position:fixed;top:12px;right:14px;z-index:99999'; document.body.appendChild(btn); }
    label(root.getAttribute('data-theme')||'light');
  }
  if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',mount); else mount();
})();
</script>
  <!-- nav-agent REMOVED (Steve 2026-08-18: "no more pop out … fix into the ui"): its floating
       movable/removable panel overlay (.na-restorebar, position:fixed) popped out over the grid.
       The native Fields show/hide/reorder panel (#fFields) is independent and stays. -->
  <!-- <script src="/nav-agent/nav-agent.js" defer></script> -->

  <!-- ── Controls-rail resize bar (Steve 2026-08-20): drag the #ctrlrail (Controls) panel wider ──
       A fixed vertical grab-bar pinned to the rail's left edge via right:var(--railW), so it auto-
       tracks the width and never scrolls with the rail content. Drag = resize, dbl-click = reset,
       width persisted to localStorage. Hidden with the rail below 1100px. -->
  <style>
    .railrsz{ position:fixed; right:var(--railW,272px); top:57px; bottom:0; width:10px; margin-right:-5px; cursor:ew-resize; z-index:25; }
    .railrsz::before{ content:''; position:absolute; left:4px; top:0; bottom:0; width:2px; background:transparent; transition:background .15s; }
    .railrsz:hover::before, .railrsz.rsz-active::before{ background:var(--blue,#3b7dff); }
    body.rsz-dragging{ cursor:ew-resize; user-select:none; }
    @media (max-width:1100px){ .railrsz{ display:none; } }
  </style>
  <script>
  (function(){
    var rail=document.getElementById('ctrlrail'); if(!rail) return;
    var KEY='crcp_railW';
    try{ var s=parseInt(localStorage.getItem(KEY),10); if(s>=200&&s<=760) document.documentElement.style.setProperty('--railW', s+'px'); }catch(_){}
    var h=document.createElement('div'); h.className='railrsz'; h.title='Drag to resize the Controls panel · double-click to reset'; document.body.appendChild(h);
    var dragging=false;
    function onMove(e){ if(!dragging) return; var w=Math.max(200, Math.min(760, window.innerWidth - e.clientX)); document.documentElement.style.setProperty('--railW', w+'px'); }
    function onUp(){ if(!dragging) return; dragging=false; document.body.classList.remove('rsz-dragging'); h.classList.remove('rsz-active');
      document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp);
      try{ localStorage.setItem(KEY, Math.round(rail.getBoundingClientRect().width)); }catch(_){} }
    h.addEventListener('mousedown',function(e){ e.preventDefault(); dragging=true; document.body.classList.add('rsz-dragging'); h.classList.add('rsz-active');
      document.addEventListener('mousemove',onMove); document.addEventListener('mouseup',onUp); });
    h.addEventListener('dblclick',function(){ document.documentElement.style.removeProperty('--railW'); try{ localStorage.removeItem(KEY); }catch(_){} });
  })();
  </script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>