[object Object]

← back to Wallco Ai

feat(admin): side-by-side prompt-vs-image A/B rating surface

2070bb064a2f09777f5543f7770fca63bf5bae1e · 2026-06-01 12:36:31 -0700 · Steve

- New admin page /admin/prompt-vs-image: each row shows the rendered IMAGE
  (left) beside its real generation PROMPT (right, from spoon_all_designs.prompt
  — fully populated for all 4937 published designs) so Steve can rate how well
  the image matches the intent. 1-5 match stars + Matches/Off-prompt verdict.
- Distinct 'prompt_match' axis (existing user_color/style/scale/stars don't
  capture image-vs-prompt fidelity). Persisted to append-only ledger
  data/prompt-match-ratings.jsonl + in-memory last-write-wins map — NO prod
  schema change (a user_prompt_match column is the right long-term home but
  that's Steve-gated; ledger backfills it in one line later).
- /api/admin/prompt-rate/list + POST /api/admin/prompt-rate/:id, both behind
  isAdmin (src/admin-gate). Rating writes NEVER touch is_published.
- Reuses design-curator dark token system; cards show created date+time via
  toLocaleString with full ISO in title= (standing rule). Density + filter
  persist to localStorage. Added admin-nav link.

Files touched

Diff

commit 2070bb064a2f09777f5543f7770fca63bf5bae1e
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 1 12:36:31 2026 -0700

    feat(admin): side-by-side prompt-vs-image A/B rating surface
    
    - New admin page /admin/prompt-vs-image: each row shows the rendered IMAGE
      (left) beside its real generation PROMPT (right, from spoon_all_designs.prompt
      — fully populated for all 4937 published designs) so Steve can rate how well
      the image matches the intent. 1-5 match stars + Matches/Off-prompt verdict.
    - Distinct 'prompt_match' axis (existing user_color/style/scale/stars don't
      capture image-vs-prompt fidelity). Persisted to append-only ledger
      data/prompt-match-ratings.jsonl + in-memory last-write-wins map — NO prod
      schema change (a user_prompt_match column is the right long-term home but
      that's Steve-gated; ledger backfills it in one line later).
    - /api/admin/prompt-rate/list + POST /api/admin/prompt-rate/:id, both behind
      isAdmin (src/admin-gate). Rating writes NEVER touch is_published.
    - Reuses design-curator dark token system; cards show created date+time via
      toLocaleString with full ISO in title= (standing rule). Density + filter
      persist to localStorage. Added admin-nav link.
---
 public/admin/prompt-vs-image.html | 267 ++++++++++++++++++++++++++++++++++++++
 server.js                         | 127 ++++++++++++++++++
 2 files changed, 394 insertions(+)

diff --git a/public/admin/prompt-vs-image.html b/public/admin/prompt-vs-image.html
new file mode 100644
index 0000000..92a7fdf
--- /dev/null
+++ b/public/admin/prompt-vs-image.html
@@ -0,0 +1,267 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Prompt vs Image — wallco.ai admin</title>
+<style>
+  /* Reuse the design-curator dark token system so this surface matches the
+     existing admin design language. */
+  :root { --cols:2; --bg:#0e0f10; --panel:#17191b; --line:#272b2e; --ink:#e9ecef;
+          --mut:#9aa3ab; --accent:#7bc96f; --good:#2e7d32; --bad:#a13030; --gold:#b5891a; }
+  * { box-sizing:border-box; }
+  body { margin:0; font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+         background:var(--bg); color:var(--ink); }
+  header { position:sticky; top:0; z-index:20; background:rgba(14,15,16,.96); backdrop-filter:blur(8px);
+           border-bottom:1px solid var(--line); padding:10px 16px; }
+  .row { display:flex; align-items:center; gap:14px; flex-wrap:wrap; }
+  h1 { font-size:16px; margin:0; font-weight:650; letter-spacing:.2px; }
+  h1 .leaf { color:var(--accent); }
+  .sub { color:var(--mut); font-size:12px; }
+  a.back { color:var(--mut); text-decoration:none; font-size:12px; }
+  a.back:hover { color:var(--ink); }
+  label.ctl { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--mut); }
+  select, input[type=range] { accent-color:var(--accent); }
+  select { background:var(--panel); color:var(--ink); border:1px solid var(--line); border-radius:7px; padding:5px 8px; font-size:13px; }
+  input[type=range] { width:120px; }
+  .pill { background:var(--panel); border:1px solid var(--line); border-radius:999px; padding:3px 10px; font-size:12px; color:var(--mut); }
+  .pill b { color:var(--ink); }
+
+  /* A/B rows — each pair is one full row: IMAGE on the left, PROMPT on the
+     right, rating bar across the bottom. Density slider controls how many
+     pairs per row. */
+  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:16px; padding:16px; padding-bottom:140px; }
+  .pair { background:var(--panel); border:1px solid var(--line); border-radius:12px; overflow:hidden;
+          display:flex; flex-direction:column; transition:opacity .2s, box-shadow .15s; }
+  .pair.rated { box-shadow:inset 0 0 0 2px var(--accent); }
+  .pair.rejected { box-shadow:inset 0 0 0 2px var(--bad); }
+  .ab { display:grid; grid-template-columns:1fr 1fr; min-height:0; }
+  .side-img { position:relative; background:#000; aspect-ratio:1/1; }
+  .side-img img { width:100%; height:100%; object-fit:cover; display:block; -webkit-user-drag:none; }
+  .side-img .tag { position:absolute; top:8px; left:8px; background:rgba(0,0,0,.7); color:#fff;
+                   font-size:10px; font-weight:700; letter-spacing:.08em; padding:3px 7px; border-radius:5px; }
+  .side-prompt { padding:12px 14px; display:flex; flex-direction:column; gap:8px; border-left:1px solid var(--line);
+                 max-height:420px; overflow:auto; }
+  .side-prompt .tag { color:var(--mut); font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
+  .prompt-text { font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--ink);
+                 white-space:pre-wrap; word-break:break-word; }
+  .meta-line { display:flex; flex-wrap:wrap; gap:6px; align-items:center; margin-top:2px; }
+  .chip { font-size:11px; color:var(--mut); background:#1d2023; border:1px solid var(--line); border-radius:6px; padding:2px 7px; }
+  .when { font-size:11px; color:var(--gold); }   /* created date+time (standing rule) */
+
+  .bar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding:10px 14px; border-top:1px solid var(--line); background:#131517; }
+  .stars { display:inline-flex; gap:3px; }
+  .star { cursor:pointer; font-size:20px; line-height:1; color:#3a3f43; transition:color .08s; user-select:none; }
+  .star.on { color:var(--gold); }
+  .btn { cursor:pointer; border:1px solid var(--line); background:#1d2023; color:var(--ink);
+         border-radius:8px; padding:6px 13px; font-size:13px; font-weight:600; }
+  .btn:hover { border-color:var(--mut); }
+  .btn.approve.on { background:var(--good); border-color:var(--good); color:#fff; }
+  .btn.reject.on  { background:var(--bad);  border-color:var(--bad);  color:#fff; }
+  .saved { font-size:11px; color:var(--accent); opacity:0; transition:opacity .2s; }
+  .saved.show { opacity:1; }
+  .empty { color:var(--mut); padding:60px 20px; text-align:center; }
+  @media (max-width:900px){ .grid{ --cols:1 !important; } }
+</style>
+</head>
+<body>
+<header>
+  <div class="row">
+    <h1><span class="leaf">◧</span> Prompt vs Image</h1>
+    <span class="sub">A/B review — does the rendered image match the generation prompt?</span>
+    <a class="back" href="/admin/design-curator">← Design Curator</a>
+    <span style="flex:1"></span>
+    <label class="ctl">Collection
+      <select id="cat">
+        <option value="">All</option>
+      </select>
+    </label>
+    <label class="ctl">Show
+      <select id="filter">
+        <option value="unrated">Unrated only</option>
+        <option value="all">All</option>
+      </select>
+    </label>
+    <label class="ctl">Density
+      <input type="range" id="density" min="1" max="4" step="1" value="2"
+             aria-label="Pairs per row"><span id="density-val" class="pill"><b>2</b></span>
+    </label>
+    <span class="pill">Rated <b id="rated-count">0</b></span>
+    <span class="pill">Showing <b id="show-count">0</b></span>
+  </div>
+</header>
+
+<div id="grid" class="grid"></div>
+<div id="empty" class="empty" style="display:none">Nothing to review here. Switch the filter to “All” or pick another collection.</div>
+
+<script>
+(function(){
+  // Carry the admin query param through (admin-gate accepts ?admin=... or host).
+  function q(p){ var s = location.search || ''; if(!s) return p; return p + (p.indexOf('?')<0?'?':'&') + s.slice(1); }
+  var grid = document.getElementById('grid');
+  var emptyEl = document.getElementById('empty');
+  var catSel = document.getElementById('cat');
+  var filterSel = document.getElementById('filter');
+  var density = document.getElementById('density');
+  var densityVal = document.getElementById('density-val');
+  var ratedCountEl = document.getElementById('rated-count');
+  var showCountEl = document.getElementById('show-count');
+
+  // ── Density + filter persistence (Steve standing rule: every grid persists
+  //    its controls to localStorage).
+  var DKEY='wallco.pvi.density', FKEY='wallco.pvi.filter', CKEY='wallco.pvi.cat';
+  try {
+    var sd = localStorage.getItem(DKEY); if(sd){ density.value = sd; }
+    var sf = localStorage.getItem(FKEY); if(sf){ filterSel.value = sf; }
+  } catch(e){}
+  function applyDensity(){
+    var n = parseInt(density.value,10) || 2;
+    document.documentElement.style.setProperty('--cols', String(n));
+    densityVal.innerHTML = '<b>'+n+'</b>';
+    try { localStorage.setItem(DKEY, density.value); } catch(e){}
+  }
+  density.addEventListener('input', applyDensity);
+  applyDensity();
+  filterSel.addEventListener('change', function(){ try{localStorage.setItem(FKEY,this.value);}catch(e){} load(); });
+  catSel.addEventListener('change', function(){ try{localStorage.setItem(CKEY,this.value);}catch(e){} load(); });
+
+  function fmtDate(iso){
+    if(!iso) return '';
+    try {
+      var d = new Date(iso);
+      return '🕓 ' + d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+    } catch(e){ return ''; }
+  }
+  function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
+
+  async function loadCats(){
+    try {
+      var r = await fetch(q('/api/admin/curator/collections'));
+      if(!r.ok) return;
+      var j = await r.json();
+      var lines = (j.lines || j.items || j || []);
+      lines.forEach(function(l){
+        var name = (typeof l === 'string') ? l : (l.line || l.category || l.name);
+        if(!name) return;
+        var o = document.createElement('option'); o.value=name; o.textContent=name;
+        catSel.appendChild(o);
+      });
+      try { var sc = localStorage.getItem(CKEY); if(sc){ catSel.value = sc; } } catch(e){}
+    } catch(e){}
+  }
+
+  function ratingBar(d){
+    var cur = d.prompt_match || 0;
+    var stars = '';
+    for(var i=1;i<=5;i++){ stars += '<span class="star'+(i<=cur?' on':'')+'" data-v="'+i+'" title="'+i+' / 5 match">★</span>'; }
+    return ''
+      + '<span class="sub">Match</span>'
+      + '<span class="stars" data-id="'+d.id+'">'+stars+'</span>'
+      + '<button class="btn approve'+(d.prompt_verdict==='approve'?' on':'')+'" data-act="approve" data-id="'+d.id+'">✓ Matches</button>'
+      + '<button class="btn reject'+(d.prompt_verdict==='reject'?' on':'')+'" data-act="reject" data-id="'+d.id+'">✕ Off-prompt</button>'
+      + '<span style="flex:1"></span>'
+      + '<a class="back" href="'+d.view_url+'" target="_blank" rel="noopener">open ↗</a>'
+      + '<span class="saved" data-saved="'+d.id+'">saved ✓</span>';
+  }
+
+  function card(d){
+    var cls = 'pair' + (d.prompt_verdict==='approve'||d.prompt_match?' rated':'') + (d.prompt_verdict==='reject'?' rejected':'');
+    return ''
+      + '<div class="'+cls+'" data-id="'+d.id+'">'
+      +   '<div class="ab">'
+      +     '<div class="side-img"><span class="tag">IMAGE · #'+d.id+'</span>'
+      +       '<img loading="lazy" src="'+esc(d.image_url)+'" alt="design #'+d.id+'"></div>'
+      +     '<div class="side-prompt">'
+      +       '<span class="tag">Generation prompt</span>'
+      +       '<div class="prompt-text">'+esc(d.prompt)+'</div>'
+      +       '<div class="meta-line">'
+      +         '<span class="chip">'+esc(d.category||'—')+'</span>'
+      +         (d.generator?'<span class="chip">'+esc(d.generator)+'</span>':'')
+      +         (d.seed!=null?'<span class="chip">seed '+esc(d.seed)+'</span>':'')
+      +         '<span class="chip">'+(d.is_published?'published':'unpublished')+'</span>'
+      +       '</div>'
+      +       '<div class="meta-line"><span class="when" title="'+esc(d.created_at)+'">'+fmtDate(d.created_at)+'</span></div>'
+      +     '</div>'
+      +   '</div>'
+      +   '<div class="bar">'+ratingBar(d)+'</div>'
+      + '</div>';
+  }
+
+  async function save(id, body){
+    var r = await fetch(q('/api/admin/prompt-rate/'+id), {
+      method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)
+    });
+    if(!r.ok) throw new Error('save failed '+r.status);
+    return r.json();
+  }
+  function flashSaved(id){
+    var el = grid.querySelector('[data-saved="'+id+'"]');
+    if(!el) return; el.classList.add('show'); setTimeout(function(){ el.classList.remove('show'); }, 1100);
+  }
+
+  // Event delegation for stars + verdict buttons.
+  grid.addEventListener('click', async function(e){
+    var star = e.target.closest('.star');
+    if(star){
+      var wrap = star.closest('.stars'); var id = parseInt(wrap.getAttribute('data-id'),10);
+      var v = parseInt(star.getAttribute('data-v'),10);
+      // Click the same value again to clear.
+      var already = wrap.querySelectorAll('.star.on').length === v && star.classList.contains('on') && v===wrap.querySelectorAll('.star').length===false;
+      var stars = [].slice.call(wrap.querySelectorAll('.star'));
+      var currentOn = stars.filter(function(s){return s.classList.contains('on');}).length;
+      var newVal = (currentOn === v) ? null : v;
+      stars.forEach(function(s,i){ s.classList.toggle('on', newVal!=null && (i+1)<=newVal); });
+      try { await save(id, { match:newVal }); flashSaved(id); markRated(); }
+      catch(err){ alert(err.message); }
+      var pair = star.closest('.pair'); if(pair) pair.classList.toggle('rated', !!newVal);
+      return;
+    }
+    var btn = e.target.closest('.btn[data-act]');
+    if(btn){
+      var id2 = parseInt(btn.getAttribute('data-id'),10);
+      var act = btn.getAttribute('data-act');
+      var bar = btn.closest('.bar');
+      var ap = bar.querySelector('.btn.approve'), rj = bar.querySelector('.btn.reject');
+      var newV;
+      if(act==='approve'){ newV = ap.classList.contains('on') ? null : 'approve'; }
+      else { newV = rj.classList.contains('on') ? null : 'reject'; }
+      ap.classList.toggle('on', newV==='approve');
+      rj.classList.toggle('on', newV==='reject');
+      try { await save(id2, { verdict:newV }); flashSaved(id2); markRated(); }
+      catch(err){ alert(err.message); }
+      var pair2 = btn.closest('.pair');
+      if(pair2){ pair2.classList.toggle('rejected', newV==='reject'); pair2.classList.toggle('rated', newV==='approve'); }
+      return;
+    }
+  });
+
+  function markRated(){
+    var n = grid.querySelectorAll('.pair.rated, .pair.rejected').length;
+    ratedCountEl.textContent = n;
+  }
+
+  async function load(){
+    grid.innerHTML = '<div class="empty">Loading…</div>';
+    var params = new URLSearchParams();
+    if(catSel.value) params.set('category', catSel.value);
+    params.set('include', filterSel.value === 'unrated' ? 'unrated' : 'all');
+    params.set('limit','120');
+    try {
+      var r = await fetch(q('/api/admin/prompt-rate/list?'+params.toString()));
+      var j = await r.json();
+      var items = j.items || [];
+      showCountEl.textContent = items.length;
+      if(!items.length){ grid.innerHTML=''; emptyEl.style.display='block'; ratedCountEl.textContent='0'; return; }
+      emptyEl.style.display='none';
+      grid.innerHTML = items.map(card).join('');
+      markRated();
+    } catch(e){
+      grid.innerHTML = '<div class="empty">Error: '+esc(e.message)+'</div>';
+    }
+  }
+
+  loadCats().then(load);
+})();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index d895997..bb2ec7f 100644
--- a/server.js
+++ b/server.js
@@ -4276,6 +4276,132 @@ app.post('/api/design/:id/rate', express.json({ limit: '4kb' }), (req, res) => {
   }
 });
 
+// ── Prompt-vs-Image A/B rating surface (admin only) ─────────────────────────
+// Steve wants to see the generation PROMPT beside the rendered IMAGE and rate
+// how well the image matches the intent. The existing user_color_good /
+// user_style_good / user_scale_good / user_stars axes don't capture "does the
+// image actually depict what the prompt asked for" (e.g. "drunken bullfrog
+// among bluebonnets" rendered as ONLY bluebonnets — color/style/scale all fine,
+// prompt-match FAIL). So this is a distinct axis.
+//
+// Persistence: a new prod PG column (user_prompt_match smallint) is the right
+// long-term home, but that's a Steve-gated schema change. Until then we persist
+// to an append-only JSONL ledger (data/prompt-match-ratings.jsonl — same pattern
+// as the votes / cactus-decisions pipelines) and replay the LATEST entry per id
+// into an in-memory map so the surface shows current state. Promotion to a real
+// column is a one-line backfill from this ledger when Steve approves it.
+const PROMPT_MATCH_LEDGER = path.join(__dirname, 'data', 'prompt-match-ratings.jsonl');
+const PROMPT_MATCH_STATE = new Map();  // id -> { match, verdict, rated_at }
+(function loadPromptMatchLedger() {
+  try {
+    if (!fs.existsSync(PROMPT_MATCH_LEDGER)) return;
+    const lines = fs.readFileSync(PROMPT_MATCH_LEDGER, 'utf8').split('\n').filter(Boolean);
+    for (const ln of lines) {
+      try {
+        const r = JSON.parse(ln);
+        if (Number.isInteger(r.id)) {
+          // Last-write-wins (ledger is append-only, chronological)
+          PROMPT_MATCH_STATE.set(r.id, { match: r.match ?? null, verdict: r.verdict ?? null, rated_at: r.ts });
+        }
+      } catch {}
+    }
+  } catch {}
+})();
+
+// GET /api/admin/prompt-rate/list?category=&include=unrated|all&limit=
+//   Returns { prompt, image_url, created_at, current rating } rows. Joins the
+//   PG `prompt` column (fully populated for published designs) with the in-memory
+//   prompt-match ledger state.
+app.get('/api/admin/prompt-rate/list', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const cat = String(req.query.category || '').replace(/[^a-zA-Z0-9 ._·-]/g, '').slice(0, 40);
+  const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 60, 1), 300);
+  const onlyUnrated = String(req.query.include || '') === 'unrated';
+  try {
+    const where = [
+      "prompt IS NOT NULL AND length(trim(prompt)) > 0",
+      "COALESCE(user_removed,false) = false",
+      "local_path NOT LIKE '%midheal_%' AND local_path NOT LIKE '%edgeheal_%' AND local_path NOT LIKE '%smartfix_%'",
+    ];
+    if (cat) where.push(`category ILIKE '%${cat}%'`);
+    const rawJson = psqlQuery(
+      "SELECT COALESCE(json_agg(t ORDER BY t.created_at DESC), '[]'::json) FROM (" +
+        "SELECT id, category, dominant_hex, prompt, generator, seed, " +
+               "is_published, created_at, " +
+               "user_color_good, user_style_good, user_scale_good, user_stars " +
+        "FROM spoon_all_designs " +
+        "WHERE " + where.join(' AND ') + " " +
+        "LIMIT " + (limit * 3) +   // overscan; we filter unrated/promote in JS
+      ") t;"
+    );
+    let items = JSON.parse(rawJson || '[]');
+    items = items.map(d => {
+      const pm = PROMPT_MATCH_STATE.get(d.id) || {};
+      return {
+        id: d.id,
+        category: d.category,
+        dominant_hex: d.dominant_hex,
+        prompt: d.prompt,
+        generator: d.generator,
+        seed: d.seed,
+        is_published: d.is_published,
+        created_at: d.created_at,
+        image_url: `/designs/img/by-id/${d.id}`,
+        view_url: `/design/${d.id}`,
+        prompt_match: pm.match ?? null,     // 1-5 score
+        prompt_verdict: pm.verdict ?? null, // 'approve' | 'reject' | null
+        prompt_rated_at: pm.rated_at ?? null,
+        user_color_good: d.user_color_good,
+        user_style_good: d.user_style_good,
+        user_scale_good: d.user_scale_good,
+        user_stars: d.user_stars,
+      };
+    });
+    if (onlyUnrated) items = items.filter(x => x.prompt_match == null && x.prompt_verdict == null);
+    items = items.slice(0, limit);
+    res.json({ ok: true, count: items.length, items });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// POST /api/admin/prompt-rate/:id  body: { match?:1-5|null, verdict?:'approve'|'reject'|null }
+//   Records a prompt-vs-image match rating to the append-only ledger and updates
+//   in-memory state. Does NOT touch is_published (no publish side-effects) — this
+//   is a curation signal only.
+app.post('/api/admin/prompt-rate/:id', express.json({ limit: '4kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  const b = req.body || {};
+  let match = null, verdict = null;
+  if (b.match === null) match = null;
+  else if (Number.isInteger(b.match) && b.match >= 1 && b.match <= 5) match = b.match;
+  else if (b.match !== undefined) return res.status(400).json({ error: 'match must be 1-5 or null' });
+  if (b.verdict === null) verdict = null;
+  else if (b.verdict === 'approve' || b.verdict === 'reject') verdict = b.verdict;
+  else if (b.verdict !== undefined) return res.status(400).json({ error: "verdict must be 'approve'|'reject'|null" });
+  if (b.match === undefined && b.verdict === undefined) return res.status(400).json({ error: 'nothing to update' });
+  // Merge with prior so a verdict-only call keeps an earlier match and vice versa
+  const prev = PROMPT_MATCH_STATE.get(id) || {};
+  if (b.match === undefined) match = prev.match ?? null;
+  if (b.verdict === undefined) verdict = prev.verdict ?? null;
+  const ts = new Date().toISOString();
+  try {
+    fs.appendFileSync(PROMPT_MATCH_LEDGER, JSON.stringify({ id, match, verdict, ts }) + '\n');
+    PROMPT_MATCH_STATE.set(id, { match, verdict, rated_at: ts });
+    res.json({ ok: true, id, match, verdict, rated_at: ts });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// GET /admin/prompt-vs-image — the side-by-side A/B rating page (admin only).
+app.get('/admin/prompt-vs-image', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'prompt-vs-image.html'));
+});
+
 // POST /api/design/:id/remove  — soft-remove a design from public listings.
 // Sets user_removed=true. Reverse with ?undo=1.
 app.post('/api/design/:id/remove', (req, res) => {
@@ -5840,6 +5966,7 @@ function htmlHeader(active) {
   <div id="admin-nav" hidden style="margin-top:14px;padding-top:14px;border-top:1px solid var(--line,#e5e0d4)">
     <div style="font:600 10.5px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a);margin:0 14px 8px">Admin</div>
     <a href="/admin/design-curator" class="${active === '/admin/design-curator' ? 'active' : ''}">🎨 Design Curator (grid)</a>
+    <a href="/admin/prompt-vs-image" class="${active === '/admin/prompt-vs-image' ? 'active' : ''}">◧ Prompt vs Image</a>
     <a href="/admin/bleed-review"   class="${active === '/admin/bleed-review'   ? 'active' : ''}">🩸 Bleed Review</a>
     <a href="/admin/drunk-curator"  class="${active === '/admin/drunk-curator'  ? 'active' : ''}">🥃 Hot-or-Not + Wall Viewer</a>
     <a href="/admin/rooms"          class="${active === '/admin/rooms'          ? 'active' : ''}">🛋 Room Renders Gallery</a>

← 5b012f9 styleguides.wallco.ai → admin-only; revert public metering o  ·  back to Wallco Ai  ·  chore(deploy): exclude data/prompt-match-ratings.jsonl from d7523ba →