[object Object]

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