← back to Contact Mailer Ideas
chip-grid UI: collapsed name-only chips by default, click opens modal dialog with full card. Cards mode toggle preserved. Density tunable per mode
bd4f0060dc7190eefcb380675af27f37ec65824a · 2026-05-11 12:55:03 -0700 · Steve Abrams
Files touched
Diff
commit bd4f0060dc7190eefcb380675af27f37ec65824a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 11 12:55:03 2026 -0700
chip-grid UI: collapsed name-only chips by default, click opens modal dialog with full card. Cards mode toggle preserved. Density tunable per mode
---
viewer/server.js | 163 +++++++++++++++++++++++++++++++++++++++++++++----------
1 file changed, 134 insertions(+), 29 deletions(-)
diff --git a/viewer/server.js b/viewer/server.js
index 61a142b..23934a5 100644
--- a/viewer/server.js
+++ b/viewer/server.js
@@ -188,10 +188,30 @@ header a:hover{color:#fff;border-color:#fff}
.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}
+.viewmode{display:flex;gap:0;border:1px solid #2a2a31;border-radius:6px;overflow:hidden;font-size:11.5px}
+.viewmode button{background:#13131a;color:#7a7a82;border:none;padding:6px 10px;cursor:pointer;font-family:inherit}
+.viewmode button.active{background:#1f1f27;color:#fff}
.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)}
+.wrap{padding:18px 48px 32px;display:flex;flex-wrap:wrap;gap:10px;align-content:flex-start}
+.chip{background:#13131a;border:1px solid #1f1f27;border-radius:10px;padding:10px 14px;cursor:pointer;display:inline-flex;align-items:center;gap:10px;transition:all .12s ease;position:relative;overflow:hidden;min-width:calc(var(--chip-min,160px))}
+.chip:hover{border-color:#3a3a47;transform:translateY(-1px);background:#181820}
+.chip .chip-swatch{width:10px;height:10px;border-radius:50%;flex-shrink:0;box-shadow:0 0 0 2px rgba(255,255,255,.04)}
+.chip .chip-name{font-weight:600;font-size:13.5px;color:#fff;letter-spacing:-0.005em}
+.chip .chip-idx{font-size:10.5px;color:#6a6a72;letter-spacing:.1em;font-variant-numeric:tabular-nums}
+.chip .chip-score{font-size:10px;color:#7dd3fc;background:rgba(56,189,248,.12);padding:2px 6px;border-radius:4px;font-variant-numeric:tabular-nums}
+.chip .chip-mark{font-size:10px;padding:2px 6px;border-radius:4px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}
+.chip .chip-mark.priority{background:rgba(251,191,36,.18);color:#fde68a}
+.chip .chip-mark.kill{background:rgba(239,68,68,.18);color:#fca5a5}
+.chip .chip-mark.keep{background:rgba(34,197,94,.18);color:#86efac}
+.chip .chip-mark.sharpen{background:rgba(56,189,248,.18);color:#7dd3fc}
+.chip .chip-note{font-size:10px;color:#86efac;opacity:.7}
+.card{background:#13131a;border:1px solid #1f1f27;border-radius:14px;padding:22px;position:relative;overflow:hidden}
+/* dialog */
+dialog.idea-dialog{background:#0e0e13;color:#e8e8ea;border:1px solid #2a2a31;border-radius:14px;padding:0;max-width:780px;width:90vw;max-height:88vh;overflow:hidden;box-shadow:0 24px 64px rgba(0,0,0,.55)}
+dialog.idea-dialog::backdrop{background:rgba(0,0,0,.65);backdrop-filter:blur(4px)}
+dialog.idea-dialog .dlg-inner{max-height:88vh;overflow-y:auto;padding:28px 32px 24px}
+dialog.idea-dialog .dlg-close{position:absolute;top:14px;right:18px;background:transparent;border:none;color:#7a7a82;cursor:pointer;font-size:22px;line-height:1}
+dialog.idea-dialog .dlg-close:hover{color:#fff}
.card .swatch{position:absolute;top:0;left:0;right:0;height:5px}
.card .idx{font-size:11px;letter-spacing:.18em;color:#6a6a72;margin-bottom:4px}
.card h2{font-size:18px;font-weight:700;margin-bottom:14px;letter-spacing:-0.01em}
@@ -270,13 +290,21 @@ footer{padding:20px 48px 40px;color:#5a5a62;font-size:12px;text-align:center}
<option value="ticks-asc">Least refined</option>
</select>
</label>
+ <div class="viewmode">
+ <button data-mode="chips" id="modeChips">Chips</button>
+ <button data-mode="cards" id="modeCards">Cards</button>
+ </div>
<div class="density">Density
- <input type="range" id="density" min="260" max="600" step="20" value="360" />
- <span id="densityLbl">360px</span>
+ <input type="range" id="density" min="120" max="600" step="20" value="160" />
+ <span id="densityLbl">160px</span>
</div>
<span class="count" id="count"></span>
</div>
<div class="wrap" id="grid"></div>
+<dialog id="ideaDialog" class="idea-dialog">
+ <button class="dlg-close" onclick="document.getElementById('ideaDialog').close()">×</button>
+ <div class="dlg-inner" id="dlgInner"></div>
+</dialog>
<footer>port 9773 · localhost only · source ~/.claude/skills/idea-loop/data/ideas.jsonl</footer>
<script>
const COLORS = ${JSON.stringify(COLORS)};
@@ -287,17 +315,37 @@ const q = document.getElementById('q');
const sortSel = document.getElementById('sort');
const density = document.getElementById('density');
const densityLbl = document.getElementById('densityLbl');
+let viewMode = LS.getItem('cmi.mode') || 'chips';
q.value = LS.getItem('cmi.q') || '';
sortSel.value = LS.getItem('cmi.sort') || 'idx';
-const dVal = LS.getItem('cmi.density') || '360';
+const dVal = LS.getItem('cmi.density') || (viewMode === 'chips' ? '160' : '360');
density.value = dVal; densityLbl.textContent = dVal+'px';
+document.documentElement.style.setProperty('--chip-min', dVal+'px');
document.documentElement.style.setProperty('--card-min', dVal+'px');
+function setMode(m){
+ viewMode = m;
+ LS.setItem('cmi.mode', m);
+ document.getElementById('modeChips').classList.toggle('active', m === 'chips');
+ document.getElementById('modeCards').classList.toggle('active', m === 'cards');
+ // Adjust density defaults per mode
+ const def = m === 'chips' ? '160' : '360';
+ if (!LS.getItem('cmi.density_'+m)) density.value = def;
+ else density.value = LS.getItem('cmi.density_'+m);
+ densityLbl.textContent = density.value+'px';
+ document.documentElement.style.setProperty('--chip-min', density.value+'px');
+ document.documentElement.style.setProperty('--card-min', density.value+'px');
+ render();
+}
+document.getElementById('modeChips').onclick = () => setMode('chips');
+document.getElementById('modeCards').onclick = () => setMode('cards');
+setTimeout(() => setMode(viewMode), 0);
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('--chip-min', density.value+'px');
document.documentElement.style.setProperty('--card-min', density.value+'px');
- LS.setItem('cmi.density', density.value);
+ LS.setItem('cmi.density_'+viewMode, density.value);
});
async function loadBatches(){
const r = await fetch('/api/batches'); const list = await r.json();
@@ -363,31 +411,88 @@ function render(){
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 || '—';
- const c = colorFor(i.name);
- const scores = last && last.scores ? last.scores : null;
- 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>\${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) : ''}
- \${renderRowActions(i)}
- \${renderNote(i)}
- \${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);
+ if (viewMode === 'chips') {
+ for (const i of ideas) grid.appendChild(renderChip(i, query));
+ } else {
+ for (const i of ideas) grid.appendChild(renderCard(i, query));
+ bindCardEvents();
}
+}
+function lastVerdictForIdea(i){
+ const ticks = (i.ticks || []).slice().sort((a,b) => (b.tick||0) - (a.tick||0));
+ return ticks[0] && ticks[0].verdict;
+}
+function renderChip(i, query){
+ const c = colorFor(i.name);
+ const last = i.ticks[0];
+ const scores = last && last.scores;
+ const total = scores ? (scores.novelty||0)+(scores.fit||0)+(scores.feas||0)+(scores.impact||0) : null;
+ const verdict = lastVerdictForIdea(i);
+ const hasNote = state.notes && state.notes[i.idx];
+ const chip = document.createElement('div');
+ chip.className = 'chip';
+ chip.dataset.idx = i.idx;
+ chip.innerHTML = \`
+ <span class="chip-swatch" style="background:linear-gradient(135deg,\${c.c},\${c.a})"></span>
+ <span class="chip-idx">#\${String(i.idx).padStart(2,'0')}</span>
+ <span class="chip-name">\${highlight(i.name, query)}</span>
+ \${verdict ? '<span class="chip-mark ' + verdict + '">' + verdict + '</span>' : ''}
+ \${total != null ? '<span class="chip-score">' + total + '/40</span>' : ''}
+ \${hasNote ? '<span class="chip-note">●</span>' : ''}
+ \`;
+ chip.onclick = () => openDialog(i.idx);
+ return chip;
+}
+function renderCard(i, query){
+ const last = i.ticks[0];
+ 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 card = document.createElement('div');
+ card.className = 'card';
+ card.style.minWidth = (parseInt(document.documentElement.style.getPropertyValue('--card-min'))||360)+'px';
+ 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>\${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) : ''}
+ \${renderRowActions(i)}
+ \${renderNote(i)}
+ \${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>
+ \`;
+ return card;
+}
+function openDialog(idx){
+ const i = state.byIdx[idx];
+ if (!i) return;
+ const last = i.ticks[0];
+ 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 inner = document.getElementById('dlgInner');
+ inner.innerHTML = \`
+ <div style="height:6px;border-radius:3px;background:linear-gradient(90deg,\${c.c},\${c.a});margin-bottom:18px"></div>
+ <div class="idx">IDEA #\${String(i.idx).padStart(2,'0')}</div>
+ <h2 style="font-size:24px;font-weight:700;margin:4px 0 16px">\${escapeHtml(i.name)}</h2>
+ <div class="headline">\${escapeHtml(headline === '—' ? i.angle : headline)}</div>
+ <div class="sub">\${escapeHtml(i.diff || '')}</div>
+ <div class="diff"><b style="color:#cfcfd5">target</b> · \${escapeHtml(i.target || '')}</div>
+ \${scores ? renderScores(scores) : ''}
+ \${renderRowActions(i)}
+ \${renderNote(i)}
+ \${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>'}
+ <a class="figma-link" href="https://www.figma.com/design/1hHZQ0xggOqzVuPIB0rD3r?node-id=\${(i.node_id||'').replace(':','-')}" target="_blank">View frame in Figma ↗</a>
+ \`;
+ document.getElementById('ideaDialog').showModal();
bindCardEvents();
}
+window.openIdeaDialog = openDialog;
function renderRowActions(i){
return \`<div class="row-actions">
<button data-action="priority" data-idx="\${i.idx}" data-name="\${escapeHtml(i.name)}" class="priority">Priority</button>
← d6482c8 universal chat + per-card actions: notes (autosave), verdict
·
back to Contact Mailer Ideas
·
viewer: preserve scrollY across re-renders, signature-gated 946b12a →