[object Object]

← back to Dw Pitch Followup

Redesign viewer: 3-column board (all lists at once) + List/Grid toggle + density scale; per-column sort, cross-column select/export

5d7759e8adc04e3f9b9af7e4faf8189819f821ab · 2026-07-07 13:52:27 -0700 · Steve Abrams

Files touched

Diff

commit 5d7759e8adc04e3f9b9af7e4faf8189819f821ab
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 7 13:52:27 2026 -0700

    Redesign viewer: 3-column board (all lists at once) + List/Grid toggle + density scale; per-column sort, cross-column select/export
---
 public/index.html | 274 +++++++++++++++++++++++++++++++-----------------------
 1 file changed, 159 insertions(+), 115 deletions(-)

diff --git a/public/index.html b/public/index.html
index 4901d40..60b7519 100644
--- a/public/index.html
+++ b/public/index.html
@@ -8,64 +8,84 @@
   :root{
     --bg:#13110e; --panel:#1c1915; --panel2:#211d18; --line:#34302a;
     --ink:#efeae1; --mut:#a59c8d; --gold:#c8a96a; --green:#7fae6a; --amber:#d9a441; --red:#cf6a5a;
-    --cols:3;
+    --scale:1;
   }
   *{box-sizing:border-box}
   body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
-  header{padding:18px 24px 10px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#181511,#13110e)}
+  header{padding:16px 24px 10px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#181511,#13110e)}
   h1{margin:0;font-size:19px;letter-spacing:.04em;font-weight:600}
   h1 small{color:var(--mut);font-weight:400;font-size:13px;letter-spacing:0}
-  .tabs{display:flex;gap:6px;margin-top:14px;flex-wrap:wrap}
-  .tab{padding:9px 16px;border:1px solid var(--line);border-bottom:none;border-radius:8px 8px 0 0;background:var(--panel);color:var(--mut);cursor:pointer;font-size:13.5px}
-  .tab.active{background:var(--panel2);color:var(--ink);border-color:var(--gold)}
-  .tab .n{display:inline-block;margin-left:7px;padding:0 7px;border-radius:10px;background:#2c2822;color:var(--gold);font-size:12px}
-  .toolbar{position:sticky;top:0;z-index:5;display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 24px;background:var(--panel2);border-bottom:1px solid var(--line)}
+
+  .toolbar{position:sticky;top:0;z-index:20;display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:11px 24px;background:var(--panel2);border-bottom:1px solid var(--line)}
   .toolbar label{color:var(--mut);font-size:12.5px;margin-right:5px}
   select,input[type=search]{background:#15120f;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:13px}
-  input[type=range]{vertical-align:middle}
+  input[type=range]{vertical-align:middle;width:120px}
   .btn{background:#2a2620;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:7px 12px;cursor:pointer;font-size:13px}
   .btn:hover{border-color:var(--gold)}
   .btn.gold{background:var(--gold);color:#1a1611;border-color:var(--gold);font-weight:600}
   .spacer{flex:1}
   .selcount{color:var(--gold);font-size:13px}
-  main{padding:18px 24px 80px}
-  .grid{display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:14px}
-  .card{background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:13px 14px;display:flex;flex-direction:column;gap:8px;position:relative}
+
+  /* View toggle (List ⇄ Grid) */
+  .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
+  .seg button{background:#15120f;color:var(--mut);border:none;padding:7px 13px;font-size:12.5px;cursor:pointer}
+  .seg button.on{background:var(--gold);color:#1a1611;font-weight:600}
+
+  /* 3-COLUMN BOARD */
+  main{padding:16px 24px 60px}
+  .board{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;align-items:start}
+  @media(max-width:1100px){.board{grid-template-columns:1fr}}
+  .col{background:var(--panel2);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;max-height:calc(100vh - 160px);overflow:hidden}
+  .col-head{padding:12px 14px 11px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#231f19,#211d18)}
+  .col-title{display:flex;align-items:center;gap:8px;font-weight:600;font-size:14.5px}
+  .col-title .n{margin-left:auto;padding:1px 9px;border-radius:11px;background:#2c2822;color:var(--gold);font-size:12px}
+  .col-defn{font-size:11px;color:var(--mut);margin:5px 0 8px;line-height:1.4}
+  .col-sort{width:100%}
+  .col-body{overflow-y:auto;padding:11px;display:flex;flex-direction:column;gap:10px}
+  .col-empty{color:var(--mut);font-size:12.5px;padding:22px 6px;text-align:center}
+
+  .card{background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:12px 13px;display:flex;flex-direction:column;gap:7px;position:relative;font-size:calc(13px*var(--scale))}
   .card.sel{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold) inset}
   .card .top{display:flex;align-items:flex-start;gap:9px}
-  .card input[type=checkbox]{width:18px;height:18px;margin-top:2px;accent-color:var(--gold);cursor:pointer;flex:0 0 auto}
-  .co{font-weight:600;font-size:15px}
-  .sub{color:var(--mut);font-size:12.5px;word-break:break-word}
+  .card input[type=checkbox]{width:17px;height:17px;margin-top:2px;accent-color:var(--gold);cursor:pointer;flex:0 0 auto}
+  .co{font-weight:600;font-size:calc(14.5px*var(--scale))}
+  .sub{color:var(--mut);font-size:calc(12px*var(--scale));word-break:break-word}
   .chips{display:flex;flex-wrap:wrap;gap:6px}
-  .chip{font-size:11.5px;padding:2px 8px;border-radius:11px;background:#26221c;color:var(--mut);border:1px solid var(--line)}
-  .chip.k{color:var(--gold)}
-  .chip.g{color:var(--green)} .chip.a{color:var(--amber)} .chip.r{color:var(--red)}
-  .when{font-size:11.5px;color:var(--mut)}
-  .pats{font-size:12px;color:var(--mut)}
+  .chip{font-size:calc(11px*var(--scale));padding:2px 8px;border-radius:11px;background:#26221c;color:var(--mut);border:1px solid var(--line)}
+  .chip.k{color:var(--gold)} .chip.g{color:var(--green)} .chip.a{color:var(--amber)} .chip.r{color:var(--red)}
+  .pats{font-size:calc(11.5px*var(--scale));color:var(--mut)}
   .acts{display:flex;gap:8px;margin-top:2px}
   .letter{margin-top:6px;border-top:1px dashed var(--line);padding-top:8px;display:none}
   .letter.show{display:block}
   .letter .subj{font-size:12.5px;color:var(--gold);margin-bottom:5px}
-  textarea{width:100%;min-height:180px;background:#15120f;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:9px;font:13px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
+  textarea{width:100%;min-height:170px;background:#15120f;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:9px;font:13px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
   .ctxnote{font-size:11.5px;color:var(--mut);margin:5px 0}
   .ctxnote.found{color:var(--green)}
-  #sentinel{height:40px}
-  .empty{color:var(--mut);padding:40px;text-align:center}
-  .note{font-size:12px;color:var(--mut);margin-top:6px}
   .meta{color:var(--mut);font-size:12px}
   a{color:var(--gold)}
+
+  /* LIST MODE — compact 3-column rows (checkbox · who · trailing facts) */
+  body.view-list .card{padding:8px 11px;gap:5px}
+  body.view-list .card .top{align-items:center}
+  body.view-list .pats{display:none}
+  body.view-list .chips{font-size:11px}
+  body.view-list .card .acts:first-of-type{margin-left:auto}
+  body.view-list .co{font-size:calc(13.5px*var(--scale))}
 </style>
 </head>
 <body>
 <header>
-  <h1>Designer Wallcoverings — Client Follow-Up <small>· select pitches · nothing sends from here</small></h1>
-  <div class="tabs" id="tabs"></div>
+  <h1>Designer Wallcoverings — Client Follow-Up <small>· every open correspondence, 3 columns · nothing sends from here</small></h1>
 </header>
 
 <div class="toolbar">
-  <span><label>Sort</label><select id="sort"></select></span>
+  <span><input type="search" id="q" placeholder="filter every column…" /></span>
+  <span><label>View</label>
+    <span class="seg" id="viewSeg">
+      <button data-mode="list">List</button><button data-mode="grid">Grid</button>
+    </span>
+  </span>
   <span><label>Density</label><input type="range" id="density" min="1" max="5" step="1" /></span>
-  <span><input type="search" id="q" placeholder="filter…" /></span>
   <span><label title="OFF (default) hides manufacturer numbers from view AND from client letters"><input type="checkbox" id="showMfr" /> show mfr #</label></span>
   <span class="spacer"></span>
   <span class="selcount" id="selcount">0 selected</span>
@@ -75,10 +95,7 @@
 </div>
 
 <main>
-  <div class="grid" id="grid"></div>
-  <div id="sentinel"></div>
-  <div class="empty" id="empty" style="display:none">No rows.</div>
-  <div class="note" id="defn"></div>
+  <div class="board" id="board"></div>
 </main>
 
 <script>
@@ -87,43 +104,45 @@ const LISTS = ['list1','list2','list3'];
 // If the tab was opened as http://user:pass@host/, a relative fetch('/api/…')
 // throws "URL that includes credentials"; rebuilding from location.host strips them.
 const API = p => `${location.protocol}//${location.host}${p}`;
-const TAB_META = {
-  list1:{title:'Samples Sent ≥90%', defn:'Projects where ≥90% of the samples the client ordered have shipped — they have the goods in hand; follow up to convert.'},
-  list2:{title:'Invoiced · No Order', defn:'Clients invoiced in the last ~4 months whose invoices were all sample-only (no order over $50) — never converted to a firm order.'},
-  list3:{title:'Vendor Memos Overdue', defn:'Sample memos WE requested from a vendor 8–180 days ago that still have not been received — chase the vendor.'},
+const META = {
+  list1:{title:'Samples Sent ≥90%', defn:'≥90% of the samples the client ordered have shipped — they have the goods in hand. Follow up to convert.'},
+  list2:{title:'Invoiced · No Order', defn:'Invoiced in the last ~4 months, all sample-only (no order over $50) — never converted to a firm order.'},
+  list3:{title:'Vendor Memos Overdue', defn:'Sample memos WE requested from a vendor 8–180 days ago that still have not arrived — chase the vendor.'},
 };
 const SORTS = {
   list1:[['Newest sent','created_at:desc'],['Oldest sent','created_at:asc'],['Company A→Z','company:asc'],['Most samples','ordered:desc']],
   list2:[['Newest invoiced','created_at:desc'],['Oldest invoiced','created_at:asc'],['Company A→Z','company:asc'],['Most invoices','invoice_count:desc']],
   list3:[['Most overdue','days_overdue:desc'],['Least overdue','days_overdue:asc'],['Vendor A→Z','vendor:asc'],['Client A→Z','company:asc']],
 };
-let DATA=null, cur='list1', rendered=0, view=[], io=null;
+let DATA=null;
+const VIEW = {list1:[],list2:[],list3:[]};   // filtered+sorted rows per column
+const RENDERED = {list1:0,list2:0,list3:0};  // how many rendered (lazy scroll)
+const CHUNK = 30;
+let mode = 'grid';
+
 const LS = (k,d)=>{try{return JSON.parse(localStorage.getItem('dwpf:'+k))??d}catch{return d}};
 const setLS=(k,v)=>localStorage.setItem('dwpf:'+k, JSON.stringify(v));
+const esc = s => String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
 const fmtWhen = iso => { if(!iso) return ''; const d=new Date(iso); return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
-// Date chip for each card's chips row — for list1 this is the LAST DATE A SAMPLE WAS SENT
-// (when_label='last sample sent'); list2='last invoiced', list3='requested'. Full ISO in title.
 const dateChip = r => r.when_iso ? `<span class="chip" title="${esc(r.when_iso)}">🕓 ${esc(r.when_label||'when')}: ${esc(fmtWhen(r.when_iso))}</span>` : '';
-const esc = s => String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
 // mfr-number scrub for display: drop SKU-ish tokens (any alnum run containing a digit), keep names.
 function stripMfr(s){ return String(s||'').replace(/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/g,t=>/\d/.test(t)?'':t).replace(/\s*[|·•—–-]+\s*/g,' · ').replace(/(?:^[\s·]+|[\s·]+$)/g,'').replace(/(?:·\s*){2,}/g,'· ').replace(/\bSample\b\s*$/i,'').replace(/\s{2,}/g,' ').trim().replace(/[·\s]+$/,''); }
 function showMfr(){ return !!document.getElementById('showMfr')?.checked; }
 const lbl = s => showMfr() ? String(s||'').trim() : stripMfr(s);
 
-function selSet(){ return new Set(LS('sel:'+cur, [])); }
-function saveSel(set){ setLS('sel:'+cur, [...set]); paintSelCount(); }
-function rowKey(r){ return cur==='list3' ? `${r.account}|${r.sku}` : r.account; }
+// --- selection (per-column) ---
+function rowKey(list,r){ return list==='list3' ? `${r.account}|${r.sku}` : r.account; }
+function selSet(list){ return new Set(LS('sel:'+list, [])); }
+function saveSel(list,set){ setLS('sel:'+list, [...set]); paintSelCount(); }
+function totalSel(){ return LISTS.reduce((n,l)=>n+selSet(l).size,0); }
+function paintSelCount(){ document.getElementById('selcount').textContent = `${totalSel()} selected`; }
 
-function paintSelCount(){
-  const n = selSet().size;
-  document.getElementById('selcount').textContent = `${n} selected`;
-}
-
-function applySortFilter(){
-  const rows = (DATA[cur]||[]).slice();
+// --- filter + sort for one column ---
+function filterSort(list){
+  const rows = (DATA[list]||[]).slice();
   const q = document.getElementById('q').value.trim().toLowerCase();
   let v = q ? rows.filter(r=>JSON.stringify(r).toLowerCase().includes(q)) : rows;
-  const [f,dir] = (LS('sort:'+cur, SORTS[cur][0][1])).split(':');
+  const [f,dir] = (LS('sort:'+list, SORTS[list][0][1])).split(':');
   v.sort((a,b)=>{
     let x=a[f], y=b[f];
     if(typeof x==='number'||typeof y==='number'){ x=+x||0;y=+y||0; return dir==='asc'?x-y:y-x; }
@@ -132,14 +151,14 @@ function applySortFilter(){
   return v;
 }
 
-function cardHTML(r){
-  const k = rowKey(r), sel = selSet().has(k);
+function cardHTML(list,r){
+  const k = rowKey(list,r), sel = selSet(list).has(k);
   let head, facts='';
-  if(cur==='list3'){
+  if(list==='list3'){
     head = `<div><div class="co">${esc(r.vendor)} <span class="chip r">${r.days_overdue}d overdue</span></div>
       <div class="sub">${esc(r.company||('account '+r.account))} · acct ${esc(r.account)}</div></div>`;
     facts = `<div class="chips">${showMfr()?`<span class="chip k">${esc(r.sku||'—')}</span>`:''}<span class="chip">${esc(lbl(r.pattern)||'pattern')}</span>${dateChip(r)}</div>`;
-  } else if(cur==='list2'){
+  } else if(list==='list2'){
     head = `<div><div class="co">${esc(r.company)}</div><div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
     facts = `<div class="chips"><span class="chip a">${r.invoice_count} sample inv</span><span class="chip">max $${r.max_total}</span>${dateChip(r)}</div>
       ${(()=>{const d=r.details&&r.details[0]?lbl(r.details[0]):'';return d?`<div class="pats">${esc(d)}</div>`:''})()}`;
@@ -149,7 +168,7 @@ function cardHTML(r){
     facts = `<div class="chips"><span class="chip">${r.sent}/${r.ordered} sent</span>${r.missing?`<span class="chip a">${r.missing} missing</span>`:''}${dateChip(r)}</div>
       ${(()=>{const p=(r.patterns||[]).map(lbl).filter(Boolean).slice(0,3).join(' · ');return p?`<div class="pats">${esc(p)}</div>`:''})()}`;
   }
-  return `<div class="card ${sel?'sel':''}" data-k="${esc(k)}">
+  return `<div class="card ${sel?'sel':''}" data-k="${esc(k)}" data-list="${list}">
     <div class="top"><input type="checkbox" ${sel?'checked':''} data-sel/>${head}</div>
     ${facts}
     <div class="acts"><button class="btn gold" data-gen>Generate letter</button></div>
@@ -158,26 +177,27 @@ function cardHTML(r){
   </div>`;
 }
 
-function renderChunk(){
-  const grid=document.getElementById('grid');
-  const slice = view.slice(rendered, rendered+30);
+function renderChunk(list){
+  const body = document.getElementById('body-'+list);
+  const slice = VIEW[list].slice(RENDERED[list], RENDERED[list]+CHUNK);
   if(!slice.length) return;
-  const frag=document.createElement('div'); frag.innerHTML = slice.map(cardHTML).join('');
-  // attach row data
-  [...frag.children].forEach((el,i)=>{ el._row = slice[i]; grid.appendChild(el); });
-  rendered += slice.length;
+  const frag=document.createElement('div'); frag.innerHTML = slice.map(r=>cardHTML(list,r)).join('');
+  [...frag.children].forEach((el,i)=>{ el._row = slice[i]; el._list = list; body.appendChild(el); });
+  RENDERED[list] += slice.length;
 }
 
-function rerender(){
-  view = applySortFilter();
-  rendered = 0;
-  const grid=document.getElementById('grid'); grid.innerHTML='';
-  document.getElementById('empty').style.display = view.length?'none':'block';
-  renderChunk();
+function rerenderCol(list){
+  VIEW[list] = filterSort(list);
+  RENDERED[list] = 0;
+  const body = document.getElementById('body-'+list);
+  body.innerHTML = VIEW[list].length ? '' : '<div class="col-empty">No matching rows.</div>';
+  document.getElementById('n-'+list).textContent = VIEW[list].length;
+  renderChunk(list);
 }
+function rerenderAll(){ LISTS.forEach(rerenderCol); }
 
 async function genLetter(card, opts={}){
-  const r = card._row;
+  const r = card._row, list = card._list;
   const box = card.querySelector('.letter');
   const ta = card.querySelector('textarea'); const subj=card.querySelector('.subj'); const note=card.querySelector('.ctxnote');
   const vlabel = card.querySelector('[data-variant]'); const rbtn = card.querySelector('[data-regen]');
@@ -187,12 +207,12 @@ async function genLetter(card, opts={}){
   const mfr = showMfr()?1:0;
   const seed = regen ? (card._seed||1)+1 : (card._seed||1);
   card._seed = seed;
-  const cacheK = `letter:${cur}:${rowKey(r)}:m${mfr}:s${seed}`;
+  const cacheK = `letter:${list}:${rowKey(list,r)}:m${mfr}:s${seed}`;
   if(!regen){ const c=LS(cacheK,null); if(c){ subj.textContent='Subject: '+c.subject; ta.value=c.body; note.textContent=c.ctx; note.className='ctxnote '+(c.found?'found':''); vlabel.textContent=c.vmeta||''; return; } }
   subj.textContent=regen?'regenerating…':'composing…'; ta.value=''; vlabel.textContent='';
   if(rbtn){ rbtn.disabled=true; rbtn.textContent='…'; }
   try{
-    const res = await fetch(API('/api/letter'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({list:cur,row:r,seed,showMfr:showMfr()})});
+    const res = await fetch(API('/api/letter'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({list,row:r,seed,showMfr:showMfr()})});
     const j = await res.json();
     if(j.error) throw new Error(j.error);
     subj.textContent='Subject: '+j.subject; ta.value=j.body;
@@ -201,75 +221,99 @@ async function genLetter(card, opts={}){
     const vmeta = `variant ${j.variant||seed} · ${j.engine||'local'}${j.engineNote?' · '+j.engineNote:''}`;
     vlabel.textContent=vmeta;
     setLS(cacheK,{subject:j.subject,body:j.body,ctx,found:!!j.context?.found,vmeta});
-    setLS('lastletter:'+cur+':'+rowKey(r),{subject:j.subject,body:j.body});
+    setLS('lastletter:'+list+':'+rowKey(list,r),{subject:j.subject,body:j.body});
   }catch(e){ subj.textContent='error'; note.textContent=e.message; }
   finally{ if(rbtn){ rbtn.disabled=false; rbtn.textContent='↻ Regenerate'; } }
 }
 
-function selectedRows(){
-  const set=selSet();
-  return (DATA[cur]||[]).filter(r=>set.has(rowKey(r)));
-}
+// --- selected → text (across ALL columns) ---
+function selectedRowsFor(list){ const set=selSet(list); return (DATA[list]||[]).filter(r=>set.has(rowKey(list,r))); }
 function selectedAsText(){
-  return selectedRows().map(r=>{
-    const k='lastletter:'+cur+':'+rowKey(r); const L=LS(k,null);
-    const who = cur==='list3'?`${r.vendor} (re: ${r.company||r.account}, ${r.sku})`:`${r.company} <${r.email||'no-email'}>`;
-    const hdr = `=== ${who} ===`;
-    return L ? `${hdr}\nSubject: ${L.subject}\n\n${L.body}\n` : `${hdr}\n(no letter generated)\n`;
-  }).join('\n----------------------------------------\n\n');
+  const blocks=[];
+  for(const list of LISTS){
+    for(const r of selectedRowsFor(list)){
+      const L=LS('lastletter:'+list+':'+rowKey(list,r),null);
+      const who = list==='list3'?`${r.vendor} (re: ${r.company||r.account}, ${r.sku})`:`${r.company} <${r.email||'no-email'}>`;
+      const hdr = `=== [${META[list].title}] ${who} ===`;
+      blocks.push(L ? `${hdr}\nSubject: ${L.subject}\n\n${L.body}\n` : `${hdr}\n(no letter generated)\n`);
+    }
+  }
+  return blocks.join('\n----------------------------------------\n\n');
 }
 
-// --- wiring ---
-function buildTabs(){
-  document.getElementById('tabs').innerHTML = LISTS.map(l=>`<div class="tab ${l===cur?'active':''}" data-l="${l}">${TAB_META[l].title}<span class="n">${(DATA[l]||[]).length}</span></div>`).join('');
-}
-function buildSort(){
-  const sel=document.getElementById('sort'); const saved=LS('sort:'+cur, SORTS[cur][0][1]);
-  sel.innerHTML = SORTS[cur].map(([t,v])=>`<option value="${v}" ${v===saved?'selected':''}>${t}</option>`).join('');
+// --- build the 3 columns ---
+function buildBoard(){
+  document.getElementById('board').innerHTML = LISTS.map(list=>{
+    const saved = LS('sort:'+list, SORTS[list][0][1]);
+    const opts = SORTS[list].map(([t,v])=>`<option value="${v}" ${v===saved?'selected':''}>${t}</option>`).join('');
+    return `<section class="col">
+      <div class="col-head">
+        <div class="col-title">${META[list].title}<span class="n" id="n-${list}">0</span></div>
+        <div class="col-defn">${META[list].defn}</div>
+        <select class="col-sort" data-list="${list}">${opts}</select>
+      </div>
+      <div class="col-body" id="body-${list}"></div>
+    </section>`;
+  }).join('');
+
+  // per-column: sort change + lazy scroll
+  LISTS.forEach(list=>{
+    const body = document.getElementById('body-'+list);
+    body.addEventListener('scroll',()=>{ if(body.scrollTop+body.clientHeight >= body.scrollHeight-300) renderChunk(list); });
+  });
 }
-function switchTab(l){ cur=l; setLS('lastTab',l); buildTabs(); buildSort(); document.getElementById('defn').textContent=TAB_META[l].defn; paintSelCount(); rerender(); }
 
-document.getElementById('tabs').addEventListener('click',e=>{const t=e.target.closest('.tab'); if(t) switchTab(t.dataset.l);});
-document.getElementById('sort').addEventListener('change',e=>{ setLS('sort:'+cur, e.target.value); rerender(); });
-document.getElementById('q').addEventListener('input',()=>rerender());
-const dens=document.getElementById('density');
-dens.value = LS('density',3); document.documentElement.style.setProperty('--cols', LS('density',3));
-dens.addEventListener('input',e=>{ setLS('density',+e.target.value); document.documentElement.style.setProperty('--cols', e.target.value); });
-const mfrToggle=document.getElementById('showMfr');
-mfrToggle.checked = LS('showMfr',false);
-mfrToggle.addEventListener('change',e=>{ setLS('showMfr',e.target.checked); rerender(); });
+// --- wiring ---
+function applyMode(){ document.body.classList.toggle('view-list', mode==='list'); document.body.classList.toggle('view-grid', mode==='grid');
+  [...document.querySelectorAll('#viewSeg button')].forEach(b=>b.classList.toggle('on', b.dataset.mode===mode)); }
 
-document.getElementById('grid').addEventListener('click',e=>{
+document.getElementById('viewSeg').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b)return; mode=b.dataset.mode; setLS('viewMode',mode); applyMode(); });
+document.getElementById('q').addEventListener('input',rerenderAll);
+document.getElementById('board').addEventListener('change',e=>{
+  if(e.target.matches('.col-sort')){ setLS('sort:'+e.target.dataset.list, e.target.value); rerenderCol(e.target.dataset.list); return; }
+  if(e.target.matches('[data-sel]')){
+    const card=e.target.closest('.card'); const list=card.dataset.list; const k=card.dataset.k; const set=selSet(list);
+    if(e.target.checked){ set.add(k); card.classList.add('sel'); } else { set.delete(k); card.classList.remove('sel'); }
+    saveSel(list,set);
+  }
+});
+document.getElementById('board').addEventListener('click',e=>{
   const card=e.target.closest('.card'); if(!card) return;
   if(e.target.matches('[data-gen]')) return genLetter(card);
   if(e.target.matches('[data-regen]')) return genLetter(card,{regenerate:true});
   if(e.target.matches('[data-copy]')){ const ta=card.querySelector('textarea'); navigator.clipboard.writeText(`Subject: ${card.querySelector('.subj').textContent.replace(/^Subject: /,'')}\n\n${ta.value}`); e.target.textContent='Copied ✓'; setTimeout(()=>e.target.textContent='Copy letter',1200); }
 });
-document.getElementById('grid').addEventListener('change',e=>{
-  if(!e.target.matches('[data-sel]')) return;
-  const card=e.target.closest('.card'); const k=card.dataset.k; const set=selSet();
-  if(e.target.checked){ set.add(k); card.classList.add('sel'); } else { set.delete(k); card.classList.remove('sel'); }
-  saveSel(set);
-});
+
+const dens=document.getElementById('density');
+dens.value = LS('density',3);
+const applyScale = v => document.documentElement.style.setProperty('--scale', (0.72 + (+v)*0.11).toFixed(2)); // 1→0.83 … 5→1.27
+applyScale(dens.value);
+dens.addEventListener('input',e=>{ setLS('density',+e.target.value); applyScale(e.target.value); });
+
+const mfrToggle=document.getElementById('showMfr');
+mfrToggle.checked = LS('showMfr',false);
+mfrToggle.addEventListener('change',e=>{ setLS('showMfr',e.target.checked); rerenderAll(); });
+
 document.getElementById('copyBtn').addEventListener('click',()=>{
   const t=selectedAsText(); if(!t){alert('Nothing selected.');return;}
   navigator.clipboard.writeText(t); const b=document.getElementById('copyBtn'); b.textContent='Copied ✓'; setTimeout(()=>b.textContent='Copy selected',1400);
 });
 document.getElementById('exportBtn').addEventListener('click',()=>{
-  const rows=selectedRows(); if(!rows.length){alert('Nothing selected.');return;}
-  const md = `# DW Follow-Up — ${TAB_META[cur].title}\n_${rows.length} selected · ${new Date().toLocaleString()}_\n\n`+selectedAsText();
+  if(!totalSel()){alert('Nothing selected.');return;}
+  const md = `# DW Follow-Up — selected correspondence\n_${totalSel()} selected · ${new Date().toLocaleString()}_\n\n`+selectedAsText();
   const blob=new Blob([md],{type:'text/markdown'}); const a=document.createElement('a');
-  a.href=URL.createObjectURL(blob); a.download=`dw-followup-${cur}-${Date.now()}.md`; a.click();
+  a.href=URL.createObjectURL(blob); a.download=`dw-followup-${Date.now()}.md`; a.click();
 });
 
-io = new IntersectionObserver(es=>{ if(es.some(x=>x.isIntersecting)) renderChunk(); }, {rootMargin:'400px'});
-io.observe(document.getElementById('sentinel'));
-
+mode = LS('viewMode','grid');
 fetch(API('/api/lists')).then(r=>r.json()).then(j=>{
-  DATA=j; cur = LS('lastTab','list1'); if(!LISTS.includes(cur)) cur='list1';
+  DATA=j;
   document.getElementById('built').textContent = 'built '+new Date(j.generatedAt).toLocaleString();
-  switchTab(cur);
-}).catch(e=>{ document.getElementById('grid').innerHTML='<div class="empty">Could not load lists: '+e.message+'<br>Run <code>npm run build</code>.</div>'; });
+  buildBoard();
+  applyMode();
+  paintSelCount();
+  rerenderAll();
+}).catch(e=>{ document.getElementById('board').innerHTML='<div class="col-empty">Could not load lists: '+e.message+'<br>Run <code>npm run build</code>.</div>'; });
 </script>
 </body>
 </html>

← 8a5b1ac Frontend: build API URLs from location.host so fetches work  ·  back to Dw Pitch Followup  ·  auto-save: 2026-07-07T14:01:00 (2 files) — public/index.html 182e2a6 →