← back to Wallco Ai
public/seam-fix-curator.html
267 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Seam-Fix Curator — heal the seams, keep the root</title>
<style>
:root {
--bg:#fafaf8; --fg:#1a1a1a; --muted:#6a6a6a; --card:#fff;
--border:#e8e6e1; --root:#2a4d6e;
--pick:#1d6b3a; --reject:#7a7a7a;
--pass:#1d6b3a; --warn:#b8860b; --fail:#a02020;
--sans:-apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 var(--sans); }
header { padding:18px 28px 14px; border-bottom:1px solid var(--border); background:var(--card); position:sticky; top:0; z-index:10; }
header h1 { margin:0 0 4px; font-size:18px; 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:#1a1a1a; color:#fff; margin-left:8px; vertical-align:middle; }
header .stats { color:var(--muted); font-size:12.5px; }
header .stats b { color:var(--fg); }
header .keyhelp { font-size:11px; color:var(--muted); margin-top:6px; }
header .keyhelp kbd { display:inline-block; padding:1px 5px; font-size:10px; background:#f1efe9; border:1px solid var(--border); border-radius:3px; font-family:ui-monospace,Menlo,monospace; margin:0 2px; }
header label.tilechk { font-size:11px; color:var(--muted); margin-left:14px; cursor:pointer; }
main { padding:22px 28px 80px; }
.row {
display:grid; grid-template-columns:repeat(3, 1fr); gap:14px;
margin-bottom:18px; background:var(--card); border:2px solid var(--border);
border-radius:10px; padding:14px; transition:opacity .25s, border-color .15s, box-shadow .15s;
}
.row.decided { opacity:0.4; pointer-events:none; }
.row.focused { border-color:var(--root); box-shadow:0 0 0 4px rgba(42,77,110,0.12); }
.row.focused .meta::after {
content:'⌨ focused · 2/3 pick a seam-fix · 1/K keep root (reject fixes) · J/↓ next · Shift+K/↑ prev';
display:block; font:10px ui-monospace,Menlo,monospace; color:var(--root); margin-top:4px; letter-spacing:.02em;
}
.tile { position:relative; aspect-ratio:1; background:#f0eee8; overflow:hidden; border-radius:6px; cursor:pointer; transition:transform .12s, box-shadow .12s; border:3px solid transparent; }
.tile:hover { transform:scale(1.02); box-shadow:0 4px 14px rgba(0,0,0,0.08); }
.tile.picked { border-color:var(--pick); box-shadow:0 0 0 2px rgba(29,107,58,0.18); }
.tile.isroot { cursor:default; }
.tile.isroot:hover { transform:none; box-shadow:none; }
/* tiled mode: show the image as a 2x2 repeat so the SEAM is visible */
.tile .img { width:100%; height:100%; background-position:center; background-size:cover; background-repeat:no-repeat; display:block; }
body.tiled .tile .img { background-size:50% 50%; background-repeat:repeat; }
.tile .label {
position:absolute; top:6px; left:6px; padding:2px 8px; font-size:10px;
letter-spacing:0.08em; text-transform:uppercase; border-radius:2px;
background:rgba(0,0,0,0.78); color:#fff; font-weight:600; z-index:2;
}
.tile .label.root { background:var(--root); }
.tile .label.v1 { background:#7a5a30; }
.tile .label.v2 { background:#30607a; }
.tile .score {
position:absolute; bottom:6px; left:6px; right:6px; padding:3px 7px; font-size:10.5px;
background:rgba(255,255,255,0.94); color:#1a1a1a; border-radius:3px;
font-weight:600; font-variant-numeric:tabular-nums; z-index:2;
display:flex; justify-content:space-between; align-items:center; gap:6px;
}
.verdict { font-weight:700; letter-spacing:.04em; }
.verdict.PASS { color:var(--pass); }
.verdict.WARN { color:var(--warn); }
.verdict.FAIL { color:var(--fail); }
.best { position:absolute; top:6px; right:6px; padding:2px 7px; font-size:9.5px; font-weight:700; letter-spacing:.05em; background:var(--pick); color:#fff; border-radius:10px; z-index:2; }
.meta {
grid-column:1/-1; display:flex; justify-content:space-between; align-items:center;
font-size:11.5px; color:var(--muted); padding:4px 4px 0;
}
.meta .info { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:8px; }
.meta .pick-status { font-weight:600; }
.meta button.keep-root {
padding:5px 12px; border:1px solid var(--border); background:var(--card);
border-radius:14px; font:11px var(--sans); cursor:pointer; color:var(--reject);
text-transform:uppercase; letter-spacing:.06em; font-weight:600;
}
.meta button.keep-root:hover { background:#f1efe9; border-color:var(--reject); }
.empty { padding:60px; text-align:center; color:var(--muted); font-size:14px; }
.empty code { background:#f1efe9; padding:2px 6px; border-radius:3px; font-size:12px; }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:8px 16px; background:#1a1a1a; color:#fff; border-radius:20px; font-size:12.5px; opacity:0; transition:opacity .2s; pointer-events:none; z-index:99; }
.toast.show { opacity:1; }
</style>
</head>
<body>
<header>
<h1>Seam-Fix Curator <span class="badge" id="count">…</span></h1>
<div class="stats">
<b id="pendingCount">…</b> roots pending · <b id="decidedCount">…</b> decided.
Same design, same aesthetic — only the tile <b>seams</b> healed. Click the seam-fix that tiles cleanest →
publishes that child + leaves the <b>root untouched</b> (round-1 sacred). <b>"keep root"</b> rejects all fixes.
<label class="tilechk"><input type="checkbox" id="tileToggle" checked> show as 2×2 tile (reveals the seam)</label>
</div>
<div class="keyhelp">
<kbd>1</kbd>/<kbd>K</kbd> keep root · <kbd>2</kbd> pick best seam-fix · <kbd>3</kbd> pick other · <kbd>J</kbd>/<kbd>↓</kbd> next · <kbd>Shift+K</kbd>/<kbd>↑</kbd> prev
</div>
</header>
<main>
<div id="rows"><div class="empty">Loading…</div></div>
</main>
<div class="toast" id="toast"></div>
<script>
let DATA = [];
let FOCUS = 0;
async function loadData() {
const r = await fetch('/api/seam-fix-curator');
if (!r.ok) throw new Error('fetch failed: ' + r.status);
return r.json();
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso); if (isNaN(d)) return '—';
return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
}
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
clearTimeout(t._tm); t._tm = setTimeout(() => t.classList.remove('show'), 1700);
}
function vClass(v){ return v==='PASS'?'PASS':v==='WARN'?'WARN':'FAIL'; }
function render() {
const main = document.getElementById('rows');
document.getElementById('count').textContent = DATA.length;
document.getElementById('pendingCount').textContent = DATA.length;
if (!DATA.length) {
main.innerHTML = '<div class="empty">No pending roots. Mint candidates with '
+ '<code>python3 scripts/seam-fix-variants.py --ids 2810,2778,43186,43198,2662</code></div>';
return;
}
main.innerHTML = DATA.map((p, i) => {
// best variant = lowest after_max
const bestId = p.variants.length ? p.variants[0].new_id : null;
const variantTiles = p.variants.map(v => `
<div class="tile" data-pick="variant" data-id="${v.new_id}" data-variant="${v.variant}" data-row="${i}"
title="Pick ${v.variant} (${v.generator}) — seam ${p.before_max}→${v.after_max}">
<div class="img" style="background-image:url('/designs/img/by-id/${v.new_id}')"></div>
<span class="label ${v.variant}">${v.variant==='v1'?'v1 · shift-feather':'v2 · per-box heal'}</span>
${v.new_id===bestId ? '<span class="best">★ cleanest</span>' : ''}
<span class="score">
<span>Δ ${p.before_max} → <b>${v.after_max}</b></span>
<span class="verdict ${vClass(v.after_verdict)}">${v.after_verdict}</span>
</span>
</div>`).join('');
return `
<div class="row" data-row="${i}" data-root="${p.root_id}" id="row-${i}">
<div class="tile isroot" title="Root #${p.root_id} — kept regardless of pick">
<div class="img" style="background-image:url('/designs/img/by-id/${p.root_id}')"></div>
<span class="label root">Root #${p.root_id}</span>
<span class="score">
<span>seam ${p.before_max}</span>
<span class="verdict ${vClass(p.before_verdict)}">${p.before_verdict}</span>
</span>
</div>
${variantTiles}
<div class="meta">
<span class="info" title="root #${p.root_id} · ${p.category||''}">
#${p.root_id} · ${p.category||''} · 🕓 ${fmtDate(p.created_at)}
</span>
<span class="pick-status" id="status-${i}"></span>
<button class="keep-root" data-pick="keep" data-row="${i}">Keep root — reject fixes</button>
</div>
</div>`;
}).join('');
main.querySelectorAll('[data-pick]').forEach(el => {
el.addEventListener('click', () => {
const kind = el.dataset.pick;
const row = parseInt(el.dataset.row, 10);
if (kind === 'variant') pickVariant(row, parseInt(el.dataset.id, 10), el.dataset.variant);
else if (kind === 'keep') keepRoot(row);
});
});
}
async function pickVariant(rowIdx, childId, variant) {
const p = DATA[rowIdx]; if (!p) return;
try {
const r = await fetch('/api/seam-fix-curator/pick', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ root_id:p.root_id, picked_variant:variant, picked_id:childId }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'pick failed');
const row = document.getElementById('row-' + rowIdx);
row.querySelectorAll('.tile').forEach(t => t.classList.remove('picked'));
row.querySelector(`[data-id="${childId}"]`)?.classList.add('picked');
document.getElementById('status-' + rowIdx).textContent =
`✓ ${variant} published (#${childId}) · ${j.unpublished_siblings} sibling(s) unpublished · root kept`;
row.classList.add('decided');
toast(`Seam-fix ${variant} published for root #${p.root_id}`);
advance(rowIdx);
} catch (e) { toast('ERR: ' + e.message); }
}
async function keepRoot(rowIdx) {
const p = DATA[rowIdx]; if (!p) return;
try {
const r = await fetch('/api/seam-fix-curator/pick', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ root_id:p.root_id, picked_variant:'NONE' }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.error || 'keep failed');
document.getElementById('status-' + rowIdx).textContent =
`✗ seam fixes rejected — root kept (${j.unpublished_siblings} unpublished)`;
document.getElementById('row-' + rowIdx).classList.add('decided');
toast(`Root #${p.root_id} kept (fixes rejected)`);
advance(rowIdx);
} catch (e) { toast('ERR: ' + e.message); }
}
function setFocus(rowIdx) {
document.querySelectorAll('.row.focused').forEach(r => r.classList.remove('focused'));
const row = document.getElementById('row-' + rowIdx);
if (row) { row.classList.add('focused'); row.scrollIntoView({behavior:'smooth', block:'center'}); FOCUS = rowIdx; }
}
function advance(rowIdx) {
for (let i = rowIdx + 1; i < DATA.length; i++) {
const row = document.getElementById('row-' + i);
if (row && !row.classList.contains('decided')) { setFocus(i); return; }
}
}
document.addEventListener('keydown', (e) => {
if (e.target.matches('input,textarea')) return;
const p = DATA[FOCUS]; if (!p) return;
const key = e.key.toLowerCase();
if ((key === 'k' && e.shiftKey) || key === 'arrowup') {
for (let i = FOCUS - 1; i >= 0; i--) {
if (!document.getElementById('row-' + i).classList.contains('decided')) { setFocus(i); break; }
}
e.preventDefault(); return;
}
if (key === 'j' || key === 'arrowdown') {
for (let i = FOCUS + 1; i < DATA.length; i++) {
if (!document.getElementById('row-' + i).classList.contains('decided')) { setFocus(i); break; }
}
e.preventDefault(); return;
}
if (key === '1' || key === 'k') { keepRoot(FOCUS); e.preventDefault(); return; }
if (key === '2' || key === '3') {
const v = p.variants[parseInt(key,10) - 2];
if (v) { pickVariant(FOCUS, v.new_id, v.variant); e.preventDefault(); }
return;
}
});
function focusFirstUndecided() {
for (let i = 0; i < DATA.length; i++) {
const row = document.getElementById('row-' + i);
if (row && !row.classList.contains('decided')) { setFocus(i); break; }
}
}
document.getElementById('tileToggle').addEventListener('change', e => {
document.body.classList.toggle('tiled', e.target.checked);
});
loadData().then(d => {
DATA = d.items || [];
document.getElementById('decidedCount').textContent = d.decided || 0;
document.body.classList.add('tiled');
render();
focusFirstUndecided();
}).catch(e => {
document.getElementById('rows').innerHTML = '<div class="empty">Failed to load: ' + e.message + '</div>';
});
</script>
</body>
</html>