← back to Rentv Sheet Enrich Refine

public_live/index.html

851 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<title>RENTV Live Console</title>
<style>
  :root{
    --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2b333d; --ink:#e6edf3;
    --dim:#9aa7b4; --accent:#4c9be8; --green:#cbe6b4; --greenink:#1a2e10; --chip:#232c37; --fs:13px; --rail:300px;
    /* interaction colors — theme-aware so nothing goes black-on-black in day mode */
    --rowhover:#1a212b; --cellfocus:#0d2b3f; --linkhover:#12283a;
    --ghdr-bg:rgba(76,155,232,.16); --ghdr-fg:#7fbcf2; --linkval-li:#6bb2ff;
  }
  *{box-sizing:border-box}
  body.light{--bg:#f4f6f9;--panel:#ffffff;--panel2:#eef2f6;--line:#d3dbe4;--ink:#1a2230;--dim:#5c6b7a;--chip:#e7edf3;--green:#dff0cf;--greenink:#26401a;
    --rowhover:#eaf1f8; --cellfocus:#dcebfb; --linkhover:#e4f0ff;
    --ghdr-bg:rgba(76,155,232,.13); --ghdr-fg:#1d5c96; --linkval-li:#1a66c2;}
  body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink);font-size:var(--fs)}
  /* scrollbars (horizontal + vertical) — visible in night mode, theme-aware */
  html{scrollbar-color:var(--line) var(--panel);scrollbar-width:thin}
  ::-webkit-scrollbar{width:14px;height:14px}
  ::-webkit-scrollbar-track{background:var(--panel)}
  ::-webkit-scrollbar-thumb{background:var(--line);border-radius:8px;border:3px solid var(--panel)}
  ::-webkit-scrollbar-thumb:hover{background:var(--accent)}
  ::-webkit-scrollbar-corner{background:var(--panel)}
  header{position:sticky;top:0;z-index:40;background:var(--panel);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;padding:9px 14px;flex-wrap:wrap}
  header h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.3px;white-space:nowrap}
  header h1 span{color:var(--accent)}
  select,button,input{font:inherit;color:var(--ink);background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:5px 9px}
  button{cursor:pointer} button:hover{border-color:var(--accent)}
  .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
  .seg button{border:0;border-radius:0;background:var(--panel2)} .seg button.on{background:var(--accent);color:#04121f;font-weight:700}
  .segsm{flex:1} .segsm button{flex:1;padding:5px 8px;font-size:11px} .segsm button+button{border-left:1px solid var(--line)}
  .grow{flex:1}
  .sync{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:12px}
  .dot{width:9px;height:9px;border-radius:50%;background:#3fb950} .dot.stale{background:#d29922} .dot.err{background:#f85149}
  .chips{display:flex;gap:6px;flex-wrap:wrap}
  .fchip{background:var(--accent);color:#04121f;border-radius:14px;padding:2px 9px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap}
  .fchip b{opacity:.7;margin-left:5px}
  /* layout */
  #main{display:flex;min-height:calc(100vh - 48px)}
  #rail{width:var(--rail);flex:0 0 var(--rail);background:var(--panel);border-right:1px solid var(--line);overflow:auto;height:calc(100vh - 48px);position:sticky;top:48px;padding:10px 10px 80px}
  #rail.hide{display:none}
  #wrap{flex:1;padding:12px 14px 120px;overflow:auto;height:calc(100vh - 48px)}
  .rrow{display:flex;gap:6px;align-items:center;margin:7px 2px}
  .rrow label{color:var(--dim);font-size:11px;min-width:42px}
  .rail input[type=search],.rail select{width:100%}
  #search{width:100%;margin-bottom:6px}
  .sec{border-top:1px solid var(--line);margin-top:8px;padding-top:6px}
  .sec>summary{cursor:pointer;color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.5px;list-style:none;user-select:none;padding:3px 0}
  .sec>summary::-webkit-details-marker{display:none}
  .sec>summary:before{content:'▸ ';color:var(--accent)} .sec[open]>summary:before{content:'▾ '}
  details.field{margin:2px 0;border:1px solid transparent;border-radius:6px}
  details.field>summary{cursor:pointer;padding:4px 6px;border-radius:6px;list-style:none;display:flex;align-items:center;gap:6px;font-size:12px}
  details.field>summary::-webkit-details-marker{display:none}
  details.field>summary:hover{background:var(--panel2)}
  details.field>summary .cnt{color:var(--dim);font-size:10px;margin-left:auto}
  details.field.active>summary{color:var(--accent);font-weight:600}
  .vals{padding:2px 6px 6px 14px;max-height:230px;overflow:auto}
  .val{display:flex;align-items:center;gap:6px;padding:2px 4px;border-radius:5px;cursor:pointer;font-size:12px}
  .val:hover{background:var(--panel2)} .val.on{color:var(--accent)}
  .val .n{color:var(--dim);font-size:10px;margin-left:auto}
  .val .bx{width:12px;height:12px;border:1px solid var(--line);border-radius:3px;flex:0 0 12px}
  .val.on .bx{background:var(--accent);border-color:var(--accent)}
  .ftog{display:flex;align-items:center;gap:6px;padding:3px 4px;font-size:12px;border-radius:5px}
  .ftog input{margin:0} .ftog .fl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer}
  .ftog .grip{cursor:grab;color:var(--dim);font-size:12px;user-select:none;padding:0 2px}
  .ftog .grip:active{cursor:grabbing}
  .ftog.dragging{opacity:.4} .ftog.drop{box-shadow:inset 0 2px 0 var(--accent)}
  .mini{font-size:10px;color:var(--dim);cursor:pointer} .mini:hover{color:var(--accent)}
  /* table */
  table{border-collapse:separate;border-spacing:0;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:10px}
  thead th{user-select:none}
  th.dragging{opacity:.5} th.droptarget{box-shadow:inset 3px 0 0 var(--accent)}
  thead th{position:sticky;top:0;background:var(--panel2);text-align:left;padding:7px 9px;border-bottom:1px solid var(--line);font-size:11px;color:var(--dim);white-space:nowrap;z-index:5;cursor:pointer;position:sticky;overflow:hidden;text-overflow:ellipsis}
  thead th:hover{color:var(--accent)}
  th .rz{position:absolute;right:0;top:0;height:100%;width:8px;cursor:col-resize;z-index:6}
  th .rz:hover{background:var(--accent)}
  .rzguide{position:fixed;top:48px;bottom:0;width:2px;background:var(--accent);z-index:200;pointer-events:none;box-shadow:0 0 6px var(--accent)}
  body.resizing,body.resizing *{cursor:col-resize !important;user-select:none !important}
  table{table-layout:fixed}
  td{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}   /* collapsed: compact single line */
  tbody td{padding:6px 9px;border-bottom:1px solid var(--line);vertical-align:top;max-width:320px}
  tbody tr:hover{background:var(--rowhover)}
  /* EXPAND ON CLICK: focus a cell (e.g. Notes) and it wraps + grows to show/edit the whole value */
  td[contenteditable]:focus{outline:none;background:var(--cellfocus);box-shadow:inset 0 0 0 2px var(--accent);
    white-space:pre-wrap;word-break:break-word;overflow:visible;text-overflow:clip;min-width:260px;max-width:520px;position:relative;z-index:4}
  td.saved{background:var(--green)!important;color:var(--greenink)!important}
  td.xcol .linkval,td.xcol .cicon{color:var(--xtext)!important}   /* exact-color cell: keep link/icon text readable on the painted bg */
  /* color key / legend panel (exact sheet colors -> provisional meanings) */
  .legendpanel{position:fixed;right:14px;top:52px;z-index:80;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 34px #000a;padding:8px;min-width:290px;max-width:380px;max-height:74vh;overflow:auto;display:none;font-size:12px}
  .legendpanel .lghead{font-weight:700;color:var(--dim);padding:2px 4px 8px;border-bottom:1px solid var(--line);margin-bottom:6px;position:sticky;top:-8px;background:var(--panel);z-index:2}
  .legendpanel .lgnote{font-weight:400;font-size:10px;display:block;margin-top:2px;color:var(--dim)}
  .legendpanel .lgempty{color:var(--dim);padding:8px 4px}
  .legendpanel .lgrow{display:flex;align-items:center;gap:8px;padding:3px 4px;border-radius:6px}
  .legendpanel .lgrow:hover{background:var(--rowhover)}
  .legendpanel .lgsw{width:16px;height:16px;border-radius:4px;border:1px solid rgba(128,128,128,.4);flex:0 0 auto}
  .legendpanel .lgname{flex:1;outline:none;border-radius:4px;padding:1px 4px;min-width:80px}
  .legendpanel .lgname:focus{box-shadow:inset 0 0 0 2px var(--accent)}
  .legendpanel .lgname.lgguess{opacity:.72;font-style:italic}
  .legendpanel .lgq{color:var(--dim);font-weight:700;font-size:11px;flex:0 0 auto}
  .legendpanel .lgclose{float:right;background:transparent;border:0;color:var(--dim);cursor:pointer;font-size:14px;line-height:1;padding:0 2px;margin:-1px -2px 0 8px}
  .legendpanel .lgclose:hover{color:var(--ink)}
  .legendpanel .lgreset{background:transparent;border:0;color:var(--dim);cursor:pointer;font-size:12px;padding:0 3px;flex:0 0 auto}
  .legendpanel .lgreset:hover{color:var(--accent)}
  .legendpanel .lgmore{cursor:pointer;color:var(--dim);padding:6px 4px 3px;font-size:11px;border-top:1px dashed var(--line);margin-top:4px}
  .legendpanel .lgmore:hover{color:var(--accent)}
  .legendpanel .lghex{color:var(--dim);font-variant-numeric:tabular-nums;font-size:10.5px}
  .legendpanel .lgn{color:var(--dim);font-variant-numeric:tabular-nums;min-width:46px;text-align:right}
  td.enriched{box-shadow:inset 3px 0 0 #7bc043;background:rgba(123,192,67,.09)}
  td a{color:var(--accent);text-decoration:none} .namecell{font-weight:600}
  a.iconbtn,a.callbtn{display:inline-flex;align-items:center;justify-content:center;margin-left:5px;text-decoration:none;cursor:pointer;opacity:.72;vertical-align:middle}
  a.iconbtn:hover,a.callbtn:hover{opacity:1}
  a.iconbtn svg,a.callbtn svg{width:1.05em;height:1.05em;display:block}
  a.callbtn{color:#16a34a} a.iconbtn.web{color:var(--accent)} a.iconbtn.li{color:#0a66c2}
  .linkval{color:inherit}
  /* whole-cell clickable links: icon on the LEFT (never clipped), entire cell opens popup / dials */
  td.linkcell,td.callcell,td.mailcell{cursor:pointer}
  td.linkcell .cicon,td.callcell .cicon,td.mailcell .cicon{display:inline-flex;vertical-align:middle;margin-right:5px}
  td.linkcell .cicon svg,td.callcell .cicon svg,td.mailcell .cicon svg{width:1.05em;height:1.05em;display:block}
  td.linkcell.web .cicon,td.linkcell.web .linkval{color:var(--accent)}
  td.linkcell.li .cicon,td.linkcell.li .linkval{color:var(--linkval-li)}
  td.callcell .cicon{color:#16a34a}
  td.mailcell .cicon,td.mailcell .linkval{color:var(--accent)}
  td.linkcell:hover,td.callcell:hover,td.mailcell:hover{background:var(--linkhover)}
  /* drag-to-fill: highlight the cell a dragged value will drop into */
  #grid td.drophi{outline:2px dashed var(--accent);outline-offset:-2px;background:var(--cellfocus)}
  /* color-coded Status field + click-to-set picker */
  td.statuscell{cursor:pointer} td.statuscell:hover{background:var(--rowhover)}
  .statuschip{display:inline-block;max-width:100%;padding:2px 10px;border-radius:12px;font-size:.92em;font-weight:600;line-height:1.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  /* derived 🔥 Warmth column — read-only heat pill */
  td.warmthcell{text-align:center;cursor:default}
  .heatpill{display:inline-block;min-width:34px;padding:1px 9px;border-radius:11px;font-weight:700;font-variant-numeric:tabular-nums}
  td.statuscell.empty{color:var(--dim);font-size:.9em}
  td.statuscell .chipdate{display:block;margin-top:3px;color:var(--dim);font-size:.72em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
  .statusmenu{position:fixed;z-index:70;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 34px #000a;padding:5px;min-width:230px;max-height:70vh;overflow:auto}
  .statusmenu .smhead{color:var(--dim);font-size:10px;text-transform:uppercase;letter-spacing:.5px;padding:5px 8px 6px}
  .smi{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;cursor:pointer;font-size:13px}
  .smi:hover{background:var(--rowhover)} .smi.cur{font-weight:700}
  .smi .sw{width:16px;height:16px;border-radius:4px;flex:0 0 16px;border:1px solid rgba(0,0,0,.15)} .smi .sl{flex:1} .smi .smck{color:var(--accent)}
  td.linkcell:hover .linkval,td.callcell:hover .linkval{text-decoration:underline}
  /* FIELD LOOKUP: click a field in the left rail -> its full value list opens here in the main area */
  .lkhead{display:flex;align-items:center;gap:10px;padding:8px 10px;position:sticky;top:0;background:var(--panel2);border-bottom:1px solid var(--line);z-index:6}
  .lkhead b{font-size:14px;color:var(--accent)} .lksub{color:var(--dim);font-size:12px}
  .lkback{cursor:pointer;color:var(--accent);font-weight:700;white-space:nowrap} .lkback:hover{text-decoration:underline}
  .lkclose{cursor:pointer;color:var(--dim);font-weight:700} .lkclose:hover{color:#fff}
  #lksearch{margin-left:auto;background:var(--panel);border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:3px 8px;font-size:12px;min-width:150px}
  table.lktbl{table-layout:auto;border-collapse:collapse;width:100%}
  .lktbl th{position:sticky;top:39px;text-align:left;background:var(--panel);color:var(--dim);font-size:11px;padding:6px 10px;border-bottom:1px solid var(--line);z-index:5}
  .lkrow{cursor:pointer;border-bottom:1px solid var(--line)} .lkrow:hover{background:var(--rowhover)}
  .lkrow td{padding:6px 10px} .lkv{max-width:680px} .lkn{color:var(--dim);text-align:right;white-space:nowrap}
  .lkrow .lkbx{width:18px;color:transparent} .lkrow.on{background:rgba(123,192,67,.12)} .lkrow.on .lkbx{color:#7bc043}
  .rowbtn{background:var(--accent);color:#04121f;border:0;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}
  /* row-number gutter down the left */
  td.rownum,th.rownum{color:var(--dim);text-align:right;font-size:.82em;font-variant-numeric:tabular-nums;background:var(--panel2);border-right:1px solid var(--line);user-select:none;padding:6px 7px}
  .grouphdr td{background:var(--ghdr-bg);color:var(--ghdr-fg);font-weight:600;font-size:11px;letter-spacing:.3px;padding:5px 9px;position:sticky;top:26px;box-shadow:inset 3px 0 0 var(--accent);border-bottom:1px solid var(--line)}
  /* cards */
  .cards{display:grid;gap:11px} .cards.grid{grid-template-columns:repeat(auto-fill,minmax(300px,1fr))} .cards.list{grid-template-columns:1fr}
  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:13px 15px;position:relative} .card:hover{border-color:var(--accent)}
  .card.enriched-card{box-shadow:inset 3px 0 0 #7bc043}
  .card .co{font-weight:700;font-size:15px} .card .nm{margin-top:2px} .card .ro{color:var(--dim);font-size:12px}
  .card .meta{margin-top:8px;display:flex;flex-wrap:wrap;gap:5px} .badge{background:var(--chip);border:1px solid var(--line);border-radius:20px;padding:2px 8px;font-size:10px;color:var(--dim)} .badge.ok{color:var(--green);border-color:#3a5228}
  .card .act{position:absolute;top:11px;right:11px}
  .empty{color:var(--dim);padding:36px;text-align:center}
  .legend{display:inline-flex;align-items:center;gap:5px;color:var(--dim);font-size:11px}
  .legend .sw{width:11px;height:11px;border-radius:3px;background:rgba(123,192,67,.25);box-shadow:inset 3px 0 0 #7bc043}
  /* composer */
  #scrim{position:fixed;inset:0;background:#000a;z-index:60;display:none} #drawer{position:fixed;top:0;right:0;height:100%;width:min(620px,100%);background:var(--panel);border-left:1px solid var(--line);z-index:61;transform:translateX(100%);transition:transform .2s;display:flex;flex-direction:column}
  #drawer.resizing{transition:none}
  #dresize{position:absolute;left:0;top:0;bottom:0;width:7px;cursor:col-resize;z-index:63} #dresize:hover{background:var(--accent)}
  #d-fields a{word-break:break-all}
  #drawer.open{transform:none} #drawer header{position:static;background:var(--panel2)}
  .dbody{padding:15px;overflow:auto;flex:1} .dbody h2{margin:0 0 2px;font-size:18px} .dbody .sub{color:var(--dim);font-size:13px;margin-bottom:12px}
  /* contact data on a light card so email/phone/LinkedIn are crisp + readable in BOTH themes */
  .fieldgrid{display:grid;grid-template-columns:auto 1fr;gap:7px 14px;font-size:13px;margin-bottom:14px;background:#ffffff;color:#1a2230;border:1px solid #d3dbe4;border-radius:10px;padding:11px 13px}
  .fieldgrid .k{color:#5c6b7a;font-weight:500}
  .fieldgrid a{color:#1a66c2;text-decoration:none;font-weight:600;word-break:break-all} .fieldgrid a:hover{text-decoration:underline}
  .drow{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:9px 0} label.lbl{color:var(--dim);font-size:12px;min-width:60px}
  /* Contact-via: clean full-width segmented control with real icons + dividers */
  #chanseg{display:flex;flex:1;min-width:220px} #chanseg button{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:7px 8px;font-size:12px}
  #chanseg button+button{border-left:1px solid var(--line)} #chanseg button svg{width:14px;height:14px}
  textarea{width:100%;min-height:300px;flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:10px;color:var(--ink);padding:11px;font:inherit;line-height:1.5;resize:vertical}
  .dbody{display:flex;flex-direction:column}
  .subj{width:100%;margin-bottom:8px} .actions{display:flex;gap:8px;flex-wrap:wrap;padding:11px 15px;border-top:1px solid var(--line);background:var(--panel2)} .actions .primary{background:var(--accent);color:#04121f;font-weight:700;border:0}
  .chdis{opacity:.4;pointer-events:none} .hint{color:var(--dim);font-size:12px}
</style>
</head>
<body>
<header>
  <button id="railtog" title="Toggle filters">☰</button>
  <h1>RENTV <span>Live Console</span></h1>
  <select id="tab" title="Sheet tab"></select>
  <div class="seg" id="viewseg" title="View mode">
    <button data-v="table">▤ Table</button><button data-v="list">☰ List</button><button data-v="grid">▦ Grid</button>
  </div>
  <span class="legend"><span class="sw"></span>enriched</span>
  <span class="chips" id="chips"></span>
  <span class="grow"></span>
  <span class="sync"><span class="dot" id="dot"></span><span id="syncmsg">loading…</span></span>
  <button id="theme" title="Night / Day">🌙</button>
  <button id="legendtog" title="Color key — what the exact sheet colors mean on this tab">🎨 Colors</button>
  <button id="refresh" title="Rebuild local copy from the sheet">↻ Refresh</button>
</header>

<div id="main">
  <aside id="rail"></aside>
  <div id="wrap"><div class="empty">Loading our local copy…</div></div>
</div>

<div id="scrim"></div>
<div id="drawer">
  <div id="dresize" title="drag to widen"></div>
  <header><strong>✍ Draft outreach</strong><span class="grow"></span><button id="dclose">✕ Close</button></header>
  <div class="dbody">
    <h2 id="d-company">—</h2><div class="sub" id="d-who">—</div>
    <div class="fieldgrid" id="d-fields"></div>
    <div class="drow"><label class="lbl">Contact via</label>
      <div class="seg" id="chanseg"><button data-c="email">Email</button><button data-c="linkedin">LinkedIn</button><button data-c="phone">Phone</button></div></div>
    <div class="drow"><label class="lbl">Purpose</label>
      <select id="purpose"><option value="partnership">Partnership / intro</option><option value="sponsorship">Conference sponsorship</option><option value="speaker">Speaker / panel invite</option><option value="followup">Follow-up</option></select>
      <label class="lbl">Tone</label><select id="tone"><option value="warm">Warm</option><option value="direct">Direct</option><option value="formal">Formal</option></select>
      <button id="regen">↻ Regenerate</button></div>
    <input class="subj" id="d-subject" placeholder="Subject line">
    <textarea id="d-body"></textarea>
    <div class="drow"><span class="hint" id="d-target"></span></div>
    <div class="drow"><button id="enhance">✨ Enhance (local AI · $0)</button><span class="hint">Polishes with a local model — nothing sent.</span></div>
  </div>
  <div class="actions"><button class="primary" id="act-go">Open in Email</button><button id="act-copy">⧉ Copy draft</button><span class="grow"></span><span class="hint">Draft only. Contacts © Steve Bloom / RENTV.</span></div>
</div>

<script>
const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s);
const LS={get:(k,d)=>{try{return JSON.parse(localStorage.getItem('rc2.'+k))??d}catch(e){return d}},set:(k,v)=>localStorage.setItem('rc2.'+k,JSON.stringify(v))};
let DATA={gid:0,title:'',headers:[],rows:[],green:[]}, GREENSET=new Set(), COLORS={}, ROLE={}, META=null;
// color-coded Status field pulled from the spreadsheet (value->color) + the canonical value list
let STATUS_FIELD=null, STATUS_COLORS={}, STATUS_VALUES=[];
const STATUS_DEFAULTS={'Active / Contacted':'#cfe2f3','Needs Email / Outreach Pending':'#fff2a8','Prospect / Follow-up':'#d9ead3','In Progress / Left Message':'#fce5cd','On Hold / Inactive':'#e6e6e6','Flag / Priority':'#f4cccc','Section / Category':'#c9daf8'};
let VIEW=LS.get('view','table'), GID=LS.get('gid',null), editing=false, curRow=null, curChan='email';
let SELECTED=new Set(LS.get('sheets',[]));   // selected sheet gids (1..all) shown combined
function curKey(){ return SELECTED.size===1 ? String([...SELECTED][0]) : ('combo.'+[...SELECTED].sort().join('-')); }
let FILTERS={}, SEARCH='', SORT=LS.get('sort',null), GROUP=LS.get('group',''), HIDDEN=new Set(LS.get('hidden.'+(GID||''),[])), COLW={}, ORDER=[], dragCol=null;
// grouping produced unwanted black "sub-sort" header bars — FORCE it off on every load (never persists).
// the Group dropdown still works within a session for anyone who wants it; it just won't stick across refreshes.
GROUP=''; LS.set('group','');
let LOOKUP=null;   // when set to a column index, the main area shows that field's full value lookup
let WIDTHPRESET=LS.get('widthpreset','');   // '', tight, cozy, wide, fit — 'fit' re-fits columns to the window on resize
const MAXROWS=2500;
function pk(){return 'p'+GID}  // per-tab localStorage namespace

// ---- field roles (for the composer) -----------------------------------------
function findCol(preds){for(const p of preds){const i=DATA.headers.findIndex(h=>p.test(h));if(i>=0)return i}return -1}
function detectRoles(){ROLE={name:findCol([/^contact name$/i,/contact name/i,/\bname\b/i]),company:findCol([/company\/venue/i,/^company$/i,/venue/i,/firm/i]),position:findCol([/^position$/i,/title/i]),email:findCol([/^email$/i]),email2:findCol([/updated email/i]),phone:findCol([/^phone 1$/i,/^phone\(s\)$/i,/^phone$/i]),cphone:findCol([/company phone/i]),liC:findCol([/linkedin \(contact\)/i]),liCo:findCol([/linkedin \(company\)/i]),mvpN:findCol([/marketing\/vp name/i]),mvpP:findCol([/marketing\/vp position/i]),mvpL:findCol([/marketing\/vp linkedin/i])}}
const g=(r,i)=> (i>=0&&i<r.length)?String(r[i]||'').trim():'';
function hrefOf(v){ v=String(v||'').trim();
  const m=v.match(/https?:\/\/[^\s"')]+/i); if(m) return m[0].replace(/[),.]+$/,'');
  // bare domain or linkedin/site path (no spaces) -> add https:// so it's clickable
  if(/^(www\.)?([a-z0-9-]+\.)+[a-z]{2,}(\/\S*)?$/i.test(v)) return 'https://'+v;  // TLD must be alphabetic — a dotted phone (310.555.9876) is NOT a domain
  return '';
}

// ---- load (from OUR local copy) ---------------------------------------------
async function loadMeta(){
  META=await (await fetch('/api/meta')).json();
  STATUS_FIELD=META.status_field||null; STATUS_COLORS=META.status_colors||{}; STATUS_VALUES=META.status_values||[];
  // drop known-junk learned status values (a stray-digit dup of "Active / Contacted" that carried the enrichment-green #cae6b4). Explicit list — no heuristic that could nuke a real value/color.
  {const BAD_VALS=['Active / Contacted3'];
   STATUS_VALUES=STATUS_VALUES.filter(v=>!BAD_VALS.includes(v));
   for(const v of BAD_VALS) delete STATUS_COLORS[v];}
  const sel=$('#tab');sel.innerHTML='';
  META.tabs.forEach(t=>{const o=document.createElement('option');o.value=t.gid;o.textContent=`${t.title} (${t.rows})`;sel.appendChild(o)});
  if(GID==null||!META.tabs.find(t=>t.gid==GID)){
    const primary = META.tabs.find(t=>/unique contacts/i.test(t.title))
      || META.tabs.slice().sort((a,b)=>b.rows-a.rows)[0] || META.tabs[0];
    GID=primary.gid;
  }
  // keep the selection valid; default to the primary tab
  SELECTED=new Set([...SELECTED].filter(g=>META.tabs.find(t=>t.gid==g)));
  if(!SELECTED.size) SELECTED.add(GID);
  sel.value=GID;
}
async function loadData(){
  setSync('load');
  const gids=[...SELECTED];
  let d;
  if(gids.length<=1){ GID=gids[0]??GID; LS.set('gid',GID); d=await (await fetch('/api/tab?gid='+GID)).json(); }
  else { d=await (await fetch('/api/combined?gids='+gids.join(','))).json(); }
  LS.set('sheets',[...SELECTED]);
  DATA=d; GREENSET=new Set(d.green||[]); COLORS=d.colors||{}; detectRoles();
  if($('#legendpanel')&&$('#legendpanel').style.display==='block')renderLegend();   // refresh color key per tab
  FILTERS={}; SEARCH=''; $('#search')&&($('#search').value='');
  const K=curKey();
  HIDDEN=new Set(LS.get('hidden.'+K,[]));
  COLW=LS.get('colw.'+K,{});
  ORDER=LS.get('order.'+K,[]);   // saved column order (per view, per user)
  if(!ORDER.length){               // default: pull First/Last Name up next to Contact Name
    const find=re=>DATA.headers.findIndex(h=>re.test(String(h).trim()));
    const nc=find(/^contact name/i), fn=find(/^first name$/i), ln=find(/^last name$/i);
    if(nc>=0&&fn>=0&&ln>=0){
      let o=DATA.headers.map((h,i)=>i).filter(i=>i!==fn&&i!==ln);
      o.splice(o.indexOf(nc)+1,0,fn,ln); ORDER=o;
    }
  }
  // restore saved sort for THIS view; else default to 🔥 Warmth desc (work-first), then Last Name asc
  const savedSort=LS.get('sort.'+K, undefined);
  if(savedSort!==undefined){ SORT=savedSort; }
  else { const wIdx=DATA.headers.findIndex(h=>String(h).trim()==='🔥 Warmth');
    if(wIdx>=0){ SORT={col:wIdx, dir:-1}; }
    else { const lnIdx=DATA.headers.findIndex(h=>/^last name$/i.test(String(h).trim())); SORT = lnIdx>=0 ? {col:lnIdx, dir:1} : null; } }
  // restore saved density
  const dens=LS.get('dens',null); if(dens) document.documentElement.style.setProperty('--fs',dens+'px');
  buildRail(); render(); setSync('ok');
}
function setSync(state){
  $('#dot').className='dot'+(state==='err'?' err':state==='load'?' stale':'');
  const age=META?Math.round((Date.now()/1000)-META.generated):0;
  $('#syncmsg').textContent = state==='load'?'loading…':state==='err'?'error':
    `local copy · ${DATA.rows.length} rows · as of ${age<90?age+'s':Math.round(age/60)+'m'} ago`;
}

// ---- filtering ---------------------------------------------------------------
function norm(s){return String(s||'').trim()}
function passes(r){
  for(const ci in FILTERS){ const set=FILTERS[ci]; if(set.size && !set.has(norm(r[ci])||'(blank)')) return false; }
  if(SEARCH){ const q=SEARCH.toLowerCase(); if(!r.some(c=>String(c||'').toLowerCase().includes(q))) return false; }
  return true;
}
function filtered(){ let rows=DATA.rows.map((r,i)=>[r,i]).filter(([r])=>passes(r));
  if(SORT){ const ci=SORT.col,dir=SORT.dir; rows.sort((a,b)=>{const rx=g(a[0],ci),ry=g(b[0],ci),x=rx.toLowerCase(),y=ry.toLowerCase();
    if(!x&&!y)return 0; if(!x)return 1; if(!y)return -1;   // blanks always sink to the bottom
    const nx=Number(rx),ny=Number(ry);                     // numeric-aware: 🔥 Warmth, # Appearances sort as numbers not text
    if(rx!==''&&ry!==''&&!isNaN(nx)&&!isNaN(ny))return (nx-ny)*dir;
    return (x<y?-1:x>y?1:0)*dir}); }
  return rows;
}

// ---- LEFT RAIL: search + sort + group + density + field toggles + per-field FILTERS ----
function buildRail(){
  const rail=$('#rail'); if(rail.classList.contains('hide')){return}
  LOOKUP=null;   // rebuilding the rail (sheet change / reset) drops any open field lookup
  const H=DATA.headers, opts=H.map((h,i)=>h?`<option value="${i}">${esc(h)}</option>`:'').join('');
  let html=`<details class="sec" open><summary>Sheets (${META?META.tabs.length:0}) · <span class="mini" id="alls">all</span> / <span class="mini" id="nones">none</span></summary>
      <div id="sheets"></div></details>
    <input type="search" id="search" placeholder="Search all fields…" value="${esc(SEARCH)}">
    <div class="rrow"><label>Sort</label><select id="sortsel"><option value="">— none —</option>${opts}</select>
      <button id="sortdir" title="asc/desc">${SORT&&SORT.dir<0?'▼':'▲'}</button></div>
    <div class="rrow"><label>Group</label><select id="groupsel"><option value="">— none —</option>${opts}</select></div>
    <div class="rrow"><label>Width</label><div class="seg segsm" id="wseg" title="column width"><button data-w="tight">Tight</button><button data-w="cozy">Cozy</button><button data-w="wide">Wide</button><button data-w="fit" title="fit all columns on screen (re-fits when you resize the window)">Fit</button></div></div>
    <div class="rrow"><label>Text</label><div class="seg segsm" id="tseg" title="text size"><button data-t="12">S</button><button data-t="14">M</button><button data-t="16">L</button></div><button id="fitcols" title="size each column to its content">⤢ Autofit</button><button id="resetcols" title="reset widths + order">↺ Reset</button></div>
    <details class="sec" open><summary>Fields (${H.filter(Boolean).length}) · <span class="mini" id="allf">all</span> / <span class="mini" id="nonef">none</span></summary>
      <div id="ftogs"></div></details>
    <details class="sec" open><summary>Filters — click a value</summary><div id="facets"></div></details>`;
  rail.innerHTML=html;
  $('#sortsel').value=SORT?SORT.col:''; $('#groupsel').value=GROUP;
  renderSheets();   // sheet checklist: pick 1..all, shown combined
  renderFields();   // field list = show/hide + move up/down (reflects & drives column order)
  buildFacets();
  wireRail();
}
function buildFacets(){
  const H=DATA.headers, host=$('#facets'); if(!host)return;
  // rows passing OTHER filters (so counts reflect current narrowing)
  const base=DATA.rows.filter(passes);
  let html='';
  H.forEach((h,ci)=>{
    if(!h) return;
    const tally={};
    for(const r of base){ let v=norm(r[ci])||'(blank)'; tally[v]=(tally[v]||0)+1; }
    const vals=Object.entries(tally).sort((a,b)=>b[1]-a[1]);
    if(vals.length<=1 && vals[0] && vals[0][0]==='(blank)') return; // skip all-blank fields
    const active=FILTERS[ci]&&FILTERS[ci].size;
    const shown=vals.slice(0,40);
    html+=`<details class="field ${active?'active':''}" data-c="${ci}"><summary>${esc(h)}<span class="cnt">${vals.length}</span></summary>
      <div class="vals">${shown.map(([v,n])=>`<div class="val ${active&&FILTERS[ci].has(v)?'on':''}" data-v="${esc(v)}"><span class="bx"></span>${esc(v.length>34?v.slice(0,33)+'…':v)}<span class="n">${n}</span></div>`).join('')}
      ${vals.length>40?`<div class="mini">+${vals.length-40} more…</div>`:''}</div></details>`;
  });
  host.innerHTML=html||'<div class="hint" style="padding:6px">No filterable fields</div>';
  host.querySelectorAll('.val').forEach(el=>el.onclick=()=>toggleFilter(+el.closest('.field').dataset.c, el.dataset.v));
  // click a FIELD (not the inline disclosure) -> open its full value lookup in the main table area on the right
  host.querySelectorAll('details.field > summary').forEach(sm=>sm.addEventListener('click',e=>{
    e.preventDefault(); openLookup(+sm.closest('.field').dataset.c);
  }));
}
function openLookup(ci){ LOOKUP=ci; render(); document.querySelector('#wrap').scrollTop=0; }
function lookupHTML(ci){
  const h=DATA.headers[ci]||'', tally={};                    // full contents of THIS field across the loaded rows
  for(const r of DATA.rows){ const raw=ci<r.length?r[ci]:''; const v=norm(raw)||'(blank)'; tally[v]=(tally[v]||0)+1; }
  const vals=Object.entries(tally).sort((a,b)=>b[1]-a[1]||a[0].localeCompare(b[0]));
  const act=FILTERS[ci];
  const body=vals.map(([v,n])=>`<tr class="lkrow${act&&act.has(v)?' on':''}" data-v="${esc(v)}">`
    +`<td class="lkbx">✓</td><td class="lkv" title="${esc(v)}">${esc(v)}</td><td class="lkn">${n}</td></tr>`).join('');
  return `<div class="lookup"><div class="lkhead">`
    +`<span class="lkback" title="back to the table">‹ Table</span><b>${esc(h)}</b>`
    +`<span class="lksub">${vals.length} distinct · click a value to filter</span>`
    +`<input id="lksearch" placeholder="Filter values…" autocomplete="off"><span class="lkclose" title="close">✕</span></div>`
    +`<table class="lktbl"><thead><tr><th class="lkbx"></th><th>Value</th><th class="lkn">Rows</th></tr></thead>`
    +`<tbody id="lkbody">${body}</tbody></table></div>`;
}
function wireLookup(){
  const w=$('#wrap'), done=()=>{LOOKUP=null;render()};
  w.querySelector('.lkback')&&(w.querySelector('.lkback').onclick=done);
  w.querySelector('.lkclose')&&(w.querySelector('.lkclose').onclick=done);
  // toggle a value's filter IN PLACE (no full re-render) so scroll + search text survive multi-select
  w.querySelectorAll('.lkrow').forEach(tr=>tr.onclick=()=>{ const ci=LOOKUP,v=tr.dataset.v;
    FILTERS[ci]=FILTERS[ci]||new Set();
    if(FILTERS[ci].has(v)){FILTERS[ci].delete(v);tr.classList.remove('on');}else{FILTERS[ci].add(v);tr.classList.add('on');}
    if(!FILTERS[ci].size)delete FILTERS[ci];
    renderChips(); });
  const s=w.querySelector('#lksearch');
  if(s)s.oninput=e=>{const q=e.target.value.toLowerCase();
    w.querySelectorAll('#lkbody .lkrow').forEach(tr=>{tr.style.display=tr.querySelector('.lkv').textContent.toLowerCase().includes(q)?'':'none';});};
}
function toggleFilter(ci,v){ FILTERS[ci]=FILTERS[ci]||new Set(); FILTERS[ci].has(v)?FILTERS[ci].delete(v):FILTERS[ci].add(v); if(!FILTERS[ci].size)delete FILTERS[ci]; buildFacets(); renderChips(); render(); }
function renderChips(){ const c=$('#chips'); let h='';
  for(const ci in FILTERS){ for(const v of FILTERS[ci]){ h+=`<span class="fchip" data-c="${ci}" data-v="${esc(v)}">${esc(DATA.headers[ci])}: ${esc(v.length>18?v.slice(0,17)+'…':v)}<b>✕</b></span>`; } }
  c.innerHTML=h; c.querySelectorAll('.fchip').forEach(el=>el.onclick=()=>toggleFilter(+el.dataset.c, el.dataset.v));
}
function wireRail(){
  $('#search').oninput=e=>{SEARCH=e.target.value;clearTimeout(window._st);window._st=setTimeout(()=>{buildFacets();render()},180)};
  $('#sortsel').onchange=e=>{SORT=e.target.value===''?null:{col:+e.target.value,dir:SORT?SORT.dir:1};LS.set('sort.'+curKey(),SORT);render()};
  $('#sortdir').onclick=()=>{if(SORT){SORT.dir*=-1;LS.set('sort.'+curKey(),SORT);$('#sortdir').textContent=SORT.dir<0?'▼':'▲';render()}};
  $('#groupsel').onchange=e=>{GROUP=e.target.value;LS.set('group',GROUP);render()};
  $$('#wseg button').forEach(b=>b.onclick=()=>setWidthPreset(b.dataset.w));
  $$('#tseg button').forEach(b=>b.onclick=()=>{document.documentElement.style.setProperty('--fs',b.dataset.t+'px');LS.set('dens',b.dataset.t);markTextBtns()});
  $('#fitcols').onclick=()=>{WIDTHPRESET='';LS.set('widthpreset','');fitAllCols();markWidthBtns()};
  $('#resetcols').onclick=()=>{WIDTHPRESET='';LS.set('widthpreset','');resetLayout()};
  markWidthBtns(); markTextBtns();
  $('#alls').onclick=()=>{SELECTED=new Set(META.tabs.map(t=>t.gid));loadData()};
  $('#nones').onclick=()=>{SELECTED=new Set([GID]);loadData()};
  $('#allf').onclick=()=>{HIDDEN.clear();LS.set('hidden.'+curKey(),[]);buildRail();render()};
  $('#nonef').onclick=()=>{HIDDEN=new Set(DATA.headers.map((h,i)=>h?String(i):null).filter(x=>x));LS.set('hidden.'+curKey(),[...HIDDEN]);buildRail();render()};
}

// ---- render: table | list | grid --------------------------------------------
function visCols(){
  let cols=DATA.headers.map((h,i)=>({h,i})).filter(o=>o.h&&!HIDDEN.has(String(o.i)));
  if(ORDER&&ORDER.length){ const pos=new Map(ORDER.map((ci,idx)=>[ci,idx]));
    cols.sort((a,b)=>(pos.has(a.i)?pos.get(a.i):1e4+a.i)-(pos.has(b.i)?pos.get(b.i):1e4+b.i)); }
  return cols;
}
function orderedFields(){   // all fields, in the current column order (hidden included)
  const all=DATA.headers.map((h,i)=>({h,i})).filter(o=>o.h);
  if(ORDER&&ORDER.length){const pos=new Map(ORDER.map((ci,idx)=>[ci,idx]));
    all.sort((a,b)=>(pos.has(a.i)?pos.get(a.i):1e4+a.i)-(pos.has(b.i)?pos.get(b.i):1e4+b.i));}
  return all;
}
function renderSheets(){
  const host=$('#sheets'); if(!host||!META)return;
  host.innerHTML=META.tabs.map(t=>`<div class="ftog" data-g="${t.gid}"><input type="checkbox" ${SELECTED.has(t.gid)?'checked':''}><span class="fl" title="${esc(t.title)}">${esc(t.title)} <span style="color:var(--dim)">(${t.rows})</span></span></div>`).join('');
  host.querySelectorAll('.ftog').forEach(row=>{const g=+row.dataset.g;
    const set=v=>{v?SELECTED.add(g):SELECTED.delete(g); if(!SELECTED.size)SELECTED.add(g); loadData();};
    row.querySelector('input').onchange=e=>set(e.target.checked);
    row.querySelector('.fl').onclick=()=>{ SELECTED=new Set([g]); loadData(); };  // click name = ONLY this sheet
  });
}
function renderFields(){
  const host=$('#ftogs'); if(!host)return;
  host.innerHTML=orderedFields().map(o=>`<div class="ftog" data-c="${o.i}"><span class="grip" title="drag to reorder">⠿</span><input type="checkbox" ${HIDDEN.has(String(o.i))?'':'checked'}><span class="fl" title="${esc(o.h)}">${esc(o.h)}</span></div>`).join('');
  host.querySelectorAll('.ftog').forEach(row=>{const ci=+row.dataset.c;
    row.querySelector('input').onchange=e=>{e.target.checked?HIDDEN.delete(String(ci)):HIDDEN.add(String(ci));LS.set('hidden.'+curKey(),[...HIDDEN]);render()};
    row.querySelector('.grip').addEventListener('mousedown',e=>startFieldDrag(e,ci,row));
  });
}
function startFieldDrag(e,ci,row){
  e.preventDefault();
  const host=$('#ftogs'); row.classList.add('dragging');
  const at=y=>[...host.querySelectorAll('.ftog')].find(r=>{const b=r.getBoundingClientRect();return y>=b.top&&y<=b.bottom});
  const mv=ev=>{host.querySelectorAll('.ftog').forEach(r=>r.classList.remove('drop'));const t=at(ev.clientY);if(t&&t!==row)t.classList.add('drop')};
  const up=ev=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);row.classList.remove('dragging');host.querySelectorAll('.ftog').forEach(r=>r.classList.remove('drop'));const t=at(ev.clientY);if(t&&+t.dataset.c!==ci)moveFieldTo(ci,+t.dataset.c)};
  document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);
}
function reorder(from,to){   // shared: move column 'from' to 'to's slot; used by table AND panel drags
  let order=(ORDER&&ORDER.length)?ORDER.slice():visCols().map(c=>c.i);
  DATA.headers.forEach((h,i)=>{if(h&&!order.includes(i))order.push(i)});
  order=order.filter(x=>x!==from);
  const ti=order.indexOf(to); order.splice(ti<0?order.length:ti,0,from);
  ORDER=order; LS.set('order.'+curKey(),ORDER); render(); renderFields();   // sync BOTH views
}
const moveCol=reorder, moveFieldTo=reorder;
// WIDTH PRESETS: one click sizes every visible column. 'fit' also re-fits on window resize.
function setWidthPreset(mode){
  WIDTHPRESET=mode; LS.set('widthpreset',mode);
  const cols=visCols();
  if(mode==='fit'){ applyFit(); }
  else if(mode==='cozy'){ COLW={}; LS.set('colw.'+curKey(),COLW); render(); }
  else{ const w=mode==='tight'?120:280; cols.forEach(c=>COLW[c.i]=w); LS.set('colw.'+curKey(),COLW); render(); }
  markWidthBtns();
}
function applyFit(){   // distribute the viewport width across visible columns so the whole table fits on screen
  const cols=visCols(); if(!cols.length)return;
  const wrap=$('#wrap'); const avail=((wrap&&wrap.clientWidth)||window.innerWidth)-44-24;   // action col + scrollbar/padding
  const w=Math.max(64, Math.floor(avail/cols.length));
  cols.forEach(c=>COLW[c.i]=w); LS.set('colw.'+curKey(),COLW); render();
}
function markWidthBtns(){ const b=document.querySelectorAll('#wseg button'); b.forEach(x=>x.classList.toggle('on',x.dataset.w===WIDTHPRESET)); }
function markTextBtns(){ const fs=(getComputedStyle(document.documentElement).getPropertyValue('--fs').trim().replace('px','')||'14'); document.querySelectorAll('#tseg button').forEach(x=>x.classList.toggle('on',x.dataset.t===fs)); }
function fitAllCols(){   // expand every visible column to fit its content
  visCols().forEach(c=>{ let max=String(DATA.headers[c.i]||'').length;
    document.querySelectorAll(`#grid td[data-c="${c.i}"]`).forEach(td=>{const t=td.textContent||'';if(t.length>max)max=t.length});
    COLW[c.i]=Math.min(640,Math.max(70,Math.round(max*7.1)+26)); });
  LS.set('colw.'+curKey(),COLW); render();
}
function resetLayout(){ COLW={}; ORDER=[]; LS.set('colw.'+curKey(),{}); LS.set('order.'+curKey(),[]);
  ORDER=LS.get('order.'+curKey(),[]); buildRail(); render(); }
function render(){
  const wrap=$('#wrap'); $$('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.v===VIEW));
  if(LOOKUP!=null && DATA.headers[LOOKUP]){ wrap.innerHTML=lookupHTML(LOOKUP); wireLookup(); setSync('ok'); return; }
  const rows=filtered();
  setSync('ok');
  if(!rows.length){wrap.innerHTML='<div class="empty">No rows match the current filters.</div>';return}
  const capped=rows.length>MAXROWS, show=rows.slice(0,MAXROWS);
  const banner=capped?`<div class="empty" style="padding:8px;color:#d29922">Showing ${MAXROWS} of ${rows.length} matches — narrow with filters (left).</div>`:'';
  wrap.innerHTML=banner+(VIEW==='table'?tableHTML(show):cardsHTML(show,VIEW));
  wireGrid();
}
function tableHTML(rows){
  const cols=visCols();
  const cg='<colgroup><col style="width:46px"><col style="width:44px">'+cols.map(c=>`<col style="width:${(COLW[c.i]||180)}px">`).join('')+'</colgroup>';
  const totalW=46+44+cols.reduce((s,c)=>s+(COLW[c.i]||180),0);   // # gutter + action col + absolute data widths
  let h='<table id="grid" style="width:'+totalW+'px">'+cg+'<thead><tr><th class="rownum">#</th><th></th>'+cols.map(c=>`<th data-c="${c.i}" title="drag to move · drag right edge to resize · dbl-click edge to autofit">${esc(c.h)}${SORT&&SORT.col===c.i?(SORT.dir<0?' ▼':' ▲'):''}<span class="rz" data-c="${c.i}"></span></th>`).join('')+'</tr></thead><tbody>';
  let lastG=null, rn=0;
  for(const [r,ri] of rows){
    if(GROUP!==''){ const gv=g(r,+GROUP)||'(blank)'; if(gv!==lastG){ lastG=gv; h+=`<tr class="grouphdr"><td colspan="${cols.length+2}">${esc(gv)}</td></tr>`; } }
    h+=`<tr><td class="rownum">${++rn}</td><td><button class="rowbtn" data-r="${ri}">✍</button></td>`;
    for(const c of cols){ const gr=GREENSET.has(ri+','+c.i), isN=c.i===ROLE.name, val=g(r,c.i);
      h+=cellTD(ri,c.i,val,isN,gr); }
    h+='</tr>';
  }
  return h+'</tbody></table>';
}
function cardsHTML(rows,mode){
  let h=`<div class="cards ${mode}" id="grid">`;
  for(const [r,ri] of rows){ const c=contact(r), any=[...GREENSET].some(k=>k.split(',')[0]==ri);
    h+=`<div class="card ${any?'enriched-card':''}" data-r="${ri}"><button class="rowbtn act" data-r="${ri}">✍ Draft</button>
      <div class="co">${esc(c.company||'—')}</div>${c.name?`<div class="nm">${esc(c.name)}</div>`:''}${c.position?`<div class="ro">${esc(c.position)}</div>`:''}
      <div class="meta">${badge('✉',c.email)}${badge('☎',c.phone)}${badge('in',c.liC||c.liCo)}${badge('★',c.mvpN)}</div></div>`; }
  return h+'</div>';
}
function badge(ic,v){return `<span class="badge${v?' ok':''}">${ic}${v?' ✓':''}</span>`}
// real inline-SVG icons (inherit currentColor, scale to text) — not emoji glyphs
const ICON={
  mail:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/></svg>',
  phone:'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.6 10.8c1.4 2.8 3.8 5.2 6.6 6.6l2.2-2.2c.28-.28.68-.36 1.02-.24 1.12.4 2.33.62 3.58.62.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.4 21 3 13.6 3 4.4c0-.55.45-1 1-1h3.4c.55 0 1 .45 1 1 0 1.25.22 2.46.62 3.58.12.34.04.74-.24 1.02l-2.18 2.18z"/></svg>',
  web:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.6 2.8 2.6 15.2 0 18M12 3c-2.6 2.8-2.6 15.2 0 18"/></svg>',
  li:'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4.98 3.5A2.5 2.5 0 1 0 5 8.5 2.5 2.5 0 0 0 4.98 3.5zM3 9h4v12H3zM9 9h3.8v1.64h.05c.53-1 1.83-2.06 3.77-2.06 4.03 0 4.78 2.65 4.78 6.1V21h-4v-5.4c0-1.29-.02-2.95-1.8-2.95-1.8 0-2.07 1.4-2.07 2.85V21H9z"/></svg>'};
function iconLink(url,kind){
  const svg=kind==='li'?ICON.li:ICON.web, label=kind==='li'?'Open LinkedIn profile':'Open website';
  return `<a class="iconbtn ${kind}" href="${esc(url)}" target="_blank" rel="noopener" contenteditable="false" title="${label}">${svg}</a>`;
}
// digits of a cell whose WHOLE value is one phone number (anchored, so a phone inside Notes prose is ignored)
// a cell whose WHOLE value is one email address -> mailto:
function emailOf(v){ v=String(v==null?'':v).trim(); return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)?v:null; }
function telDigits(v){ v=String(v==null?'':v).trim();
  if(!/^(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}(\s*(x|ext\.?)\s*\d+)?$/i.test(v))return null;
  const core=v.match(/(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/)[0];
  let d=core.replace(/\D/g,''); if(d.length===10)d='1'+d; return d; }
// build one <td>. Website/LinkedIn/phone => whole cell is CLICKABLE (icon on the LEFT so it's never clipped,
// entire cell opens a popup / dials). Everything else stays contenteditable + expand-on-click.
// ---- color-coded Status field (colors pulled from the sheet) ----
function hashColor(s){ let h=0; for(let i=0;i<s.length;i++)h=(h*31+s.charCodeAt(i))>>>0; return `hsl(${h%360} 68% 84%)`; }
function statusColor(v){ return (STATUS_COLORS&&STATUS_COLORS[v]) || STATUS_DEFAULTS[v] || hashColor(v); }
function txtOn(color){ const m=color.match(/^#([0-9a-f]{6})$/i); if(!m)return '#111';   // hsl fallbacks are light -> dark text
  const n=parseInt(m[1],16), r=n>>16, g=(n>>8)&255, b=n&255; return (0.299*r+0.587*g+0.114*b)>150?'#111':'#fff'; }
// ---- color key / legend (exact sheet colors -> provisional, editable meanings; no sheet write) ----
const CNAMED={'#cfe2f3':'Contacted','#0000ff':'Contacted','#3d85c6':'Contacted','#4a86e8':'Contacted','#00ffff':'Contacted ❓','#00ff00':'Has email','#ff00ff':'Needs email / Outreach pending','#ffff00':'Prospect / Follow-up ❓'};
function nameSure(hex){ const o=LS.get('cname.'+hex,null); return (o!=null&&o!=='')||!!CNAMED[hex]; }   // grounded (user-set or known) vs heuristic guess
function provName(hex){ const o=LS.get('cname.'+hex,null); if(o!=null&&o!=='')return o;   // user rename wins (saved locally; empty never overrides)
  if(CNAMED[hex])return CNAMED[hex];
  const m=hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); if(!m)return '';
  const r=parseInt(m[1],16),g=parseInt(m[2],16),b=parseInt(m[3],16),mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn;
  if(d<22)return 'Section / On hold';                              // greyscale
  let h; if(mx===r)h=((g-b)/d)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4; h=(h*60+360)%360;
  if(h<20||h>=330)return 'Flag / Priority';                        // red
  if(h<45)return 'In progress / Left message';                     // orange
  if(h<70)return 'Prospect / Follow-up';                           // yellow
  if(h<160)return 'Has email';                                     // green
  if(h<200)return 'Contacted';                                     // cyan/aqua
  if(h<290)return 'Contacted';                                     // blue
  return 'Needs email / Outreach pending';                         // magenta/pink
}
function colorTally(){ const t={}; for(const hx of Object.values(COLORS))t[hx]=(t[hx]||0)+1; return Object.entries(t).sort((a,b)=>b[1]-a[1]); }
let _minorOpen=false;   // persist the 'more one-off colors' fold state across re-renders
function renderLegend(){ let p=$('#legendpanel'); if(p&&document.activeElement&&p.contains(document.activeElement))return;   // don't nuke a name mid-edit
  if(!p){p=document.createElement('div');p.id='legendpanel';p.className='legendpanel';document.body.appendChild(p);}
  const items=colorTally();
  const CLOSE='<button class="lgclose" title="Close (Esc)">✕</button>';
  if(!items.length){ p.innerHTML='<div class="lghead">'+CLOSE+'Color key — this tab</div><div class="lgempty">No color-coded cells on this tab.</div>'; p.querySelector('.lgclose').onclick=closeLegend; return; }
  const row=([hx,n])=>{const sure=nameSure(hx);const ov=LS.get('cname.'+hx,null),hasOv=ov!=null&&ov!=='';const mid=hasOv?`<button class="lgreset" data-hx="${hx}" title="Reset to provisional name">↺</button>`:(sure?'':'<span class="lgq" title="unverified guess">?</span>');return `<div class="lgrow"><span class="lgsw" style="background:${hx}"></span><span class="lgname${sure?'':' lgguess'}" contenteditable="true" data-hx="${hx}" title="${sure?'Click to rename':'Heuristic guess — click to rename'}">${esc(provName(hx))}</span>${mid}<span class="lghex">${hx}</span><span class="lgn">${n.toLocaleString()}</span></div>`;};
  const primary=items.filter(([hx,n])=>n>=15||nameSure(hx)), minor=items.filter(([hx,n])=>!(n>=15||nameSure(hx)));   // fold the long tail of one-off tints
  p.innerHTML='<div class="lghead">'+CLOSE+'Color key — this tab<span class="lgnote">Exact sheet colors + provisional meaning. Click a name to rename (saved locally); ↺ resets a rename. ❓ / grey “?” = unverified guess.</span></div>'
    +primary.map(row).join('')
    +(minor.length?`<div class="lgmore" id="lgmore">▸ ${minor.length} more one-off colors</div><div class="lgminor" id="lgminor" style="display:none">${minor.map(row).join('')}</div>`:'');
  p.querySelector('.lgclose').onclick=closeLegend;
  const mo=p.querySelector('#lgmore');
  if(mo){ if(_minorOpen){ p.querySelector('#lgminor').style.display='block'; mo.textContent='▾ '+minor.length+' more one-off colors'; }   // restore fold state
    mo.onclick=()=>{ const mn=p.querySelector('#lgminor'); _minorOpen=(mn.style.display==='none'); mn.style.display=_minorOpen?'block':'none'; mo.textContent=(_minorOpen?'▾ ':'▸ ')+minor.length+' more one-off colors'; }; }
  p.querySelectorAll('.lgreset').forEach(b=>b.onclick=()=>{ localStorage.removeItem('rc2.cname.'+b.dataset.hx); renderLegend(); });
  p.querySelectorAll('.lgname').forEach(el=>{ el.onblur=()=>{ if(window._legEsc){window._legEsc=false;return;} const v=el.textContent.trim(); if(v)LS.set('cname.'+el.dataset.hx, v); };
    el.onkeydown=e=>{ if(e.key==='Enter'){e.preventDefault();el.blur();} }; });
}
function closeLegend(){ const p=$('#legendpanel'); if(p)p.style.display='none'; document.removeEventListener('mousedown',legendOutside); }
function legendOutside(e){ if(!e.target.closest('#legendpanel')&&!e.target.closest('#legendtog'))closeLegend(); }
function toggleLegend(){ let p=$('#legendpanel'); const open=p&&p.style.display==='block';
  if(open){ closeLegend(); return; } renderLegend(); $('#legendpanel').style.display='block';
  setTimeout(()=>document.addEventListener('mousedown',legendOutside),0); }
function isStatusCol(ci){ return STATUS_FIELD && DATA.headers[ci]===STATUS_FIELD; }
function lmCol(){ return DATA.headers.findIndex(h=>/^last modified$/i.test(String(h).trim())); }
async function stampLastMod(r){   // write "now" to the row's Last Modified cell (shown under the status chip)
  const lm=lmCol(); if(lm<0)return null;
  const ts=new Date().toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
  try{ await postWrite(r,lm,ts); DATA.rows[r][lm]=ts; GREENSET.add(r+','+lm); }catch(e){}
  return ts;
}
function openStatusPicker(td){
  closeStatusPicker();
  const r=+td.dataset.r, c=+td.dataset.c, cur=(DATA.rows[r]&&DATA.rows[r][c])||'';
  const vals=(STATUS_VALUES&&STATUS_VALUES.length)?STATUS_VALUES:Object.keys(STATUS_DEFAULTS);
  const m=document.createElement('div'); m.className='statusmenu'; m.id='statusmenu';
  m.innerHTML=`<div class="smhead">Set status — writes to the sheet</div>`+vals.map(v=>{const col=statusColor(v);
      return `<div class="smi${v===cur?' cur':''}" data-v="${esc(v)}"><span class="sw" style="background:${col}"></span><span class="sl">${esc(v)}</span>${v===cur?'<span class="smck">✓</span>':''}</div>`;}).join('')
    +`<div class="smi smclear" data-v=""><span class="sw" style="background:transparent;border:1px dashed var(--line)"></span><span class="sl">Clear</span></div>`;
  document.body.appendChild(m);
  const b=td.getBoundingClientRect(), mw=250;
  m.style.left=Math.max(6,Math.min(b.left,window.innerWidth-mw-6))+'px';
  m.style.top=Math.min(b.bottom+4, window.innerHeight-m.offsetHeight-10)+'px';
  m.querySelectorAll('.smi').forEach(el=>el.onclick=async()=>{ const v=el.dataset.v; closeStatusPicker();
    try{ const res=await postWrite(r,c,v); if(!res.ok)throw new Error(res.error||'write failed');
      DATA.rows[r][c]=v; if(v)GREENSET.add(r+','+c); await stampLastMod(r); render(); setSync('ok'); }
    catch(err){ setSync('err'); alert('Status write failed: '+err.message); } });
  setTimeout(()=>document.addEventListener('mousedown',closeStatusPickerOutside),0);
}
function closeStatusPicker(){const m=$('#statusmenu');if(m)m.remove();document.removeEventListener('mousedown',closeStatusPickerOutside);}
function closeStatusPickerOutside(e){ if(!e.target.closest('#statusmenu')&&!e.target.closest('.statuscell'))closeStatusPicker(); }
function isWarmthCol(ci){ return DATA.headers && String(DATA.headers[ci]).trim()==='🔥 Warmth'; }
function cellTD(ri,ci,val,isN,gr){
  const cls=[isN?'namecell':'',gr?'enriched':''].filter(Boolean).join(' ');
  const s=String(val==null?'':val);
  if(isWarmthCol(ci)){                                       // derived, read-only heat pill (hotter = deeper warm tone)
    const n=Number(s)||0, t=Math.max(0,Math.min(1,n/120));
    const bg=`hsl(${Math.round(16-t*6)} 90% ${Math.round(90-52*t)}%)`, fg=t>0.55?'#fff':'#5a2600';
    return `<td data-r="${ri}" data-c="${ci}" contenteditable="false" class="warmthcell ${cls}" title="Warmth ${esc(s)} — derived (appearances + status + reachability), read-only"><span class="heatpill" style="background:${bg};color:${fg}">${esc(s)}</span></td>`;
  }
  const xc=COLORS[ri+','+ci], xt=xc?txtOn(xc):'';   // EXACT original sheet cell color + auto-contrast text
  const xsty=xc?` style="background:${xc};color:${xt};--xtext:${xt}"`:'', xcls=xc?' xcol':'';
  if(isStatusCol(ci)){                                          // color-coded status chip + date/time under it + click-to-set picker
    const lm=lmCol(), dt=(lm>=0 && DATA.rows[ri] && DATA.rows[ri][lm])||'';
    const dtHtml=dt?`<div class="chipdate" title="Last modified">🕓 ${esc(String(dt))}</div>`:'';
    if(!s) return `<td data-r="${ri}" data-c="${ci}" contenteditable="false" role="button" tabindex="0" aria-label="Set status" class="statuscell empty ${cls}" title="Click or press Enter to set status">+ set status${dtHtml}</td>`;
    const col=statusColor(s);
    return `<td data-r="${ri}" data-c="${ci}" contenteditable="false" role="button" tabindex="0" aria-label="Status: ${esc(s)}. Change status" class="statuscell ${cls}" title="Click or press Enter to change status"><span class="statuschip" style="background:${col};color:${txtOn(col)}">${esc(s)}</span>${dtHtml}</td>`;
  }
  const em=emailOf(s);
  if(em){ return `<td data-r="${ri}" data-c="${ci}" contenteditable="false" data-mail="${esc(em)}" title="Email ${esc(em)}" class="mailcell ${cls}${xcls}"${xsty}><span class="cicon">${ICON.mail}</span><span class="linkval">${esc(em)}</span></td>`; }
  const u=hrefOf(s);
  if(u){ const li=/linkedin\.com/i.test(u), disp=s.replace(/^https?:\/\/(www\.)?/i,'').replace(/\/+$/,'');
    return `<td data-r="${ri}" data-c="${ci}" contenteditable="false" data-href="${esc(u)}" title="Open ${esc(u)}" class="linkcell ${li?'li':'web'} ${cls}${xcls}"${xsty}><span class="cicon">${li?ICON.li:ICON.web}</span><span class="linkval">${esc(disp)}</span></td>`; }
  const d=telDigits(s);
  if(d){ return `<td data-r="${ri}" data-c="${ci}" contenteditable="false" data-tel="+${d}" title="Call ${esc(s)}" class="callcell ${cls}${xcls}"${xsty}><span class="cicon">${ICON.phone}</span><span class="linkval">${esc(s)}</span></td>`; }
  return `<td data-r="${ri}" data-c="${ci}" contenteditable="true" title="${esc(s)}" class="${cls}"${xsty}>${esc(s)}</td>`;
}
function cellRender(v){v=String(v==null?'':v);const u=hrefOf(v);
  if(u){ const li=/linkedin\.com/i.test(u);                            // value stays plain text; icon opens the link
    const disp=v.replace(/^https?:\/\/(www\.)?/i,'').replace(/\/+$/,'');
    return `<span class="linkval">${esc(disp)}</span>${iconLink(u, li?'li':'web')}`; }
  if(/\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/.test(v))return phoneLinkify(v);   // phone numbers (incl. "(310) 555-1234") -> tap-to-call
  return esc(v)}
function phoneLinkify(v){
  const re=/(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g; let out='',last=0,m;
  while((m=re.exec(v))){ out+=esc(v.slice(last,m.index));
    let d=m[0].replace(/\D/g,''); if(d.length===10)d='1'+d;
    out+=esc(m[0])+`<a class="callbtn" href="tel:+${esc(d)}" contenteditable="false" title="Call ${esc(m[0])}">${ICON.phone}</a>`; last=m.index+m[0].length; }
  out+=esc(v.slice(last)); return out||esc(v);
}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]))}
function contact(r){const nm=g(r,ROLE.name),co=g(r,ROLE.company);return{name:nm,first:(nm.split(/[\s,]+/)[0]||'').trim(),company:co,position:g(r,ROLE.position),email:g(r,ROLE.email)||g(r,ROLE.email2),phone:g(r,ROLE.phone)||g(r,ROLE.cphone),liC:hrefOf(g(r,ROLE.liC)),liCo:hrefOf(g(r,ROLE.liCo)),mvpN:g(r,ROLE.mvpN),mvpP:g(r,ROLE.mvpP),mvpL:hrefOf(g(r,ROLE.mvpL))}}
function wireGrid(){
  $$('.rowbtn').forEach(b=>b.onclick=e=>{e.stopPropagation();openDraft(+b.dataset.r)});
  // icons live inside contenteditable cells, so the editor would swallow the click — intercept on the
  // (per-render) #grid element and open the link in a POPUP WINDOW. tel: links proceed as normal.
  const gridEl=$('#grid');
  if(gridEl){
    gridEl.addEventListener('keydown',e=>{                                  // keyboard access to the status picker (a11y)
      if(e.key!=='Enter'&&e.key!==' ')return;
      const st=e.target.closest('td.statuscell'); if(!st)return;
      e.preventDefault(); openStatusPicker(st);
    });
    gridEl.addEventListener('click',e=>{
      const st=e.target.closest('td.statuscell');                          // Status -> color-coded picker (writes to sheet)
      if(st){ e.preventDefault(); openStatusPicker(st); return; }
      const lc=e.target.closest('td.linkcell');                            // Website / LinkedIn -> popup window
      if(lc){ e.preventDefault(); window.open(lc.dataset.href,'linkpop','popup=yes,noopener,width=1100,height=850,left=140,top=90'); return; }
      const cc=e.target.closest('td.callcell');                            // phone -> dial
      if(cc){ location.href='tel:'+cc.dataset.tel; return; }
      const mc=e.target.closest('td.mailcell');                            // email -> mail client
      if(mc){ location.href='mailto:'+mc.dataset.mail; return; }
    });
    // DRAG-TO-FILL: drag a value (from another cell or from outside the app) and drop it onto ANY field to
    // REPLACE that field's value in the sheet — EXCEPT the Notes column, where the dropped text is APPENDED.
    document.querySelectorAll('#grid td.linkcell,#grid td.callcell,#grid td.mailcell,#grid td.statuscell').forEach(td=>td.setAttribute('draggable','true'));
    gridEl.addEventListener('dragstart',e=>{ const td=e.target.closest('td[data-c]');
      if(td && td.getAttribute('draggable')==='true'){ window._dragText=false;        // our intentional value drag
        const r=+td.dataset.r,c=+td.dataset.c, v=(DATA.rows[r]&&DATA.rows[r][c])||td.textContent.trim();
        e.dataTransfer.setData('text/plain',String(v)); e.dataTransfer.effectAllowed='copyMove';
      } else { window._dragText=true; } });                                            // native text-selection drag -> ignore on drop
    gridEl.addEventListener('dragover',e=>{ const td=e.target.closest('td[data-c]'); if(!td)return; e.preventDefault(); e.dataTransfer.dropEffect='copy'; td.classList.add('drophi'); });
    gridEl.addEventListener('dragleave',e=>{ const td=e.target.closest('td[data-c]'); if(td)td.classList.remove('drophi'); });
    gridEl.addEventListener('drop',async e=>{ const td=e.target.closest('td[data-c]'); if(!td)return; e.preventDefault(); td.classList.remove('drophi');
      if(window._dragText){ window._dragText=false; return; }                          // native text-selection move, not an intentional fill
      const txt=(e.dataTransfer.getData('text/plain')||'').trim(); if(!txt)return;
      const r=+td.dataset.r, c=+td.dataset.c; if(isNaN(r)||isNaN(c))return;
      const isNotes=/notes/i.test(DATA.headers[c]||'');
      const cur=(DATA.rows[r]&&DATA.rows[r][c])||'';
      if(isNotes && String(cur).includes(txt))return;                       // don't append a duplicate note
      const newVal=isNotes ? (String(cur).trim() ? String(cur).replace(/\s+$/,'')+'\n'+txt : txt) : txt;   // Notes appends, everything else replaces
      try{ const res=await postWrite(r,c,newVal); if(!res.ok)throw new Error(res.error||'write failed');
        DATA.rows[r][c]=newVal; GREENSET.add(r+','+c); await stampLastMod(r); render(); setSync('ok'); }
      catch(err){ setSync('err'); alert('Drop write failed: '+err.message); } });
  }
  $$('.card').forEach(c=>c.onclick=()=>openDraft(+c.dataset.r));
  // header: click = sort, drag = MOVE column (manual drag, 5px threshold; order persists per tab)
  $$('#grid thead th[data-c]').forEach(th=>th.addEventListener('mousedown',e=>{
    if(e.target.classList.contains('rz'))return;                 // resize handle is separate
    e.preventDefault();                                          // stop text-selection from hijacking the drag
    document.body.style.cursor='grabbing';
    const startX=e.clientX, col=+th.dataset.c, ths=[...document.querySelectorAll('#grid thead th[data-c]')];
    let moved=false;
    const at=x=>ths.find(t=>{const r=t.getBoundingClientRect();return x>=r.left&&x<=r.right});
    const mv=ev=>{ if(!moved&&Math.abs(ev.clientX-startX)>5){moved=true;th.classList.add('dragging')}
      if(moved){ths.forEach(t=>t.classList.remove('droptarget'));const tg=at(ev.clientX);if(tg&&tg!==th)tg.classList.add('droptarget')} };
    const up=ev=>{ document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);
      document.body.style.cursor='';
      th.classList.remove('dragging');ths.forEach(t=>t.classList.remove('droptarget'));
      if(moved){const tg=at(ev.clientX);if(tg&&+tg.dataset.c!==col)moveCol(col,+tg.dataset.c);}
      else{SORT=(SORT&&SORT.col===col)?{col,dir:-SORT.dir}:{col,dir:1};LS.set('sort.'+curKey(),SORT);render();$('#sortsel')&&($('#sortsel').value=col);} };
    document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);
  }));
  // resizable columns — drag a column's right edge; width persists per tab
  $$('#grid th .rz').forEach(rz=>rz.addEventListener('mousedown',e=>{
    e.preventDefault();e.stopPropagation();
    const ci=+rz.dataset.c, cols=visCols(), idx=cols.findIndex(c=>c.i===ci);
    const col=document.querySelectorAll('#grid colgroup col')[idx+2];  // +2 for the # gutter + action col
    const startX=e.clientX, startW=col.getBoundingClientRect().width;
    // SMOOTH: drag a light guide line (no per-frame table reflow); apply width once on release
    document.body.classList.add('resizing');
    const guide=document.createElement('div'); guide.className='rzguide'; guide.style.left=e.clientX+'px'; document.body.appendChild(guide);
    let newW=startW;
    const mv=ev=>{ newW=Math.max(60,startW+(ev.clientX-startX)); guide.style.left=(startX+(newW-startW))+'px'; };
    const up=()=>{ document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);
      guide.remove(); document.body.classList.remove('resizing');
      COLW[ci]=Math.round(newW); WIDTHPRESET=''; LS.set('widthpreset',''); LS.set('colw.'+curKey(),COLW); render(); markWidthBtns(); };  // manual width exits any preset
    document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);
  }));
  // double-click a resize handle to auto-fit the column to its content
  $$('#grid th .rz').forEach(rz=>rz.addEventListener('dblclick',e=>{
    e.preventDefault();e.stopPropagation();
    const ci=+rz.dataset.c, cols=visCols(), idx=cols.findIndex(c=>c.i===ci);
    const col=document.querySelectorAll('#grid colgroup col')[idx+2];
    let max=String(DATA.headers[ci]||'').length;
    document.querySelectorAll('#grid tbody tr').forEach(tr=>{const t=tr.children[idx+2]?.textContent||'';if(t.length>max)max=t.length;});
    const w=Math.min(600,Math.max(70,Math.round(max*7.1)+26));col.style.width=w+'px';COLW[ci]=w;LS.set('colw.'+curKey(),COLW);
  }));
  $$('td[contenteditable="true"]').forEach(td=>{td.addEventListener('focus',()=>{editing=true;td._o=td.textContent});td.addEventListener('blur',()=>saveCell(td));td.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();td.blur()}})});
}
function postWrite(r,c,value){
  const body={gid:DATA.gid,row0:r,col0:c,value};                 // DATA.gid is 'combined' in multi-sheet view
  if(DATA.gid==='combined') body.gids=[...SELECTED];             // let the server reverse-map to the source cell
  return fetch('/api/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(x=>x.json());
}
async function saveCell(td){ editing=false; const v=td.textContent.trim(); if(v===String(td._o||'').trim())return;
  const r=+td.dataset.r,c=+td.dataset.c;
  try{const res=await postWrite(r,c,v);
    if(!res.ok)throw new Error(res.error);
    DATA.rows[r][c]=v;GREENSET.add(r+','+c);td.classList.add('saved');setTimeout(()=>{td.classList.remove('saved');td.classList.add('enriched')},1000);
    // any Notes edit -> stamp Last Modified date+time on that record
    if(/^notes$/i.test(String(DATA.headers[c]||'').trim())){
      const lm=DATA.headers.findIndex(h=>/^last modified$/i.test(String(h).trim()));
      if(lm>=0){
        const ts=new Date().toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
        await postWrite(r,lm,ts);DATA.rows[r][lm]=ts;GREENSET.add(r+','+lm);
        const cell=document.querySelector(`td[data-r="${r}"][data-c="${lm}"]`);
        if(cell){cell.textContent=ts;cell.title=ts;cell.classList.add('enriched','saved');setTimeout(()=>cell.classList.remove('saved'),1000)}
      }
    }
  }catch(e){td.textContent=td._o||'';setSync('err')}
}

// ---- draft composer ----------------------------------------------------------
function openDraft(ri){curRow=ri;const c=contact(DATA.rows[ri]);
  $('#d-company').textContent=c.company||'(no company)';$('#d-who').textContent=[c.name,c.position].filter(Boolean).join(' · ')||'—';
  const F=$('#d-fields');F.innerHTML='';const add=(k,v,l)=>{if(!v)return;F.insertAdjacentHTML('beforeend',`<div class="k">${k}</div><div>${l?`<a href="${esc(l)}" target="_blank" rel="noopener">${esc(v)}</a>`:esc(v)}</div>`)};
  add('Email',c.email,c.email?'mailto:'+c.email:'');add('Phone',c.phone,c.phone?'tel:'+c.phone.replace(/[^0-9+]/g,''):'');
  add('LinkedIn',c.liC?'Contact profile':(c.liCo?'Company page':''),c.liC||c.liCo);add('Marketing/VP',[c.mvpN,c.mvpP].filter(Boolean).join(' — '),c.mvpL);
  const av={email:!!c.email,linkedin:!!(c.liC||c.mvpL||c.liCo),phone:!!c.phone};
  const chIcon={email:ICON.mail,linkedin:ICON.li,phone:ICON.phone}, chLbl={email:'Email',linkedin:'LinkedIn',phone:'Phone'};
  $$('#chanseg button').forEach(b=>{const k=b.dataset.c; b.innerHTML=chIcon[k]+'<span>'+chLbl[k]+'</span>'; b.classList.toggle('chdis',!av[k]);});
  curChan=av[curChan]?curChan:(av.email?'email':av.linkedin?'linkedin':av.phone?'phone':'email');
  syncChan();regen();$('#scrim').style.display='block';$('#drawer').classList.add('open');
}
function closeDraft(){$('#scrim').style.display='none';$('#drawer').classList.remove('open')}
function syncChan(){$$('#chanseg button').forEach(b=>b.classList.toggle('on',b.dataset.c===curChan));$('#d-subject').style.display=curChan==='email'?'block':'none';$('#act-go').textContent=curChan==='email'?'Open in Email':curChan==='linkedin'?'Open LinkedIn':'Call'}
function regen(){const c=contact(DATA.rows[curRow]),d=buildDraft(c,curChan,$('#purpose').value,$('#tone').value);$('#d-subject').value=d.subject||'';$('#d-body').value=d.body;$('#d-target').textContent=d.target?('→ '+d.target):''}
function buildDraft(c,chan,purpose,tone){const first=(c.first&&/^[A-Za-z]/.test(c.first))?c.first:'there',co=c.company||'your team';
  const hi=tone==='formal'?('Dear '+(c.name||first)+','):(tone==='direct'?('Hi '+first+' —'):('Hi '+first+','));
  const val={partnership:`I run partnerships for RENTV (Real Estate News Television), the CRE industry's video news network. I'd love to explore ways ${co} and RENTV could work together — featuring your deals and leadership on our broadcasts and co-marketing to our commercial-real-estate audience.`,sponsorship:`RENTV is lining up sponsors for our upcoming commercial real estate conference, and ${co} stood out as a strong fit — on-camera visibility, stage presence, and direct access to CRE principals, brokers, and capital sources.`,speaker:`We're building the panel lineup for RENTV's upcoming CRE conference and I'd like to invite ${co} to join as a speaker.`,followup:`Following up on our conversation about RENTV and ${co} — and thank you again for taking the time to review it with me.`}[purpose];
  const close=tone==='formal'?'Kind regards,\nSteve Bloom\nRENTV — Real Estate News Television':'Best,\nSteve Bloom\nRENTV — Real Estate News Television';
  if(chan==='email'){const subj={partnership:'RENTV × '+co+' — partnership',sponsorship:co+' as a RENTV CRE conference sponsor',speaker:'Speaker invite — RENTV CRE conference',followup:'Great connecting — next steps for RENTV × '+co}[purpose];
    return{subject:subj,body:(purpose==='followup'
      ?`${hi}\n\nThank you for taking the time to review RENTV with me — I really enjoyed the conversation and appreciate you thinking it through with me.\n\nA couple of quick questions so I can tailor this to ${co}:\n• Did any of the formats — deal features, leadership spotlights, or our CRE conference — stand out as the best fit?\n• Are you the right person to move this forward, or is there a colleague I should loop in?\n\nWhenever you're ready, here are a few easy ways to take the next step — pick whatever's simplest:\n• 📅 A quick 15-minute call — I'm open Tuesday or Thursday afternoon this week (or just send two times that work and I'll make one work).\n• 🗓️ Prefer to circle back later? Name a better week and I'll follow up then — no chasing.\n• 📄 Short on time? I'll send a one-page overview you can review whenever it suits you.\n\nEither way, thanks again — I'm excited about what RENTV and ${co} could do together.\n\n${close}`
      :`${hi}\n\n${val}\n\nWould you be open to a 15-minute call this week?\n\n${close}`),target:c.email||'(no email)'}}
  if(chan==='linkedin'){const note=purpose==='followup'?`${hi.replace(/ —$/,'')} — thanks for taking the time to look at RENTV with me, I really appreciated it. Quick follow-up: did any of the ideas for ${co} stand out, and are you the right person to run with it or should I loop in a colleague? Happy to grab 15 min — I'm open Tue/Thu this week, or name a better week and I'll circle back. Or I can send a one-pager first — whatever's easiest.`:`${hi.replace(/ —$/,'')} — I run partnerships at RENTV (CRE video news). ${purpose==='sponsorship'?"We're selecting sponsors for our CRE conference and "+co+' is a great fit.':purpose==='speaker'?"I'd love to invite "+co+' onto a panel at our CRE conference.':"I'd love to connect about ways RENTV and "+co+' could work together.'} Open to a quick chat?`;
    return{subject:'',body:note.slice(0,600),target:(c.liC||c.mvpL||c.liCo)||'(no LinkedIn)'}}
  return{subject:'',target:c.phone||'(no phone on file)',
    body:(purpose==='followup'
    ?`📞 FOLLOW-UP CALL — ${co}${c.name?' · '+c.name:''}\n\nOPEN\n"Hi${c.name?', '+first:''} — it's Steve Bloom at RENTV again. Thanks for taking the time to look this over with me — do you have a quick minute?"\n\nRECONNECT\n"Just following up on RENTV and ${co} — wanted to see what stood out and answer any questions that came up after we spoke."\n\nQUESTIONS (ask, then listen)\n• "Did any of the formats — deal features, leadership spots, or the CRE conference — feel like the best fit for ${co}?"\n• "Are you the right person to move this forward, or should I loop in a colleague?"\n• "Anything about timing or budget I should keep in mind?"\n\nSCHEDULE (offer options)\n"Let's put 15 minutes on the calendar — does Tuesday or Thursday afternoon work? If this week's tight, just tell me a better week and I'll follow up then. I can also send a one-page overview first so you have it in writing."\n\nLEAVE-BEHIND\n"No worries at all if now isn't ideal — I'll email you a quick overview and circle back${c.email?' at '+c.email:''}. Thanks again for your time."`
    :`📞 PHONE PITCH — ${co}${c.name?' · '+c.name:''}\n\nOPEN\n"Hi${c.name?', '+first:''} — this is Steve Bloom, President of RENTV, the commercial real estate news network. Do you have a quick minute?"\n\nPITCH\n"${val} We reach roughly 50,000 CRE professionals — investors, developers, brokers and capital sources — twice a week, and I think ${co} would land really well with that audience."\n\nASK\n"I'd love to grab 15 minutes this week to walk you through a couple of options tailored to ${co}. What does later this week look like?"\n\nIF A GATEKEEPER ANSWERS\n"Who handles marketing or partnerships for ${co}?${c.mvpN?' I believe it may be '+c.mvpN+'.':''} Happy to send a quick email first${c.email?' — is '+c.email+' best?':'.'}"\n\nLEAVE-BEHIND\n"No problem if now isn't ideal — I'll follow up by email so you have it in writing."`)}
}
async function doEnhance(){const b=$('#enhance'),o=b.textContent;b.textContent='✨ thinking…';b.disabled=true;
  try{const r=await (await fetch('/api/enhance',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt:`Rewrite this ${curChan} outreach so it sounds natural, concise, professional. Keep it truthful, sender "Steve Bloom, RENTV". Return ONLY the message.\n\n---\n${$('#d-body').value}`})})).json();
    if(r.ok&&r.text)$('#d-body').value=r.text;else alert(r.error||'local model unavailable')}catch(e){alert('enhance failed')}finally{b.textContent=o;b.disabled=false}}
function doGo(){const c=contact(DATA.rows[curRow]);if(curChan==='email'){if(!c.email)return alert('No email');location.href='mailto:'+c.email+'?subject='+encodeURIComponent($('#d-subject').value)+'&body='+encodeURIComponent($('#d-body').value)}else if(curChan==='linkedin'){const u=c.liC||c.mvpL||c.liCo;if(!u)return alert('No LinkedIn');navigator.clipboard?.writeText($('#d-body').value);window.open(u,'_blank','noopener')}else{if(!c.phone)return alert('No phone');location.href='tel:'+c.phone.replace(/[^0-9+]/g,'')}}

// ---- events + boot -----------------------------------------------------------
$('#tab').onchange=e=>{SELECTED=new Set([+e.target.value]);loadData()};
$$('#viewseg button').forEach(b=>b.onclick=()=>{VIEW=b.dataset.v;LS.set('view',VIEW);render()});
$('#railtog').onclick=()=>{$('#rail').classList.toggle('hide');if(!$('#rail').classList.contains('hide'))buildRail()};
$('#refresh').onclick=async()=>{setSync('load');$('#refresh').textContent='↻ refreshing…';try{await fetch('/api/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});await loadMeta();$('#tab').value=GID;await loadData()}catch(e){setSync('err')}$('#refresh').textContent='↻ Refresh'};
$('#legendtog').onclick=toggleLegend;
$('#dclose').onclick=closeDraft;$('#scrim').onclick=closeDraft;
(function(){const dr=$('#drawer'),h=$('#dresize');const sw=LS.get('drawerW',0);if(sw)dr.style.width=sw+'px';
  h.addEventListener('mousedown',e=>{e.preventDefault();dr.classList.add('resizing');
    const sx=e.clientX,sW=dr.getBoundingClientRect().width;
    const mv=ev=>{dr.style.width=Math.min(innerWidth-40,Math.max(380,sW+(sx-ev.clientX)))+'px'};
    const up=()=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);dr.classList.remove('resizing');LS.set('drawerW',Math.round(dr.getBoundingClientRect().width))};
    document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);});
})();
$$('#chanseg button').forEach(b=>b.onclick=()=>{if(b.classList.contains('chdis'))return;curChan=b.dataset.c;syncChan();regen()});
$('#purpose').onchange=regen;$('#tone').onchange=regen;$('#regen').onclick=regen;$('#enhance').onclick=doEnhance;$('#act-go').onclick=doGo;
$('#act-copy').onclick=()=>{navigator.clipboard?.writeText(($('#d-subject').value?('Subject: '+$('#d-subject').value+'\n\n'):'')+$('#d-body').value);$('#act-copy').textContent='✓ Copied';setTimeout(()=>$('#act-copy').textContent='⧉ Copy draft',1200)};
function applyTheme(t){document.body.classList.toggle('light',t==='light');$('#theme').textContent=t==='light'?'☀️':'🌙'}
$('#theme').onclick=()=>{const t=document.body.classList.contains('light')?'dark':'light';LS.set('theme',t);applyTheme(t)};
applyTheme(LS.get('theme', (window.matchMedia&&matchMedia('(prefers-color-scheme: light)').matches)?'light':'dark'));
// Fit mode = table always fits the window: re-fit columns to the viewport when it resizes
window.addEventListener('resize',()=>{ if(WIDTHPRESET==='fit'){ clearTimeout(window._rzfit); window._rzfit=setTimeout(applyFit,120); } });
document.addEventListener('dragend',()=>{ window._dragText=false; });   // clear the text-move guard after any drag
document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeDraft(); if($('#legendpanel')&&$('#legendpanel').style.display==='block'){window._legEsc=true;closeLegend();}}});
(async()=>{await loadMeta();await loadData(); if(WIDTHPRESET==='fit')applyFit();})();
</script>
</body>
</html>