[object Object]

← back to Contact Mailer Ideas

viewer: search across all fields (name/headline/target/actions/ticks/scores) + sort (score/axes/refinement count) + density slider, localStorage persisted

bd6d8c4a9a2ca8498c31af76cbcd7b548e598e96 · 2026-05-11 12:51:02 -0700 · Steve Abrams

Files touched

Diff

commit bd6d8c4a9a2ca8498c31af76cbcd7b548e598e96
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 12:51:02 2026 -0700

    viewer: search across all fields (name/headline/target/actions/ticks/scores) + sort (score/axes/refinement count) + density slider, localStorage persisted
---
 viewer/server.js | 115 ++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 101 insertions(+), 14 deletions(-)

diff --git a/viewer/server.js b/viewer/server.js
index 7a8113c..8b8ec0a 100644
--- a/viewer/server.js
+++ b/viewer/server.js
@@ -117,7 +117,15 @@ header .right{margin-left:auto;color:#7a7a82;font-size:12px;text-align:right}
 .banner .next{margin-left:auto;color:#7aa3bf;font-size:12px}
 header a{color:#9aa3b2;text-decoration:none;border-bottom:1px solid #2a2a31}
 header a:hover{color:#fff;border-color:#fff}
-.wrap{padding:32px 48px;display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:20px}
+.controls{padding:18px 48px 0;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
+.controls input,.controls select{background:#13131a;color:#e8e8ea;border:1px solid #2a2a31;padding:8px 12px;border-radius:8px;font-size:13px;font-family:inherit;outline:none}
+.controls input:focus,.controls select:focus{border-color:#3a3a47}
+.controls input[type=search]{flex:1;min-width:240px;max-width:480px}
+.controls label{font-size:12px;color:#7a7a82;display:flex;align-items:center;gap:8px}
+.controls .density{display:flex;align-items:center;gap:10px;color:#7a7a82;font-size:12px}
+.controls .density input[type=range]{accent-color:#0EA5E9;width:140px}
+.controls .count{margin-left:auto;color:#7a7a82;font-size:12px}
+.wrap{padding:18px 48px 32px;display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card-min,360px),1fr));gap:20px}
 .card{background:#13131a;border:1px solid #1f1f27;border-radius:14px;padding:22px;position:relative;overflow:hidden;transition:transform .15s ease,border-color .15s ease}
 .card:hover{border-color:#33333d;transform:translateY(-2px)}
 .card .swatch{position:absolute;top:0;left:0;right:0;height:5px}
@@ -167,11 +175,51 @@ footer{padding:20px 48px 40px;color:#5a5a62;font-size:12px;text-align:center}
   </span>
 </header>
 <div class="banner" id="banner" style="margin-top:18px"></div>
+<div class="controls">
+  <input type="search" id="q" placeholder="Search ideas — name, headline, target, action items, refinements…" />
+  <label>Sort
+    <select id="sort">
+      <option value="idx">Default (idx)</option>
+      <option value="name">Name A→Z</option>
+      <option value="score-desc">Total score ↓</option>
+      <option value="score-asc">Total score ↑</option>
+      <option value="novelty-desc">Novelty ↓</option>
+      <option value="fit-desc">Fit ↓</option>
+      <option value="feas-desc">Feasibility ↓</option>
+      <option value="impact-desc">Impact ↓</option>
+      <option value="ticks-desc">Most refined</option>
+      <option value="ticks-asc">Least refined</option>
+    </select>
+  </label>
+  <div class="density">Density
+    <input type="range" id="density" min="260" max="600" step="20" value="360" />
+    <span id="densityLbl">360px</span>
+  </div>
+  <span class="count" id="count"></span>
+</div>
 <div class="wrap" id="grid"></div>
 <footer>port 9773 · localhost only · source ~/.claude/skills/idea-loop/data/ideas.jsonl</footer>
 <script>
 const COLORS = ${JSON.stringify(COLORS)};
 let currentDate = null;
+let state = null;
+const LS = window.localStorage;
+const q = document.getElementById('q');
+const sortSel = document.getElementById('sort');
+const density = document.getElementById('density');
+const densityLbl = document.getElementById('densityLbl');
+q.value = LS.getItem('cmi.q') || '';
+sortSel.value = LS.getItem('cmi.sort') || 'idx';
+const dVal = LS.getItem('cmi.density') || '360';
+density.value = dVal; densityLbl.textContent = dVal+'px';
+document.documentElement.style.setProperty('--card-min', dVal+'px');
+q.addEventListener('input', () => { LS.setItem('cmi.q', q.value); render(); });
+sortSel.addEventListener('change', () => { LS.setItem('cmi.sort', sortSel.value); render(); });
+density.addEventListener('input', () => {
+  densityLbl.textContent = density.value+'px';
+  document.documentElement.style.setProperty('--card-min', density.value+'px');
+  LS.setItem('cmi.density', density.value);
+});
 async function loadBatches(){
   const r = await fetch('/api/batches'); const list = await r.json();
   const sel = document.getElementById('batchSel');
@@ -200,31 +248,70 @@ function renderBanner(list){
 async function load(){
   const url = currentDate ? '/api/state?date='+encodeURIComponent(currentDate) : '/api/state';
   const r = await fetch(url); const j = await r.json();
+  state = j;
+  render();
+}
+function render(){
+  if (!state) return;
   const grid = document.getElementById('grid'); grid.innerHTML = '';
-  const ideas = Object.values(j.byIdx).sort((a,b)=>a.idx-b.idx);
+  let ideas = Object.values(state.byIdx);
+  // Search across every meaningful field
+  const query = (q.value || '').trim().toLowerCase();
+  if (query){
+    ideas = ideas.filter(i => {
+      const hay = [
+        i.name, i.angle, i.target, i.diff, i.headline,
+        (i.action_items||[]).map(a => a.step + ' ' + a.what).join(' '),
+        i.ticks.map(t => (t.refinement||'') + ' ' + (t.new_headline||'') + ' ' + (t.verdict||'')).join(' '),
+        i.ticks.map(t => t.scores ? Object.entries(t.scores).map(([k,v])=>k+':'+v).join(' ') : '').join(' ')
+      ].join(' ').toLowerCase();
+      return hay.includes(query);
+    });
+  }
+  // Sort
+  const totalScore = i => { const s = i.ticks[0] && i.ticks[0].scores; return s ? (s.novelty||0)+(s.fit||0)+(s.feas||0)+(s.impact||0) : -1; };
+  const axisScore = (i,axis) => { const s = i.ticks[0] && i.ticks[0].scores; return s ? (s[axis]||0) : -1; };
+  switch (sortSel.value){
+    case 'name': ideas.sort((a,b)=>a.name.localeCompare(b.name)); break;
+    case 'score-desc': ideas.sort((a,b)=>totalScore(b)-totalScore(a) || a.idx-b.idx); break;
+    case 'score-asc': ideas.sort((a,b)=>totalScore(a)-totalScore(b) || a.idx-b.idx); break;
+    case 'novelty-desc': ideas.sort((a,b)=>axisScore(b,'novelty')-axisScore(a,'novelty') || a.idx-b.idx); break;
+    case 'fit-desc': ideas.sort((a,b)=>axisScore(b,'fit')-axisScore(a,'fit') || a.idx-b.idx); break;
+    case 'feas-desc': ideas.sort((a,b)=>axisScore(b,'feas')-axisScore(a,'feas') || a.idx-b.idx); break;
+    case 'impact-desc': ideas.sort((a,b)=>axisScore(b,'impact')-axisScore(a,'impact') || a.idx-b.idx); break;
+    case 'ticks-desc': ideas.sort((a,b)=>b.ticks.length-a.ticks.length || a.idx-b.idx); break;
+    case 'ticks-asc': ideas.sort((a,b)=>a.ticks.length-b.ticks.length || a.idx-b.idx); break;
+    default: ideas.sort((a,b)=>a.idx-b.idx);
+  }
+  document.getElementById('count').textContent = ideas.length + (query ? ' / ' + Object.keys(state.byIdx).length : '') + ' ideas';
   for (const i of ideas){
     const last = i.ticks[0];
-    const headline = (last && last.new_headline) || (last && last.replacement && last.replacement.headline) || i.headline_seed || '—';
+    const headline = (last && last.new_headline) || (last && last.replacement && last.replacement.headline) || i.headline || '—';
     const c = colorFor(i.name);
     const scores = last && last.scores ? last.scores : null;
-    const verdict = last ? last.verdict : '';
     const card = document.createElement('div');
     card.className = 'card';
     card.innerHTML = \`
       <div class="swatch" style="background:linear-gradient(90deg,\${c.c},\${c.a})"></div>
       <div class="idx">IDEA #\${String(i.idx).padStart(2,'0')}</div>
-      <h2>\${i.name}</h2>
-      <div class="headline">\${headline === '—' ? i.angle : escapeHtml(headline)}</div>
-      <div class="sub">\${escapeHtml(i.diff || '')}</div>
-      <div class="diff"><b style="color:#cfcfd5">target</b> · \${escapeHtml(i.target || '')}</div>
+      <h2>\${highlight(i.name, query)}</h2>
+      <div class="headline">\${highlight(headline === '—' ? i.angle : headline, query)}</div>
+      <div class="sub">\${highlight(i.diff || '', query)}</div>
+      <div class="diff"><b style="color:#cfcfd5">target</b> · \${highlight(i.target || '', query)}</div>
       \${scores ? renderScores(scores) : ''}
-      \${i.action_items && i.action_items.length ? renderActions(i.action_items) : ''}
-      \${i.ticks.length ? renderTicks(i.ticks) : '<div class="tick" style="color:#5a5a62">no refinement ticks yet</div>'}
+      \${i.action_items && i.action_items.length ? renderActions(i.action_items, query) : ''}
+      \${i.ticks.length ? renderTicks(i.ticks, query) : '<div class="tick" style="color:#5a5a62">no refinement ticks yet</div>'}
       <a class="figma-link" href="https://www.figma.com/design/1hHZQ0xggOqzVuPIB0rD3r?node-id=\${(i.node_id||'').replace(':','-')}" target="_blank">View frame in Figma ↗</a>
     \`;
     grid.appendChild(card);
   }
 }
+function highlight(text, q){
+  const safe = escapeHtml(String(text||''));
+  if (!q) return safe;
+  const re = new RegExp('('+q.replace(/[.*+?^${}()|[\\]\\\\]/g,'\\\\$&')+')','gi');
+  return safe.replace(re,'<mark style="background:rgba(234,179,8,.25);color:#fde68a;padding:1px 2px;border-radius:2px">$1</mark>');
+}
 function renderScores(s){
   const total = (s.novelty||0)+(s.fit||0)+(s.feas||0)+(s.impact||0);
   return \`<div class="scores">
@@ -235,17 +322,17 @@ function renderScores(s){
     <div class="score" style="background:#1c1c24"><b>\${total}</b><span>total/40</span></div>
   </div>\`;
 }
-function renderActions(items){
+function renderActions(items, query){
   return '<div class="actions"><h3>Action items</h3>' + items.map(a => \`
     <div class="action">
       <span class="step s-\${a.step}">\${a.step}</span>
-      <span class="what">\${escapeHtml(a.what)}</span>
+      <span class="what">\${highlight(a.what, query)}</span>
     </div>\`).join('') + '</div>';
 }
-function renderTicks(ts){
+function renderTicks(ts, query){
   return '<div class="ticks">' + ts.slice(0,4).map(t => \`
     <div class="tick">
-      <b>tick \${t.tick}</b> · \${escapeHtml(t.refinement || t.reason || '—')}
+      <b>tick \${t.tick}</b> · \${highlight(t.refinement || t.reason || '—', query)}
       <span class="verdict v-\${t.verdict || 'skip'}">\${t.verdict || 'skip'}</span>
     </div>\`).join('') + '</div>';
 }

← fbef505 viewer banner: today's batch + countdown to next 5 AM auto-b  ·  back to Contact Mailer Ideas  ·  universal chat + per-card actions: notes (autosave), verdict d6482c8 →