[object Object]

← back to Wallco Ai

hot-or-not: filter broken images, always 6 thumbs, NOT modal with reason chips + notes

9cf27ca5f227e597c1ff027083facf794ae7b0c1 · 2026-05-13 11:16:06 -0700 · Steve Abrams

- /api/design/random now scans a pool of 30 and returns the first whose
  image file actually exists on disk (was ~50% broken)
- /api/hot-or-not/recent hydrates the 6-thumb row from prior votes per
  voter_session
- /api/hot-or-not/reasons merges top historic reasons with a 12-item
  default fallback
- POST /user-vote accepts reasons[] + note. If schema is migrated, writes
  to wallco_votes; if not, appends to logs/hon-reasons-queue.jsonl for
  later replay
- frontend always renders 6 history slots (dashed skeletons when empty),
  uses onerror to hide any image that does slip through
- NOT button opens a modal with reason chips + free-text note before
  firing the vote. Esc closes, Cmd/Ctrl+Enter submits
- sql/002_hot_or_not_reasons.sql is the prod migration — DRAFT, not yet
  applied

Files touched

Diff

commit 9cf27ca5f227e597c1ff027083facf794ae7b0c1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 11:16:06 2026 -0700

    hot-or-not: filter broken images, always 6 thumbs, NOT modal with reason chips + notes
    
    - /api/design/random now scans a pool of 30 and returns the first whose
      image file actually exists on disk (was ~50% broken)
    - /api/hot-or-not/recent hydrates the 6-thumb row from prior votes per
      voter_session
    - /api/hot-or-not/reasons merges top historic reasons with a 12-item
      default fallback
    - POST /user-vote accepts reasons[] + note. If schema is migrated, writes
      to wallco_votes; if not, appends to logs/hon-reasons-queue.jsonl for
      later replay
    - frontend always renders 6 history slots (dashed skeletons when empty),
      uses onerror to hide any image that does slip through
    - NOT button opens a modal with reason chips + free-text note before
      firing the vote. Esc closes, Cmd/Ctrl+Enter submits
    - sql/002_hot_or_not_reasons.sql is the prod migration — DRAFT, not yet
      applied
---
 server.js                      | 332 +++++++++++++++++++++++++++++++++++++----
 sql/002_hot_or_not_reasons.sql |  22 +++
 2 files changed, 323 insertions(+), 31 deletions(-)

diff --git a/server.js b/server.js
index dd23078..0cc7fe1 100644
--- a/server.js
+++ b/server.js
@@ -11015,21 +11015,68 @@ function voterSession(req, res) {
 
 app.post('/api/design/:id/user-vote', (req, res) => {
   const id = parseInt(req.params.id, 10);
-  const s = parseInt((req.body || {}).score, 10);
-  const quick = !!(req.body || {}).quick;
+  const body = req.body || {};
+  const s = parseInt(body.score, 10);
+  const quick = !!body.quick;
   if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
   if (!Number.isFinite(s) || s < 1 || s > 5) return res.status(400).json({ error: 'score 1..5 required' });
+
+  // Reasons + note are NOT-only metadata — but accept them on any score for forward-compat.
+  // Reasons: array of short strings, cap at 12. Note: free text, cap at 2 KB.
+  const reasons = Array.isArray(body.reasons)
+    ? body.reasons.map(r => String(r || '').trim()).filter(Boolean).slice(0, 12)
+    : [];
+  const note = (typeof body.note === 'string') ? body.note.trim().slice(0, 2048) : '';
+
   const session = voterSession(req, res);
+  const sqlEsc = (v) => String(v).replace(/'/g, "''");
   try {
-    psqlQuery(`INSERT INTO wallco_votes (design_id, voter_session, score, quick)
-      VALUES (${id}, '${session}', ${s}, ${quick ? 'TRUE' : 'FALSE'})
-      ON CONFLICT (design_id, voter_session) DO UPDATE SET score=EXCLUDED.score, created_at=now();`);
+    // Detect once whether the schema migration has been applied. Cached on first call.
+    if (typeof app.locals._hon_cols_ready === 'undefined') {
+      app.locals._hon_cols_ready = !!psqlQuery(
+        `SELECT 1 FROM information_schema.columns WHERE table_name='wallco_votes' AND column_name='reasons' LIMIT 1;`
+      );
+    }
+    const colsReady = app.locals._hon_cols_ready;
+
+    if (colsReady) {
+      const reasonsSql = reasons.length
+        ? `ARRAY[${reasons.map(r => `'${sqlEsc(r)}'`).join(',')}]::text[]`
+        : `NULL`;
+      const noteSql = note ? `'${sqlEsc(note)}'` : `NULL`;
+      psqlQuery(`INSERT INTO wallco_votes (design_id, voter_session, score, quick, reasons, note)
+        VALUES (${id}, '${sqlEsc(session)}', ${s}, ${quick ? 'TRUE' : 'FALSE'}, ${reasonsSql}, ${noteSql})
+        ON CONFLICT (design_id, voter_session) DO UPDATE SET
+          score=EXCLUDED.score, reasons=EXCLUDED.reasons, note=EXCLUDED.note, created_at=now();`);
+    } else {
+      // Graceful degrade: persist the vote, log reasons/note to disk for later replay.
+      psqlQuery(`INSERT INTO wallco_votes (design_id, voter_session, score, quick)
+        VALUES (${id}, '${sqlEsc(session)}', ${s}, ${quick ? 'TRUE' : 'FALSE'})
+        ON CONFLICT (design_id, voter_session) DO UPDATE SET score=EXCLUDED.score, created_at=now();`);
+      if (reasons.length || note) {
+        try {
+          const queue = path.join(__dirname, 'logs', 'hon-reasons-queue.jsonl');
+          fs.mkdirSync(path.dirname(queue), { recursive: true });
+          fs.appendFileSync(queue, JSON.stringify({
+            ts: new Date().toISOString(),
+            design_id: id, voter_session: session, score: s, reasons, note
+          }) + '\n');
+        } catch (qe) { /* non-fatal */ }
+      }
+    }
+
     const agg = JSON.parse(psqlQuery(`SELECT row_to_json(t) FROM (SELECT
       ROUND(AVG(score)::numeric, 2)::text AS avg, COUNT(*)::int AS cnt
       FROM wallco_votes WHERE design_id=${id}) t;`));
     psqlQuery(`UPDATE spoon_all_designs SET user_score_avg=${agg.avg || 'NULL'},
       user_vote_count=${agg.cnt} WHERE id=${id};`);
-    res.json({ ok: true, user_score_avg: parseFloat(agg.avg), user_vote_count: agg.cnt });
+    res.json({
+      ok: true,
+      user_score_avg: parseFloat(agg.avg),
+      user_vote_count: agg.cnt,
+      reasons_persisted: colsReady,
+      reasons_queued: !colsReady && (reasons.length > 0 || !!note)
+    });
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
@@ -11291,27 +11338,116 @@ ${HAMBURGER_JS}
 </html>`);
 });
 
-// ── GET /api/design/random — random rate-able design for hot-or-not + swipe
+// ── GET /api/design/random — random rate-able design for hot-or-not + swipe.
+// Filters to designs whose image file actually exists on disk so the UI never
+// renders a broken-image card. Resolves the basename of local_path against
+// IMG_DIR; if it's not there, skip and try again. Bounded retries so the
+// loop can't run away if the entire table is broken.
 app.get('/api/design/random', (req, res) => {
   const excl = String(req.query.exclude || '').split(',').map(x => parseInt(x,10)).filter(Number.isFinite);
-  const exclSql = excl.length ? `AND id NOT IN (${excl.join(',')})` : '';
+  const exclSet = new Set(excl);
   try {
-    const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, dominant_hex, category, local_path
+    // Pull a small candidate pool, then filter by file existence client-side.
+    const exclSql = excl.length ? `AND id NOT IN (${excl.join(',')})` : '';
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (SELECT id, dominant_hex, category, local_path
       FROM spoon_all_designs WHERE local_path IS NOT NULL ${exclSql}
-      ORDER BY random() LIMIT 1) t;`);
-    if (!raw) return res.status(404).json({});
-    const d = JSON.parse(raw);
-    const fn = (d.local_path || '').split('/').pop();
-    res.json({
-      id: d.id,
-      title: titleFor(d.category, d.dominant_hex, d.id),
-      image_url: `/designs/img/${fn}`,
-      category: d.category,
-      dominant_hex: d.dominant_hex
-    });
+      ORDER BY random() LIMIT 30) t;`);
+    const pool = JSON.parse(raw || '[]');
+    for (const d of pool) {
+      if (exclSet.has(d.id)) continue;
+      const fn = (d.local_path || '').split('/').pop();
+      if (!fn) continue;
+      const abs = path.join(IMG_DIR, fn);
+      try { if (!fs.existsSync(abs)) continue; } catch { continue; }
+      return res.json({
+        id: d.id,
+        title: titleFor(d.category, d.dominant_hex, d.id),
+        image_url: `/designs/img/${fn}`,
+        category: d.category,
+        dominant_hex: d.dominant_hex
+      });
+    }
+    return res.status(404).json({ error: 'no renderable design found in pool of 30' });
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// ── Hot-or-Not: per-session recent picks + historic rejection-reason chips.
+// Recent picks are derived from wallco_votes (last 6 by voter_session). Skip
+// rows whose image file no longer exists. Always returns up to 6 — the UI
+// pads to 6 slots with skeletons if fewer come back.
+app.get('/api/hot-or-not/recent', (req, res) => {
+  const session = voterSession(req, res);
+  try {
+    const limit = Math.max(1, Math.min(parseInt(req.query.limit || '6', 10), 24));
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+      SELECT v.design_id AS id, v.score, v.created_at,
+             d.local_path, d.dominant_hex, d.category
+      FROM wallco_votes v
+      JOIN spoon_all_designs d ON d.id = v.design_id
+      WHERE v.voter_session = '${session.replace(/'/g, "''")}'
+        AND d.local_path IS NOT NULL
+      ORDER BY v.created_at DESC LIMIT ${limit * 3}
+    ) t;`);
+    const rows = JSON.parse(raw || '[]');
+    const out = [];
+    for (const r of rows) {
+      if (out.length >= limit) break;
+      const fn = (r.local_path || '').split('/').pop();
+      if (!fn) continue;
+      try { if (!fs.existsSync(path.join(IMG_DIR, fn))) continue; } catch { continue; }
+      out.push({
+        id: r.id,
+        score: r.score,
+        action: r.score >= 4 ? 'hot' : r.score <= 2 ? 'not' : 'skip',
+        title: titleFor(r.category, r.dominant_hex, r.id),
+        image_url: `/designs/img/${fn}`,
+        created_at: r.created_at
+      });
+    }
+    res.json({ picks: out });
+  } catch (e) { res.status(500).json({ error: e.message, picks: [] }); }
+});
+
+// Default rejection reasons — used when the historic top-N is too thin OR the
+// reasons column hasn't been migrated yet. Kept short + scannable. The
+// dynamic-from-history layer merges these with whatever's most-clicked.
+const HON_DEFAULT_REASONS = [
+  'Colors clash',
+  'Pattern feels AI-generated / artifacts',
+  'Too busy',
+  'Too plain',
+  'Wrong scale for a wall',
+  "Doesn't tile cleanly",
+  'Cliché / overdone motif',
+  'Off-trend / dated',
+  'Resolution looks low',
+  'Wrong palette for me',
+  'Composition feels off',
+  'Tags / category seem wrong'
+];
+
+app.get('/api/hot-or-not/reasons', (_req, res) => {
+  try {
+    // If the column exists, prefer the live top-N (mixed with defaults to
+    // backfill). If not, fall through to the defaults silently.
+    const colExists = psqlQuery(`SELECT 1 FROM information_schema.columns WHERE table_name='wallco_votes' AND column_name='reasons' LIMIT 1;`);
+    if (colExists) {
+      const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+        SELECT unnest(reasons) AS reason, COUNT(*) AS n
+        FROM wallco_votes WHERE reasons IS NOT NULL AND array_length(reasons,1) > 0
+        GROUP BY 1 ORDER BY n DESC LIMIT 12
+      ) t;`);
+      const live = JSON.parse(raw || '[]').map(r => r.reason).filter(Boolean);
+      const seen = new Set(live.map(s => s.toLowerCase()));
+      const merged = live.concat(HON_DEFAULT_REASONS.filter(d => !seen.has(d.toLowerCase()))).slice(0, 12);
+      return res.json({ reasons: merged, source: live.length ? 'historic+defaults' : 'defaults' });
+    }
+    res.json({ reasons: HON_DEFAULT_REASONS, source: 'defaults' });
+  } catch (e) {
+    res.json({ reasons: HON_DEFAULT_REASONS, source: 'defaults-fallback', error: e.message });
+  }
+});
+
 // ── /hot-or-not — rapid-fire HOT/NOT/SKIP single-card
 app.get('/hot-or-not', (_req, res) => {
   res.type('html').send(`${htmlHead({
@@ -11346,6 +11482,26 @@ ${htmlHeader('')}
     <div id="hon-history" style="display:grid;grid-template-columns:repeat(6,1fr);gap:6px"></div>
   </div>
 </main>
+
+<!-- NOT modal — reason checkboxes (top-N historic merged w/ defaults) + free-text note -->
+<div id="hon-not-modal" role="dialog" aria-modal="true" aria-labelledby="hon-not-title" style="display:none;position:fixed;inset:0;background:rgba(15,14,12,.78);z-index:9999;align-items:center;justify-content:center;padding:18px;backdrop-filter:blur(4px)">
+  <div style="background:#fbfaf6;color:#1a1a1a;max-width:480px;width:100%;border-radius:14px;box-shadow:0 28px 80px rgba(0,0,0,.5);overflow:hidden">
+    <div style="padding:18px 22px;border-bottom:1px solid #e6e2d8;display:flex;justify-content:space-between;align-items:center">
+      <h2 id="hon-not-title" style="margin:0;font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px">Why not?</h2>
+      <button id="hon-not-cancel" aria-label="Close" style="background:transparent;border:0;font-size:22px;cursor:pointer;color:#888;line-height:1;padding:4px 8px">×</button>
+    </div>
+    <div style="padding:14px 22px 6px;font-size:12px;color:#666">Pick any that apply. Add a note if you want to teach the system.</div>
+    <div id="hon-not-reasons" style="padding:8px 22px 4px;display:grid;grid-template-columns:1fr 1fr;gap:6px 14px;font-size:13px"></div>
+    <div style="padding:8px 22px 4px">
+      <label style="font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em">Note for the LLM <span style="text-transform:none;color:#aaa">— optional</span></label>
+      <textarea id="hon-not-note" rows="3" placeholder="e.g., the scale is too tight for a master bedroom; pattern feels stamped, not drawn" style="width:100%;margin-top:6px;padding:10px 12px;border:1px solid #d8d2c5;border-radius:8px;font:13px/1.4 system-ui, sans-serif;background:#fff;resize:vertical;box-sizing:border-box"></textarea>
+    </div>
+    <div style="padding:14px 22px 18px;display:flex;justify-content:flex-end;gap:8px">
+      <button id="hon-not-skip" style="padding:10px 16px;border:1px solid #d8d2c5;background:#fff;border-radius:8px;cursor:pointer;font-size:13px;color:#666">Skip — just NOT</button>
+      <button id="hon-not-submit" style="padding:10px 18px;border:0;background:#c44;color:#fff;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600;letter-spacing:.04em">Submit NOT</button>
+    </div>
+  </div>
+</div>
 ${FOOTER}
 <style>
   .hon-btn { padding:18px 12px; font-size:14px; font-weight:600; letter-spacing:.1em; border:0; border-radius:10px; cursor:pointer; transition:transform .12s, background .15s; }
@@ -11358,10 +11514,16 @@ ${FOOTER}
   #hon-card.swipe-left { transform:translateX(-120%) rotate(-14deg); opacity:0 }
   #hon-card.swipe-right { transform:translateX(120%) rotate(14deg); opacity:0 }
   #hon-card.swipe-up { transform:translateY(-120%); opacity:0 }
-  .hist-card img { width:100%; aspect-ratio:1; object-fit:cover; display:block; border-radius:4px; border:2px solid transparent }
-  .hist-card.hot img  { border-color:#d2b15c }
-  .hist-card.not img  { border-color:#c44 }
-  .hist-card.skip img { border-color:#888 }
+  .hist-card { display:block; aspect-ratio:1; border-radius:5px; overflow:hidden; border:2px solid transparent; background:#f4f1ea }
+  .hist-card.hot  { border-color:#d2b15c }
+  .hist-card.not  { border-color:#c44 }
+  .hist-card.skip { border-color:#888 }
+  .hist-card.empty { border:1px dashed #d8d2c5; background:repeating-linear-gradient(135deg,#f4f1ea 0 8px,#ede9de 8px 16px) }
+  .hist-card img { width:100%; height:100%; object-fit:cover; display:block }
+  .hon-reason-chip { display:flex; align-items:center; gap:8px; padding:7px 10px; border:1px solid #e6e2d8; border-radius:8px; cursor:pointer; background:#fff; transition:background .12s, border-color .12s; user-select:none }
+  .hon-reason-chip:hover { background:#faf6ec }
+  .hon-reason-chip.checked { background:#fde6e2; border-color:#e8aaa5 }
+  .hon-reason-chip input { margin:0 }
 </style>
 <script>
 var seen = [], picks = [];  /* named picks because window.history shadows a global */
@@ -11370,6 +11532,37 @@ var todayKey = 'wc-day-' + new Date().toISOString().slice(0,10);
 var todayCount = parseInt(localStorage.getItem(todayKey) || '0', 10);
 function renderStreak(){ document.getElementById('streak').textContent = '🔥 ' + streak + ' in a row · ' + todayCount + ' votes today'; }
 renderStreak();
+
+/* Always show 6 thumb slots. Empty slots are dashed placeholders. */
+function renderHistory(){
+  var grid = document.getElementById('hon-history');
+  var html = '';
+  for (var i = 0; i < 6; i++) {
+    var h = picks[i];
+    if (h) {
+      html += '<a class="hist-card '+h.action+'" href="/design/'+h.id+'" title="'+h.title.replace(/"/g,'&quot;')+'">'
+            + '<img src="'+h.image_url+'" alt="'+h.title.replace(/"/g,'&quot;')+'" onerror="this.parentNode.classList.add(\\'empty\\');this.remove()">'
+            + '</a>';
+    } else {
+      html += '<span class="hist-card empty" aria-label="empty slot"></span>';
+    }
+  }
+  grid.innerHTML = html;
+}
+renderHistory();
+
+/* Hydrate from server-side history so the row is always populated. */
+(async function hydrateRecent(){
+  try {
+    var r = await (await fetch('/api/hot-or-not/recent?limit=6')).json();
+    if (r && Array.isArray(r.picks) && r.picks.length) {
+      picks = r.picks.map(function(p){ return { id:p.id, action:p.action, title:p.title || ('Design #'+p.id), image_url:p.image_url }; });
+      r.picks.forEach(function(p){ if (!seen.includes(p.id)) seen.push(p.id); });
+      renderHistory();
+    }
+  } catch(e) { /* non-fatal */ }
+})();
+
 async function load(){
   var url = '/api/design/random' + (seen.length ? '?exclude=' + seen.slice(-30).join(',') : '');
   var r = await (await fetch(url)).json();
@@ -11386,16 +11579,60 @@ async function load(){
   document.getElementById('hon-card').dataset.hex = r.dominant_hex || '';
   seen.push(r.id);
 }
+
+/* ---------- NOT modal ---------- */
+var honReasonsCache = null;
+async function ensureReasons(){
+  if (honReasonsCache) return honReasonsCache;
+  try {
+    var r = await (await fetch('/api/hot-or-not/reasons')).json();
+    honReasonsCache = (r && Array.isArray(r.reasons)) ? r.reasons : [];
+  } catch(e) { honReasonsCache = []; }
+  return honReasonsCache;
+}
+
+async function openNotModal(){
+  var reasons = await ensureReasons();
+  var box = document.getElementById('hon-not-reasons');
+  box.innerHTML = reasons.map(function(r, i){
+    var v = r.replace(/"/g, '&quot;');
+    return '<label class="hon-reason-chip" data-reason="'+v+'"><input type="checkbox" value="'+v+'">'+r+'</label>';
+  }).join('');
+  /* toggle .checked on chip when its input flips */
+  box.querySelectorAll('.hon-reason-chip').forEach(function(chip){
+    var input = chip.querySelector('input');
+    chip.addEventListener('click', function(e){
+      if (e.target !== input) { input.checked = !input.checked; }
+      chip.classList.toggle('checked', input.checked);
+    });
+  });
+  document.getElementById('hon-not-note').value = '';
+  var m = document.getElementById('hon-not-modal');
+  m.style.display = 'flex';
+  setTimeout(function(){ document.getElementById('hon-not-note').focus(); }, 60);
+}
+function closeNotModal(){ document.getElementById('hon-not-modal').style.display = 'none'; }
+
+function collectModalPayload(){
+  var reasons = [].slice.call(document.querySelectorAll('#hon-not-reasons input:checked')).map(function(i){ return i.value; });
+  var note = (document.getElementById('hon-not-note').value || '').trim();
+  return { reasons: reasons, note: note };
+}
+
 async function act(action, evt){
   var card = document.getElementById('hon-card');
   var id = parseInt(card.dataset.id, 10);
   if (!id) return;
-  var anim = action==='hot' ? 'swipe-right' : action==='not' ? 'swipe-left' : 'swipe-up';
+
+  /* NOT opens the reason modal — actual vote fires from the modal's submit/skip. */
+  if (action === 'not') { window._honPendingId = id; openNotModal(); return; }
+
+  var anim = action==='hot' ? 'swipe-right' : 'swipe-up';
   card.classList.add(anim);
-  if (action === 'hot' || action === 'not') {
-    var score = action === 'hot' ? 5 : 1;
+  if (action === 'hot') {
+    var score = 5;
     fetch('/api/design/' + id + '/user-vote', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({score: score, quick: true}) });
-    streak = (action === 'hot') ? streak + 1 : 0;
+    streak = streak + 1;
     localStorage.setItem('wc-streak', String(streak));
   }
   todayCount++; localStorage.setItem(todayKey, String(todayCount));
@@ -11403,13 +11640,46 @@ async function act(action, evt){
   if (window.WC) WC.reward(action, evt, {id: id, category: card.dataset.category, dominant_hex: card.dataset.hex});
   picks.unshift({id: id, action: action, title: card.dataset.title, image_url: card.dataset.image});
   picks = picks.slice(0, 6);
-  document.getElementById('hon-history').innerHTML = picks.map(function(h){
-    return '<a class="hist-card '+h.action+'" href="/design/'+h.id+'" title="'+h.title+'"><img src="'+h.image_url+'" alt="'+h.title+'"></a>';
-  }).join('');
+  renderHistory();
+  setTimeout(load, 280);
+}
+
+/* NOT-with-reasons path: invoked from the modal. Mirrors act('not') flow
+   but carries reasons + note to the server. */
+function submitNot(reasons, note, evt){
+  var id = window._honPendingId;
+  if (!id) { closeNotModal(); return; }
+  window._honPendingId = null;
+  var card = document.getElementById('hon-card');
+  card.classList.add('swipe-left');
+  fetch('/api/design/' + id + '/user-vote', {
+    method:'POST', headers:{'Content-Type':'application/json'},
+    body: JSON.stringify({ score: 1, quick: false, reasons: reasons || [], note: note || '' })
+  });
+  streak = 0; localStorage.setItem('wc-streak', String(streak));
+  todayCount++; localStorage.setItem(todayKey, String(todayCount));
+  renderStreak();
+  if (window.WC) WC.reward('not', evt, {id: id, category: card.dataset.category, dominant_hex: card.dataset.hex});
+  picks.unshift({ id: id, action: 'not', title: card.dataset.title, image_url: card.dataset.image });
+  picks = picks.slice(0, 6);
+  renderHistory();
+  closeNotModal();
   setTimeout(load, 280);
 }
+
 document.querySelectorAll('.hon-btn').forEach(function(b){ b.addEventListener('click', function(evt){ act(b.dataset.action, evt); }); });
+document.getElementById('hon-not-cancel').addEventListener('click', closeNotModal);
+document.getElementById('hon-not-skip').addEventListener('click', function(evt){ submitNot([], '', evt); });
+document.getElementById('hon-not-submit').addEventListener('click', function(evt){ var p = collectModalPayload(); submitNot(p.reasons, p.note, evt); });
+document.getElementById('hon-not-modal').addEventListener('click', function(e){ if (e.target === this) closeNotModal(); });
+
 document.addEventListener('keydown', function(e){
+  var modalOpen = document.getElementById('hon-not-modal').style.display === 'flex';
+  if (modalOpen) {
+    if (e.key === 'Escape') { e.preventDefault(); closeNotModal(); }
+    else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); var p = collectModalPayload(); submitNot(p.reasons, p.note); }
+    return;
+  }
   if (e.key === 'ArrowLeft') act('not');
   else if (e.key === 'ArrowRight') act('hot');
   else if (e.key === ' ' || e.key === 'ArrowUp') { e.preventDefault(); act('skip'); }
diff --git a/sql/002_hot_or_not_reasons.sql b/sql/002_hot_or_not_reasons.sql
new file mode 100644
index 0000000..8afc21a
--- /dev/null
+++ b/sql/002_hot_or_not_reasons.sql
@@ -0,0 +1,22 @@
+-- Hot-or-Not reasons + notes + image-file existence flag.
+-- Apply on Kamatera prod with: sudo -u postgres psql -d dw_unified -f 002_hot_or_not_reasons.sql
+-- DRAFT — not yet applied to prod as of 2026-05-13. Steve must approve.
+
+ALTER TABLE wallco_votes
+  ADD COLUMN IF NOT EXISTS reasons TEXT[],
+  ADD COLUMN IF NOT EXISTS note TEXT;
+
+ALTER TABLE spoon_all_designs
+  ADD COLUMN IF NOT EXISTS image_file_exists BOOLEAN;
+
+CREATE INDEX IF NOT EXISTS idx_wallco_votes_reasons
+  ON wallco_votes USING GIN (reasons);
+
+CREATE INDEX IF NOT EXISTS idx_wallco_votes_session_created
+  ON wallco_votes (voter_session, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_spoon_image_file_exists
+  ON spoon_all_designs (image_file_exists) WHERE image_file_exists IS TRUE;
+
+GRANT SELECT, INSERT, UPDATE, DELETE ON wallco_votes TO dw_admin;
+GRANT UPDATE ON spoon_all_designs TO dw_admin;

← bfb6108 snapshot: include gemini-2.5-flash-image-edit generator (10  ·  back to Wallco Ai  ·  designs: NEW badge on cards created in last 72h 96a4bc0 →