← back to Visual Factory
public/index.html
424 lines
<!doctype html>
<html lang="en">
<head>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>The Visual Factory</title>
<style>
:root {
--bg: #0e0e10; --panel: #16161a; --line: #2a2a30;
--text: #e8e8ea; --muted: #8a8a92; --accent: #d4af37;
}
html, body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; }
header { padding: 24px 32px; border-bottom: 1px solid var(--line); display: flex; align-items: baseline; gap: 16px; }
header h1 { margin: 0; font-size: 20px; letter-spacing: 0.02em; }
header .sub { color: var(--muted); font-size: 12px; }
main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
form { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin-bottom: 24px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: start; }
form .full { grid-column: 1 / -1; }
form label { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
input[type=text], textarea { width: 100%; box-sizing: border-box; background: #050507; border: 1px solid var(--line); color: var(--text); padding: 10px 12px; border-radius: 6px; font: inherit; }
textarea { resize: vertical; min-height: 60px; }
form .actions { grid-column: 1 / -1; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
form .toggle { color: var(--muted); font-size: 12px; cursor: pointer; user-select: none; }
form .toggle:hover { color: var(--accent); }
button { background: var(--accent); color: #1b1b1b; border: 0; padding: 10px 16px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.activate-btn { padding: 4px 10px; font-size: 12px; }
.row-btn { padding: 4px 8px; font-size: 11px; background: transparent; color: var(--muted); border: 1px solid var(--line); }
.row-btn:hover { color: var(--text); border-color: var(--accent); }
.row-btn.danger:hover { color: #ff6b6b; border-color: #ff6b6b; }
.tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
.tab { background: var(--panel); border: 1px solid var(--line); color: var(--muted); padding: 6px 12px; border-radius: 999px; font-size: 12px; cursor: pointer; user-select: none; }
.tab:hover { color: var(--text); }
.tab.active { background: var(--accent); color: #1b1b1b; border-color: var(--accent); font-weight: 600; }
.tab .count { opacity: 0.65; margin-left: 6px; font-size: 10px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
.card:hover { border-color: var(--accent); }
.thumb { width: 100%; aspect-ratio: 1/1; background: #050507 center/contain no-repeat; border-bottom: 1px solid var(--line); }
.meta { padding: 12px 14px; }
.meta .name { font-weight: 600; }
.meta .brief { color: var(--muted); font-size: 12px; margin-top: 4px; line-height: 1.4; max-height: 3.6em; overflow: hidden; }
.meta .row { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; gap: 8px; }
.meta .status { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
.meta .status.activated { color: #5fb878; }
.meta .status.unapproved, .meta .status.completed_unapproved, .meta .status.failed, .meta .status.failed_intake, .meta .status.failed_orphan { color: #ff6b6b; }
.meta .status.awaiting_activation, .meta .status.awaiting_activation_v2 { color: var(--accent); }
.meta .status.running, .meta .status.pending { color: #6ab7ff; }
.meta .badges { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.meta .badge { font-size: 10px; padding: 2px 7px; border-radius: 4px; background: #050507; border: 1px solid var(--line); color: var(--muted); letter-spacing: 0.04em; }
.meta .badge.score { color: #5fb878; border-color: #2c4d36; }
.meta .badge.iter { color: var(--accent); border-color: #5a4912; }
.meta .badge.stage { color: #6ab7ff; border-color: #1f3a5a; }
.meta .errsnip { color: #ff6b6b; font-size: 11px; margin-top: 6px; max-height: 2.6em; overflow: hidden; line-height: 1.3; }
.meta .actions-row { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
.empty { color: var(--muted); padding: 60px 0; text-align: center; }
.err { color: #ff6b6b; font-size: 12px; margin-top: 8px; }
.health { display: flex; gap: 12px; flex-wrap: wrap; padding: 10px 14px; margin-bottom: 16px; border-radius: 6px; background: var(--panel); border: 1px solid var(--line); font-size: 12px; align-items: center; }
.health.bad { border-color: #ff6b6b; background: #2a1212; }
.health.warn { border-color: #f4b400; }
.health .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
.health .ok { background: #5fb878; }
.health .nok { background: #ff6b6b; }
.health .wn { background: #f4b400; }
.health .lbl { color: var(--muted); }
.health .qd { margin-left: auto; color: var(--muted); }
.modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
.modal-bg.open { display: flex; }
.modal { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; max-width: 1100px; width: 100%; max-height: 90vh; overflow: auto; padding: 20px 24px; }
.modal h2 { margin: 0 0 8px; font-size: 16px; }
.modal .brief-full { color: var(--muted); margin-bottom: 16px; }
.modal img { width: 100%; max-height: 60vh; object-fit: contain; background: #050507; border: 1px solid var(--line); border-radius: 4px; }
.event { display: grid; grid-template-columns: 60px 110px 50px 1fr; gap: 12px; padding: 4px 0; border-bottom: 1px dotted #222; font-size: 12px; }
.event:last-child { border-bottom: 0; }
.event .stg { color: var(--muted); } .event .nm { color: var(--accent); }
.event .lv { font-size: 10px; text-transform: uppercase; }
.event .lv.warn { color: #f4b400; } .event .lv.error { color: #ff6b6b; } .event .lv.info { color: #5fb878; }
.event details summary { color: var(--muted); cursor: pointer; font-size: 10px; }
.event pre { background: #050507; color: #d0d0d4; padding: 8px; margin: 4px 0 0; border-radius: 4px; font-size: 11px; overflow-x: auto; max-height: 200px; }
</style>
</head>
<body>
<header>
<h1>The Visual Factory</h1>
<span class="sub">brief → composed HTML → headless render → vision review · :9892</span>
</header>
<main>
<form id="newRun">
<div>
<label for="domain">Domain</label>
<input id="domain" type="text" placeholder="philipperomano.com" autocomplete="off" />
</div>
<div>
<label for="purpose">Purpose</label>
<input id="purpose" type="text" placeholder="Spring 2026 collection launch — Instagram feed" autocomplete="off" />
</div>
<div class="full">
<label for="details">Details (optional — palette, mood, copy, dimensions)</label>
<textarea id="details" placeholder="Editorial mood, ivory + deep green, bold serif headline `New: Mind the Gap collection`, 1080×1080…"></textarea>
</div>
<div class="full">
<label for="brief">Or paste a free-text brief instead (overrides above)</label>
<textarea id="brief" placeholder="Leave blank unless you want to skip the structured fields."></textarea>
</div>
<div class="actions">
<span class="toggle" id="exampleBtn">Fill example →</span>
<button type="submit">Build</button>
</div>
</form>
<div id="err" class="err"></div>
<div id="health"></div>
<div class="tabs" id="tabs"></div>
<div class="grid" id="grid"><div class="empty">No runs yet.</div></div>
</main>
<div class="modal-bg" id="modal-bg">
<div class="modal" id="modal">
<h2 id="m-title"></h2>
<div class="brief-full" id="m-brief"></div>
<img id="m-img" />
<div id="m-events" style="margin-top: 16px;"></div>
</div>
</div>
<script>
// Same-origin: the orchestrator now serves this page, so paths are relative.
const ORCH = '';
const STAGE_NAMES = ['queued','intake','compose','render','vision_check','critic','iterate','catalog','activate'];
let currentFilter = localStorage.getItem('vf:filter') || 'all';
let lastRuns = [];
// Bucket a run into a UI tab. Server statuses are messier than the user
// thinks about them — collapse here so the tabs stay simple.
function bucket(status) {
if (!status) return 'other';
if (status === 'pending' || status === 'running') return 'running';
if (status.startsWith('awaiting_activation')) return 'awaiting';
if (status === 'activated') return 'activated';
if (status.startsWith('failed') || status === 'completed_unapproved' || status === 'unapproved') return 'failed';
return 'other';
}
const TABS = [
{ id: 'all', label: 'All' },
{ id: 'running', label: 'Running' },
{ id: 'awaiting', label: 'Awaiting activation' },
{ id: 'activated', label: 'Activated' },
{ id: 'failed', label: 'Failed' },
];
function renderTabs(runs) {
const counts = Object.fromEntries(TABS.map(t => [t.id, 0]));
counts.all = runs.length;
for (const r of runs) {
const b = bucket(r.status);
if (b in counts) counts[b]++;
}
document.getElementById('tabs').innerHTML = TABS.map(t =>
`<span class="tab ${currentFilter===t.id?'active':''}" data-filter="${t.id}">${t.label}<span class="count">${counts[t.id]||0}</span></span>`
).join('');
document.querySelectorAll('.tab').forEach(el => {
el.addEventListener('click', () => {
currentFilter = el.dataset.filter;
localStorage.setItem('vf:filter', currentFilter);
renderTabs(lastRuns);
renderGrid(lastRuns);
});
});
}
function renderGrid(runs) {
const grid = document.getElementById('grid');
const filtered = currentFilter === 'all' ? runs : runs.filter(r => bucket(r.status) === currentFilter);
if (!filtered.length) { grid.innerHTML = `<div class="empty">No runs in “${currentFilter}”.</div>`; return; }
grid.innerHTML = filtered.map(r => {
const status = String(r.status || '');
const canActivate = status.startsWith('awaiting_activation');
const canRetry = /^(failed|completed_unapproved|unapproved)/.test(status);
const isRunning = status === 'running' || status === 'pending';
const dimText = `${r.width||'?'}×${r.height||'?'}`;
const domainBadge = r.domain
? `<span style="color:var(--accent);font-size:10px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase">${escapeHtml(r.domain)}</span>`
: '';
const subline = r.purpose ? escapeHtml(r.purpose) : escapeHtml(r.brief || '');
const vMatch = status.match(/^awaiting_activation_v(\d+)$/);
const badges = [];
if (isRunning && r.current_stage != null) {
const sn = STAGE_NAMES[r.current_stage] || `stage ${r.current_stage}`;
badges.push(`<span class="badge stage">▸ ${escapeHtml(sn)}</span>`);
}
if (r.score != null) badges.push(`<span class="badge score">★ ${r.score}/100</span>`);
if (vMatch) {
const v = Number(vMatch[1]);
badges.push(`<span class="badge iter">v${v} · iterated ${v - 1}×</span>`);
}
const errsnip = (status.startsWith('failed') || status === 'completed_unapproved') && r.last_error
? `<div class="errsnip" title="${escapeHtml(r.last_error)}">${escapeHtml(r.last_error.slice(0, 120))}${r.last_error.length > 120 ? '…' : ''}</div>`
: '';
const actions = [];
if (canActivate) actions.push(`<button data-id="${r.id}" class="activate-btn">Activate</button>`);
if (canRetry) actions.push(`<button data-id="${r.id}" class="row-btn retry-btn">Retry</button>`);
if (status === 'activated') actions.push(`<button data-id="${r.id}" class="row-btn ig-btn">Post to IG</button>`);
if (status !== 'activated' && !isRunning) actions.push(`<button data-id="${r.id}" class="row-btn danger delete-btn">Delete</button>`);
return `
<div class="card" data-id="${r.id}">
<div class="thumb" style="background-image:url('${ORCH}/runs/${r.id}/png?t=${Date.now()}')"></div>
<div class="meta">
${domainBadge}
<div class="name">${escapeHtml(r.artifact_name || `run ${r.id}`)} <span style="color:var(--muted);font-weight:400;font-size:11px">${dimText}</span></div>
<div class="brief">${subline}</div>
${badges.length ? `<div class="badges">${badges.join('')}</div>` : ''}
${errsnip}
<div class="row"><span class="status ${status}">${escapeHtml(status)}</span></div>
${actions.length ? `<div class="actions-row">${actions.join('')}</div>` : ''}
</div>
</div>`;
}).join('');
document.querySelectorAll('.activate-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(`Publish run ${btn.dataset.id} to ~/Pictures/visual-factory-published/ ?`)) return;
btn.disabled = true;
const r = await fetch(`${ORCH}/runs/${btn.dataset.id}/activate`, { method: 'POST' });
const d = await r.json();
if (d.error) alert('Activate failed: ' + d.error + '\n' + (d.hint||''));
else alert('Published → ' + d.destPng);
loadRuns();
});
});
document.querySelectorAll('.retry-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(`Retry run ${btn.dataset.id}? Wipes its events + artifact, re-runs the pipeline.`)) return;
btn.disabled = true;
const r = await fetch(`${ORCH}/runs/${btn.dataset.id}/retry`, { method: 'POST' });
const d = await r.json();
if (d.error) alert('Retry failed: ' + d.error);
loadRuns();
});
});
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(`Delete run ${btn.dataset.id}? Removes its events, artifact row, and output dir. Activated PNGs (in Pictures) are NOT touched.`)) return;
btn.disabled = true;
const r = await fetch(`${ORCH}/runs/${btn.dataset.id}`, { method: 'DELETE' });
const d = await r.json();
if (d.error) alert('Delete failed: ' + d.error);
loadRuns();
});
});
document.querySelectorAll('.ig-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!confirm(`Post run ${btn.dataset.id} to Instagram via Norma?\n\nNorma is in SIMULATION mode unless IG creds are set — this will probably just log a fake media_id. Real posts also require the image to be hosted at a public URL (loopback won't work for Meta).`)) return;
btn.disabled = true;
const r = await fetch(`${ORCH}/runs/${btn.dataset.id}/post-ig`, { method: 'POST' });
const d = await r.json();
if (d.ok) {
const sim = d.response?.result?.simulated;
alert(`IG post ${sim ? 'SIMULATED' : 'submitted'} → media_id=${d.response?.result?.media_id || '?'}\n\nCaption:\n${d.caption}`);
} else {
alert(`IG post failed: ${d.error || 'http ' + d.status}`);
}
loadRuns();
});
});
document.querySelectorAll('.card').forEach(c => {
c.addEventListener('click', () => openModal(c.dataset.id));
});
}
// Codex P2 #14 fix: cancel an in-flight loadRuns / openModal before issuing a
// new one, so an older slow response can't overwrite newer state when the user
// switches filters fast or the 2s modal poll laps the previous request.
let _loadRunsCtrl = null;
let _modalCtrl = null;
async function loadRuns() {
if (_loadRunsCtrl) _loadRunsCtrl.abort();
const ctrl = (_loadRunsCtrl = new AbortController());
try {
const res = await fetch(ORCH + '/runs', { signal: ctrl.signal });
if (ctrl !== _loadRunsCtrl) return; // we were superseded mid-flight
const data = await res.json();
const errEl = document.getElementById('err');
if (data.error) { errEl.textContent = data.error + (data.hint ? ' — ' + data.hint : ''); return; }
errEl.textContent = '';
if (!data.runs) { document.getElementById('grid').innerHTML = '<div class="empty">No runs yet.</div>'; renderTabs([]); return; }
lastRuns = data.runs;
renderTabs(lastRuns);
renderGrid(lastRuns);
} catch (e) {
if (e.name === 'AbortError') return; // superseded — don't surface
document.getElementById('err').textContent = `orchestrator unreachable — ${e.message}`;
}
}
async function openModal(id, keepOpen) {
// Cancel any prior modal fetch so a slow `/events` response from a different
// run can't paint into the modal we're now showing for a different id.
if (_modalCtrl) _modalCtrl.abort();
const ctrl = (_modalCtrl = new AbortController());
// Capture the modal target locally; if modalRunId changes during await
// (user clicked another card / closed modal), bail before painting.
const targetId = String(id);
try {
const res = await fetch(ORCH + '/runs', { signal: ctrl.signal });
const d = await res.json();
if (ctrl !== _modalCtrl) return;
const r = (d.runs||[]).find(x => String(x.id) === targetId);
if (!r) return;
if (!keepOpen) modalRunId = id;
// After the await the user may have closed the modal; if so, don't paint.
if (keepOpen && String(modalRunId) !== targetId) return;
document.getElementById('m-title').textContent = `${r.artifact_name||'run '+id} — ${r.status}`;
document.getElementById('m-brief').textContent = r.brief;
// Only refresh the image src on open — re-setting it every 2s causes flicker.
if (!keepOpen) {
document.getElementById('m-img').src = `${ORCH}/runs/${id}/png?t=${Date.now()}`;
}
const evRes = await fetch(`${ORCH}/runs/${id}/events`, { signal: ctrl.signal });
if (ctrl !== _modalCtrl) return;
const ev = await evRes.json();
if (keepOpen && String(modalRunId) !== targetId) return;
const evEl = document.getElementById('m-events');
const wasAtBottom = evEl.scrollHeight - evEl.scrollTop - evEl.clientHeight < 40;
evEl.innerHTML = (ev.events||[]).map(e => {
const payload = e.payload ? `<details><summary>payload</summary><pre>${escapeHtml(JSON.stringify(e.payload, null, 2))}</pre></details>` : '';
return `<div class="event"><span class="stg">stage ${e.stage}</span><span class="nm">${e.stage_name}</span><span class="lv ${e.level}">${e.level}</span><span>${escapeHtml(e.message||'')}${payload}</span></div>`;
}).join('');
if (wasAtBottom) evEl.scrollTop = evEl.scrollHeight;
if (!keepOpen) document.getElementById('modal-bg').classList.add('open');
} catch (e) {
if (e.name !== 'AbortError') console.error('openModal failed:', e);
}
}
document.getElementById('modal-bg').addEventListener('click', (e) => {
if (e.target.id === 'modal-bg') {
e.target.classList.remove('open');
modalRunId = null;
}
});
document.getElementById('newRun').addEventListener('submit', async (e) => {
e.preventDefault();
const brief = document.getElementById('brief').value.trim();
const domain = document.getElementById('domain').value.trim();
const purpose = document.getElementById('purpose').value.trim();
const details = document.getElementById('details').value.trim();
if (!brief && (!domain || !purpose)) {
document.getElementById('err').textContent = 'Need either a brief OR domain + purpose.';
return;
}
document.getElementById('err').textContent = '';
const res = await fetch(ORCH + '/runs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ brief, domain, purpose, details })
});
const data = await res.json();
if (data.error) { document.getElementById('err').textContent = data.error; return; }
['brief','domain','purpose','details'].forEach(id => document.getElementById(id).value = '');
loadRuns();
});
document.getElementById('exampleBtn').addEventListener('click', () => {
document.getElementById('domain').value = 'philipperomano.com';
document.getElementById('purpose').value = 'Spring 2026 collection launch — Instagram 1080×1080';
document.getElementById('details').value = 'Editorial mood, ivory + deep coastal blue palette, bold serif headline "Coastal: Spring 2026", subhead "Inspired by the New England shoreline", CTA pill "View collection →".';
});
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
// Health banner — calls /health/deep on load + every 30s. Shows red when DB or
// Ollama is unreachable so users don't submit doomed runs. Queue depth + cap
// shown on the right so concurrency caps are visible.
async function loadHealth() {
const el = document.getElementById('health');
try {
const r = await fetch(ORCH + '/health/deep');
const d = await r.json();
const dot = (s) => s === 'ok' || (typeof s === 'string' && s.startsWith('ok'))
? '<span class="dot ok"></span>'
: (typeof s === 'string' && s.includes('missing'))
? '<span class="dot wn"></span>'
: '<span class="dot nok"></span>';
const cls = d.ok ? '' : 'bad';
const q = d.queue || {};
el.className = 'health ' + cls;
el.innerHTML = `
<span>${dot(d.checks.db)}<span class="lbl">db</span> ${escapeHtml(String(d.checks.db))}</span>
<span>${dot(d.checks.ollama)}<span class="lbl">ollama</span> ${escapeHtml(String(d.checks.ollama))}</span>
<span>${dot(d.checks.claude_cli)}<span class="lbl">critic</span> ${escapeHtml(String(d.checks.claude_cli))}</span>
<span>${dot(d.checks.playwright)}<span class="lbl">playwright</span> ${escapeHtml(String(d.checks.playwright))}</span>
<span class="qd">queue ${q.in_flight||0}/${q.capacity||'?'} in flight · ${q.queued||0} queued</span>`;
} catch (e) {
el.className = 'health bad';
el.innerHTML = `<span><span class="dot nok"></span>orchestrator unreachable</span>`;
}
}
let pollIv = setInterval(loadRuns, 4000);
let modalRunId = null;
async function refreshModal() {
if (!modalRunId) return;
await openModal(modalRunId, /* keepOpen */ true);
}
setInterval(refreshModal, 2000);
loadRuns();
loadHealth();
setInterval(loadHealth, 30_000);
</script>
</body>
</html>