← back to Wallco Ai

public/subject-mismatch.html

148 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Subject-Mismatch Gallery — dropped-subject failures</title>
<style>
  :root { --bg:#fafaf8; --fg:#1a1a1a; --muted:#6a6a6a; --card:#fff; --border:#e8e6e1; --accent:#b3261e; --warn:#c98a00; --gold:#c9a14b; --card-min:360px; }
  * { box-sizing: border-box; }
  body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; }
  header { padding:22px 28px 16px; border-bottom:1px solid var(--border); background:var(--card); position:sticky; top:0; z-index:10; }
  header h1 { margin:0 0 4px; font-size:20px; font-weight:600; letter-spacing:-0.01em; }
  header h1 .badge { display:inline-block; padding:2px 8px; font-size:11px; font-weight:600; border-radius:10px; background:var(--accent); color:#fff; margin-left:8px; vertical-align:middle; }
  header .stats { color:var(--muted); font-size:13px; }
  header .stats b { color:var(--fg); font-weight:600; }
  header .meta { color:var(--muted); font-size:12px; margin-top:6px; }
  .filters { margin-top:12px; display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
  .filters select, .filters input { padding:6px 10px; border:1px solid var(--border); border-radius:4px; background:var(--card); font:inherit; }
  .filters label { font-size:12px; color:var(--muted); margin-right:4px; }
  .filters input[type=range] { accent-color:var(--gold); vertical-align:middle; padding:0; }
  main { padding:22px 28px 64px; }
  .grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(var(--card-min), 1fr)); gap:20px; }
  .card { background:var(--card); border:1px solid var(--border); border-radius:8px; overflow:hidden; position:relative; }
  .card .img { aspect-ratio:1; background:#f0eee8; overflow:hidden; position:relative; }
  .card .img img { width:100%; height:100%; object-fit:cover; display:block; }
  .card .label { position:absolute; top:6px; left:6px; padding:3px 9px; background:var(--accent); color:#fff; font-size:11px; letter-spacing:0.06em; text-transform:uppercase; font-weight:700; border-radius:2px; }
  .card .meta { padding:12px 14px; }
  .card .meta .row1 { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:6px; }
  .card .meta .row1 .id a { color:var(--accent); text-decoration:none; font-weight:600; font-size:13px; }
  .card .meta .row1 .id a:hover { text-decoration:underline; }
  .card .meta .row1 .subject { font-weight:600; color:var(--fg); font-size:13px; }
  .card .meta .row1 .subject .missing { color:var(--accent); }
  .card .meta .prompt { color:var(--muted); font-size:11.5px; line-height:1.4; margin:6px 0 4px; font-style:italic; }
  .card .meta .saw { color:var(--fg); font-size:11.5px; line-height:1.4; padding:6px 8px; background:#fbf6e8; border-left:3px solid var(--warn); border-radius:0 4px 4px 0; }
  .card .meta .saw b { color:var(--warn); }
  .card .meta .when { margin-top:8px; display:inline-flex; align-items:center; gap:5px; font-size:11px; color:var(--gold); font-variant-numeric:tabular-nums; }
  .card .meta .when .sw { width:11px; height:11px; border-radius:50%; border:1px solid rgba(0,0,0,.18); display:inline-block; }
  .empty { padding:60px; text-align:center; color:var(--muted); }
</style>
</head>
<body>
<header>
  <h1>Subject-Mismatch Gallery <span class="badge" id="count">…</span></h1>
  <div class="stats">Roots flagged as <b>primary subject dropped</b> by the post-gen vision detector
    (<code>lib/subject-detector</code>). Image shows only background/foliage — the animal/object the prompt requested never landed.</div>
  <div class="meta">Scanned <b id="scanned">…</b> drunk-animals roots · use <code>node scripts/scan-subject-mismatch.js --limit=N --seed=S</code> to rescan.</div>
  <div class="filters">
    <label>Sort</label>
    <select id="sortBy">
      <option value="default">Default (scan order)</option>
      <option value="newest">Newest</option>
      <option value="oldest">Oldest</option>
      <option value="color">Color (by hue)</option>
      <option value="subject">Subject A→Z</option>
      <option value="id_desc">Root ID ↓</option>
      <option value="id">Root ID ↑</option>
    </select>
    <label>Density</label>
    <input type="range" id="density" min="280" max="600" step="20" title="card size">
  </div>
</header>
<main>
  <div class="grid" id="grid"><div class="empty">Loading…</div></div>
</main>
<script>
const $ = (id) => document.getElementById(id);
let ALL = [];

// sort + density persist (Steve standing rule: every card grid; both persist).
// The /api/subject-mismatch route now enriches each row with the design's real
// dominant_hex + created_at (PG lookup), so color-sort + the date chip are live.
$('sortBy').value = localStorage.getItem('subjMismatch_sort') || 'default';
$('density').value = localStorage.getItem('subjMismatch_density') || '360';
document.documentElement.style.setProperty('--card-min', $('density').value + 'px');
$('sortBy').addEventListener('change', () => { localStorage.setItem('subjMismatch_sort', $('sortBy').value); render(); });
$('density').addEventListener('input', () => {
  localStorage.setItem('subjMismatch_density', $('density').value);
  document.documentElement.style.setProperty('--card-min', $('density').value + 'px');
});

function hexHue(hex) {
  const m = /^#?([0-9a-fA-F]{6})$/.exec(hex || ''); if (!m) return 1e6;
  const n = parseInt(m[1], 16), r = (n >> 16 & 255) / 255, g = (n >> 8 & 255) / 255, b = (n & 255) / 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min; let h = 0;
  if (d === 0) h = 0; else if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
  h *= 60; if (h < 0) h += 360;
  const s = max === 0 ? 0 : d / max;
  if (s < 0.12) return 1e5 + (1 - max);
  return h;
}
function fmtDate(s) {
  if (!s) return '—';
  const d = new Date(s); if (isNaN(d)) return '—';
  return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function sorted(rows) {
  const r = rows.slice(), m = $('sortBy').value;
  if (m === 'id_desc') r.sort((a, b) => b.root_id - a.root_id);
  else if (m === 'id') r.sort((a, b) => a.root_id - b.root_id);
  else if (m === 'newest') r.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')) || (b.root_id - a.root_id));
  else if (m === 'oldest') r.sort((a, b) => String(a.created_at || '').localeCompare(String(b.created_at || '')) || (a.root_id - b.root_id));
  else if (m === 'color') r.sort((a, b) => hexHue(a.dominant_hex) - hexHue(b.dominant_hex) || (b.root_id - a.root_id));
  else if (m === 'subject') r.sort((a, b) => String(a.primary_subject || '').localeCompare(String(b.primary_subject || '')) || (b.root_id - a.root_id));
  return r; // 'default' keeps scan order
}

function render() {
  const grid = $('grid');
  if (!ALL.length) {
    grid.innerHTML = '<div class="empty">No flagged designs yet. Run <code>node scripts/scan-subject-mismatch.js</code>.</div>';
    return;
  }
  grid.innerHTML = sorted(ALL).map(p => `
    <div class="card">
      <div class="img">
        <span class="label">${p.primary_subject || '?'} MISSING</span>
        <img loading="lazy" src="/designs/img/by-id/${p.root_id}?asis=1" alt="root ${p.root_id}">
      </div>
      <div class="meta">
        <div class="row1">
          <div class="id"><a href="/design/${p.root_id}?asis=1">root #${p.root_id}</a></div>
          <div class="subject">prompt asked: <span class="missing">${(p.subjects || []).join(', ')}</span></div>
        </div>
        <div class="prompt">"${p.prompt_head || ''}"</div>
        <div class="saw"><b>Vision saw:</b> ${p.what_you_see || '—'}</div>
        <div class="when" title="created ${p.created_at || ''}">${p.dominant_hex ? `<span class="sw" style="background:${p.dominant_hex}"></span>` : ''}🕓 ${fmtDate(p.created_at)}</div>
      </div>
    </div>
  `).join('');
}

async function load() {
  try {
    const r = await fetch('/api/subject-mismatch');
    const d = await r.json();
    $('count').textContent = d.count || 0;
    $('scanned').textContent = d.scanned || 0;
    ALL = d.items || [];
    render();
  } catch (e) {
    $('grid').innerHTML = '<div class="empty">Failed: ' + e.message + '</div>';
  }
}
load();
</script>
</body>
</html>