[object Object]

← back to Pitch Guard

Pitch Guard: resizable / collapsible / reorderable / pop-out panels

5c43a5ee779f539cf4ad67df5e55c06f8fe2cf9c · 2026-05-19 08:59:45 -0700 · Steve

Files touched

Diff

commit 5c43a5ee779f539cf4ad67df5e55c06f8fe2cf9c
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 08:59:45 2026 -0700

    Pitch Guard: resizable / collapsible / reorderable / pop-out panels
---
 public/index.html | 287 ++++++++++++++++++++++++++++++++++++++++++++----------
 1 file changed, 235 insertions(+), 52 deletions(-)

diff --git a/public/index.html b/public/index.html
index a1f5ab3..9dcbcde 100644
--- a/public/index.html
+++ b/public/index.html
@@ -15,23 +15,45 @@
   body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--cream);color:var(--ink);height:100vh;overflow:hidden}
   h1,h2,h3,.serif{font-family:Georgia,"Times New Roman",serif}
   header{height:54px;background:var(--ink);color:var(--cream);display:flex;align-items:center;
-    padding:0 18px;gap:14px;border-bottom:3px solid var(--gold)}
+    padding:0 18px;gap:14px;border-bottom:3px solid var(--gold);flex-shrink:0}
   header h1{font-size:18px;font-weight:600;letter-spacing:.3px}
   header .sub{font-size:12px;color:#cdc6b3;letter-spacing:.4px}
   header .spacer{flex:1}
   header .acct{font-size:12px;background:#3a352b;padding:5px 11px;border-radius:20px;color:#e8e1cd}
-  .layout{display:flex;height:calc(100vh - 54px)}
+  header .reset{font-size:11px;background:none;border:1px solid #4a4438;color:#cdc6b3;
+    padding:4px 10px;border-radius:5px;cursor:pointer}
+  header .reset:hover{border-color:var(--gold);color:#fff}
+  .layout{display:flex;height:calc(100vh - 54px);overflow:hidden}
   /* ── panels ── */
-  .panel{display:flex;flex-direction:column;height:100%;overflow:hidden}
-  .pane-left{width:336px;border-right:1px solid var(--line);background:var(--paper)}
-  .pane-mid{flex:1;min-width:340px;background:var(--cream)}
-  .pane-right{width:438px;border-left:1px solid var(--line);background:var(--paper)}
-  .pane-hd{padding:11px 14px;border-bottom:1px solid var(--line);display:flex;
-    align-items:center;gap:8px;background:var(--paper);flex-shrink:0}
-  .pane-hd h2{font-size:13px;text-transform:uppercase;letter-spacing:.9px;color:var(--muted);font-family:inherit;font-weight:700}
+  .panel{display:flex;flex-direction:column;height:100%;overflow:hidden;background:var(--paper);
+    flex:0 0 auto;min-width:46px}
+  .panel.flexed{flex:1 1 0;min-width:300px}
+  .panel.collapsed{flex:0 0 42px !important;min-width:42px;background:var(--cream)}
+  .panel.collapsed .pane-body,.panel.collapsed .pane-hd>*:not(.collapse-strip){display:none}
+  .panel.dragging{opacity:.4}
+  .panel.drop-target{outline:2px dashed var(--gold);outline-offset:-2px}
+  .pane-hd{padding:9px 12px;border-bottom:1px solid var(--line);display:flex;
+    align-items:center;gap:7px;background:var(--paper);flex-shrink:0;min-height:44px;cursor:grab}
+  .pane-hd:active{cursor:grabbing}
+  .pane-hd h2{font-size:12px;text-transform:uppercase;letter-spacing:.9px;color:var(--muted);
+    font-family:inherit;font-weight:700;white-space:nowrap}
+  .pane-hd .grip{color:var(--line);font-size:13px;cursor:grab;flex-shrink:0;letter-spacing:-2px}
   .pane-hd .count{font-size:11px;background:var(--ink);color:var(--cream);border-radius:10px;padding:1px 8px}
-  .pane-body{flex:1;overflow-y:auto}
   .pane-hd .spacer{flex:1}
+  .hd-btn{font-size:12px;line-height:1;background:none;border:1px solid var(--line);color:var(--muted);
+    width:24px;height:24px;border-radius:5px;cursor:pointer;flex-shrink:0;padding:0}
+  .hd-btn:hover{border-color:var(--gold);color:var(--gold);background:var(--cream)}
+  .collapse-strip{display:none;flex-direction:column;align-items:center;gap:10px;padding-top:4px;cursor:pointer}
+  .panel.collapsed .pane-hd{flex-direction:column;cursor:pointer;padding:9px 4px;min-height:0}
+  .panel.collapsed .collapse-strip{display:flex}
+  .collapse-strip .vlabel{writing-mode:vertical-rl;font-size:11px;text-transform:uppercase;
+    letter-spacing:1.5px;color:var(--muted);font-weight:700}
+  .pane-body{flex:1;overflow-y:auto}
+  /* ── resize handle ── */
+  .rz-handle{flex:0 0 7px;cursor:col-resize;background:var(--line);position:relative}
+  .rz-handle:hover,.rz-handle.active{background:var(--gold)}
+  .rz-handle::after{content:"";position:absolute;left:2px;top:50%;width:3px;height:34px;
+    transform:translateY(-50%);background:var(--paper);border-radius:2px;opacity:.6}
   /* ── buttons ── */
   button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--paper);
     color:var(--ink);border-radius:6px;padding:6px 12px;transition:.12s}
@@ -49,7 +71,6 @@
   .draft .subj{font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px}
   .draft .meta{font-size:11px;color:var(--muted);margin-top:3px}
   .draft .badge{position:absolute;top:9px;right:12px}
-  /* ── risk badges ── */
   .badge{font-size:10px;font-weight:700;letter-spacing:.5px;border-radius:4px;padding:2px 6px;text-transform:uppercase}
   .r-high{background:var(--redbg);color:var(--red)}
   .r-elevated{background:var(--amberbg);color:var(--amber)}
@@ -119,6 +140,7 @@
   .toast.show{opacity:1}
   .toast.err{background:var(--red)}
   .sent-stamp{font-size:11px;color:var(--green);font-weight:700}
+  body.solo .rz-handle,body.solo .grip,body.solo [data-act="popout"]{display:none}
 </style>
 </head>
 <body>
@@ -126,37 +148,53 @@
   <h1>Pitch Guard</h1>
   <span class="sub">review before you follow up</span>
   <span class="spacer"></span>
+  <button class="reset" id="resetLayout" title="Reset panels to default layout">⤢ reset layout</button>
   <span class="acct" id="acct">info@designerwallcoverings.com</span>
 </header>
 
-<div class="layout">
-  <!-- LEFT: drafts -->
-  <div class="panel pane-left">
+<div class="layout" id="layout">
+  <!-- DRAFTS -->
+  <div class="panel" id="panel-drafts" data-panel="drafts">
     <div class="pane-hd">
+      <span class="collapse-strip"><button class="hd-btn" data-act="collapse">▸</button><span class="vlabel">Drafts</span></span>
+      <span class="grip">⠿</span>
       <h2>Drafts</h2><span class="count" id="draftCount">·</span>
       <span class="spacer"></span>
       <button class="btn-sm" id="scanAll" title="Scan every contact for prior orders">Scan risk</button>
+      <button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
+      <button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
     </div>
     <div class="pane-body" id="draftList">
       <div class="empty"><div class="spin"></div> loading drafts…</div>
     </div>
   </div>
 
-  <!-- MIDDLE: correspondence -->
-  <div class="panel pane-mid">
+  <!-- CORRESPONDENCE -->
+  <div class="panel flexed" id="panel-corr" data-panel="corr">
     <div class="pane-hd">
+      <span class="collapse-strip"><button class="hd-btn" data-act="collapse">▸</button><span class="vlabel">Correspondence</span></span>
+      <span class="grip">⠿</span>
       <h2>Correspondence</h2>
-      <span class="spacer"></span>
       <span class="count" id="threadCount" style="display:none"></span>
+      <span class="spacer"></span>
+      <button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
+      <button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
     </div>
     <div class="pane-body" id="corr">
-      <div class="empty"><div class="big">✉</div>Pick a draft on the left to see every thread<br>with that contact before you decide.</div>
+      <div class="empty"><div class="big">✉</div>Pick a draft to see every thread<br>with that contact before you decide.</div>
     </div>
   </div>
 
-  <!-- RIGHT: the pitch -->
-  <div class="panel pane-right">
-    <div class="pane-hd"><h2>The Pitch</h2></div>
+  <!-- PITCH -->
+  <div class="panel" id="panel-pitch" data-panel="pitch">
+    <div class="pane-hd">
+      <span class="collapse-strip"><button class="hd-btn" data-act="collapse">▸</button><span class="vlabel">The Pitch</span></span>
+      <span class="grip">⠿</span>
+      <h2>The Pitch</h2>
+      <span class="spacer"></span>
+      <button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
+      <button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
+    </div>
     <div class="pane-body" id="pitch">
       <div class="pitch-empty">No draft selected.<br>The followup waiting for this client will appear here.</div>
     </div>
@@ -169,7 +207,7 @@
 const $ = s => document.querySelector(s);
 let DRAFTS = [], CURRENT = null, MORE = false;
 const riskCache = {}; // email -> full /api/contact result
-const liteCache = {}; // email -> light /api/risk result {risk,signals}
+const liteCache = {}; // email -> light /api/risk result
 const verdictOf = email => riskCache[email] || liteCache[email] || null;
 
 function toast(msg, err){ const t=$('#toast'); t.textContent=msg; t.className='toast show'+(err?' err':''); setTimeout(()=>t.className='toast',2600); }
@@ -178,26 +216,171 @@ function fmtDate(d){ if(!d) return ''; const t=Date.parse(d); if(!t) return d; c
   return dt.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'2-digit'}); }
 const riskWord = {high:'High risk',elevated:'Elevated',caution:'Caution',clear:'Clear'};
 
-// ── load drafts ──
+/* ════════ LAYOUT MANAGER — resize · collapse · reorder · pop-out ════════ */
+const PANELS = ['drafts','corr','pitch'];
+const LS = 'pg.layout.v1';
+const DEFAULT_LAYOUT = { order:['drafts','corr','pitch'], width:{drafts:336,corr:0,pitch:438}, collapsed:{} };
+let layout = loadLayout();
+
+function loadLayout(){
+  try{ const v=JSON.parse(localStorage.getItem(LS)); if(v&&v.order) return Object.assign({},DEFAULT_LAYOUT,v,{width:Object.assign({},DEFAULT_LAYOUT.width,v.width),collapsed:v.collapsed||{}}); }catch(e){}
+  return JSON.parse(JSON.stringify(DEFAULT_LAYOUT));
+}
+function saveLayout(){ try{ localStorage.setItem(LS,JSON.stringify(layout)); }catch(e){} }
+
+// apply order + widths + collapsed to the DOM
+function applyLayout(){
+  const lay = $('#layout');
+  // strip existing handles
+  lay.querySelectorAll('.rz-handle').forEach(h=>h.remove());
+  // order the panels
+  layout.order.forEach(key=>{
+    const p = document.getElementById('panel-'+key);
+    if(p) lay.appendChild(p);
+  });
+  // the middle slot flexes; edges are fixed-width
+  layout.order.forEach((key,i)=>{
+    const p = document.getElementById('panel-'+key);
+    if(!p) return;
+    p.classList.toggle('collapsed', !!layout.collapsed[key]);
+    const mid = (i===1);
+    p.classList.toggle('flexed', mid && !layout.collapsed[key]);
+    if(!mid && !layout.collapsed[key]){
+      p.style.flex = '0 0 ' + (layout.width[key]||340) + 'px';
+    } else {
+      p.style.flex = '';
+    }
+  });
+  // insert resize handles between panels
+  for(let i=0;i<layout.order.length-1;i++){
+    const after = document.getElementById('panel-'+layout.order[i]);
+    const h = document.createElement('div');
+    h.className='rz-handle'; h.dataset.idx=i;
+    after.after(h);
+    wireHandle(h, i);
+  }
+  saveLayout();
+}
+
+// resize: handle i sits between slot i and slot i+1; drag resizes the non-middle one
+function wireHandle(h, i){
+  h.addEventListener('pointerdown', e=>{
+    e.preventDefault();
+    const leftKey = layout.order[i], rightKey = layout.order[i+1];
+    // pick the edge (non-middle) panel this handle should resize
+    const targetKey = (i===0) ? leftKey : rightKey;
+    const grow = (i===0) ? 1 : -1; // dragging right widens slot0; widens slot2 when dragging left
+    const startX = e.clientX, startW = layout.width[targetKey] || document.getElementById('panel-'+targetKey).offsetWidth;
+    h.classList.add('active');
+    h.setPointerCapture(e.pointerId);
+    const move = ev=>{
+      let w = startW + grow*(ev.clientX-startX);
+      w = Math.max(180, Math.min(900, w));
+      layout.width[targetKey] = w;
+      const p = document.getElementById('panel-'+targetKey);
+      if(p) p.style.flex = '0 0 '+w+'px';
+    };
+    const up = ()=>{ h.classList.remove('active'); h.removeEventListener('pointermove',move);
+      h.removeEventListener('pointerup',up); saveLayout(); };
+    h.addEventListener('pointermove',move);
+    h.addEventListener('pointerup',up);
+  });
+}
+
+// reorder: drag a panel by its header onto another panel
+let dragKey = null;
+function wireDragReorder(){
+  PANELS.forEach(key=>{
+    const p = document.getElementById('panel-'+key);
+    const hd = p.querySelector('.pane-hd');
+    hd.setAttribute('draggable','true');
+    hd.addEventListener('dragstart', e=>{
+      dragKey = key; p.classList.add('dragging');
+      e.dataTransfer.effectAllowed='move';
+      try{ e.dataTransfer.setData('text/plain',key); }catch(_){}
+    });
+    hd.addEventListener('dragend', ()=>{ p.classList.remove('dragging');
+      PANELS.forEach(k=>document.getElementById('panel-'+k).classList.remove('drop-target')); dragKey=null; });
+    p.addEventListener('dragover', e=>{ if(dragKey&&dragKey!==key){ e.preventDefault(); p.classList.add('drop-target'); } });
+    p.addEventListener('dragleave', ()=>p.classList.remove('drop-target'));
+    p.addEventListener('drop', e=>{
+      e.preventDefault(); p.classList.remove('drop-target');
+      if(!dragKey||dragKey===key) return;
+      const ord = layout.order.slice();
+      ord.splice(ord.indexOf(dragKey),1);
+      ord.splice(ord.indexOf(key),0,dragKey);
+      layout.order = ord; applyLayout();
+    });
+  });
+}
+
+function toggleCollapse(key){
+  layout.collapsed[key] = !layout.collapsed[key];
+  applyLayout();
+}
+function popout(key){
+  const u = new URL(location.href);
+  u.searchParams.set('only', key);
+  if(CURRENT) u.searchParams.set('draft', CURRENT);
+  window.open(u.toString(), 'pg-'+key, 'width=560,height=900');
+}
+function resetLayout(){ localStorage.removeItem(LS); layout = JSON.parse(JSON.stringify(DEFAULT_LAYOUT)); applyLayout(); toast('Layout reset'); }
+
+// header buttons (collapse / popout) + collapsed-strip click
+function wirePanelControls(){
+  PANELS.forEach(key=>{
+    const p = document.getElementById('panel-'+key);
+    if(!p) return; // removed in solo mode
+    p.querySelectorAll('[data-act]').forEach(btn=>{
+      btn.addEventListener('click', e=>{
+        e.stopPropagation();
+        if(btn.dataset.act==='collapse') toggleCollapse(key);
+        else if(btn.dataset.act==='popout') popout(key);
+      });
+    });
+    // clicking anywhere on a collapsed panel re-expands it
+    p.querySelector('.pane-hd').addEventListener('click', e=>{
+      if(p.classList.contains('collapsed') && !e.target.closest('[data-act]')) toggleCollapse(key);
+    });
+  });
+}
+
+// solo mode — a popped-out single panel
+function applySoloMode(){
+  const params = new URLSearchParams(location.search);
+  const only = params.get('only');
+  if(!only || !PANELS.includes(only)) return null;
+  document.body.classList.add('solo');
+  PANELS.forEach(k=>{
+    const p = document.getElementById('panel-'+k);
+    if(k===only){ p.classList.add('flexed'); p.classList.remove('collapsed'); p.style.flex='1 1 0'; }
+    else p.remove();
+  });
+  $('#resetLayout').style.display='none';
+  document.querySelector('header .sub').textContent = only==='drafts'?'drafts':only==='corr'?'correspondence':'the pitch';
+  return params.get('draft');
+}
+
+/* ════════ DATA ════════ */
 async function loadDrafts(){
   try{
     const r = await fetch('/api/drafts'); const d = await r.json();
     if(d.error) throw new Error(d.error);
-    DRAFTS = d.drafts; MORE = d.more; $('#acct').textContent = d.account;
-    $('#draftCount').textContent = d.count + (d.more ? '+' : '');
-    $('#draftCount').title = d.more ? d.count + ' newest shown — the followup-bot backlog is larger' : d.count + ' drafts';
+    DRAFTS = d.drafts; MORE = d.more;
+    const acct = $('#acct'); if(acct) acct.textContent = d.account;
+    const dc = $('#draftCount'); if(dc) dc.textContent = d.count + (d.more ? '+' : '');
     renderDrafts();
-  }catch(e){ $('#draftList').innerHTML = '<div class="empty">⚠ '+esc(e.message)+'</div>'; }
+  }catch(e){ const el = $('#draftList'); if(el) el.innerHTML = '<div class="empty">⚠ '+esc(e.message)+'</div>'; }
 }
 function renderDrafts(){
   const el = $('#draftList');
+  if(!el) return; // drafts panel popped out / not in this window
   if(!DRAFTS.length){ el.innerHTML='<div class="empty">No drafts waiting.</div>'; return; }
   el.innerHTML = (MORE?'<div class="meta" style="padding:7px 14px;color:var(--muted);border-bottom:1px solid var(--line)">Showing the '+DRAFTS.length+' most recent followup drafts — the bot backlog is larger.</div>':'') +
   DRAFTS.map(d=>{
     const c = verdictOf(d.toEmail);
     let badge = '<span class="badge r-pending">·</span>';
-    if(d._scanning) badge = '<span class="badge r-pending"><span class="spin"></span></span>';
-    else if(c) badge = '<span class="badge r-'+c.risk+'">'+c.risk+'</span>';
+    if(c) badge = '<span class="badge r-'+c.risk+'">'+c.risk+'</span>';
     return '<div class="draft'+(CURRENT===d.id?' active':'')+'" data-id="'+d.id+'">'+
       badge+
       '<div class="who">'+esc(d.toName)+'</div>'+
@@ -208,20 +391,17 @@ function renderDrafts(){
   el.querySelectorAll('.draft').forEach(n=>n.onclick=()=>selectDraft(n.dataset.id));
 }
 
-// ── select a draft ──
 async function selectDraft(id){
   CURRENT = id; renderDrafts();
   const d = DRAFTS.find(x=>x.id===id); if(!d) return;
-  if(!d.toEmail){
-    $('#corr').innerHTML = '<div class="empty">This draft has no recipient address — nothing to cross-check.</div>';
-  } else {
-    $('#corr').innerHTML = '<div class="empty"><div class="spin"></div> pulling every thread with '+esc(d.toEmail)+'…</div>';
-    loadContact(d.toEmail);
-  }
+  const corr = $('#corr');
+  if(corr){
+    if(!d.toEmail) corr.innerHTML = '<div class="empty">This draft has no recipient address — nothing to cross-check.</div>';
+    else { corr.innerHTML = '<div class="empty"><div class="spin"></div> pulling every thread with '+esc(d.toEmail)+'…</div>'; loadContact(d.toEmail); }
+  } else if(d.toEmail){ loadContact(d.toEmail); }
   loadPitch(d);
 }
 
-// ── middle: correspondence ──
 async function loadContact(email){
   try{
     let c = riskCache[email];
@@ -231,6 +411,7 @@ async function loadContact(email){
   }catch(e){ $('#corr').innerHTML='<div class="empty">⚠ '+esc(e.message)+'</div>'; }
 }
 function renderCorr(c){
+  if(!$('#corr')) return; // correspondence panel not in this window
   $('#threadCount').style.display='inline-block';
   $('#threadCount').textContent = c.threadCount+' thread'+(c.threadCount===1?'':'s');
   const sigChips = c.signals.map(s=>'<span class="badge r-high">'+esc(s)+'</span>').join(' ');
@@ -265,24 +446,22 @@ function renderCorr(c){
   $('#corr').innerHTML = banner +
     '<div class="contact-hd"><b>'+esc(c.email)+'</b> — '+c.totalMessages+' messages across '+c.threadCount+' thread'+(c.threadCount===1?'':'s')+
     (c.customerReplied?' · customer has replied':'')+'</div>' + threads;
-
   $('#corr').querySelectorAll('.msg').forEach(n=>n.onclick=()=>toggleMsg(n));
 }
 async function toggleMsg(node){
   node.classList.toggle('open');
   const body = node.querySelector('.m-body');
   if(node.classList.contains('open') && body.dataset.loaded==='0'){
-    body.dataset.loaded='1';
-    body.textContent='loading…';
+    body.dataset.loaded='1'; body.textContent='loading…';
     try{ const r=await fetch('/api/message/'+node.dataset.mid); const m=await r.json();
       body.textContent = m.body || m.snippet || '(no text body)';
     }catch(e){ body.textContent='⚠ '+e.message; }
   }
 }
 
-// ── right: the pitch ──
 async function loadPitch(d){
   const el = $('#pitch');
+  if(!el) return; // pitch panel not in this window
   el.innerHTML = '<div class="pitch-empty"><div class="spin"></div> loading draft…</div>';
   let body='';
   try{ const r=await fetch('/api/message/'+d.id); const m=await r.json(); body=m.body||''; }
@@ -304,7 +483,6 @@ async function loadPitch(d){
     '</div>';
   $('#skipBtn').onclick=()=>{ toast('Skipped — draft left untouched in Gmail'); };
   $('#sendBtn').onclick=()=>sendPitch(d);
-  // verdict — show any cached read instantly; the full /api/contact refines it
   const c = verdictOf(d.toEmail);
   if(c) renderVerdict(c);
   else if(d.toEmail) $('#verdictSlot').innerHTML='<div class="verdict caution"><h3><span class="spin"></span> Checking every thread with this contact…</h3></div>';
@@ -314,11 +492,11 @@ function renderVerdict(c){
   const slot = $('#verdictSlot'); if(!slot) return;
   let html='', gated = (c.risk==='high'||c.risk==='elevated');
   if(c.risk==='high') html='<h3>⛔ '+riskWord.high+'</h3><p>Order / payment activity found. This client should likely <b>not</b> get a followup pitch. Confirm with Samantha first.</p>';
-  else if(c.risk==='elevated') html='<h3>⚠ '+riskWord.elevated+'</h3><p>Invoice / order signals found in other threads — review the middle panel before sending.</p>';
+  else if(c.risk==='elevated') html='<h3>⚠ '+riskWord.elevated+'</h3><p>Invoice / order signals found in other threads — review the correspondence before sending.</p>';
   else if(c.risk==='caution') html='<h3>↔ '+riskWord.caution+'</h3><p>Multiple threads exist. Make sure this followup is not a duplicate.</p>';
   else html='<h3>✓ '+riskWord.clear+'</h3><p>No prior-order signals. Safe to follow up.</p>';
   if(gated) html+='<label class="override"><input type="checkbox" id="ovr">'+
-    '<span>I reviewed every thread above and confirm this followup is still appropriate.</span></label>';
+    '<span>I reviewed every thread and confirm this followup is still appropriate.</span></label>';
   slot.className=''; slot.innerHTML='<div class="verdict '+c.risk+'">'+html+'</div>';
   const sb=$('#sendBtn');
   if(sb){
@@ -328,7 +506,6 @@ function renderVerdict(c){
   }
 }
 
-// ── send ──
 async function sendPitch(d){
   const subj=$('#pSubj').value.trim(), body=$('#pBody').value.trim();
   if(!d.toEmail){ toast('No recipient on this draft',1); return; }
@@ -346,11 +523,9 @@ async function sendPitch(d){
     if(out.error) throw new Error(out.error);
     $('#sendState').innerHTML='<span class="sent-stamp">✓ sent in-thread</span>';
     toast('Followup sent to '+d.toEmail);
-    d._sent=true;
   }catch(e){ $('#sendState').textContent=''; sb.disabled=false; toast('Send failed: '+e.message,1); }
 }
 
-// ── scan all contacts for risk ──
 async function scanAll(){
   const btn=$('#scanAll'); btn.disabled=true;
   const targets=[...new Set(DRAFTS.filter(d=>d.toEmail&&!verdictOf(d.toEmail)).map(d=>d.toEmail))];
@@ -362,20 +537,28 @@ async function scanAll(){
     while(i<targets.length){
       const email=targets[i++];
       try{ const r=await fetch('/api/risk?email='+encodeURIComponent(email)); const v=await r.json();
-        if(!v.error) liteCache[email]=v; }catch{}
+        if(!v.error) liteCache[email]=v; }catch(e){}
       done++; tick(); renderDrafts();
-      await new Promise(r=>setTimeout(r,250)); // gentle on Gmail quota
+      await new Promise(r=>setTimeout(r,250));
     }
   }
-  await Promise.all([worker(),worker()]); // concurrency 2
+  await Promise.all([worker(),worker()]);
   btn.disabled=false; btn.textContent='Scan risk';
   const high=DRAFTS.filter(d=>{const v=verdictOf(d.toEmail);return v&&v.risk==='high';}).length;
   const elev=DRAFTS.filter(d=>{const v=verdictOf(d.toEmail);return v&&v.risk==='elevated';}).length;
   toast('Scan done — '+high+' high-risk, '+elev+' elevated');
 }
-$('#scanAll').onclick=scanAll;
 
-loadDrafts();
+/* ════════ BOOT ════════ */
+$('#scanAll').onclick=scanAll;
+$('#resetLayout').onclick=resetLayout;
+const soloDraft = applySoloMode();
+if(!document.body.classList.contains('solo')){
+  applyLayout();
+  wireDragReorder();
+}
+wirePanelControls();
+loadDrafts().then(()=>{ if(soloDraft) selectDraft(soloDraft); });
 </script>
 </body>
 </html>

← dd9f2df move Pitch Guard to port 8123  ·  back to Pitch Guard  ·  Split drafts into Clients + collapsible Vendors section (ven e6f61db →