← back to Contact Mailer Ideas
viewer/server.js
835 lines
const express = require('express');
const fs = require('fs');
const path = require('path');
const app = express();
const PORT = parseInt(process.env.PORT, 10) || 9773;
const BATCH = process.env.BATCH || 'contact-mailer-cc-01';
const IDEAS_JSON = process.env.IDEAS_JSON || '/Users/macstudio3/Projects/contact-mailer-ideas/ideas.json';
const IDEAS_JSONL = process.env.IDEAS_JSONL || '/Users/macstudio3/.claude/skills/idea-loop/data/ideas.jsonl';
const BATCHES_DIR = process.env.BATCHES_DIR || '/Users/macstudio3/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' },
VoiceMailer: { c: '#A855F7', a: '#6B21A8' },
PostNeighbor: { c: '#22C55E', a: '#15803D' },
OneSendNoApp: { c: '#F59E0B', a: '#B45309' },
WallcoverDM: { c: '#0F766E', a: '#134E4A' },
AftermarketCC: { c: '#DC2626', a: '#7F1D1D' },
LeaveBehind: { c: '#EA580C', a: '#7C2D12' },
PaidPress: { c: '#1E40AF', a: '#1E3A8A' },
FormForward: { c: '#9333EA', a: '#581C87' },
SunsetList: { c: '#0891B2', a: '#155E75' },
DearPast: { c: '#4338CA', a: '#3730A3' },
InboxStudio: { c: '#DB2777', a: '#9D174D' },
EmailDM: { c: '#475569', a: '#1E293B' },
NewsletterDating: { c: '#EF4444', a: '#991B1B' },
HandwrittenMailer: { c: '#1E3A8A', a: '#0F172A' },
BackChannel: { c: '#52525B', a: '#18181B' },
MailerFork: { c: '#10B981', a: '#047857' },
GhostList: { c: '#7C3AED', a: '#4C1D95' },
TippableNewsletter: { c: '#EAB308', a: '#854D0E' },
InboxHeartbeat: { c: '#FB7185', a: '#BE123C' }
};
function listBatches() {
const batches = [];
try {
for (const f of fs.readdirSync(BATCHES_DIR).sort().reverse()) {
if (!f.endsWith('.json')) continue;
try {
const doc = JSON.parse(fs.readFileSync(path.join(BATCHES_DIR, f), 'utf8'));
batches.push({
file: f,
date: doc.batch_date || f.replace(/\.json$/, '').slice(0,10),
batch: doc.batch || f.replace(/\.json$/, ''),
count: (doc.ideas || []).length,
generator: doc.generator || 'manual'
});
} catch (e) {}
}
} catch (e) {}
return batches;
}
function loadBatch(dateOrBatch) {
const batches = listBatches();
// Match by batch slug first (more specific when multiple batches share a date), then by date
let target = batches.find(b => b.batch === dateOrBatch)
|| batches.find(b => b.date === dateOrBatch)
|| batches[0];
if (!target) return { meta: {}, byIdx: {}, allTicks: [], batches: [] };
const doc = JSON.parse(fs.readFileSync(path.join(BATCHES_DIR, target.file), 'utf8'));
const ideas = doc.ideas || [];
let lines = [];
try { lines = fs.readFileSync(IDEAS_JSONL, 'utf8').split('\n').filter(Boolean); } catch (e) {}
const ticks = lines
.map(l => { try { return JSON.parse(l); } catch (e) { return null; } })
.filter(t => t && t.batch === doc.batch);
const byIdx = {};
for (const idea of ideas) byIdx[idea.idx] = { ...idea, ticks: [] };
for (const t of ticks) {
if (t.tick === 0) continue;
if (byIdx[t.idx]) byIdx[t.idx].ticks.push(t);
}
for (const k in byIdx) byIdx[k].ticks.sort((a,b) => b.tick - a.tick);
return { meta: doc, byIdx, allTicks: ticks.sort((a,b) => (b.tick||0)-(a.tick||0)), batches, selected: target };
}
function loadIdeas() {
// Default: latest batch from batches/. Fallback to legacy ideas.json if no batches.
const batches = listBatches();
if (batches.length) return loadBatch(batches[0].date);
const json = JSON.parse(fs.readFileSync(IDEAS_JSON, 'utf8'));
const ideas = json.ideas;
let lines = [];
try { lines = fs.readFileSync(IDEAS_JSONL, 'utf8').split('\n').filter(Boolean); } catch (e) {}
const ticks = lines.map(l => { try { return JSON.parse(l); } catch (e) { return null; } }).filter(t => t && t.batch === BATCH);
const byIdx = {};
for (const idea of ideas) byIdx[idea.idx] = { ...idea, ticks: [] };
for (const t of ticks) {
if (t.tick === 0) continue;
if (byIdx[t.idx]) byIdx[t.idx].ticks.push(t);
}
for (const k in byIdx) byIdx[k].ticks.sort((a,b) => b.tick - a.tick);
return { meta: json, byIdx, allTicks: ticks.sort((a,b) => (b.tick||0)-(a.tick||0)), batches: [], selected: null };
}
app.get('/api/state', (req, res) => {
const selector = req.query.batch || req.query.date;
const data = selector ? loadBatch(selector) : 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>
<meta charset="utf-8">
<title>Contact Mailer — 10 Ideas (yolo loop)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:#0b0b0d;color:#e8e8ea;font-family:-apple-system,BlinkMacSystemFont,'Inter',system-ui,sans-serif;font-size:14px;line-height:1.5}
header{padding:32px 48px 16px;display:flex;align-items:baseline;gap:24px;border-bottom:1px solid #1a1a1f}
header h1{font-size:22px;font-weight:700;letter-spacing:-0.01em}
header .meta{color:#7a7a82;font-size:13px}
header .right{margin-left:auto;color:#7a7a82;font-size:12px;text-align:right}
.banner{margin:0 48px;padding:14px 18px;background:linear-gradient(90deg,#0d2738,#0e1f30);border:1px solid #1c3a52;border-radius:10px;display:flex;gap:18px;align-items:center;font-size:13px;color:#bcd5e5}
.banner .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;box-shadow:0 0 8px #22c55e;flex-shrink:0}
.banner b{color:#fff;font-weight:600}
.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}
.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}
.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: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.tier-top{background:linear-gradient(135deg,rgba(34,197,94,.13),rgba(34,197,94,.04));border-color:rgba(34,197,94,.45);box-shadow:0 0 0 1px rgba(34,197,94,.08)}
.chip.tier-top:hover{border-color:rgba(34,197,94,.7);background:linear-gradient(135deg,rgba(34,197,94,.18),rgba(34,197,94,.06))}
.chip.tier-top .chip-name{color:#bbf7d0}
.chip.tier-top .chip-score{background:rgba(34,197,94,.22);color:#86efac}
.chip.tier-top::after{content:"●";position:absolute;top:6px;right:8px;color:#22c55e;font-size:8px;text-shadow:0 0 6px rgba(34,197,94,.6)}
.chip.tier-strong{border-color:rgba(56,189,248,.35)}
.chip.tier-strong .chip-name{color:#bae6fd}
.chip.tier-weak{opacity:.55;border-color:#1c1c22}
.chip.tier-weak .chip-name{color:#9a9aa2}
.chip.tier-weak .chip-score{background:rgba(239,68,68,.12);color:#fca5a5}
.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}
.card .headline{font-size:17px;font-weight:600;line-height:1.3;margin-bottom:10px;color:#fff}
.card .sub{color:#a1a1aa;font-size:13.5px;line-height:1.55;margin-bottom:14px}
.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}
.action .step{flex-shrink:0;font-size:9.5px;font-weight:700;letter-spacing:.08em;padding:3px 7px;border-radius:4px;height:fit-content;margin-top:1px}
.s-VALIDATE{background:rgba(56,189,248,.15);color:#7dd3fc}
.s-MVP{background:rgba(34,197,94,.15);color:#86efac}
.s-DISTRIBUTION{background:rgba(251,191,36,.15);color:#fde68a}
.action .what{color:#bcbcc4;flex:1}
.scores{display:flex;gap:6px;margin-bottom:14px}
.score{flex:1;font-size:11px;background:#0d0d12;padding:6px 8px;border-radius:6px;text-align:center}
.score b{display:block;font-size:14px;color:#fff;font-weight:700}
.score span{color:#6a6a72;letter-spacing:.05em;text-transform:uppercase;font-size:9.5px}
.ticks{margin-top:14px;border-top:1px dashed #2a2a31;padding-top:12px;max-height:120px;overflow-y:auto}
.tick{font-size:11.5px;color:#9090a0;margin-bottom:6px;line-height:1.4}
.tick b{color:#cfcfd5}
.verdict{display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;letter-spacing:.05em;text-transform:uppercase;font-weight:700;margin-left:6px}
.v-sharpen{background:rgba(34,197,94,.15);color:#86efac}
.v-keep{background:rgba(56,189,248,.15);color:#7dd3fc}
.v-kill{background:rgba(239,68,68,.15);color:#fca5a5}
.v-skip{background:rgba(168,168,168,.15);color:#bbb}
.figma-link{display:inline-flex;align-items:center;gap:4px;color:#9aa3b2;font-size:11.5px;text-decoration:none;margin-top:10px}
.figma-link:hover{color:#fff}
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>
<span class="right">
<label style="margin-right:14px;color:#7a7a82;font-size:12px">batch
<select id="batchSel" style="background:#13131a;color:#e8e8ea;border:1px solid #2a2a31;padding:5px 8px;border-radius:6px;font-size:12px;margin-left:6px"></select>
</label>
<a href="https://www.figma.com/design/1hHZQ0xggOqzVuPIB0rD3r" target="_blank">Figma file ↗</a>
·
<span class="refresh" id="rfx">auto-refresh 15s</span>
</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="viewmode">
<button data-mode="chips" id="modeChips">Chips</button>
<button data-mode="cards" id="modeCards">Cards</button>
</div>
<label>Tier
<select id="tier">
<option value="all">All ideas</option>
<option value="opps">Opportunities only (30+)</option>
<option value="top">🟢 Top tier (31+)</option>
<option value="weak">Weak (under 28)</option>
</select>
</label>
<div class="density">Density
<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)};
// Parse URL params for deep-link to a specific batch or date
const _urlParams = new URLSearchParams(window.location.search);
let currentDate = _urlParams.get('batch') || _urlParams.get('date') || 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');
let viewMode = LS.getItem('cmi.mode') || 'chips';
const tierSel = document.getElementById('tier');
tierSel.value = LS.getItem('cmi.tier') || 'all';
tierSel.addEventListener('change', () => { LS.setItem('cmi.tier', tierSel.value); render(); });
q.value = LS.getItem('cmi.q') || '';
sortSel.value = LS.getItem('cmi.sort') || 'idx';
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_'+viewMode, density.value);
});
async function loadBatches(){
const r = await fetch('/api/batches'); const list = await r.json();
const sel = document.getElementById('batchSel');
// Use batch slug as the value (unique) instead of date (which can collide)
sel.innerHTML = list.map(b => '<option value="'+b.batch+'">'+b.date+' · '+b.batch+' · '+b.count+' ideas'+(b.generator && b.generator!=='manual'?' (auto)':'')+'</option>').join('');
// Map currentDate to a matching batch if user passed a date
if (currentDate) {
const direct = list.find(b => b.batch === currentDate);
if (!direct) {
const byDate = list.find(b => b.date === currentDate);
if (byDate) currentDate = byDate.batch;
}
}
if (!currentDate && list.length) currentDate = list[0].batch;
sel.value = currentDate || '';
sel.onchange = () => {
currentDate = sel.value;
// Update URL so it's deep-linkable
const u = new URL(window.location);
u.searchParams.set('batch', currentDate);
window.history.replaceState({}, '', u);
load();
};
renderBanner(list);
}
function renderBanner(list){
const banner = document.getElementById('banner');
// Batches are stamped with PT date (date +%Y-%m-%d on Mac2 in LA), so compare
// in LA time — using UTC would show "No batch yet" every evening after 5pm PT
// when UTC ticks over to the next day. (2026-05-24 fix.)
const today = new Intl.DateTimeFormat('en-CA',{timeZone:'America/Los_Angeles'}).format(new Date());
const todays = list.find(b => b.date === today);
// Compute next 5am PT
const now = new Date();
const fire = new Date(now);
fire.setHours(5,0,0,0);
if (fire <= now) fire.setDate(fire.getDate()+1);
const hrs = Math.floor((fire - now)/3600000);
const mins = Math.floor(((fire - now) % 3600000)/60000);
const status = todays
? '<b>Today (' + today + ')</b> · ' + todays.count + ' ideas · ' + (todays.generator.includes('claude') ? 'auto-generated 5:00 AM PT' : 'manual seed batch')
: '<b>No batch yet for today</b> · daily generator fires at 5:00 AM PT';
banner.innerHTML = '<span class="dot"></span><span>' + status + '</span><span class="next">Next auto-batch: tomorrow 5 AM PT (in ' + hrs + 'h ' + mins + 'm)</span>';
}
let stateSig = '';
async function load(){
const url = currentDate ? '/api/state?batch='+encodeURIComponent(currentDate) : '/api/state';
const r = await fetch(url); const j = await r.json();
// Compare a cheap signature so background refreshes don't disturb DOM/scroll when nothing changed
const sig = JSON.stringify({
sel: j.selected && j.selected.date,
count: Object.keys(j.byIdx || {}).length,
ticks: (j.allTicks || []).length,
notes: Object.keys(j.notes || {}).join(',')
});
state = j;
if (sig === stateSig) return;
stateSig = sig;
render();
}
function render(){
if (!state) return;
// Preserve scroll position across re-renders
const sy = window.scrollY;
const grid = document.getElementById('grid'); grid.innerHTML = '';
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);
});
}
// Resolve latest-known score from any tick that has a scores block (pass-1 or pass-2 or pass-4 rescore)
const latestScored = i => {
const sorted = (i.ticks||[]).slice().sort((a,b) => (b.tick||0) - (a.tick||0));
for (const t of sorted) if (t.scores) return t;
return null;
};
const totalScore = i => { const t = latestScored(i); if (!t) return -1; const s = t.scores; return (s.novelty||0)+(s.fit||0)+(s.feas||0)+(s.impact||0); };
const axisScore = (i,axis) => { const t = latestScored(i); return t ? (t.scores[axis]||0) : -1; };
// Tier filter
switch (tierSel.value){
case 'opps': ideas = ideas.filter(i => totalScore(i) >= 30); break;
case 'top': ideas = ideas.filter(i => totalScore(i) >= 31); break;
case 'weak': ideas = ideas.filter(i => { const s = totalScore(i); return s > 0 && s < 28; }); break;
}
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';
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();
}
// Restore scroll
requestAnimationFrame(() => window.scrollTo({ top: sy, behavior: 'instant' }));
}
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 scoredTick = (function(){
for (const t of (i.ticks||[]).slice().sort((a,b)=>(b.tick||0)-(a.tick||0))) if (t.scores) return t;
return null;
})();
const scores = scoredTick && scoredTick.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];
// Use display name from latest tick if renamed (e.g., FormForward → CompliantSend)
const displayName = (scoredTick && scoredTick.name && scoredTick.name !== i.name) ? scoredTick.name : i.name;
// Tier coloring
let tier = '';
if (total != null){
if (total >= 31) tier = 'tier-top';
else if (total >= 28) tier = 'tier-strong';
else tier = 'tier-weak';
}
const chip = document.createElement('div');
chip.className = 'chip ' + tier;
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(displayName, 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 sortedTicks = (i.ticks||[]).slice().sort((a,b)=>(b.tick||0)-(a.tick||0));
const scoredTick = sortedTicks.find(t => t.scores) || null;
const lastHeadlineTick = sortedTicks.find(t => t.new_headline) || null;
const headline = (lastHeadlineTick && lastHeadlineTick.new_headline) || (sortedTicks[0] && sortedTicks[0].replacement && sortedTicks[0].replacement.headline) || i.headline || '—';
const displayName = (scoredTick && scoredTick.name && scoredTick.name !== i.name) ? scoredTick.name : i.name;
const c = colorFor(i.name);
const scores = scoredTick ? scoredTick.scores : null;
const total = scores ? (scores.novelty||0)+(scores.fit||0)+(scores.feas||0)+(scores.impact||0) : null;
let tierBadge = '';
if (total != null){
if (total >= 31) tierBadge = '<span style="background:rgba(34,197,94,.18);color:#86efac;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;margin-left:10px">Top opportunity · '+total+'/40</span>';
else if (total >= 28) tierBadge = '<span style="background:rgba(56,189,248,.15);color:#7dd3fc;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;margin-left:10px">Strong · '+total+'/40</span>';
else tierBadge = '<span style="background:rgba(239,68,68,.12);color:#fca5a5;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;margin-left:10px">Weak · '+total+'/40</span>';
}
// Find pass-3+ artifacts
const artifacts = sortedTicks.filter(t => t.artifact && t.artifact_type).reverse();
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;display:flex;align-items:center;flex-wrap:wrap">\${escapeHtml(displayName)}\${tierBadge}</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, '') : ''}
\${artifacts.length ? renderArtifacts(artifacts) : ''}
\${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();
}
function renderArtifacts(arts){
const labels = {
swot:'⚖️ SWOT + Competitor Landscape',
marketing:'📣 Go-to-market + MCPs Needed',
pitch:'🎤 Investor pitch',
coldlines:'📧 Cold-email openers',
competitors:'🥊 Competitor matrix',
spike:'🧱 MVP architecture',
pricing:'💰 Pricing'
};
// Reorder: swot + marketing first (since they're synthesis), then everything else
const priority = ['swot','marketing','pitch','spike','pricing','competitors','coldlines'];
arts = [...arts].sort((a,b) => (priority.indexOf(a.artifact_type) - priority.indexOf(b.artifact_type)));
return '<div style="margin-top:20px;border-top:1px solid #1f1f27;padding-top:18px">' +
'<h3 style="font-size:11px;letter-spacing:.15em;color:#7a7a82;margin-bottom:14px;text-transform:uppercase">Opportunity assets</h3>' +
arts.map((a, ix) => {
const body = renderArtifactBody(a);
return \`
<details style="background:#0d0d12;border:1px solid #1c1c22;border-radius:8px;padding:12px 14px;margin-bottom:10px" \${ix === 0 ? 'open' : ''}>
<summary style="cursor:pointer;font-size:13px;font-weight:600;color:#cfcfd5;outline:none">\${labels[a.artifact_type] || a.artifact_type} <span style="color:#5a5a62;font-weight:400;font-size:11px;margin-left:8px">tick \${a.tick}</span></summary>
<div style="margin-top:12px">\${body}</div>
</details>
\`;
}).join('') + '</div>';
}
function renderArtifactBody(a){
if (a.artifact_type === 'swot' && a.swot) {
const s = a.swot;
const tile = (label, color, text) => \`<div style="background:\${color}1a;border-left:3px solid \${color};padding:10px 12px;border-radius:6px;margin-bottom:8px"><div style="font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:\${color};margin-bottom:4px">\${label}</div><div style="font-size:13px;color:#d4d4d8;line-height:1.5">\${escapeHtml(text)}</div></div>\`;
let html = '';
html += tile('Strengths', '#22c55e', s.strengths || '—');
html += tile('Weaknesses', '#ef4444', s.weaknesses || '—');
html += tile('Opportunities', '#0EA5E9', s.opportunities || '—');
html += tile('Threats', '#f59e0b', s.threats || '—');
if (s.competitors && s.competitors.length){
html += '<div style="margin-top:6px"><div style="font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#a78bfa;margin-bottom:8px">Competitor Landscape</div>';
html += '<div style="display:flex;flex-wrap:wrap;gap:6px">';
html += s.competitors.map(c => \`<span style="background:#1c1c24;border:1px solid #2a2a31;color:#cfcfd5;padding:5px 10px;border-radius:6px;font-size:12px">\${escapeHtml(c)}</span>\`).join('');
html += '</div></div>';
}
return html;
}
if (a.artifact_type === 'marketing' && a.marketing) {
const m = a.marketing;
const mcps = a.mcps_needed || [];
const row = (label, color, text) => \`<div style="margin-bottom:10px"><div style="font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:\${color};margin-bottom:4px">\${label}</div><div style="font-size:13px;color:#d4d4d8;line-height:1.5">\${escapeHtml(text)}</div></div>\`;
let html = '';
html += row('Channel', '#0EA5E9', m.channel || '—');
html += row('Hook', '#a78bfa', m.hook || '—');
html += row('Repeat / network effect', '#22c55e', m.repeat || '—');
if (mcps.length){
html += '<div style="margin-top:14px;padding-top:12px;border-top:1px dashed #2a2a31"><div style="font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:#fb923c;margin-bottom:8px">MCPs / Skills / APIs needed</div>';
html += '<div style="display:flex;flex-wrap:wrap;gap:6px">';
html += mcps.map(c => \`<span style="background:#2a1a0a;border:1px solid #7c2d12;color:#fed7aa;padding:5px 10px;border-radius:6px;font-size:11.5px;font-family:'SF Mono',Menlo,monospace">\${escapeHtml(c)}</span>\`).join('');
html += '</div></div>';
}
return html;
}
// Default: pre-formatted text
return \`<pre style="white-space:pre-wrap;font-family:'SF Mono',Menlo,monospace;font-size:12.5px;line-height:1.55;color:#bcbcc4;margin:0">\${escapeHtml(a.artifact)}</pre>\`;
}
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>
<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');
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">
<div class="score"><b>\${s.novelty||'-'}</b><span>novelty</span></div>
<div class="score"><b>\${s.fit||'-'}</b><span>fit</span></div>
<div class="score"><b>\${s.feas||'-'}</b><span>feas</span></div>
<div class="score"><b>\${s.impact||'-'}</b><span>impact</span></div>
<div class="score" style="background:#1c1c24"><b>\${total}</b><span>total/40</span></div>
</div>\`;
}
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">\${highlight(a.what, query)}</span>
</div>\`).join('') + '</div>';
}
function renderTicks(ts, query){
return '<div class="ticks">' + ts.slice(0,4).map(t => \`
<div class="tick">
<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>';
}
function escapeHtml(s){return String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
function colorFor(name){
if (COLORS[name]) return COLORS[name];
// Deterministic fallback for newly-generated ideas without a preset palette
let h = 0;
for (let i=0;i<name.length;i++) h = (h*31 + name.charCodeAt(i)) & 0xffff;
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');
let chatDebounce = null;
universalChat.addEventListener('input', () => {
const v = universalChat.value;
if (v.startsWith('/')) return;
// Debounce so render doesn't fire on every keystroke
clearTimeout(chatDebounce);
chatDebounce = setTimeout(() => {
q.value = v;
LS.setItem('cmi.q', v);
render();
}, 150);
});
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(() => {
// Don't disturb the page while the user is typing, has the dialog open, or is editing a note
const dlg = document.getElementById('ideaDialog');
if (dlg && dlg.open) return;
const ae = document.activeElement;
if (ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT')) return;
loadBatches(); load();
}, 15000);
</script>
</body></html>`);
});
app.listen(PORT, '127.0.0.1', () => console.log(`contact-mailer-ideas-viewer http://127.0.0.1:${PORT}`));