← back to Contact Mailer Ideas
universal chat + per-card actions: notes (autosave), verdict logging (priority/keep/sharpen/kill), slash commands (/note /kill /keep /sharpen /priority /open /figma), copy-as-markdown
d6482c8ec4abac2963db14c72c82800b77028b75 · 2026-05-11 12:53:51 -0700 · Steve Abrams
Files touched
Diff
commit d6482c8ec4abac2963db14c72c82800b77028b75
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon May 11 12:53:51 2026 -0700
universal chat + per-card actions: notes (autosave), verdict logging (priority/keep/sharpen/kill), slash commands (/note /kill /keep /sharpen /priority /open /figma), copy-as-markdown
---
viewer/server.js | 213 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 211 insertions(+), 2 deletions(-)
diff --git a/viewer/server.js b/viewer/server.js
index 8b8ec0a..61a142b 100644
--- a/viewer/server.js
+++ b/viewer/server.js
@@ -8,6 +8,20 @@ const BATCH = process.env.BATCH || 'contact-mailer-cc-01';
const IDEAS_JSON = process.env.IDEAS_JSON || '/Users/stevestudio2/Projects/contact-mailer-ideas/ideas.json';
const IDEAS_JSONL = process.env.IDEAS_JSONL || '/Users/stevestudio2/.claude/skills/idea-loop/data/ideas.jsonl';
const BATCHES_DIR = process.env.BATCHES_DIR || '/Users/stevestudio2/Projects/contact-mailer-ideas/batches';
+const NOTES_PATH = process.env.NOTES_PATH || path.join(BATCHES_DIR, '..', 'notes.json');
+const ACTIONS_LOG = process.env.ACTIONS_LOG || path.join(BATCHES_DIR, '..', 'actions.jsonl');
+
+app.use(express.json({ limit: '256kb' }));
+
+function readNotes() {
+ try { return JSON.parse(fs.readFileSync(NOTES_PATH, 'utf8')); } catch (e) { return {}; }
+}
+function writeNotes(n) {
+ fs.writeFileSync(NOTES_PATH, JSON.stringify(n, null, 2));
+}
+function logAction(entry) {
+ fs.appendFileSync(ACTIONS_LOG, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
+}
const COLORS = {
AddressAI: { c: '#0EA5E9', a: '#0369A1' },
@@ -94,10 +108,54 @@ function loadIdeas() {
app.get('/api/state', (req, res) => {
const date = req.query.date;
- res.json(date ? loadBatch(date) : loadIdeas());
+ const data = date ? loadBatch(date) : loadIdeas();
+ data.notes = readNotes()[data.selected && data.selected.batch] || {};
+ res.json(data);
});
app.get('/api/batches', (req, res) => res.json(listBatches()));
+app.get('/api/notes', (req, res) => {
+ const batch = req.query.batch;
+ const all = readNotes();
+ res.json(batch ? (all[batch] || {}) : all);
+});
+app.post('/api/note', (req, res) => {
+ const { batch, idx, text } = req.body || {};
+ if (!batch || idx == null) return res.status(400).json({ error: 'batch + idx required' });
+ const all = readNotes();
+ all[batch] = all[batch] || {};
+ if (text && text.trim()) {
+ all[batch][idx] = { text: String(text).slice(0, 4000), updated_at: new Date().toISOString() };
+ } else {
+ delete all[batch][idx];
+ }
+ writeNotes(all);
+ logAction({ kind: 'note', batch, idx, len: (text || '').length });
+ res.json({ ok: true });
+});
+app.post('/api/verdict', (req, res) => {
+ const { batch, idx, name, verdict, reason } = req.body || {};
+ if (!batch || idx == null || !verdict) return res.status(400).json({ error: 'batch + idx + verdict required' });
+ const allowed = ['keep', 'sharpen', 'kill', 'priority', 'skip'];
+ if (!allowed.includes(verdict)) return res.status(400).json({ error: 'verdict must be one of ' + allowed.join(',') });
+ const line = JSON.stringify({
+ batch, idx: parseInt(idx, 10), name: name || '',
+ tick: -1, // manual verdict, distinguish from loop ticks
+ source: 'viewer-manual',
+ verdict, refinement: reason || '(manual viewer click)',
+ ts: new Date().toISOString()
+ }) + '\n';
+ fs.appendFileSync(IDEAS_JSONL, line);
+ logAction({ kind: 'verdict', batch, idx, verdict, reason });
+ res.json({ ok: true });
+});
+app.get('/api/actions', (req, res) => {
+ try {
+ const lines = fs.readFileSync(ACTIONS_LOG, 'utf8').split('\n').filter(Boolean).slice(-50);
+ res.json(lines.map(l => { try { return JSON.parse(l); } catch (e) { return null; }}).filter(Boolean).reverse());
+ } catch (e) { res.json([]); }
+});
+
app.get('/', (req, res) => {
res.type('html').send(`<!doctype html>
<html lang="en"><head>
@@ -117,6 +175,12 @@ 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}
+.chatbar{position:sticky;top:0;z-index:10;background:rgba(11,11,13,.94);backdrop-filter:blur(10px);border-bottom:1px solid #1a1a1f;padding:14px 48px;display:flex;gap:14px;align-items:center}
+.chatbar input{flex:1;background:#13131a;color:#e8e8ea;border:1px solid #2a2a31;padding:10px 14px;border-radius:8px;font-size:14px;outline:none;font-family:inherit}
+.chatbar input:focus{border-color:#3a3a47}
+.chatbar .hint{font-size:11px;color:#5a5a62;letter-spacing:.05em}
+.chatbar .toast{font-size:12px;color:#86efac;opacity:0;transition:opacity .3s ease}
+.chatbar .toast.show{opacity:1}
.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}
@@ -136,6 +200,16 @@ header a:hover{color:#fff;border-color:#fff}
.card .diff{font-size:12.5px;color:#cfcfd5;margin-bottom:14px;padding:10px 12px;background:#0d0d12;border-radius:8px;border-left:3px solid #444}
.card .meta{display:flex;gap:14px;font-size:11.5px;color:#7a7a82;flex-wrap:wrap}
.card .meta b{color:#cfcfd5;font-weight:600}
+.row-actions{margin-top:14px;display:flex;gap:6px;flex-wrap:wrap}
+.row-actions button{background:#1a1a22;color:#bcbcc4;border:1px solid #2a2a31;padding:5px 10px;border-radius:6px;font-size:11.5px;cursor:pointer;font-family:inherit;transition:all .12s ease}
+.row-actions button:hover{background:#22222b;color:#fff;border-color:#3a3a47}
+.row-actions button.danger:hover{border-color:#7f1d1d;color:#fca5a5}
+.row-actions button.priority:hover{border-color:#854d0e;color:#fde68a}
+.note-area{margin-top:12px}
+.note-area textarea{width:100%;background:#0d0d12;color:#e8e8ea;border:1px solid #2a2a31;border-radius:6px;padding:8px 10px;font-family:inherit;font-size:12.5px;line-height:1.5;resize:vertical;min-height:60px;outline:none}
+.note-area textarea:focus{border-color:#3a3a47}
+.note-area .saved{font-size:10.5px;color:#5a5a62;margin-top:4px;display:flex;justify-content:space-between}
+.note-area .saved .ok{color:#86efac}
.actions{margin-top:14px;border-top:1px solid #1f1f27;padding-top:14px}
.actions h3{font-size:11px;letter-spacing:.15em;color:#7a7a82;margin-bottom:10px;text-transform:uppercase}
.action{display:flex;gap:10px;margin-bottom:10px;font-size:12.5px;line-height:1.5}
@@ -162,6 +236,11 @@ footer{padding:20px 48px 40px;color:#5a5a62;font-size:12px;text-align:center}
.refresh{font-size:11px;color:#5a5a62;margin-left:auto}
</style></head>
<body>
+<div class="chatbar">
+ <input type="text" id="universalChat" placeholder="Universal chat — search any field, or type a slash command /help" autocomplete="off" />
+ <span class="hint">try: /help · /note 5 important · /kill 12 · /priority 3 · /open 7 · /figma 9</span>
+ <span class="toast" id="chatToast"></span>
+</div>
<header>
<h1>Contact Mailer · 10 ideas</h1>
<span class="meta">batch <code style="color:#9aa3b2">contact-mailer-cc-01</code> · ideate-only loop · refines one idea every ~30 min</span>
@@ -299,17 +378,98 @@ function render(){
<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);
}
+ bindCardEvents();
+}
+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>
+ <button data-action="keep" data-idx="\${i.idx}" data-name="\${escapeHtml(i.name)}">Keep</button>
+ <button data-action="sharpen" data-idx="\${i.idx}" data-name="\${escapeHtml(i.name)}">Sharpen</button>
+ <button data-action="kill" data-idx="\${i.idx}" data-name="\${escapeHtml(i.name)}" class="danger">Kill</button>
+ <button data-action="copy" data-idx="\${i.idx}">Copy markdown</button>
+ </div>\`;
+}
+function renderNote(i){
+ const existing = (state && state.notes && state.notes[i.idx]) || {};
+ return \`<div class="note-area">
+ <textarea data-idx="\${i.idx}" placeholder="Notes on \${escapeHtml(i.name)} — auto-saves on blur">\${escapeHtml(existing.text || '')}</textarea>
+ <div class="saved"><span class="saved-status">\${existing.updated_at ? 'saved · ' + new Date(existing.updated_at).toLocaleString() : 'no notes yet'}</span></div>
+ </div>\`;
+}
+function bindCardEvents(){
+ document.querySelectorAll('.row-actions button').forEach(b => b.onclick = onActionClick);
+ document.querySelectorAll('.note-area textarea').forEach(t => {
+ t.onblur = onNoteBlur;
+ t.onkeydown = e => { if ((e.ctrlKey||e.metaKey) && e.key === 'Enter') t.blur(); };
+ });
+}
+async function onActionClick(e){
+ const btn = e.currentTarget;
+ const action = btn.dataset.action;
+ const idx = parseInt(btn.dataset.idx, 10);
+ const name = btn.dataset.name;
+ const idea = state.byIdx[idx];
+ if (action === 'copy'){
+ const md = ideaToMarkdown(idea);
+ await navigator.clipboard.writeText(md);
+ return toast('Copied · ' + idea.name);
+ }
+ const verdict = action;
+ const reason = prompt('Reason for marking #' + idx + ' (' + name + ') as ' + verdict + '?', '');
+ if (reason === null) return;
+ const r = await fetch('/api/verdict', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ batch: state.selected && state.selected.batch, idx, name, verdict, reason }) });
+ if (r.ok) { toast(verdict + ' logged · #' + idx); load(); }
+ else toast('failed', true);
+}
+async function onNoteBlur(e){
+ const ta = e.currentTarget;
+ const idx = parseInt(ta.dataset.idx, 10);
+ const text = ta.value;
+ const status = ta.parentElement.querySelector('.saved-status');
+ const r = await fetch('/api/note', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ batch: state.selected && state.selected.batch, idx, text }) });
+ if (r.ok) { status.innerHTML = '<span class="ok">saved · just now</span>'; }
+ else status.textContent = 'save failed';
+}
+function ideaToMarkdown(i){
+ const last = i.ticks[0];
+ const headline = (last && last.new_headline) || i.headline || '';
+ const s = last && last.scores ? last.scores : null;
+ return [
+ '# ' + i.name,
+ '> ' + headline,
+ '',
+ '**Target:** ' + (i.target || '—'),
+ '**Diff:** ' + (i.diff || '—'),
+ '**Angle:** ' + (i.angle || '—'),
+ s ? '**Scores:** novelty=' + s.novelty + ' fit=' + s.fit + ' feas=' + s.feas + ' impact=' + s.impact : '',
+ '',
+ '## Action items',
+ ...(i.action_items || []).map(a => '- **' + a.step + '** · ' + a.what),
+ '',
+ '## Refinement ticks',
+ ...(i.ticks || []).map(t => '- tick ' + t.tick + ' [' + (t.verdict || 'skip') + '] ' + (t.refinement || t.reason || ''))
+ ].filter(Boolean).join('\\n');
+}
+function toast(msg, isErr){
+ const t = document.getElementById('chatToast');
+ t.textContent = msg;
+ t.style.color = isErr ? '#fca5a5' : '#86efac';
+ t.classList.add('show');
+ clearTimeout(window._toastT);
+ window._toastT = setTimeout(() => t.classList.remove('show'), 2400);
}
function highlight(text, q){
const safe = escapeHtml(String(text||''));
if (!q) return safe;
- const re = new RegExp('('+q.replace(/[.*+?^${}()|[\\]\\\\]/g,'\\\\$&')+')','gi');
+ 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){
@@ -345,6 +505,55 @@ function colorFor(name){
const hue = h % 360;
return { c: 'hsl('+hue+',70%,55%)', a: 'hsl('+hue+',70%,30%)' };
}
+// Universal chat — search + slash commands
+const universalChat = document.getElementById('universalChat');
+universalChat.addEventListener('input', () => {
+ const v = universalChat.value;
+ if (!v.startsWith('/')) {
+ // Mirror into search box
+ q.value = v;
+ LS.setItem('cmi.q', v);
+ render();
+ }
+});
+universalChat.addEventListener('keydown', async e => {
+ if (e.key !== 'Enter') return;
+ const v = universalChat.value.trim();
+ if (!v.startsWith('/')) return;
+ e.preventDefault();
+ const [cmd, ...rest] = v.slice(1).split(/\s+/);
+ const arg = rest.join(' ');
+ if (cmd === 'help') {
+ toast('Commands: /note N text · /kill N · /keep N · /sharpen N · /priority N · /open N · /figma N · /clear');
+ return;
+ }
+ if (cmd === 'clear') { universalChat.value=''; q.value=''; LS.setItem('cmi.q',''); render(); return toast('cleared'); }
+ const m = /^(\d+)\b\s*(.*)$/.exec(arg);
+ if (!m) return toast('usage: /' + cmd + ' <idx> [text]', true);
+ const idx = parseInt(m[1], 10);
+ const restText = m[2];
+ const idea = state && state.byIdx[idx];
+ if (!idea) return toast('no idea #' + idx, true);
+ if (cmd === 'note') {
+ const r = await fetch('/api/note', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ batch: state.selected && state.selected.batch, idx, text: restText }) });
+ if (r.ok) { universalChat.value=''; await load(); toast('note saved · #' + idx); }
+ else toast('save failed', true);
+ return;
+ }
+ if (['kill','keep','sharpen','priority'].includes(cmd)) {
+ const r = await fetch('/api/verdict', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ batch: state.selected && state.selected.batch, idx, name: idea.name, verdict: cmd, reason: restText || '(via /' + cmd + ')' }) });
+ if (r.ok) { universalChat.value=''; await load(); toast(cmd + ' logged · #' + idx); }
+ else toast('failed', true);
+ return;
+ }
+ if (cmd === 'open' || cmd === 'figma') {
+ const url = 'https://www.figma.com/design/1hHZQ0xggOqzVuPIB0rD3r?node-id=' + (idea.node_id||'').replace(':','-');
+ window.open(url, '_blank');
+ universalChat.value='';
+ return toast('opened Figma · #' + idx);
+ }
+ toast('unknown command /' + cmd, true);
+});
loadBatches().then(load);
setInterval(() => { loadBatches(); load(); }, 15000);
</script>
← bd6d8c4 viewer: search across all fields (name/headline/target/actio
·
back to Contact Mailer Ideas
·
chip-grid UI: collapsed name-only chips by default, click op bd4f006 →