← 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 →