← 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 &ldquo;${currentFilter}&rdquo;.</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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>